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 401pull/10548/head
parent
47fa1626c6
commit
9e60723e4d
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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.
|
||||||
|
|
|
@ -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@': {
|
||||||
|
|
|
@ -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() {
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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>
|
||||||
|
|
Loading…
Reference in New Issue