mirror of
https://gitee.com/nocobase/nocobase.git
synced 2025-05-05 21:49:25 +08:00
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:
parent
5050439686
commit
cab54e9ebb
@ -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';
|
||||||
|
@ -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;
|
||||||
|
@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
10
packages/core/auth/src/client.ts
Normal file
10
packages/core/auth/src/client.ts
Normal 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';
|
@ -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,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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;
|
||||||
|
@ -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 },
|
||||||
|
@ -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,
|
||||||
|
@ -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",
|
||||||
|
@ -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": "小时",
|
||||||
|
@ -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);
|
||||||
|
|
||||||
|
@ -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: {
|
||||||
|
Loading…
x
Reference in New Issue
Block a user