mirror of
https://gitee.com/nocobase/nocobase.git
synced 2025-05-05 13:39:24 +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 { Registry } from '@nocobase/utils';
|
||||
import { ACL } from '@nocobase/acl';
|
||||
import { Auth, AuthExtend } from './auth';
|
||||
import { JwtOptions, JwtService } from './base/jwt-service';
|
||||
import { ITokenBlacklistService } from './base/token-blacklist-service';
|
||||
|
@ -25,6 +25,7 @@ export const AuthErrorCode = {
|
||||
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,
|
||||
};
|
||||
|
||||
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 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);
|
||||
if (blocked) {
|
||||
this.ctx.throw(401, {
|
||||
@ -138,14 +151,6 @@ export class BaseAuth extends Auth {
|
||||
}
|
||||
|
||||
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) {
|
||||
this.ctx.throw(401, {
|
||||
message: this.ctx.t('Your session has expired. Please sign in again.', { ns: localeNamespace }),
|
||||
@ -154,20 +159,45 @@ export class BaseAuth extends Auth {
|
||||
}
|
||||
|
||||
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);
|
||||
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 newToken = this.jwt.sign({ userId, roleName, temp, signInTime }, { jwtid: renewedResult.jti, expiresIn });
|
||||
this.ctx.res.setHeader('x-new-token', newToken);
|
||||
return user;
|
||||
} 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 =
|
||||
err instanceof AuthError
|
||||
? { type: err.code, message: err.message }
|
||||
: { message: err.message, type: AuthErrorCode.INVALID_TOKEN };
|
||||
? { code: err.code, message: err.message }
|
||||
: { message: err.message, code: err.code ?? AuthErrorCode.INVALID_TOKEN };
|
||||
|
||||
this.ctx.throw(401, {
|
||||
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,
|
||||
});
|
||||
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 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) {
|
||||
// Escape special characters in basename for use in regex
|
||||
const escapedBasename = basename.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
@ -39,15 +50,29 @@ export function authCheckMiddleware({ app }: { app: Application }) {
|
||||
};
|
||||
const errHandler = (error) => {
|
||||
const newToken = error.response.headers['x-new-token'];
|
||||
|
||||
if (newToken) {
|
||||
app.apiClient.auth.setToken(newToken);
|
||||
}
|
||||
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 firstError = Array.isArray(errors) ? errors[0] : null;
|
||||
if (!firstError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// if the code
|
||||
if (firstError?.code === AuthErrorCode.SKIP_TOKEN_RENEW) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (firstError?.code === 'USER_HAS_NO_ROLES_ERR') {
|
||||
app.error = firstError;
|
||||
throw error;
|
||||
|
@ -12,6 +12,7 @@ import { App as AntdApp } from 'antd';
|
||||
import { useForm } from '@formily/react';
|
||||
import { createForm } from '@formily/core';
|
||||
import { useAPIClient } from '@nocobase/client';
|
||||
import ms from 'ms';
|
||||
import { tokenPolicyCollectionName, tokenPolicyRecordKey } from '../../../constants';
|
||||
import { useAuthTranslation } from '../../locale';
|
||||
|
||||
@ -41,6 +42,21 @@ export const useSubmitActionProps = () => {
|
||||
return {
|
||||
type: 'primary',
|
||||
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();
|
||||
const res = await apiClient.resource(tokenPolicyCollectionName).update({
|
||||
values: { config: form.values },
|
||||
|
@ -28,7 +28,7 @@ const schema: ISchema & { properties: Properties } = {
|
||||
properties: {
|
||||
sessionExpirationTime: {
|
||||
type: 'string',
|
||||
title: "{{t('Session validity')}}",
|
||||
title: "{{t('Session validity period')}}",
|
||||
'x-decorator': 'FormItem',
|
||||
'x-component': componentsNameMap.InputTime,
|
||||
required: true,
|
||||
|
@ -32,9 +32,9 @@
|
||||
"Password is not allowed to be changed": "Password is not allowed to be changed",
|
||||
"Token policy": "Token policy",
|
||||
"Token validity period": "Token validity period",
|
||||
"Session validity": "Session validity",
|
||||
"Session validity period": "Session validity period",
|
||||
"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",
|
||||
"Minutes": "Minutes",
|
||||
"Hours": "Hours",
|
||||
|
@ -31,10 +31,10 @@
|
||||
"At least one of the username or email fields is required": "至少需要设置用户名或邮箱中的一个字段为必填字段",
|
||||
"Password is not allowed to be changed": "密码不允许修改",
|
||||
"Token policy": "Token 策略",
|
||||
"Token validity period": "Token 有效周期",
|
||||
"Session validity": "会话有效期",
|
||||
"Token validity period": "Token 有效期",
|
||||
"Session validity period": "会话有效期",
|
||||
"Expired token refresh limit": "过期 Token 刷新时限",
|
||||
"Enable operation timeout control": "启用操作超时控制",
|
||||
"Token validity period must be less than session validity period!": "Token 有效期必须小于会话有效期!",
|
||||
"Seconds": "秒",
|
||||
"Minutes": "分钟",
|
||||
"Hours": "小时",
|
||||
|
@ -21,7 +21,6 @@ const InnerMobileTabBarMessageItem = (props) => {
|
||||
};
|
||||
useEffect(() => {
|
||||
startMsgSSEStreamWithRetry();
|
||||
updateUnreadMsgsCount();
|
||||
}, []);
|
||||
const selected = props.url && location.pathname.startsWith(props.url);
|
||||
|
||||
|
@ -39,9 +39,11 @@ export const startMsgSSEStreamWithRetry: () => () => void = () => {
|
||||
|
||||
const clientId = uid();
|
||||
const createMsgSSEConnection = async (clientId: string) => {
|
||||
await updateUnreadMsgsCount();
|
||||
const apiClient = getAPIClient();
|
||||
const res = await apiClient.silent().request({
|
||||
url: 'myInAppMessages:sse',
|
||||
skipAuth: true,
|
||||
method: 'get',
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
|
Loading…
x
Reference in New Issue
Block a user