diff --git a/packages/core/auth/src/auth-manager.ts b/packages/core/auth/src/auth-manager.ts index 4d17487dd7..3bc0a6a168 100644 --- a/packages/core/auth/src/auth-manager.ts +++ b/packages/core/auth/src/auth-manager.ts @@ -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'; diff --git a/packages/core/auth/src/auth.ts b/packages/core/auth/src/auth.ts index 19130c374e..901c599e86 100644 --- a/packages/core/auth/src/auth.ts +++ b/packages/core/auth/src/auth.ts @@ -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; diff --git a/packages/core/auth/src/base/auth.ts b/packages/core/auth/src/base/auth.ts index b158fc8c2c..4284bfa6eb 100644 --- a/packages/core/auth/src/base/auth.ts +++ b/packages/core/auth/src/base/auth.ts @@ -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, }); } } diff --git a/packages/core/auth/src/client.ts b/packages/core/auth/src/client.ts new file mode 100644 index 0000000000..f897fea0b7 --- /dev/null +++ b/packages/core/auth/src/client.ts @@ -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'; diff --git a/packages/plugins/@nocobase/plugin-auth/src/client/index.tsx b/packages/plugins/@nocobase/plugin-auth/src/client/index.tsx index 7aa0faa643..cc1bdcbcac 100644 --- a/packages/plugins/@nocobase/plugin-auth/src/client/index.tsx +++ b/packages/plugins/@nocobase/plugin-auth/src/client/index.tsx @@ -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, + }); } } diff --git a/packages/plugins/@nocobase/plugin-auth/src/client/interceptors.ts b/packages/plugins/@nocobase/plugin-auth/src/client/interceptors.ts index 8c5e903b85..afb6d24fcd 100644 --- a/packages/plugins/@nocobase/plugin-auth/src/client/interceptors.ts +++ b/packages/plugins/@nocobase/plugin-auth/src/client/interceptors.ts @@ -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; diff --git a/packages/plugins/@nocobase/plugin-auth/src/client/settings/token-policy/hooks.ts b/packages/plugins/@nocobase/plugin-auth/src/client/settings/token-policy/hooks.ts index 6b06e36730..8ccafd15ec 100644 --- a/packages/plugins/@nocobase/plugin-auth/src/client/settings/token-policy/hooks.ts +++ b/packages/plugins/@nocobase/plugin-auth/src/client/settings/token-policy/hooks.ts @@ -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 }, diff --git a/packages/plugins/@nocobase/plugin-auth/src/client/settings/token-policy/index.tsx b/packages/plugins/@nocobase/plugin-auth/src/client/settings/token-policy/index.tsx index 27626484a3..648814ccc7 100644 --- a/packages/plugins/@nocobase/plugin-auth/src/client/settings/token-policy/index.tsx +++ b/packages/plugins/@nocobase/plugin-auth/src/client/settings/token-policy/index.tsx @@ -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, diff --git a/packages/plugins/@nocobase/plugin-auth/src/locale/en-US.json b/packages/plugins/@nocobase/plugin-auth/src/locale/en-US.json index 400578a25e..3f89a6f86a 100644 --- a/packages/plugins/@nocobase/plugin-auth/src/locale/en-US.json +++ b/packages/plugins/@nocobase/plugin-auth/src/locale/en-US.json @@ -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", diff --git a/packages/plugins/@nocobase/plugin-auth/src/locale/zh-CN.json b/packages/plugins/@nocobase/plugin-auth/src/locale/zh-CN.json index f1a7a0f165..db768abd74 100644 --- a/packages/plugins/@nocobase/plugin-auth/src/locale/zh-CN.json +++ b/packages/plugins/@nocobase/plugin-auth/src/locale/zh-CN.json @@ -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": "小时", diff --git a/packages/plugins/@nocobase/plugin-notification-in-app-message/src/client/components/mobile/MobileTabBarMessageItem.tsx b/packages/plugins/@nocobase/plugin-notification-in-app-message/src/client/components/mobile/MobileTabBarMessageItem.tsx index 2ab7d05a37..8531d4a1aa 100644 --- a/packages/plugins/@nocobase/plugin-notification-in-app-message/src/client/components/mobile/MobileTabBarMessageItem.tsx +++ b/packages/plugins/@nocobase/plugin-notification-in-app-message/src/client/components/mobile/MobileTabBarMessageItem.tsx @@ -21,7 +21,6 @@ const InnerMobileTabBarMessageItem = (props) => { }; useEffect(() => { startMsgSSEStreamWithRetry(); - updateUnreadMsgsCount(); }, []); const selected = props.url && location.pathname.startsWith(props.url); diff --git a/packages/plugins/@nocobase/plugin-notification-in-app-message/src/client/observables/sse.ts b/packages/plugins/@nocobase/plugin-notification-in-app-message/src/client/observables/sse.ts index 17de66ae95..ce0329758f 100644 --- a/packages/plugins/@nocobase/plugin-notification-in-app-message/src/client/observables/sse.ts +++ b/packages/plugins/@nocobase/plugin-notification-in-app-message/src/client/observables/sse.ts @@ -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: {