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",
rateLimiter.LimitAccess(bouncer.PublicAccess(httperror.LoggerHandler(h.authenticate)))).Methods(http.MethodPost)
h.Handle("/auth/logout",
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.logout))).Methods(http.MethodPost)
bouncer.PublicAccess(httperror.LoggerHandler(h.logout))).Methods(http.MethodPost)
return h
}

View File

@ -7,26 +7,29 @@ import (
"github.com/portainer/portainer/api/internal/logoutcontext"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/response"
"github.com/rs/zerolog/log"
)
// @id Logout
// @summary Logout
// @description **Access policy**: authenticated
// @description **Access policy**: public
// @security ApiKeyAuth
// @security jwt
// @tags auth
// @success 204 "Success"
// @failure 500 "Server error"
// @router /auth/logout [post]
func (handler *Handler) logout(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
tokenData, err := security.RetrieveTokenData(r)
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)
logoutcontext.Cancel(tokenData.Token)
if tokenData != nil {
handler.KubernetesTokenCacheManager.RemoveUserFromCache(tokenData.ID)
logoutcontext.Cancel(tokenData.Token)
}
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).
// No authentication is required to access these environments(endpoints).
// PublicAccess defines a security check for public API endpoints.
// No authentication is required to access these endpoints.
func (bouncer *RequestBouncer) PublicAccess(h http.Handler) http.Handler {
return mwSecureHeaders(h)
}
// AdminAccess defines a security check for API environments(endpoints) that require an authorization check.
// Authentication is required to access these environments(endpoints).
// The administrator role is required to use these environments(endpoints).
// AdminAccess defines a security check for API endpoints that require an authorization check.
// Authentication is required to access these endpoints.
// The administrator role is required to use these endpoints.
// The request context will be enhanced with a RestrictedRequestContext object
// that might be used later to inside the API operation for extra authorization validation
// and resource filtering.
@ -79,8 +79,8 @@ func (bouncer *RequestBouncer) AdminAccess(h http.Handler) http.Handler {
return h
}
// RestrictedAccess defines a security check for restricted API environments(endpoints).
// Authentication is required to access these environments(endpoints).
// RestrictedAccess defines a security check for restricted API endpoints.
// Authentication is required to access these endpoints.
// The request context will be enhanced with a RestrictedRequestContext object
// that might be used later to inside the API operation for extra authorization validation
// and resource filtering.
@ -104,8 +104,8 @@ func (bouncer *RequestBouncer) TeamLeaderAccess(h http.Handler) http.Handler {
return h
}
// AuthenticatedAccess defines a security check for restricted API environments(endpoints).
// Authentication is required to access these environments(endpoints).
// AuthenticatedAccess defines a security check for restricted API endpoints.
// Authentication is required to access these endpoints.
// The request context will be enhanced with a RestrictedRequestContext object
// that might be used later to inside the API operation for extra authorization validation
// and resource filtering.

View File

@ -12,18 +12,33 @@ import { reactModule } from './react';
import { sidebarModule } from './react/views/sidebar';
import environmentsModule from './environments';
import { helpersModule } from './helpers';
import { AXIOS_UNAUTHENTICATED } from './services/axios';
async function initAuthentication(authManager, Authentication, $rootScope, $state) {
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
// hitting a 401. We're using this instead of the usual combination of
// authManager.redirectWhenUnauthenticated() + unauthenticatedRedirector
// to have more controls on which URL should trigger the unauthenticated state.
$rootScope.$on('unauthenticated', function (event, data) {
if (!_.includes(data.config.url, '/v2/') && !_.includes(data.config.url, '/api/v4/') && isTransitionRequiresAuthentication($state.transition)) {
$state.go('portainer.logout', { error: 'Your session has expired' });
window.location.reload();
}
handleUnauthenticated(data, true);
});
// 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();
@ -157,7 +172,6 @@ angular
url: '/logout',
params: {
error: '',
performApiLogout: true,
},
views: {
'content@': {

View File

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

View File

@ -49,6 +49,8 @@ export function agentInterceptor(config: AxiosRequestConfig) {
axios.interceptors.request.use(agentInterceptor);
export const AXIOS_UNAUTHENTICATED = '__axios__unauthenticated__';
/**
* Parses an Axios error and returns a PortainerError.
* @param err The original error.
@ -72,6 +74,16 @@ export function parseAxiosError(
} else {
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);

View File

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

View File

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