1
0
Fork 0
mirror of https://github.com/portainer/portainer.git synced 2025-08-07 23:05:26 +02:00

feat(auth): save jwt in cookie [EE-5864] (#10527)

This commit is contained in:
Chaim Lev-Ari 2023-11-20 09:35:03 +02:00 committed by GitHub
parent ecce501cf3
commit 436da01bce
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
51 changed files with 679 additions and 312 deletions

View file

@ -1,7 +1,7 @@
import $ from 'jquery';
/* @ngInject */
export function onStartupAngular($rootScope, $state, LocalStorage, cfpLoadingBar, $transitions, HttpRequestHelper) {
export function onStartupAngular($rootScope, $state, cfpLoadingBar, $transitions, HttpRequestHelper) {
$rootScope.$state = $state;
// Workaround to prevent the loading bar from going backward
@ -23,6 +23,7 @@ export function onStartupAngular($rootScope, $state, LocalStorage, cfpLoadingBar
if (type && hasNoContentType) {
jqXhr.setRequestHeader('Content-Type', 'application/json');
}
jqXhr.setRequestHeader('Authorization', 'Bearer ' + LocalStorage.getJWT());
const csrfCookie = window.cookieStore.get('_gorilla_csrf');
jqXhr.setRequestHeader('X-CSRF-Token', csrfCookie);
});
}

View file

@ -1,24 +1,16 @@
import { Terminal } from 'xterm';
import * as fit from 'xterm/lib/addons/fit/fit';
import { csrfInterceptor, csrfTokenReaderInterceptorAngular } from './portainer/services/csrf';
import { agentInterceptor } from './portainer/services/axios';
/* @ngInject */
export function configApp($urlRouterProvider, $httpProvider, localStorageServiceProvider, jwtOptionsProvider, $uibTooltipProvider, $compileProvider, cfpLoadingBarProvider) {
export function configApp($urlRouterProvider, $httpProvider, localStorageServiceProvider, $uibTooltipProvider, $compileProvider, cfpLoadingBarProvider) {
if (process.env.NODE_ENV === 'testing') {
$compileProvider.debugInfoEnabled(false);
}
localStorageServiceProvider.setPrefix('portainer');
jwtOptionsProvider.config({
tokenGetter: /* @ngInject */ function tokenGetter(LocalStorage) {
return LocalStorage.getJWT();
},
whiteListedDomains: ['localhost'],
});
$httpProvider.interceptors.push('jwtInterceptor');
$httpProvider.defaults.headers.post['Content-Type'] = 'application/json';
$httpProvider.defaults.headers.put['Content-Type'] = 'application/json';
$httpProvider.defaults.headers.patch['Content-Type'] = 'application/json';
@ -27,6 +19,11 @@ export function configApp($urlRouterProvider, $httpProvider, localStorageService
request: agentInterceptor,
}));
$httpProvider.interceptors.push(() => ({
response: csrfTokenReaderInterceptorAngular,
request: csrfInterceptor,
}));
Terminal.applyAddon(fit);
$uibTooltipProvider.setTriggers({

View file

@ -168,8 +168,6 @@ angular.module('portainer.docker').controller('ContainerConsoleController', [
url += '&nodeName=' + $transition$.params().nodeName;
}
url += '&token=' + LocalStorage.getJWT();
if (url.indexOf('https') > -1) {
url = url.replace('https://', 'wss://');
} else {

View file

@ -34,7 +34,6 @@ angular
'ngResource',
'angularUtils.directives.dirPagination',
'LocalStorageModule',
'angular-jwt',
'angular-json-tree',
'angular-loading-bar',
'angular-clipboard',

View file

@ -1,5 +1,3 @@
import _ from 'lodash-es';
import featureFlagModule from '@/react/portainer/feature-flags';
import './rbac';
@ -12,35 +10,8 @@ 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) {
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);
});
async function initAuthentication(Authentication) {
return await Authentication.init();
}
@ -65,14 +36,14 @@ angular
var root = {
name: 'root',
abstract: true,
onEnter: /* @ngInject */ function onEnter($async, StateManager, Authentication, Notifications, authManager, $rootScope, $state) {
onEnter: /* @ngInject */ function onEnter($async, StateManager, Authentication, Notifications, $state) {
return $async(async () => {
const appState = StateManager.getState();
if (!appState.loading) {
return;
}
try {
const loggedIn = await initAuthentication(authManager, Authentication, $rootScope, $state);
const loggedIn = await initAuthentication(Authentication);
await StateManager.initialize();
if (!loggedIn && isTransitionRequiresAuthentication($state.transition)) {
$state.go('portainer.logout');

View file

@ -1,3 +1,4 @@
import { getCurrentUser } from '../users/queries/useLoadCurrentUser';
import { clear as clearSessionStorage } from './session-storage';
const DEFAULT_USER = 'admin';
@ -7,13 +8,11 @@ angular.module('portainer.app').factory('Authentication', [
'$async',
'Auth',
'OAuth',
'jwtHelper',
'LocalStorage',
'StateManager',
'EndpointProvider',
'UserService',
'ThemeManager',
function AuthenticationFactory($async, Auth, OAuth, jwtHelper, LocalStorage, StateManager, EndpointProvider, UserService, ThemeManager) {
function AuthenticationFactory($async, Auth, OAuth, LocalStorage, StateManager, EndpointProvider, ThemeManager) {
'use strict';
var service = {};
@ -29,11 +28,12 @@ angular.module('portainer.app').factory('Authentication', [
async function initAsync() {
try {
const jwt = LocalStorage.getJWT();
if (!jwt || jwtHelper.isTokenExpired(jwt)) {
return tryAutoLoginExtension();
const userId = LocalStorage.getUserId();
if (userId && user.ID === userId) {
return true;
}
await setUser(jwt);
await tryAutoLoginExtension();
await loadUserData();
return true;
} catch (error) {
return tryAutoLoginExtension();
@ -62,16 +62,8 @@ angular.module('portainer.app').factory('Authentication', [
}
async function OAuthLoginAsync(code) {
const response = await OAuth.validate({ code: code }).$promise;
const jwt = setJWTFromResponse(response);
await setUser(jwt);
}
function setJWTFromResponse(response) {
const jwt = response.jwt;
LocalStorage.storeJWT(jwt);
return response.jwt;
await OAuth.validate({ code: code }).$promise;
await loadUserData();
}
function OAuthLogin(code) {
@ -79,9 +71,8 @@ angular.module('portainer.app').factory('Authentication', [
}
async function loginAsync(username, password) {
const response = await Auth.login({ username: username, password: password }).$promise;
const jwt = setJWTFromResponse(response);
await setUser(jwt);
await Auth.login({ username: username, password: password }).$promise;
await loadUserData();
}
function login(username, password) {
@ -89,33 +80,31 @@ angular.module('portainer.app').factory('Authentication', [
}
function isAuthenticated() {
var jwt = LocalStorage.getJWT();
return !!jwt && !jwtHelper.isTokenExpired(jwt);
return !!user.ID;
}
function getUserDetails() {
return user;
}
async function setUserTheme() {
const data = await UserService.user(user.ID);
async function loadUserData() {
const userData = await getCurrentUser();
user.username = userData.Username;
user.ID = userData.Id;
user.role = userData.Role;
user.forceChangePassword = userData.forceChangePassword;
user.endpointAuthorizations = userData.EndpointAuthorizations;
user.portainerAuthorizations = userData.PortainerAuthorizations;
// Initialize user theme base on UserTheme from database
const userTheme = data.ThemeSettings ? data.ThemeSettings.color : 'auto';
const userTheme = userData.ThemeSettings ? userData.ThemeSettings.color : 'auto';
if (userTheme === 'auto' || !userTheme) {
ThemeManager.autoTheme();
} else {
ThemeManager.setTheme(userTheme);
}
}
async function setUser(jwt) {
var tokenPayload = jwtHelper.decodeToken(jwt);
user.username = tokenPayload.username;
user.ID = tokenPayload.id;
user.role = tokenPayload.role;
user.forceChangePassword = tokenPayload.forceChangePassword;
await setUserTheme();
LocalStorage.storeUserId(userData.Id);
}
function tryAutoLoginExtension() {

View file

@ -3,7 +3,6 @@ import { loadProgressBar } from 'axios-progress-bar';
import 'axios-progress-bar/dist/nprogress.css';
import PortainerError from '@/portainer/error';
import { get as localStorageGet } from '@/react/hooks/useLocalStorage';
import {
portainerAgentManagerOperation,
@ -16,17 +15,6 @@ loadProgressBar(undefined, axios);
export default axios;
axios.interceptors.request.use(async (config) => {
const newConfig = { headers: config.headers || {}, ...config };
const jwt = localStorageGet('JWT', '');
if (jwt) {
newConfig.headers.Authorization = `Bearer ${jwt}`;
}
return newConfig;
});
export const agentTargetHeader = 'X-PortainerAgent-Target';
export function agentInterceptor(config: AxiosRequestConfig) {
@ -49,7 +37,33 @@ export function agentInterceptor(config: AxiosRequestConfig) {
axios.interceptors.request.use(agentInterceptor);
export const AXIOS_UNAUTHENTICATED = '__axios__unauthenticated__';
axios.interceptors.response.use(undefined, (error) => {
if (
error.response?.status === 401 &&
!error.config.url.includes('/v2/') &&
!error.config.url.includes('/api/v4/') &&
isTransitionRequiresAuthentication()
) {
// eslint-disable-next-line no-console
console.error('Unauthorized request, logging out');
window.location.hash = '/logout';
window.location.reload();
}
return Promise.reject(error);
});
const UNAUTHENTICATED_ROUTES = [
'/logout',
'/internal-auth',
'/auth',
'/init/admin',
];
function isTransitionRequiresAuthentication() {
return !UNAUTHENTICATED_ROUTES.some((route) =>
window.location.hash.includes(route)
);
}
/**
* Parses an Axios error and returns a PortainerError.
@ -74,16 +88,6 @@ 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

@ -0,0 +1,37 @@
import { AxiosRequestConfig, AxiosResponse } from 'axios';
import { IHttpResponse } from 'angular';
import axios from './axios';
axios.interceptors.response.use(csrfTokenReaderInterceptor);
axios.interceptors.request.use(csrfInterceptor);
let csrfToken: string | null = null;
export function csrfTokenReaderInterceptor(config: AxiosResponse) {
const csrfTokenHeader = config.headers['x-csrf-token'];
if (csrfTokenHeader) {
csrfToken = csrfTokenHeader;
}
return config;
}
export function csrfTokenReaderInterceptorAngular(
config: IHttpResponse<unknown>
) {
const csrfTokenHeader = config.headers('x-csrf-token');
if (csrfTokenHeader) {
csrfToken = csrfTokenHeader;
}
return config;
}
export function csrfInterceptor(config: AxiosRequestConfig) {
if (!csrfToken) {
return config;
}
const newConfig = { headers: config.headers || {}, ...config };
newConfig.headers['X-CSRF-Token'] = csrfToken;
return newConfig;
}

View file

@ -29,14 +29,14 @@ angular.module('portainer.app').factory('LocalStorage', [
getUIState: function () {
return localStorageService.get('UI_STATE');
},
storeJWT: function (jwt) {
localStorageService.set('JWT', jwt);
getUserId() {
localStorageService.get('USER_ID');
},
getJWT: function () {
return localStorageService.get('JWT');
storeUserId: function (userId) {
localStorageService.set('USER_ID', userId);
},
deleteJWT: function () {
localStorageService.remove('JWT');
deleteUserId: function () {
localStorageService.remove('USER_ID');
},
storePaginationLimit: function (key, count) {
localStorageService.set('datatable_pagination_' + key, count);
@ -119,7 +119,7 @@ angular.module('portainer.app').factory('LocalStorage', [
localStorageService.clearAll();
},
cleanAuthData() {
localStorageService.remove('JWT', 'APPLICATION_STATE', 'LOGIN_STATE_UUID', 'ALLOWED_NAMESPACES');
localStorageService.remove('USER_ID', 'APPLICATION_STATE', 'LOGIN_STATE_UUID', 'ALLOWED_NAMESPACES');
},
storeKubernetesSummaryToggle(value) {
localStorageService.set('kubernetes_summary_expanded', value);

View file

@ -1,7 +1,6 @@
import _ from 'lodash';
import toastr from 'toastr';
import sanitize from 'sanitize-html';
import jwtDecode from 'jwt-decode';
import { v4 as uuid } from 'uuid';
import { get as localStorageGet } from '@/react/hooks/useLocalStorage';
@ -109,11 +108,8 @@ function saveNotification(title: string, text: string, type: string) {
type,
timeStamp: new Date(),
};
const jwt = localStorageGet('JWT', '');
if (jwt !== '') {
const { id } = jwtDecode(jwt) as { id: number };
if (id) {
addNotification(id, notif);
}
const userId = localStorageGet('USER_ID', '');
if (userId !== '') {
addNotification(userId, notif);
}
}

View file

@ -3,4 +3,5 @@ import { UserId } from '../types';
export const queryKeys = {
base: () => ['users'] as const,
user: (id: UserId) => [...queryKeys.base(), id] as const,
me: () => [...queryKeys.base(), 'me'] as const,
};

View file

@ -0,0 +1,32 @@
import { useQuery } from 'react-query';
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { withError } from '@/react-tools/react-query';
import { buildUrl } from '../user.service';
import { User } from '../types';
import { queryKeys } from './queryKeys';
interface CurrentUserResponse extends User {
forceChangePassword: boolean;
}
export function useLoadCurrentUser({ staleTime }: { staleTime?: number } = {}) {
return useQuery(queryKeys.me(), () => getCurrentUser(), {
...withError('Unable to retrieve user details'),
staleTime,
});
}
export async function getCurrentUser() {
try {
const { data: user } = await axios.get<CurrentUserResponse>(
buildUrl(undefined, 'me')
);
return user;
} catch (e) {
throw parseAxiosError(e as Error, 'Unable to retrieve user details');
}
}

View file

@ -1,4 +1,3 @@
import jwtDecode from 'jwt-decode';
import { useCurrentStateAndParams } from '@uirouter/react';
import {
createContext,
@ -11,9 +10,7 @@ import {
import { isAdmin } from '@/portainer/users/user.helpers';
import { EnvironmentId } from '@/react/portainer/environments/types';
import { User } from '@/portainer/users/types';
import { useUser as useLoadUser } from '@/portainer/users/queries/useUser';
import { useLocalStorage } from './useLocalStorage';
import { useLoadCurrentUser } from '@/portainer/users/queries/useLoadCurrentUser';
interface State {
user?: User;
@ -163,20 +160,14 @@ interface UserProviderProps {
}
export function UserProvider({ children }: UserProviderProps) {
const [jwt] = useLocalStorage('JWT', '');
const tokenPayload = useMemo(() => jwtDecode(jwt) as { id: number }, [jwt]);
const userQuery = useLoadUser(tokenPayload.id, {
staleTime: Infinity, // should reload te user details only on page load
});
const userQuery = useLoadCurrentUser();
const providerState = useMemo(
() => ({ user: userQuery.data }),
[userQuery.data]
);
if (jwt === '' || !providerState.user) {
if (!providerState.user) {
return null;
}

View file

@ -3,7 +3,6 @@ import { useCurrentStateAndParams } from '@uirouter/react';
import { Terminal as TerminalIcon } from 'lucide-react';
import { Terminal } from 'xterm';
import { get } from '@/react/hooks/useLocalStorage';
import { baseHref } from '@/portainer/helpers/pathHelper';
import { notifyError } from '@/portainer/services/notifications';
@ -169,10 +168,7 @@ export function ConsoleView() {
);
function connectConsole() {
const jwtToken = get('JWT', '');
const params: StringDictionary = {
token: jwtToken,
endpointId: environmentId,
namespace,
podName: podID,

View file

@ -11,7 +11,6 @@ import {
} from '@/portainer/services/terminal-window';
import { EnvironmentId } from '@/react/portainer/environments/types';
import { error as notifyError } from '@/portainer/services/notifications';
import { useLocalStorage } from '@/react/hooks/useLocalStorage';
import { Icon } from '@@/Icon';
import { Button } from '@@/buttons';
@ -40,8 +39,6 @@ export function KubeCtlShell({ environmentId, onClose }: Props) {
const terminalElem = useRef(null);
const [jwt] = useLocalStorage('JWT', '');
const handleClose = useCallback(() => {
terminalClose(); // only css trick
socket?.close();
@ -103,7 +100,7 @@ export function KubeCtlShell({ environmentId, onClose }: Props) {
// on component load/destroy
useEffect(() => {
const socket = new WebSocket(buildUrl(jwt, environmentId));
const socket = new WebSocket(buildUrl(environmentId));
setShell((shell) => ({ ...shell, socket }));
terminal.onData((data) => socket.send(data));
@ -122,7 +119,7 @@ export function KubeCtlShell({ environmentId, onClose }: Props) {
}
return close;
}, [environmentId, jwt, terminal]);
}, [environmentId, terminal]);
return (
<div className={clsx(styles.root, { [styles.minimized]: shell.minimized })}>
@ -182,9 +179,8 @@ export function KubeCtlShell({ environmentId, onClose }: Props) {
}
}
function buildUrl(jwt: string, environmentId: EnvironmentId) {
function buildUrl(environmentId: EnvironmentId) {
const params = {
token: jwt,
endpointId: environmentId,
};

View file

@ -6,7 +6,6 @@ import 'angular-messages';
import 'angular-resource';
import 'angular-utils-pagination';
import 'angular-local-storage';
import 'angular-jwt';
import 'angular-json-tree';
import 'angular-loading-bar';
import 'angular-clipboard';