fix(app/logout): always perform API logout + make API logout route public [EE-6198] (#10448)

* feat(api/logout): make logout route public

* feat(app/logout): always perform API logout on /logout redirect

* fix(app): send a logout event to AngularJS when axios hits a 401
pull/10548/head
LP B 2023-10-27 14:44:05 +02:00 committed by GitHub
parent 47fa1626c6
commit 9e60723e4d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 54 additions and 27 deletions

View File

@ -38,7 +38,7 @@ func NewHandler(bouncer security.BouncerService, rateLimiter *security.RateLimit
h.Handle("/auth", h.Handle("/auth",
rateLimiter.LimitAccess(bouncer.PublicAccess(httperror.LoggerHandler(h.authenticate)))).Methods(http.MethodPost) rateLimiter.LimitAccess(bouncer.PublicAccess(httperror.LoggerHandler(h.authenticate)))).Methods(http.MethodPost)
h.Handle("/auth/logout", h.Handle("/auth/logout",
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.logout))).Methods(http.MethodPost) bouncer.PublicAccess(httperror.LoggerHandler(h.logout))).Methods(http.MethodPost)
return h return h
} }

View File

@ -7,26 +7,29 @@ import (
"github.com/portainer/portainer/api/internal/logoutcontext" "github.com/portainer/portainer/api/internal/logoutcontext"
httperror "github.com/portainer/portainer/pkg/libhttp/error" httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/response" "github.com/portainer/portainer/pkg/libhttp/response"
"github.com/rs/zerolog/log"
) )
// @id Logout // @id Logout
// @summary Logout // @summary Logout
// @description **Access policy**: authenticated // @description **Access policy**: public
// @security ApiKeyAuth // @security ApiKeyAuth
// @security jwt // @security jwt
// @tags auth // @tags auth
// @success 204 "Success" // @success 204 "Success"
// @failure 500 "Server error" // @failure 500 "Server error"
// @router /auth/logout [post] // @router /auth/logout [post]
func (handler *Handler) logout(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { func (handler *Handler) logout(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
tokenData, err := security.RetrieveTokenData(r) tokenData, err := security.RetrieveTokenData(r)
if err != nil { if err != nil {
return httperror.InternalServerError("Unable to retrieve user details from authentication token", err) log.Warn().Err(err).Msg("unable to retrieve user details from authentication token")
} }
handler.KubernetesTokenCacheManager.RemoveUserFromCache(tokenData.ID) if tokenData != nil {
handler.KubernetesTokenCacheManager.RemoveUserFromCache(tokenData.ID)
logoutcontext.Cancel(tokenData.Token) logoutcontext.Cancel(tokenData.Token)
}
return response.Empty(w) return response.Empty(w)
} }

View File

@ -60,15 +60,15 @@ func NewRequestBouncer(dataStore dataservices.DataStore, jwtService dataservices
} }
} }
// PublicAccess defines a security check for public API environments(endpoints). // PublicAccess defines a security check for public API endpoints.
// No authentication is required to access these environments(endpoints). // No authentication is required to access these endpoints.
func (bouncer *RequestBouncer) PublicAccess(h http.Handler) http.Handler { func (bouncer *RequestBouncer) PublicAccess(h http.Handler) http.Handler {
return mwSecureHeaders(h) return mwSecureHeaders(h)
} }
// AdminAccess defines a security check for API environments(endpoints) that require an authorization check. // AdminAccess defines a security check for API endpoints that require an authorization check.
// Authentication is required to access these environments(endpoints). // Authentication is required to access these endpoints.
// The administrator role is required to use these environments(endpoints). // The administrator role is required to use these endpoints.
// The request context will be enhanced with a RestrictedRequestContext object // The request context will be enhanced with a RestrictedRequestContext object
// that might be used later to inside the API operation for extra authorization validation // that might be used later to inside the API operation for extra authorization validation
// and resource filtering. // and resource filtering.
@ -79,8 +79,8 @@ func (bouncer *RequestBouncer) AdminAccess(h http.Handler) http.Handler {
return h return h
} }
// RestrictedAccess defines a security check for restricted API environments(endpoints). // RestrictedAccess defines a security check for restricted API endpoints.
// Authentication is required to access these environments(endpoints). // Authentication is required to access these endpoints.
// The request context will be enhanced with a RestrictedRequestContext object // The request context will be enhanced with a RestrictedRequestContext object
// that might be used later to inside the API operation for extra authorization validation // that might be used later to inside the API operation for extra authorization validation
// and resource filtering. // and resource filtering.
@ -104,8 +104,8 @@ func (bouncer *RequestBouncer) TeamLeaderAccess(h http.Handler) http.Handler {
return h return h
} }
// AuthenticatedAccess defines a security check for restricted API environments(endpoints). // AuthenticatedAccess defines a security check for restricted API endpoints.
// Authentication is required to access these environments(endpoints). // Authentication is required to access these endpoints.
// The request context will be enhanced with a RestrictedRequestContext object // The request context will be enhanced with a RestrictedRequestContext object
// that might be used later to inside the API operation for extra authorization validation // that might be used later to inside the API operation for extra authorization validation
// and resource filtering. // and resource filtering.

View File

@ -12,18 +12,33 @@ import { reactModule } from './react';
import { sidebarModule } from './react/views/sidebar'; import { sidebarModule } from './react/views/sidebar';
import environmentsModule from './environments'; import environmentsModule from './environments';
import { helpersModule } from './helpers'; import { helpersModule } from './helpers';
import { AXIOS_UNAUTHENTICATED } from './services/axios';
async function initAuthentication(authManager, Authentication, $rootScope, $state) { async function initAuthentication(authManager, Authentication, $rootScope, $state) {
authManager.checkAuthOnRefresh(); authManager.checkAuthOnRefresh();
function handleUnauthenticated(data, performReload) {
if (!_.includes(data.config.url, '/v2/') && !_.includes(data.config.url, '/api/v4/') && isTransitionRequiresAuthentication($state.transition)) {
$state.go('portainer.logout', { error: 'Your session has expired' });
if (performReload) {
window.location.reload();
}
}
}
// The unauthenticated event is broadcasted by the jwtInterceptor when // The unauthenticated event is broadcasted by the jwtInterceptor when
// hitting a 401. We're using this instead of the usual combination of // hitting a 401. We're using this instead of the usual combination of
// authManager.redirectWhenUnauthenticated() + unauthenticatedRedirector // authManager.redirectWhenUnauthenticated() + unauthenticatedRedirector
// to have more controls on which URL should trigger the unauthenticated state. // to have more controls on which URL should trigger the unauthenticated state.
$rootScope.$on('unauthenticated', function (event, data) { $rootScope.$on('unauthenticated', function (event, data) {
if (!_.includes(data.config.url, '/v2/') && !_.includes(data.config.url, '/api/v4/') && isTransitionRequiresAuthentication($state.transition)) { handleUnauthenticated(data, true);
$state.go('portainer.logout', { error: 'Your session has expired' }); });
window.location.reload();
} // the AXIOS_UNAUTHENTICATED event is emitted by axios when a request returns with a 401 code
// the event contains the entire AxiosError in detail.err
window.addEventListener(AXIOS_UNAUTHENTICATED, (event) => {
const data = event.detail.err;
handleUnauthenticated(data);
}); });
return await Authentication.init(); return await Authentication.init();
@ -157,7 +172,6 @@ angular
url: '/logout', url: '/logout',
params: { params: {
error: '', error: '',
performApiLogout: true,
}, },
views: { views: {
'content@': { 'content@': {

View File

@ -40,8 +40,8 @@ angular.module('portainer.app').factory('Authentication', [
} }
} }
async function logoutAsync(performApiLogout) { async function logoutAsync() {
if (performApiLogout && isAuthenticated()) { if (isAuthenticated()) {
await Auth.logout().$promise; await Auth.logout().$promise;
} }
@ -53,8 +53,8 @@ angular.module('portainer.app').factory('Authentication', [
tryAutoLoginExtension(); tryAutoLoginExtension();
} }
function logout(performApiLogout) { function logout() {
return $async(logoutAsync, performApiLogout); return $async(logoutAsync);
} }
function init() { function init() {

View File

@ -49,6 +49,8 @@ export function agentInterceptor(config: AxiosRequestConfig) {
axios.interceptors.request.use(agentInterceptor); axios.interceptors.request.use(agentInterceptor);
export const AXIOS_UNAUTHENTICATED = '__axios__unauthenticated__';
/** /**
* Parses an Axios error and returns a PortainerError. * Parses an Axios error and returns a PortainerError.
* @param err The original error. * @param err The original error.
@ -72,6 +74,16 @@ export function parseAxiosError(
} else { } else {
resultMsg = msg || details; resultMsg = msg || details;
} }
// dispatch an event for unauthorized errors that AngularJS can catch
if (err.response?.status === 401) {
dispatchEvent(
new CustomEvent(AXIOS_UNAUTHENTICATED, {
detail: {
err,
},
})
);
}
} }
return new PortainerError(resultMsg, resultErr); return new PortainerError(resultMsg, resultErr);

View File

@ -25,11 +25,10 @@ class LogoutController {
*/ */
async logoutAsync() { async logoutAsync() {
const error = this.$transition$.params().error; const error = this.$transition$.params().error;
const performApiLogout = this.$transition$.params().performApiLogout;
const settings = await this.SettingsService.publicSettings(); const settings = await this.SettingsService.publicSettings();
try { try {
await this.Authentication.logout(performApiLogout); await this.Authentication.logout();
} finally { } finally {
this.LocalStorage.storeLogoutReason(error); this.LocalStorage.storeLogoutReason(error);
if (settings.OAuthLogoutURI && this.Authentication.getUserDetails().ID !== 1) { if (settings.OAuthLogoutURI && this.Authentication.getUserDetails().ID !== 1) {

View File

@ -56,7 +56,6 @@ export function UserMenu() {
to="portainer.logout" to="portainer.logout"
label="Log out" label="Log out"
data-cy="userMenu-logOut" data-cy="userMenu-logOut"
params={{ performApiLogout: true }}
/> />
</MenuList> </MenuList>
</Menu> </Menu>