fix(auth): update client auth middleware logic to prevent token update failure due to concurrency (#6135)

* feat(auth): add logging for token renewal process and new error code for stream requests

* feat(auth): session expiration check everytime

* feat(auth): validate token validity period against session validity period

* fix(auth): correct wording for session validity period in localization file

* fix(auth): update redirect logic to handle specific auth error codes

* fix(auth): correct error response structure for token renewal and stream requests

* fix(auth): add token expiration check to update token status

* fix(notification): add skipAuth option to SSE stream request

* fix(auth): simplify redirect logic in auth check middleware

* fix(auth): update logging to include request headers and enhance error handling in auth middleware
This commit is contained in:
Sheldon Guo 2025-02-06 15:31:18 +08:00 committed by GitHub
parent 5050439686
commit cab54e9ebb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 107 additions and 20 deletions

View File

@ -9,7 +9,6 @@
import { Context, Next } from '@nocobase/actions'; import { Context, Next } from '@nocobase/actions';
import { Registry } from '@nocobase/utils'; import { Registry } from '@nocobase/utils';
import { ACL } from '@nocobase/acl';
import { Auth, AuthExtend } from './auth'; import { Auth, AuthExtend } from './auth';
import { JwtOptions, JwtService } from './base/jwt-service'; import { JwtOptions, JwtService } from './base/jwt-service';
import { ITokenBlacklistService } from './base/token-blacklist-service'; import { ITokenBlacklistService } from './base/token-blacklist-service';

View File

@ -25,6 +25,7 @@ export const AuthErrorCode = {
BLOCKED_TOKEN: 'BLOCKED_TOKEN' as const, BLOCKED_TOKEN: 'BLOCKED_TOKEN' as const,
EXPIRED_SESSION: 'EXPIRED_SESSION' as const, EXPIRED_SESSION: 'EXPIRED_SESSION' as const,
NOT_EXIST_USER: 'NOT_EXIST_USER' as const, NOT_EXIST_USER: 'NOT_EXIST_USER' as const,
SKIP_TOKEN_RENEW: 'SKIP_TOKEN_RENEW' as const,
}; };
export type AuthErrorType = keyof typeof AuthErrorCode; export type AuthErrorType = keyof typeof AuthErrorCode;

View File

@ -100,6 +100,19 @@ export class BaseAuth extends Auth {
const { userId, roleName, iat, temp, jti, exp, signInTime } = payload ?? {}; const { userId, roleName, iat, temp, jti, exp, signInTime } = payload ?? {};
const tokenPolicy = await this.tokenController.getConfig();
if (!signInTime || Date.now() - signInTime > tokenPolicy.sessionExpirationTime) {
this.ctx.throw(401, {
message: this.ctx.t('Your session has expired. Please sign in again.', { ns: localeNamespace }),
code: AuthErrorCode.EXPIRED_SESSION,
});
}
if (tokenStatus === 'valid' && Date.now() - iat * 1000 > tokenPolicy.tokenExpirationTime) {
tokenStatus = 'expired';
}
const blocked = await this.jwt.blacklist.has(jti ?? token); const blocked = await this.jwt.blacklist.has(jti ?? token);
if (blocked) { if (blocked) {
this.ctx.throw(401, { this.ctx.throw(401, {
@ -138,14 +151,6 @@ export class BaseAuth extends Auth {
} }
if (tokenStatus === 'expired') { if (tokenStatus === 'expired') {
const tokenPolicy = await this.tokenController.getConfig();
if (!signInTime || Date.now() - signInTime > tokenPolicy.sessionExpirationTime) {
this.ctx.throw(401, {
message: this.ctx.t('Your session has expired. Please sign in again.', { ns: localeNamespace }),
code: AuthErrorCode.EXPIRED_SESSION,
});
}
if (tokenPolicy.expiredTokenRenewLimit > 0 && Date.now() - exp * 1000 > tokenPolicy.expiredTokenRenewLimit) { if (tokenPolicy.expiredTokenRenewLimit > 0 && Date.now() - exp * 1000 > tokenPolicy.expiredTokenRenewLimit) {
this.ctx.throw(401, { this.ctx.throw(401, {
message: this.ctx.t('Your session has expired. Please sign in again.', { ns: localeNamespace }), message: this.ctx.t('Your session has expired. Please sign in again.', { ns: localeNamespace }),
@ -154,20 +159,45 @@ export class BaseAuth extends Auth {
} }
try { try {
this.ctx.logger.info('token renewing', {
method: 'auth.check',
url: this.ctx.originalUrl,
headers: JSON.stringify(this.ctx?.req?.headers),
});
const isStreamRequest = this.ctx.req.headers['accept'] === 'text/event-stream';
if (isStreamRequest) {
this.ctx.throw(401, {
message: 'Stream api not allow renew token.',
code: AuthErrorCode.SKIP_TOKEN_RENEW,
});
}
const renewedResult = await this.tokenController.renew(jti); const renewedResult = await this.tokenController.renew(jti);
this.ctx.logger.info('token renewed', {
method: 'auth.check',
url: this.ctx.originalUrl,
headers: JSON.stringify(this.ctx?.req?.headers),
});
const expiresIn = Math.floor(tokenPolicy.tokenExpirationTime / 1000); const expiresIn = Math.floor(tokenPolicy.tokenExpirationTime / 1000);
const newToken = this.jwt.sign({ userId, roleName, temp, signInTime }, { jwtid: renewedResult.jti, expiresIn }); const newToken = this.jwt.sign({ userId, roleName, temp, signInTime }, { jwtid: renewedResult.jti, expiresIn });
this.ctx.res.setHeader('x-new-token', newToken); this.ctx.res.setHeader('x-new-token', newToken);
return user; return user;
} catch (err) { } catch (err) {
this.ctx.logger.info('token renew failed', {
method: 'auth.check',
url: this.ctx.originalUrl,
err,
headers: JSON.stringify(this.ctx?.req?.headers),
});
const options = const options =
err instanceof AuthError err instanceof AuthError
? { type: err.code, message: err.message } ? { code: err.code, message: err.message }
: { message: err.message, type: AuthErrorCode.INVALID_TOKEN }; : { message: err.message, code: err.code ?? AuthErrorCode.INVALID_TOKEN };
this.ctx.throw(401, { this.ctx.throw(401, {
message: this.ctx.t(options.message, { ns: localeNamespace }), message: this.ctx.t(options.message, { ns: localeNamespace }),
code: options.type, code: options.code,
}); });
} }
} }

View File

@ -0,0 +1,10 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
export { AuthErrorCode } from './auth';

View File

@ -90,7 +90,12 @@ export class PluginAuthClient extends Plugin {
sort: 0, sort: 0,
}); });
const [fulfilled, rejected] = authCheckMiddleware({ app: this.app }); const [fulfilled, rejected] = authCheckMiddleware({ app: this.app });
this.app.apiClient.axios.interceptors.response.use(fulfilled, rejected); this.app.apiClient.axios.interceptors.response['handlers'].unshift({
fulfilled,
rejected,
synchronous: false,
runWhen: null,
});
} }
} }

View File

@ -11,6 +11,17 @@ import { Application } from '@nocobase/client';
import type { AxiosResponse } from 'axios'; import type { AxiosResponse } from 'axios';
import debounce from 'lodash/debounce'; import debounce from 'lodash/debounce';
const AuthErrorCode = {
EMPTY_TOKEN: 'EMPTY_TOKEN' as const,
EXPIRED_TOKEN: 'EXPIRED_TOKEN' as const,
INVALID_TOKEN: 'INVALID_TOKEN' as const,
TOKEN_RENEW_FAILED: 'TOKEN_RENEW_FAILED' as const,
BLOCKED_TOKEN: 'BLOCKED_TOKEN' as const,
EXPIRED_SESSION: 'EXPIRED_SESSION' as const,
NOT_EXIST_USER: 'NOT_EXIST_USER' as const,
SKIP_TOKEN_RENEW: 'SKIP_TOKEN_RENEW' as const,
};
function removeBasename(pathname, basename) { function removeBasename(pathname, basename) {
// Escape special characters in basename for use in regex // Escape special characters in basename for use in regex
const escapedBasename = basename.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const escapedBasename = basename.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
@ -39,15 +50,29 @@ export function authCheckMiddleware({ app }: { app: Application }) {
}; };
const errHandler = (error) => { const errHandler = (error) => {
const newToken = error.response.headers['x-new-token']; const newToken = error.response.headers['x-new-token'];
if (newToken) { if (newToken) {
app.apiClient.auth.setToken(newToken); app.apiClient.auth.setToken(newToken);
} }
if (error.status === 401 && !error.config?.skipAuth) { if (error.status === 401 && !error.config?.skipAuth) {
const requestToken = error?.config?.headers.Authorization?.replace(/^Bearer\s+/gi, '');
const currentToken = app.apiClient.auth.getToken();
if (currentToken && currentToken !== requestToken) {
error.config.skipNotify = true;
return app.apiClient.request(error.config);
}
app.apiClient.auth.setToken('');
const errors = error?.response?.data?.errors; const errors = error?.response?.data?.errors;
const firstError = Array.isArray(errors) ? errors[0] : null; const firstError = Array.isArray(errors) ? errors[0] : null;
if (!firstError) { if (!firstError) {
throw error; throw error;
} }
// if the code
if (firstError?.code === AuthErrorCode.SKIP_TOKEN_RENEW) {
throw error;
}
if (firstError?.code === 'USER_HAS_NO_ROLES_ERR') { if (firstError?.code === 'USER_HAS_NO_ROLES_ERR') {
app.error = firstError; app.error = firstError;
throw error; throw error;

View File

@ -12,6 +12,7 @@ import { App as AntdApp } from 'antd';
import { useForm } from '@formily/react'; import { useForm } from '@formily/react';
import { createForm } from '@formily/core'; import { createForm } from '@formily/core';
import { useAPIClient } from '@nocobase/client'; import { useAPIClient } from '@nocobase/client';
import ms from 'ms';
import { tokenPolicyCollectionName, tokenPolicyRecordKey } from '../../../constants'; import { tokenPolicyCollectionName, tokenPolicyRecordKey } from '../../../constants';
import { useAuthTranslation } from '../../locale'; import { useAuthTranslation } from '../../locale';
@ -41,6 +42,21 @@ export const useSubmitActionProps = () => {
return { return {
type: 'primary', type: 'primary',
async onClick() { async onClick() {
form.clearErrors('*');
const { tokenExpirationTime, sessionExpirationTime, expiredTokenRenewLimit } = form.values;
if (ms(tokenExpirationTime) >= ms(sessionExpirationTime)) {
form.setFieldState('tokenExpirationTime', (state) => {
state.feedbacks = [
{
type: 'error',
code: 'ValidateError',
messages: [t('Token validity period must be less than session validity period!')],
},
];
});
return;
}
await form.submit(); await form.submit();
const res = await apiClient.resource(tokenPolicyCollectionName).update({ const res = await apiClient.resource(tokenPolicyCollectionName).update({
values: { config: form.values }, values: { config: form.values },

View File

@ -28,7 +28,7 @@ const schema: ISchema & { properties: Properties } = {
properties: { properties: {
sessionExpirationTime: { sessionExpirationTime: {
type: 'string', type: 'string',
title: "{{t('Session validity')}}", title: "{{t('Session validity period')}}",
'x-decorator': 'FormItem', 'x-decorator': 'FormItem',
'x-component': componentsNameMap.InputTime, 'x-component': componentsNameMap.InputTime,
required: true, required: true,

View File

@ -32,9 +32,9 @@
"Password is not allowed to be changed": "Password is not allowed to be changed", "Password is not allowed to be changed": "Password is not allowed to be changed",
"Token policy": "Token policy", "Token policy": "Token policy",
"Token validity period": "Token validity period", "Token validity period": "Token validity period",
"Session validity": "Session validity", "Session validity period": "Session validity period",
"Expired token refresh limit": "Expired token refresh limit", "Expired token refresh limit": "Expired token refresh limit",
"Enable operation timeout control": "Enable operation timeout control", "Token validity period must be less than session validity period!": "Token validity period must be less than session validity period!",
"Seconds": "Seconds", "Seconds": "Seconds",
"Minutes": "Minutes", "Minutes": "Minutes",
"Hours": "Hours", "Hours": "Hours",

View File

@ -31,10 +31,10 @@
"At least one of the username or email fields is required": "至少需要设置用户名或邮箱中的一个字段为必填字段", "At least one of the username or email fields is required": "至少需要设置用户名或邮箱中的一个字段为必填字段",
"Password is not allowed to be changed": "密码不允许修改", "Password is not allowed to be changed": "密码不允许修改",
"Token policy": "Token 策略", "Token policy": "Token 策略",
"Token validity period": "Token 有效期", "Token validity period": "Token 有效期",
"Session validity": "会话有效期", "Session validity period": "会话有效期",
"Expired token refresh limit": "过期 Token 刷新时限", "Expired token refresh limit": "过期 Token 刷新时限",
"Enable operation timeout control": "启用操作超时控制", "Token validity period must be less than session validity period!": "Token 有效期必须小于会话有效期!",
"Seconds": "秒", "Seconds": "秒",
"Minutes": "分钟", "Minutes": "分钟",
"Hours": "小时", "Hours": "小时",

View File

@ -21,7 +21,6 @@ const InnerMobileTabBarMessageItem = (props) => {
}; };
useEffect(() => { useEffect(() => {
startMsgSSEStreamWithRetry(); startMsgSSEStreamWithRetry();
updateUnreadMsgsCount();
}, []); }, []);
const selected = props.url && location.pathname.startsWith(props.url); const selected = props.url && location.pathname.startsWith(props.url);

View File

@ -39,9 +39,11 @@ export const startMsgSSEStreamWithRetry: () => () => void = () => {
const clientId = uid(); const clientId = uid();
const createMsgSSEConnection = async (clientId: string) => { const createMsgSSEConnection = async (clientId: string) => {
await updateUnreadMsgsCount();
const apiClient = getAPIClient(); const apiClient = getAPIClient();
const res = await apiClient.silent().request({ const res = await apiClient.silent().request({
url: 'myInAppMessages:sse', url: 'myInAppMessages:sse',
skipAuth: true,
method: 'get', method: 'get',
signal: controller.signal, signal: controller.signal,
headers: { headers: {