From 10d21bcc739630b702e4bb300a002fc96b39db78 Mon Sep 17 00:00:00 2001 From: shortlight5980 Date: Wed, 15 Jul 2026 11:57:09 +0800 Subject: [PATCH 01/10] account verification # Conflicts: # pro --- packages/global/common/system/types/index.ts | 1 + .../openapi/support/user/account/index.ts | 4 +- .../openapi/support/user/account/login/api.ts | 120 ++++++++++---- .../support/user/account/login/index.ts | 26 +++ .../support/user/account/password/api.ts | 17 +- .../support/user/account/register/api.ts | 9 +- .../support/user/account/verification/api.ts | 59 +++++++ .../user/account/verification/index.ts | 55 ++++++ .../user/account/verification/constants.ts | 40 +++++ .../support/user/account/verification/type.ts | 66 ++++++++ .../user/account/verification/utils.ts | 107 ++++++++++++ .../global/support/user/auth/constants.ts | 6 +- .../account/verification/oauthApi.test.ts | 86 ++++++++++ .../user/account/verification/utils.test.ts | 118 +++++++++++++ .../user/account/verification/entity.ts | 148 +++++++++++++++++ .../user/account/verification/index.ts | 5 + .../account/verification/password/service.ts | 91 ++++++++++ .../user/account/verification/schema.ts | 49 ++++++ .../user/account/verification/service.ts | 39 +++++ .../user/account/verification/utils.ts | 15 ++ .../user/account/verification/entity.test.ts | 156 ++++++++++++++++++ .../verification/password/service.test.ts | 82 +++++++++ .../user/account/verification/utils.test.ts | 25 +++ pnpm-lock.yaml | 12 ++ pnpm-workspace.yaml | 1 + pro | 2 +- .../user/inform/UpdateContactModal.tsx | 3 +- .../login/ForgetPasswordForm.tsx | 17 +- .../login/LoginForm/FormLayout.tsx | 124 ++++++-------- .../login/LoginForm/WechatForm.tsx | 29 +++- .../src/pageComponents/login/RegisterForm.tsx | 17 +- .../support/user/account/loginByPassword.ts | 86 ++-------- .../api/support/user/account/preLogin.ts | 39 ++--- projects/app/src/pages/login/provider.tsx | 80 ++++++--- .../src/service/support/user/login/service.ts | 69 ++++++++ .../src/web/common/system/useSystemStore.ts | 10 +- projects/app/src/web/support/user/api.ts | 26 +-- .../web/support/user/hooks/useSendCode.tsx | 4 +- .../user/account/loginByPassword.test.ts | 33 ++-- .../support/user/login/service.test.ts | 86 ++++++++++ 40 files changed, 1655 insertions(+), 307 deletions(-) create mode 100644 packages/global/openapi/support/user/account/verification/api.ts create mode 100644 packages/global/openapi/support/user/account/verification/index.ts create mode 100644 packages/global/support/user/account/verification/constants.ts create mode 100644 packages/global/support/user/account/verification/type.ts create mode 100644 packages/global/support/user/account/verification/utils.ts create mode 100644 packages/global/test/support/user/account/verification/oauthApi.test.ts create mode 100644 packages/global/test/support/user/account/verification/utils.test.ts create mode 100644 packages/service/support/user/account/verification/entity.ts create mode 100644 packages/service/support/user/account/verification/index.ts create mode 100644 packages/service/support/user/account/verification/password/service.ts create mode 100644 packages/service/support/user/account/verification/schema.ts create mode 100644 packages/service/support/user/account/verification/service.ts create mode 100644 packages/service/support/user/account/verification/utils.ts create mode 100644 packages/service/test/support/user/account/verification/entity.test.ts create mode 100644 packages/service/test/support/user/account/verification/password/service.test.ts create mode 100644 packages/service/test/support/user/account/verification/utils.test.ts create mode 100644 projects/app/src/service/support/user/login/service.ts create mode 100644 projects/app/test/service/support/user/login/service.test.ts diff --git a/packages/global/common/system/types/index.ts b/packages/global/common/system/types/index.ts index 71b4a3ba4812..7512015725d1 100644 --- a/packages/global/common/system/types/index.ts +++ b/packages/global/common/system/types/index.ts @@ -110,6 +110,7 @@ export type FastGPTFeConfigsType = { url?: string; autoLogin?: boolean; }; + oauthVerificationV2?: boolean; oauth?: { github?: string; google?: string; diff --git a/packages/global/openapi/support/user/account/index.ts b/packages/global/openapi/support/user/account/index.ts index b812405f16a5..8d4f533bf969 100644 --- a/packages/global/openapi/support/user/account/index.ts +++ b/packages/global/openapi/support/user/account/index.ts @@ -2,9 +2,11 @@ import type { OpenAPIPath } from '../../../type'; import { LoginPath } from './login'; import { RegisterPath } from './register'; import { PasswordPath } from './password'; +import { AccountVerificationPath } from './verification'; export const UserAccountPath: OpenAPIPath = { ...LoginPath, ...RegisterPath, - ...PasswordPath + ...PasswordPath, + ...AccountVerificationPath }; diff --git a/packages/global/openapi/support/user/account/login/api.ts b/packages/global/openapi/support/user/account/login/api.ts index 36570afb02e4..b1e65818066f 100644 --- a/packages/global/openapi/support/user/account/login/api.ts +++ b/packages/global/openapi/support/user/account/login/api.ts @@ -1,9 +1,9 @@ import { z } from 'zod'; -import { OAuthEnum } from '../../../../../support/user/constant'; import { TrackRegisterParamsSchema } from '../../../../../support/marketing/type'; import { LanguageSchema } from '../../../../../common/i18n/type'; import { UserSchema } from '../../../../../support/user/type'; import { TeamTmbItemSchema } from '../../../../../support/user/team/type'; +import { OAuthAccountVerificationProviderSchema } from '../../../../../support/user/account/verification/type'; const OpenAPITeamTmbItemSchema = TeamTmbItemSchema.omit({ permission: true @@ -35,12 +35,14 @@ export const LoginSuccessResponseSchema = z.object({ export type LoginSuccessResponseType = z.infer; // ===== Pre login - get login verification code ===== -export const PreLoginQuerySchema = z.object({ - username: z.string().meta({ - example: 'admin', - description: '用户名' +export const PreLoginQuerySchema = z + .object({ + username: z.string().meta({ + example: 'admin', + description: '用户名' + }) }) -}); + .strict(); export type PreLoginQueryType = z.infer; export const PreLoginResponseSchema = z @@ -54,7 +56,8 @@ export const PreLoginResponseSchema = z example: { code: 'a1b2c3' } - }); + }) + .strict(); export type PreLoginResponseType = z.infer; // ===== Login by password ===== @@ -75,33 +78,75 @@ export const LoginByPasswordBodySchema = TrackRegisterParamsSchema.extend({ example: 'zh-CN', description: '用户语言偏好' }) -}).meta({ - example: { - username: 'admin', - password: 'hashed_password', - code: '123456', - language: 'zh-CN' - } -}); +}) + .meta({ + example: { + username: 'admin', + password: 'hashed_password', + code: '123456', + language: 'zh-CN' + } + }) + .strict(); export type LoginByPasswordBodyType = z.infer; -/* ===== Wecom Login ===== */ -export const WecomGetRedirectURLBodySchema = z.object({ - redirectUri: z.string(), - state: z.string(), - isWecomWorkTerminal: z.boolean() -}); -export const WecomGetRedirectURLResponseSchema = z.string(); -export type WecomGetRedirectURLBodyType = z.infer; -export type WecomGetRedirectURLResponseType = z.infer; +// ===== OAuth Login V2 ===== +export const CreateOauthLoginBodySchema = z + .object({ + provider: OAuthAccountVerificationProviderSchema.meta({ description: 'OAuth Provider' }), + callbackUrl: z.url().max(2048).meta({ description: '登录回调 URL' }), + isWecomWorkTerminal: z.boolean().optional().default(false).meta({ + description: '是否在企业微信工作台内发起登录' + }) + }) + .strict(); +export type CreateOauthLoginBodyType = z.infer; + +export const CreateOauthLoginResponseSchema = z + .object({ + state: z.string().min(32).max(128).meta({ description: '服务端生成的一次性 OAuth state' }), + url: z.url().meta({ description: 'Provider 授权地址' }) + }) + .strict(); +export type CreateOauthLoginResponseType = z.infer; + +const reservedOAuthCallbackProps = new Set(['method', 'username', 'state', 'code', 'callbackUrl']); + +export const OAuthCallbackPropsSchema = z + .record( + z + .string() + .regex(/^[A-Za-z0-9_.-]+$/) + .max(64), + z.string().max(4096) + ) + .superRefine((value, context) => { + const keys = Object.keys(value); + if (keys.length > 20) { + context.addIssue({ + code: 'custom', + message: 'OAuth callback props cannot contain more than 20 fields' + }); + } + for (const key of keys) { + if (reservedOAuthCallbackProps.has(key)) { + context.addIssue({ + code: 'custom', + path: [key], + message: 'OAuth callback props contain a reserved field' + }); + } + } + }); -// ===== OAuth Login ===== export const OauthLoginBodySchema = TrackRegisterParamsSchema.extend({ - type: z.enum(OAuthEnum).meta({ description: 'OAuth 登录类型' }), - callbackUrl: z.string().meta({ description: '回调 URL' }), - props: z.record(z.string(), z.string()).meta({ description: '附加属性' }), + provider: OAuthAccountVerificationProviderSchema.meta({ description: 'OAuth Provider' }), + callbackUrl: z.url().max(2048).meta({ description: '登录回调 URL' }), + state: z.string().min(32).max(128).meta({ description: '服务端生成的一次性 OAuth state' }), + code: z.string().min(1).max(4096).meta({ description: 'Provider 返回的授权 Code' }), + props: OAuthCallbackPropsSchema.optional().meta({ description: 'SSO 回调附加属性' }), language: LanguageSchema.optional().meta({ description: '语言' }) -}); +}).strict(); export type OauthLoginBodyType = z.infer; // ===== Fast Login ===== @@ -109,17 +154,20 @@ export const FastLoginBodySchema = TrackRegisterParamsSchema.extend({ token: z.string().meta({ description: 'Token' }), code: z.string().meta({ description: 'Code' }), language: LanguageSchema.optional().meta({ description: '语言' }) -}); +}).strict(); export type FastLoginBodyType = z.infer; // ===== WeChat Login Result ===== export const WxLoginBodySchema = TrackRegisterParamsSchema.extend({ - code: z.string().meta({ description: '微信登录 Code' }), + code: z.string().min(16).max(128).meta({ description: '微信登录 Code' }), language: LanguageSchema.optional().meta({ description: '语言' }) -}); +}).strict(); export type WxLoginBodyType = z.infer; -export const GetWXLoginQRResponseSchema = z.object({ - code: z.string().meta({ description: '微信登录 Code' }), - codeUrl: z.string().meta({ description: '微信登录二维码 URL' }) -}); +export const GetWXLoginQRResponseSchema = z + .object({ + code: z.string().min(16).max(128).meta({ description: '微信登录 Code' }), + codeUrl: z.url().meta({ description: '微信登录二维码 URL' }), + expiredAt: z.iso.datetime().optional().meta({ description: '二维码业务过期时间' }) + }) + .strict(); export type GetWXLoginQRResponseType = z.infer; diff --git a/packages/global/openapi/support/user/account/login/index.ts b/packages/global/openapi/support/user/account/login/index.ts index f39e7fec7559..22be117b9adc 100644 --- a/packages/global/openapi/support/user/account/login/index.ts +++ b/packages/global/openapi/support/user/account/login/index.ts @@ -5,6 +5,8 @@ import { PreLoginQuerySchema, PreLoginResponseSchema, OauthLoginBodySchema, + CreateOauthLoginBodySchema, + CreateOauthLoginResponseSchema, FastLoginBodySchema, WxLoginBodySchema, GetWXLoginQRResponseSchema, @@ -98,6 +100,30 @@ export const LoginPath: OpenAPIPath = { } } }, + '/proApi/support/user/account/login/oauth/create': { + post: { + summary: '创建 OAuth 登录流程', + description: '由服务端创建一次性 state 并返回 Provider 授权地址', + tags: [DevApiTagsMap.userLogin], + requestBody: { + content: { + 'application/json': { + schema: CreateOauthLoginBodySchema + } + } + }, + responses: { + 200: { + description: 'OAuth 登录流程创建成功', + content: { + 'application/json': { + schema: CreateOauthLoginResponseSchema + } + } + } + } + } + }, '/proApi/support/user/account/login/fastLogin': { post: { summary: '快捷登录', diff --git a/packages/global/openapi/support/user/account/password/api.ts b/packages/global/openapi/support/user/account/password/api.ts index 8d885788047a..ffa95a68bbcd 100644 --- a/packages/global/openapi/support/user/account/password/api.ts +++ b/packages/global/openapi/support/user/account/password/api.ts @@ -1,5 +1,6 @@ import { z } from 'zod'; import { LanguageSchema } from '../../../../../common/i18n/type'; +import { AccountContactUsernameSchema } from '../../../../../support/user/account/verification/type'; // ===== Update password by old password ===== export const UpdatePasswordByOldBodySchema = z @@ -53,12 +54,14 @@ export const ResetExpiredPswResponseSchema = z.undefined().meta({ export type ResetExpiredPswResponseType = z.infer; // ===== Find Password (update by code) ===== -export const UpdatePasswordByCodeBodySchema = z.object({ - username: z.string().trim().min(1).meta({ description: '用户名' }), - code: z.string().meta({ description: '验证码' }), - password: z.string().trim().min(1).meta({ description: '新密码' }), - tmbId: z.string().optional().meta({ description: '团队成员 ID(可选)' }), - language: LanguageSchema.optional().meta({ description: '语言' }) -}); +export const UpdatePasswordByCodeBodySchema = z + .object({ + username: AccountContactUsernameSchema.meta({ description: '用户名(邮箱或手机号)' }), + code: z.string().length(6).meta({ description: '验证码' }), + password: z.string().trim().min(1).max(512).meta({ description: '新密码' }), + tmbId: z.string().optional().meta({ description: '团队成员 ID(可选)' }), + language: LanguageSchema.optional().meta({ description: '语言' }) + }) + .strict(); export type UpdatePasswordByCodeBodyType = z.infer; diff --git a/packages/global/openapi/support/user/account/register/api.ts b/packages/global/openapi/support/user/account/register/api.ts index 60b567839319..b16453bab6c3 100644 --- a/packages/global/openapi/support/user/account/register/api.ts +++ b/packages/global/openapi/support/user/account/register/api.ts @@ -1,13 +1,14 @@ import { z } from 'zod'; import { TrackRegisterParamsSchema } from '../../../../../support/marketing/type'; import { LanguageSchema } from '../../../../../common/i18n/type'; +import { AccountContactUsernameSchema } from '../../../../../support/user/account/verification/type'; // ===== Register by email or phone ===== export const AccountRegisterBodySchema = TrackRegisterParamsSchema.extend({ - username: z.string().meta({ description: '用户名(邮箱或手机号)' }), - code: z.string().meta({ description: '验证码' }), - password: z.string().meta({ description: '密码(已加密)' }), + username: AccountContactUsernameSchema.meta({ description: '用户名(邮箱或手机号)' }), + code: z.string().length(6).meta({ description: '验证码' }), + password: z.string().min(1).max(512).meta({ description: '密码(已加密)' }), language: LanguageSchema.optional().meta({ description: '语言' }) -}); +}).strict(); export type AccountRegisterBodyType = z.infer; diff --git a/packages/global/openapi/support/user/account/verification/api.ts b/packages/global/openapi/support/user/account/verification/api.ts new file mode 100644 index 000000000000..ee58d2ca8888 --- /dev/null +++ b/packages/global/openapi/support/user/account/verification/api.ts @@ -0,0 +1,59 @@ +import { z } from 'zod'; +import { LanguageSchema } from '../../../../../common/i18n/type'; +import { + AccountContactUsernameSchema, + CodeAccountVerificationSceneSchema +} from '../../../../../support/user/account/verification/type'; + +export const GetAccountCaptchaQuerySchema = z + .object({ + username: AccountContactUsernameSchema.meta({ + description: '待验证的邮箱或手机号', + example: 'user@example.com' + }) + }) + .strict(); +export type GetAccountCaptchaQuery = z.infer; + +export const GetAccountCaptchaResponseSchema = z + .object({ + captchaImage: z.string().startsWith('data:image/').meta({ + description: 'Data URL 格式的图片验证码' + }) + }) + .strict(); +export type GetAccountCaptchaResponse = z.infer; + +export const SendAccountVerificationCodeBodySchema = z + .object({ + username: AccountContactUsernameSchema.meta({ + description: '接收验证码的邮箱或手机号', + example: 'user@example.com' + }), + type: CodeAccountVerificationSceneSchema.meta({ + description: '验证码业务场景', + example: 'register' + }), + googleToken: z.string().max(4096).default('').meta({ + description: '部署启用 reCAPTCHA 时由客户端取得的校验 token' + }), + captcha: z.string().min(1).max(64).meta({ + description: '图片验证码答案', + example: 'A1B2C3' + }), + lang: LanguageSchema.meta({ + description: '验证码消息语言', + example: 'zh-CN' + }) + }) + .strict(); +export type SendAccountVerificationCodeBody = z.infer; + +export const SendAccountVerificationCodeResponseSchema = z + .object({ + message: z.string().meta({ description: '发送结果说明' }) + }) + .strict(); +export type SendAccountVerificationCodeResponse = z.infer< + typeof SendAccountVerificationCodeResponseSchema +>; diff --git a/packages/global/openapi/support/user/account/verification/index.ts b/packages/global/openapi/support/user/account/verification/index.ts new file mode 100644 index 000000000000..47c640bfd828 --- /dev/null +++ b/packages/global/openapi/support/user/account/verification/index.ts @@ -0,0 +1,55 @@ +import type { OpenAPIPath } from '../../../../type'; +import { DevApiTagsMap } from '../../../../tag'; +import { + GetAccountCaptchaQuerySchema, + GetAccountCaptchaResponseSchema, + SendAccountVerificationCodeBodySchema, + SendAccountVerificationCodeResponseSchema +} from './api'; + +export const AccountVerificationPath: OpenAPIPath = { + '/proApi/support/user/account/captcha/getImgCaptcha': { + get: { + summary: '获取账号图片验证码', + description: '获取发送邮箱或短信验证码前的人机校验图片', + tags: [DevApiTagsMap.userLogin], + requestParams: { + query: GetAccountCaptchaQuerySchema + }, + responses: { + 200: { + description: '成功创建图片验证码', + content: { + 'application/json': { + schema: GetAccountCaptchaResponseSchema + } + } + } + } + } + }, + '/proApi/support/user/inform/sendAuthCode': { + post: { + summary: '发送账号验证码', + description: '消费图片验证码后,按注册、找回密码或绑定场景发送邮箱/短信验证码', + tags: [DevApiTagsMap.userLogin], + requestBody: { + content: { + 'application/json': { + schema: SendAccountVerificationCodeBodySchema + } + } + }, + responses: { + 200: { + description: '验证码发送成功', + content: { + 'application/json': { + schema: SendAccountVerificationCodeResponseSchema + } + } + } + } + } + } +}; diff --git a/packages/global/support/user/account/verification/constants.ts b/packages/global/support/user/account/verification/constants.ts new file mode 100644 index 000000000000..c139a06cf256 --- /dev/null +++ b/packages/global/support/user/account/verification/constants.ts @@ -0,0 +1,40 @@ +export enum AccountVerificationMaterialTypeEnum { + register = 'register', + findPassword = 'findPassword', + wxLogin = 'wxLogin', + bindNotification = 'bindNotification', + captcha = 'captcha', + login = 'login', + oauthLogin = 'oauthLogin' +} + +export const accountVerificationMethods = [ + 'code', + 'oldPassword', + 'wechat', + 'oauth/github', + 'oauth/google', + 'oauth/microsoft', + 'oauth/wecom', + 'oauth/sso' +] as const; + +export const recognizedAccountKinds = [ + 'email', + 'phone', + 'local', + 'wechat', + 'github', + 'google', + 'microsoft', + 'wecom', + 'sso' +] as const; + +export const oauthAccountVerificationProviders = [ + 'github', + 'google', + 'microsoft', + 'wecom', + 'sso' +] as const; diff --git a/packages/global/support/user/account/verification/type.ts b/packages/global/support/user/account/verification/type.ts new file mode 100644 index 000000000000..6fc3a2eaed77 --- /dev/null +++ b/packages/global/support/user/account/verification/type.ts @@ -0,0 +1,66 @@ +import { z } from 'zod'; +import { + accountVerificationMethods, + oauthAccountVerificationProviders, + recognizedAccountKinds +} from './constants'; + +export const AccountVerificationMethodSchema = z.enum(accountVerificationMethods); +export type AccountVerificationMethod = z.infer; + +export const AccountEmailUsernameSchema = z.email().max(254); +export const AccountPhoneUsernameSchema = z.string().regex(/^1[3456789]\d{9}$/); +export const AccountContactUsernameSchema = z.union([ + AccountEmailUsernameSchema, + AccountPhoneUsernameSchema +]); + +export const AccountVerificationCapabilitiesSchema = z.object({ + emailCode: z.boolean(), + phoneCode: z.boolean(), + wechat: z.boolean(), + oauth: z.object({ + github: z.boolean(), + google: z.boolean(), + microsoft: z.boolean(), + wecom: z.boolean(), + sso: z.boolean() + }) +}); +export type AccountVerificationCapabilities = z.infer; + +export const RecognizedAccountKindSchema = z.enum(recognizedAccountKinds); +export type RecognizedAccountKind = z.infer; + +export const AccountKindSchema = z.union([RecognizedAccountKindSchema, z.literal('invalid')]); +export type AccountKind = z.infer; + +export const AccountVerificationUnsupportedReasonSchema = z.literal('empty_username'); + +export const AccountVerificationResolutionSchema = z.discriminatedUnion('status', [ + z.object({ + status: z.literal('supported'), + accountKind: RecognizedAccountKindSchema, + method: AccountVerificationMethodSchema, + unsupportedReason: z.undefined().optional() + }), + z.object({ + status: z.literal('unsupported'), + accountKind: z.literal('invalid'), + method: z.undefined().optional(), + unsupportedReason: AccountVerificationUnsupportedReasonSchema + }) +]); +export type AccountVerificationResolution = z.infer; + +export const CodeAccountVerificationSceneSchema = z.enum([ + 'register', + 'findPassword', + 'bindNotification' +]); +export type CodeAccountVerificationScene = z.infer; + +export const OAuthAccountVerificationProviderSchema = z.enum(oauthAccountVerificationProviders); +export type OAuthAccountVerificationProvider = z.infer< + typeof OAuthAccountVerificationProviderSchema +>; diff --git a/packages/global/support/user/account/verification/utils.ts b/packages/global/support/user/account/verification/utils.ts new file mode 100644 index 000000000000..57c5373e53c4 --- /dev/null +++ b/packages/global/support/user/account/verification/utils.ts @@ -0,0 +1,107 @@ +import { + AccountEmailUsernameSchema, + AccountPhoneUsernameSchema, + type AccountVerificationCapabilities, + type AccountVerificationMethod, + type AccountVerificationResolution, + type RecognizedAccountKind +} from './type'; + +/** + * 根据持久化 username 和部署能力推导唯一验证方式。 + * 该纯函数只做分类和降级,不读取运行环境,也不改写传入的 username。 + */ +export const resolveAccountVerificationByUsername = ({ + username, + capabilities +}: { + username: string; + capabilities: AccountVerificationCapabilities; +}): AccountVerificationResolution => { + const normalizedUsername = username.trim(); + if (!normalizedUsername) { + return { + status: 'unsupported', + accountKind: 'invalid', + unsupportedReason: 'empty_username' + }; + } + + /** Provider 前缀必须完整匹配,且分隔符后至少保留一个字符。 */ + const hasPrefix = (prefix: string) => + normalizedUsername.startsWith(`${prefix}-`) && normalizedUsername.length > prefix.length + 1; + + const firstSeparatorIndex = normalizedUsername.indexOf('-'); + const hasSsoPrefix = + firstSeparatorIndex > 0 && firstSeparatorIndex < normalizedUsername.length - 1; + + // 邮箱和手机号必须先于通用连字符规则,避免合法邮箱被误判为 SSO。 + const accountKind = (() => { + if (AccountEmailUsernameSchema.safeParse(normalizedUsername).success) return 'email'; + if (AccountPhoneUsernameSchema.safeParse(normalizedUsername).success) return 'phone'; + if (hasPrefix('wechat')) return 'wechat'; + if (hasPrefix('git')) return 'github'; + if (hasPrefix('google')) return 'google'; + if (hasPrefix('microsoft')) return 'microsoft'; + if (hasPrefix('wecom')) return 'wecom'; + if (capabilities.oauth.sso && hasSsoPrefix) return 'sso'; + return 'local'; + })() satisfies RecognizedAccountKind; + + type ConfiguredAccountVerificationMethod = Exclude; + + const candidateMethods: readonly ConfiguredAccountVerificationMethod[] = (() => { + switch (accountKind) { + case 'email': + case 'phone': + return ['code'] as const; + case 'local': + return []; + case 'wechat': + return ['wechat'] as const; + case 'github': + return ['oauth/github'] as const; + case 'google': + return ['oauth/google'] as const; + case 'microsoft': + return ['oauth/microsoft'] as const; + case 'sso': + return ['oauth/sso'] as const; + case 'wecom': + return ['oauth/sso', 'oauth/wecom'] as const; + default: { + const exhaustiveAccountKind: never = accountKind; + return exhaustiveAccountKind; + } + } + })(); + + const isMethodAvailable = (method: ConfiguredAccountVerificationMethod) => { + switch (method) { + case 'code': + return accountKind === 'email' ? capabilities.emailCode : capabilities.phoneCode; + case 'wechat': + return capabilities.wechat; + case 'oauth/github': + return capabilities.oauth.github; + case 'oauth/google': + return capabilities.oauth.google; + case 'oauth/microsoft': + return capabilities.oauth.microsoft; + case 'oauth/wecom': + return capabilities.oauth.wecom; + case 'oauth/sso': + return capabilities.oauth.sso; + default: { + const exhaustiveMethod: never = method; + return exhaustiveMethod; + } + } + }; + + return { + status: 'supported', + accountKind, + method: candidateMethods.find(isMethodAvailable) ?? 'oldPassword' + }; +}; diff --git a/packages/global/support/user/auth/constants.ts b/packages/global/support/user/auth/constants.ts index 7d909b71613e..f59f1bd212d7 100644 --- a/packages/global/support/user/auth/constants.ts +++ b/packages/global/support/user/auth/constants.ts @@ -4,7 +4,8 @@ export enum UserAuthTypeEnum { wxLogin = 'wxLogin', bindNotification = 'bindNotification', captcha = 'captcha', - login = 'login' + login = 'login', + oauthLogin = 'oauthLogin' } export const userAuthTypeMap = { @@ -13,5 +14,6 @@ export const userAuthTypeMap = { [UserAuthTypeEnum.wxLogin]: 'wxLogin', [UserAuthTypeEnum.bindNotification]: 'bindNotification', [UserAuthTypeEnum.captcha]: 'captcha', - [UserAuthTypeEnum.login]: 'login' + [UserAuthTypeEnum.login]: 'login', + [UserAuthTypeEnum.oauthLogin]: 'oauthLogin' }; diff --git a/packages/global/test/support/user/account/verification/oauthApi.test.ts b/packages/global/test/support/user/account/verification/oauthApi.test.ts new file mode 100644 index 000000000000..e94f853effb3 --- /dev/null +++ b/packages/global/test/support/user/account/verification/oauthApi.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from 'vitest'; +import { + CreateOauthLoginBodySchema, + OauthLoginBodySchema +} from '@fastgpt/global/openapi/support/user/account/login/api'; + +describe('OAuth login API contracts', () => { + it('accepts only OAuth V2 providers and a callback URL', () => { + expect( + CreateOauthLoginBodySchema.parse({ + provider: 'github', + callbackUrl: 'https://fastgpt.example.com/login/provider' + }) + ).toEqual({ + provider: 'github', + callbackUrl: 'https://fastgpt.example.com/login/provider', + isWecomWorkTerminal: false + }); + expect( + CreateOauthLoginBodySchema.safeParse({ + provider: 'wechat', + callbackUrl: 'https://fastgpt.example.com/login/provider' + }).success + ).toBe(false); + }); + + it('requires provider, code and server-generated state when consuming OAuth', () => { + const state = 'abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG'; + expect( + OauthLoginBodySchema.safeParse({ + provider: 'github', + callbackUrl: 'https://fastgpt.example.com/login/provider', + code: 'provider-code', + state + }).success + ).toBe(true); + expect( + OauthLoginBodySchema.safeParse({ + type: 'github', + callbackUrl: 'https://fastgpt.example.com/login/provider', + props: { code: 'legacy-code' } + }).success + ).toBe(false); + }); + + it('limits SSO callback fields and rejects reserved or malformed keys', () => { + const base = { + provider: 'sso', + callbackUrl: 'https://fastgpt.example.com/login/provider', + code: 'provider-code', + state: 'abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG' + }; + expect( + OauthLoginBodySchema.safeParse({ + ...base, + props: Object.fromEntries( + Array.from({ length: 21 }, (_, index) => [`key${index}`, 'value']) + ) + }).success + ).toBe(false); + expect( + OauthLoginBodySchema.safeParse({ + ...base, + props: { value: 'x'.repeat(4097) } + }).success + ).toBe(false); + expect( + OauthLoginBodySchema.safeParse({ ...base, props: { code: 'cannot-override' } }).success + ).toBe(false); + expect( + OauthLoginBodySchema.safeParse({ ...base, props: { 'invalid key': 'value' } }).success + ).toBe(false); + }); + + it('rejects unknown top-level OAuth fields', () => { + expect( + OauthLoginBodySchema.safeParse({ + provider: 'github', + callbackUrl: 'https://fastgpt.example.com/login/provider', + code: 'provider-code', + state: 'abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG', + username: 'forged-user' + }).success + ).toBe(false); + }); +}); diff --git a/packages/global/test/support/user/account/verification/utils.test.ts b/packages/global/test/support/user/account/verification/utils.test.ts new file mode 100644 index 000000000000..7cd38fde4116 --- /dev/null +++ b/packages/global/test/support/user/account/verification/utils.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it } from 'vitest'; +import type { AccountVerificationCapabilities } from '@fastgpt/global/support/user/account/verification/type'; +import { resolveAccountVerificationByUsername } from '@fastgpt/global/support/user/account/verification/utils'; + +const capabilities = { + emailCode: true, + phoneCode: true, + wechat: true, + oauth: { + github: true, + google: true, + microsoft: true, + wecom: true, + sso: true + } +} satisfies AccountVerificationCapabilities; + +type CapabilityOverrides = Partial> & { + oauth?: Partial; +}; + +const resolve = (username: string, overrides: CapabilityOverrides = {}) => + resolveAccountVerificationByUsername({ + username, + capabilities: { + ...capabilities, + ...overrides, + oauth: { + ...capabilities.oauth, + ...overrides.oauth + } + } + }); + +describe('resolveAccountVerificationByUsername', () => { + it.each(['', ' '])('rejects an empty username: %j', (username) => { + expect(resolve(username)).toEqual({ + status: 'unsupported', + accountKind: 'invalid', + unsupportedReason: 'empty_username' + }); + }); + + it.each([ + ['user@example.com', 'email', 'code'], + ['user-name@example-domain.com', 'email', 'code'], + ['13800138000', 'phone', 'code'], + ['local', 'local', 'oldPassword'], + ['-leading', 'local', 'oldPassword'], + ['trailing-', 'local', 'oldPassword'], + ['wechat-openid', 'wechat', 'wechat'], + ['git-octocat', 'github', 'oauth/github'], + ['google-sub', 'google', 'oauth/google'], + ['microsoft-id', 'microsoft', 'oauth/microsoft'], + ['wecom-id', 'wecom', 'oauth/sso'], + ['customer-user', 'sso', 'oauth/sso'] + ])('resolves %s to one method', (username, accountKind, method) => { + expect(resolve(username)).toEqual({ status: 'supported', accountKind, method }); + }); + + it('falls back to old password when a contact channel is unavailable', () => { + expect(resolve('user@example.com', { emailCode: false })).toMatchObject({ + accountKind: 'email', + method: 'oldPassword' + }); + expect(resolve('13800138000', { phoneCode: false })).toMatchObject({ + accountKind: 'phone', + method: 'oldPassword' + }); + }); + + it.each([ + ['wechat-openid', { wechat: false }, 'wechat'], + ['git-octocat', { oauth: { github: false } }, 'github'], + ['google-sub', { oauth: { google: false } }, 'google'], + ['microsoft-id', { oauth: { microsoft: false } }, 'microsoft'] + ] as const)( + 'does not route a disabled direct provider through SSO: %s', + (username, overrides, accountKind) => { + expect(resolve(username, overrides)).toMatchObject({ + accountKind, + method: 'oldPassword' + }); + } + ); + + it('uses Wecom SSO, direct Wecom and old password in order', () => { + expect(resolve('wecom-id')).toMatchObject({ method: 'oauth/sso' }); + expect(resolve('wecom-id', { oauth: { sso: false } })).toMatchObject({ + method: 'oauth/wecom' + }); + expect( + resolve('wecom-id', { + oauth: { sso: false, wecom: false } + }) + ).toMatchObject({ method: 'oldPassword' }); + }); + + it('treats unknown hyphenated names as local when SSO is unavailable', () => { + expect(resolve('customer-user', { oauth: { sso: false } })).toEqual({ + status: 'supported', + accountKind: 'local', + method: 'oldPassword' + }); + }); + + it.each(['Git-user', 'git-', 'wechat-', '1380013800', '138001380000'])( + 'applies strict provider and phone boundaries: %s', + (username) => { + const result = resolve(username, { oauth: { sso: false } }); + expect(result).toMatchObject({ + status: 'supported', + accountKind: 'local', + method: 'oldPassword' + }); + } + ); +}); diff --git a/packages/service/support/user/account/verification/entity.ts b/packages/service/support/user/account/verification/entity.ts new file mode 100644 index 000000000000..a2f469a7c99a --- /dev/null +++ b/packages/service/support/user/account/verification/entity.ts @@ -0,0 +1,148 @@ +import type { ClientSession, FilterQuery } from 'mongoose'; +import { AccountVerificationMaterialTypeEnum } from '@fastgpt/global/support/user/account/verification/constants'; +import { + MongoAccountVerificationMaterial, + type AccountVerificationMaterialSchemaType +} from './schema'; +import { buildVerificationCodeFilter } from './utils'; + +type MaterialIdentity = { + key: string; + type: `${AccountVerificationMaterialTypeEnum}`; +}; + +type CreateVerificationMaterialData = MaterialIdentity & { + code?: string; + openid?: string; + expiredTime: Date; + createTime?: Date; +}; + +type QueryValidVerificationMaterialData = MaterialIdentity & { + code?: string; + caseInsensitiveCode?: boolean; + requireOpenid?: boolean; + now?: Date; +}; + +/** 创建随机键材料。调用方负责处理极低概率的唯一键碰撞。 */ +export const createVerificationMaterial = ( + data: CreateVerificationMaterialData, + session?: ClientSession +) => + MongoAccountVerificationMaterial.create( + [ + { + ...data, + createTime: data.createTime ?? new Date() + } + ], + { session } + ); + +/** 查询同一 key/type 是否已经存在,用于随机材料创建前的碰撞检测。 */ +export const findVerificationMaterial = (data: MaterialIdentity, session?: ClientSession) => + MongoAccountVerificationMaterial.findOne(data, undefined, { session }).lean(); + +/** 覆盖同一 key/type 的普通验证码,并同步刷新创建和过期时间。 */ +export const upsertVerificationMaterial = ( + data: CreateVerificationMaterialData, + session?: ClientSession +) => { + const { key, type, code, openid, expiredTime, createTime = new Date() } = data; + + return MongoAccountVerificationMaterial.updateOne( + { key, type }, + { + $set: { + code, + openid, + createTime, + expiredTime + } + }, + { upsert: true, session } + ); +}; + +const buildValidMaterialFilter = ({ + key, + type, + code, + caseInsensitiveCode, + requireOpenid, + now = new Date() +}: QueryValidVerificationMaterialData): FilterQuery => ({ + key, + type, + expiredTime: { $gt: now }, + ...(code !== undefined && { + code: buildVerificationCodeFilter({ code, caseInsensitive: caseInsensitiveCode }) + }), + ...(requireOpenid && { openid: { $exists: true, $ne: '' } }) +}); + +/** 查询仍在业务有效期内的材料,不依赖 TTL 清理时机。 */ +export const findValidVerificationMaterial = ( + data: QueryValidVerificationMaterialData, + session?: ClientSession +) => + MongoAccountVerificationMaterial.findOne(buildValidMaterialFilter(data), undefined, { + session + }).lean(); + +/** 原子消费仍有效的材料,并返回被删除记录。 */ +export const consumeVerificationMaterial = ( + data: QueryValidVerificationMaterialData, + session?: ClientSession +) => + MongoAccountVerificationMaterial.findOneAndDelete(buildValidMaterialFilter(data), { + session + }).lean(); + +/** 微信 callback 只能把身份写入已存在、未过期且尚未完成的占位材料。 */ +export const updateWechatMaterialIdentity = ( + { + key, + openid, + now = new Date() + }: { + key: string; + openid: string; + now?: Date; + }, + session?: ClientSession +) => + MongoAccountVerificationMaterial.findOneAndUpdate( + { + key, + type: AccountVerificationMaterialTypeEnum.wxLogin, + expiredTime: { $gt: now }, + openid: { $exists: false } + }, + { $set: { openid } }, + { new: true, session } + ).lean(); + +/** 上游创建失败时按本次材料内容条件清理,避免误删并发重试的新材料。 */ +export const deleteVerificationMaterialIfMatch = ( + { + key, + type, + code, + openid + }: MaterialIdentity & { + code?: string; + openid?: string; + }, + session?: ClientSession +) => + MongoAccountVerificationMaterial.deleteOne( + { + key, + type, + ...(code !== undefined && { code }), + ...(openid !== undefined && { openid }) + }, + { session } + ); diff --git a/packages/service/support/user/account/verification/index.ts b/packages/service/support/user/account/verification/index.ts new file mode 100644 index 000000000000..ac1052ea0399 --- /dev/null +++ b/packages/service/support/user/account/verification/index.ts @@ -0,0 +1,5 @@ +export * from './entity'; +export * from './schema'; +export * from './service'; +export * from './utils'; +export * from './password/service'; diff --git a/packages/service/support/user/account/verification/password/service.ts b/packages/service/support/user/account/verification/password/service.ts new file mode 100644 index 000000000000..d881c06f4d3a --- /dev/null +++ b/packages/service/support/user/account/verification/password/service.ts @@ -0,0 +1,91 @@ +import { addSeconds } from 'date-fns'; +import { getNanoid } from '@fastgpt/global/common/string/tools'; +import { UserErrEnum } from '@fastgpt/global/common/error/code/user'; +import { UserError } from '@fastgpt/global/common/error/utils'; +import { i18nT } from '@fastgpt/global/common/i18n/utils'; +import { UserStatusEnum } from '@fastgpt/global/support/user/constant'; +import { AccountVerificationMaterialTypeEnum } from '@fastgpt/global/support/user/account/verification/constants'; +import { MongoUser } from '../../../schema'; +import { consumeVerificationMaterial, upsertVerificationMaterial } from '../entity'; +import { AccountVerification, type LocalAccountIdentity } from '../service'; + +type PasswordVerificationDependencies = { + generateCode: () => string; + now: () => Date; +}; + +/** + * 校验预登录材料和本地密码,只返回可信本地身份。 + * Session、团队加载及 Wecom 登录策略由上层登录应用服务负责。 + */ +export class PasswordAccountVerification extends AccountVerification< + { username: string }, + { code: string }, + { username: string; password: string; code: string }, + LocalAccountIdentity +> { + private readonly dependencies: PasswordVerificationDependencies; + + constructor(dependencies: Partial = {}) { + super(); + this.dependencies = { + generateCode: () => getNanoid(6), + now: () => new Date(), + ...dependencies + }; + } + + async create({ username }: { username: string }) { + const code = this.dependencies.generateCode(); + const now = this.dependencies.now(); + + await upsertVerificationMaterial({ + key: username, + type: AccountVerificationMaterialTypeEnum.login, + code, + createTime: now, + expiredTime: addSeconds(now, 30) + }); + + return { code }; + } + + async consume({ + username, + password, + code + }: { + username: string; + password: string; + code: string; + }): Promise { + const material = await consumeVerificationMaterial({ + key: username, + type: AccountVerificationMaterialTypeEnum.login, + code, + caseInsensitiveCode: true, + now: this.dependencies.now() + }); + if (!material) { + throw new UserError(i18nT('common:error.code_error')); + } + + const user = await MongoUser.findOne({ username, password }); + if (!user) { + return Promise.reject(UserErrEnum.account_psw_error); + } + if (user.status === UserStatusEnum.forbidden) { + return Promise.reject('Invalid account!'); + } + + return { + kind: 'local', + userId: String(user._id), + username: user.username, + lastLoginTmbId: user.lastLoginTmbId ? String(user.lastLoginTmbId) : undefined, + isRoot: user.username === 'root' + }; + } +} + +export const passwordAccountVerification = new PasswordAccountVerification(); diff --git a/packages/service/support/user/account/verification/schema.ts b/packages/service/support/user/account/verification/schema.ts new file mode 100644 index 000000000000..3d220c8dc0cb --- /dev/null +++ b/packages/service/support/user/account/verification/schema.ts @@ -0,0 +1,49 @@ +import { AccountVerificationMaterialTypeEnum } from '@fastgpt/global/support/user/account/verification/constants'; +import { connectionMongo, getMongoModel } from '../../../../common/mongo'; + +const { Schema } = connectionMongo; + +export type AccountVerificationMaterialSchemaType = { + key: string; + type: `${AccountVerificationMaterialTypeEnum}`; + code?: string; + openid?: string; + createTime: Date; + expiredTime: Date; +}; + +const AccountVerificationMaterialSchema = new Schema({ + key: { + type: String, + required: true + }, + code: { + type: String, + minLength: 6, + maxLength: 6 + }, + openid: String, + type: { + type: String, + enum: Object.values(AccountVerificationMaterialTypeEnum), + required: true + }, + createTime: { + type: Date, + required: true + }, + expiredTime: { + type: Date, + required: true + } +}); + +// 唯一索引需在生产重复数据清理后单独上线,本轮先保持兼容索引。 +AccountVerificationMaterialSchema.index({ key: 1, type: 1 }); +AccountVerificationMaterialSchema.index({ expiredTime: 1 }, { expireAfterSeconds: 0 }); + +export const MongoAccountVerificationMaterial = + getMongoModel( + 'auth_codes', + AccountVerificationMaterialSchema + ); diff --git a/packages/service/support/user/account/verification/service.ts b/packages/service/support/user/account/verification/service.ts new file mode 100644 index 000000000000..851a77f654cc --- /dev/null +++ b/packages/service/support/user/account/verification/service.ts @@ -0,0 +1,39 @@ +import type { CodeAccountVerificationScene } from '@fastgpt/global/support/user/account/verification/type'; + +/** 统一账号验证方式的材料创建与消费模型。 */ +export abstract class AccountVerification< + TCreateParams, + TCreateResult, + TConsumeParams, + TConsumeResult +> { + abstract create(params: TCreateParams): Promise; + abstract consume(params: TConsumeParams): Promise; +} + +export type LocalAccountIdentity = { + kind: 'local'; + userId: string; + username: string; + lastLoginTmbId?: string; + isRoot: boolean; +}; + +export type VerifiedContactIdentity = { + kind: 'contact'; + account: string; + scene: CodeAccountVerificationScene; +}; + +export type ExternalAccountIdentity = { + kind: 'external'; + provider: 'github' | 'google' | 'microsoft' | 'wecom' | 'sso' | 'wechat'; + subject: string; + username: string; + avatar?: string; + notificationAccount?: string; + phonePrefix?: number; + teamName?: string; + memberName?: string; + organizationId?: string; +}; diff --git a/packages/service/support/user/account/verification/utils.ts b/packages/service/support/user/account/verification/utils.ts new file mode 100644 index 000000000000..896f041bd1d1 --- /dev/null +++ b/packages/service/support/user/account/verification/utils.ts @@ -0,0 +1,15 @@ +/** 将用户输入转成可安全用于锚定正则的字面量。 */ +export const escapeVerificationCodeForRegExp = (code: string) => + code.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + +/** 构造兼容历史验证码大小写行为的精确匹配条件。 */ +export const buildVerificationCodeFilter = ({ + code, + caseInsensitive = false +}: { + code: string; + caseInsensitive?: boolean; +}) => + caseInsensitive + ? { $regex: new RegExp(`^${escapeVerificationCodeForRegExp(code)}$`, 'i') } + : code; diff --git a/packages/service/test/support/user/account/verification/entity.test.ts b/packages/service/test/support/user/account/verification/entity.test.ts new file mode 100644 index 000000000000..8210939dc4d8 --- /dev/null +++ b/packages/service/test/support/user/account/verification/entity.test.ts @@ -0,0 +1,156 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { addMinutes } from 'date-fns'; +import { AccountVerificationMaterialTypeEnum } from '@fastgpt/global/support/user/account/verification/constants'; +import { + consumeVerificationMaterial, + createVerificationMaterial, + deleteVerificationMaterialIfMatch, + findValidVerificationMaterial, + updateWechatMaterialIdentity, + upsertVerificationMaterial +} from '@fastgpt/service/support/user/account/verification/entity'; +import { MongoAccountVerificationMaterial } from '@fastgpt/service/support/user/account/verification/schema'; + +describe('verification material entity', () => { + beforeEach(async () => { + await MongoAccountVerificationMaterial.deleteMany({}); + }); + + it('upserts only the latest material and refreshes its timestamps', async () => { + const firstCreatedAt = new Date('2026-07-14T00:00:00.000Z'); + const secondCreatedAt = new Date('2026-07-14T00:01:00.000Z'); + + await upsertVerificationMaterial({ + key: 'user@example.com', + type: AccountVerificationMaterialTypeEnum.register, + code: '111111', + createTime: firstCreatedAt, + expiredTime: addMinutes(firstCreatedAt, 5) + }); + await upsertVerificationMaterial({ + key: 'user@example.com', + type: AccountVerificationMaterialTypeEnum.register, + code: '222222', + createTime: secondCreatedAt, + expiredTime: addMinutes(secondCreatedAt, 5) + }); + + const records = await MongoAccountVerificationMaterial.find({}).lean(); + expect(records).toHaveLength(1); + expect(records[0]).toMatchObject({ code: '222222' }); + expect(records[0].createTime).toEqual(secondCreatedAt); + expect(records[0].expiredTime).toEqual(addMinutes(secondCreatedAt, 5)); + }); + + it('rejects material at and after the expiration boundary', async () => { + const expiredTime = new Date('2026-07-14T00:05:00.000Z'); + await upsertVerificationMaterial({ + key: 'account', + type: AccountVerificationMaterialTypeEnum.findPassword, + code: '123456', + expiredTime + }); + + await expect( + findValidVerificationMaterial({ + key: 'account', + type: AccountVerificationMaterialTypeEnum.findPassword, + code: '123456', + now: new Date(expiredTime.getTime() - 1) + }) + ).resolves.toBeTruthy(); + await expect( + findValidVerificationMaterial({ + key: 'account', + type: AccountVerificationMaterialTypeEnum.findPassword, + code: '123456', + now: expiredTime + }) + ).resolves.toBeNull(); + }); + + it('allows only one concurrent consumer', async () => { + await upsertVerificationMaterial({ + key: 'account', + type: AccountVerificationMaterialTypeEnum.login, + code: '123456', + expiredTime: addMinutes(new Date(), 1) + }); + + const results = await Promise.all( + Array.from({ length: 8 }, () => + consumeVerificationMaterial({ + key: 'account', + type: AccountVerificationMaterialTypeEnum.login, + code: '123456' + }) + ) + ); + expect(results.filter(Boolean)).toHaveLength(1); + }); + + it('matches case-insensitive codes literally without regex injection', async () => { + await MongoAccountVerificationMaterial.create({ + key: 'account', + type: AccountVerificationMaterialTypeEnum.captcha, + code: 'A.[B]C', + createTime: new Date(), + expiredTime: addMinutes(new Date(), 1) + }); + + await expect( + consumeVerificationMaterial({ + key: 'account', + type: AccountVerificationMaterialTypeEnum.captcha, + code: 'a.[b]c', + caseInsensitiveCode: true + }) + ).resolves.toBeTruthy(); + }); + + it('updates only a valid empty WeChat placeholder', async () => { + const [placeholder] = await createVerificationMaterial({ + key: 'wechat-scene', + type: AccountVerificationMaterialTypeEnum.wxLogin, + expiredTime: addMinutes(new Date(), 1) + }); + + await expect( + updateWechatMaterialIdentity({ key: 'wechat-scene', openid: 'openid' }) + ).resolves.toMatchObject({ openid: 'openid' }); + await expect( + updateWechatMaterialIdentity({ key: 'wechat-scene', openid: 'other-openid' }) + ).resolves.toBeNull(); + + await MongoAccountVerificationMaterial.updateOne( + { _id: placeholder._id }, + { expiredTime: new Date(0), $unset: { openid: 1 } } + ); + await expect( + updateWechatMaterialIdentity({ key: 'wechat-scene', openid: 'late-openid' }) + ).resolves.toBeNull(); + }); + + it('conditionally deletes only the material from the failed attempt', async () => { + await upsertVerificationMaterial({ + key: 'account', + type: AccountVerificationMaterialTypeEnum.register, + code: '222222', + expiredTime: addMinutes(new Date(), 1) + }); + + await deleteVerificationMaterialIfMatch({ + key: 'account', + type: AccountVerificationMaterialTypeEnum.register, + code: '111111' + }); + await expect(MongoAccountVerificationMaterial.countDocuments({})).resolves.toBe(1); + + await deleteVerificationMaterialIfMatch({ + key: 'account', + type: AccountVerificationMaterialTypeEnum.register, + code: '222222' + }); + await expect(MongoAccountVerificationMaterial.countDocuments({})).resolves.toBe(0); + }); +}); diff --git a/packages/service/test/support/user/account/verification/password/service.test.ts b/packages/service/test/support/user/account/verification/password/service.test.ts new file mode 100644 index 000000000000..aa38025fdd47 --- /dev/null +++ b/packages/service/test/support/user/account/verification/password/service.test.ts @@ -0,0 +1,82 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { UserErrEnum } from '@fastgpt/global/common/error/code/user'; +import { UserStatusEnum } from '@fastgpt/global/support/user/constant'; +import { MongoUser } from '@fastgpt/service/support/user/schema'; +import { MongoAccountVerificationMaterial } from '@fastgpt/service/support/user/account/verification/schema'; +import { PasswordAccountVerification } from '@fastgpt/service/support/user/account/verification/password/service'; + +describe('PasswordAccountVerification', () => { + beforeEach(async () => { + await MongoAccountVerificationMaterial.deleteMany({}); + }); + + it('creates a deterministic 30-second pre-login material', async () => { + const now = new Date('2026-07-14T00:00:00.000Z'); + const verification = new PasswordAccountVerification({ + generateCode: () => 'ABC123', + now: () => now + }); + + await expect(verification.create({ username: 'user' })).resolves.toEqual({ code: 'ABC123' }); + await expect( + MongoAccountVerificationMaterial.findOne({ key: 'user' }).lean() + ).resolves.toMatchObject({ + code: 'ABC123', + createTime: now, + expiredTime: new Date('2026-07-14T00:00:30.000Z') + }); + }); + + it('consumes the code and returns a local identity for a third-party username', async () => { + await MongoUser.create({ + username: 'git-user', + password: 'password', + status: UserStatusEnum.active + }); + const verification = new PasswordAccountVerification({ generateCode: () => 'ABC123' }); + await verification.create({ username: 'git-user' }); + + await expect( + verification.consume({ username: 'git-user', password: 'password', code: 'abc123' }) + ).resolves.toMatchObject({ + kind: 'local', + username: 'git-user', + isRoot: false + }); + await expect( + verification.consume({ username: 'git-user', password: 'password', code: 'ABC123' }) + ).rejects.toThrow(); + }); + + it('uses one account error for an unknown user or wrong password', async () => { + const verification = new PasswordAccountVerification({ generateCode: () => 'ABC123' }); + await verification.create({ username: 'missing' }); + await expect( + verification.consume({ username: 'missing', password: 'password', code: 'ABC123' }) + ).rejects.toBe(UserErrEnum.account_psw_error); + + await MongoUser.create({ + username: 'user', + password: 'password', + status: UserStatusEnum.active + }); + await verification.create({ username: 'user' }); + await expect( + verification.consume({ username: 'user', password: 'wrong', code: 'ABC123' }) + ).rejects.toBe(UserErrEnum.account_psw_error); + }); + + it('rejects forbidden users after consuming their code', async () => { + await MongoUser.create({ + username: 'user', + password: 'password', + status: UserStatusEnum.forbidden + }); + const verification = new PasswordAccountVerification({ generateCode: () => 'ABC123' }); + await verification.create({ username: 'user' }); + + await expect( + verification.consume({ username: 'user', password: 'password', code: 'ABC123' }) + ).rejects.toBe('Invalid account!'); + }); +}); diff --git a/packages/service/test/support/user/account/verification/utils.test.ts b/packages/service/test/support/user/account/verification/utils.test.ts new file mode 100644 index 000000000000..83a3958e862d --- /dev/null +++ b/packages/service/test/support/user/account/verification/utils.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest'; +import { + buildVerificationCodeFilter, + escapeVerificationCodeForRegExp +} from '@fastgpt/service/support/user/account/verification/utils'; + +describe('escapeVerificationCodeForRegExp', () => { + it('escapes every regular expression metacharacter', () => { + expect(escapeVerificationCodeForRegExp('.*+?^${}()|[]\\')).toBe( + '\\.\\*\\+\\?\\^\\$\\{\\}\\(\\)\\|\\[\\]\\\\' + ); + }); +}); + +describe('buildVerificationCodeFilter', () => { + it('uses an exact string for case-sensitive codes', () => { + expect(buildVerificationCodeFilter({ code: '123456' })).toBe('123456'); + }); + + it('builds an escaped and anchored filter for legacy case-insensitive codes', () => { + const filter = buildVerificationCodeFilter({ code: 'a.b[c]', caseInsensitive: true }); + expect(filter.$regex.test('A.B[C]')).toBe(true); + expect(filter.$regex.test('xa.b[c]')).toBe(false); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 85cd53eca4cf..96f0febbda3d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -105,6 +105,9 @@ catalogs: file-type: specifier: 21.3.0 version: 21.3.0 + google-auth-library: + specifier: 10.6.2 + version: 10.6.2 gpt-tokenizer: specifier: 3.4.0 version: 3.4.0 @@ -1002,6 +1005,9 @@ importers: gcp-metadata: specifier: ^5.3.0 version: 5.3.0(encoding@0.1.13) + google-auth-library: + specifier: 'catalog:' + version: 10.6.2 i18next: specifier: 'catalog:' version: 23.16.8 @@ -1267,12 +1273,18 @@ importers: '@types/express': specifier: ^4.17.21 version: 4.17.25 + '@types/node': + specifier: 'catalog:' + version: 20.17.24 '@types/xml2js': specifier: ^0.4.14 version: 0.4.14 tsdown: specifier: 'catalog:' version: 0.21.10(typescript@6.0.3) + vitest: + specifier: 'catalog:' + version: 4.1.5(@opentelemetry/api@1.9.0)(@types/node@20.17.24)(@vitest/coverage-v8@4.1.5)(jsdom@26.1.0(bufferutil@4.1.0)(canvas@3.2.3)(utf-8-validate@5.0.10))(vite@6.2.2(@types/node@20.17.24)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.85.1)(terser@5.39.0)(tsx@4.20.6)(yaml@2.8.4)) projects/app: dependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 7df15528423c..e539c1ae70f6 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -51,6 +51,7 @@ catalog: debug: ^4.4.3 express: ^4 file-type: 21.3.0 + google-auth-library: 10.6.2 gpt-tokenizer: 3.4.0 hono: 4.12.27 '@hono/node-server': ^2.0.10 diff --git a/pro b/pro index 7027ac732193..b61a6c17bb36 160000 --- a/pro +++ b/pro @@ -1 +1 @@ -Subproject commit 7027ac732193b6997eac387f556f23eb2e3bab22 +Subproject commit b61a6c17bb36e93307591b1cc02c60d60f0c3b3f diff --git a/projects/app/src/components/support/user/inform/UpdateContactModal.tsx b/projects/app/src/components/support/user/inform/UpdateContactModal.tsx index 6aae58ddf138..c4ca3f878f18 100644 --- a/projects/app/src/components/support/user/inform/UpdateContactModal.tsx +++ b/projects/app/src/components/support/user/inform/UpdateContactModal.tsx @@ -9,7 +9,6 @@ import Icon from '@fastgpt/web/components/common/Icon'; import { useSendCode } from '@/web/support/user/hooks/useSendCode'; import { useUserStore } from '@/web/support/user/useUserStore'; import { useSystemStore } from '@/web/common/system/useSystemStore'; -import { UserAuthTypeEnum } from '@fastgpt/global/support/user/auth/constants'; type FormType = { contact: string; @@ -62,7 +61,7 @@ const UpdateContactModal = ({ } ); - const { SendCodeBox } = useSendCode({ type: UserAuthTypeEnum.bindNotification }); + const { SendCodeBox } = useSendCode({ type: 'bindNotification' }); const placeholder = feConfigs?.bind_notification_method ?.map((item) => { diff --git a/projects/app/src/pageComponents/login/ForgetPasswordForm.tsx b/projects/app/src/pageComponents/login/ForgetPasswordForm.tsx index a6b5da6f8abd..74a6e8c4c4d5 100644 --- a/projects/app/src/pageComponents/login/ForgetPasswordForm.tsx +++ b/projects/app/src/pageComponents/login/ForgetPasswordForm.tsx @@ -11,20 +11,21 @@ import { useRequest } from '@fastgpt/web/hooks/useRequest'; import { checkPasswordRule } from '@fastgpt/global/common/string/password'; import type { LoginSuccessResponseType } from '@fastgpt/global/openapi/support/user/account/login/api'; import type { LangEnum } from '@fastgpt/global/common/i18n/type'; +import { AccountContactUsernameSchema } from '@fastgpt/global/support/user/account/verification/type'; type LoginSuccessHandler = (res: LoginSuccessResponseType) => void | Promise; -interface Props { +type Props = { setPageType: Dispatch<`${LoginPageTypeEnum}`>; loginSuccess: LoginSuccessHandler; -} +}; -interface RegisterType { +type RegisterType = { username: string; code: string; password: string; password2: string; -} +}; const RegisterForm = ({ setPageType, loginSuccess }: Props) => { const { toast } = useToast(); @@ -107,11 +108,9 @@ const RegisterForm = ({ setPageType, loginSuccess }: Props) => { placeholder={placeholder} {...register('username', { required: t('user:password.email_phone_void'), - pattern: { - value: - /(^1[3456789]\d{9}$)|(^[A-Za-z0-9]+([_\.][A-Za-z0-9]+)*@([A-Za-z0-9\-]+\.)+[A-Za-z]{2,6}$)/, - message: t('user:password.email_phone_error') - } + validate: (value) => + AccountContactUsernameSchema.safeParse(value).success || + t('user:password.email_phone_error') })} > diff --git a/projects/app/src/pageComponents/login/LoginForm/FormLayout.tsx b/projects/app/src/pageComponents/login/LoginForm/FormLayout.tsx index af9585a3bbf3..ae3c5cc5c269 100644 --- a/projects/app/src/pageComponents/login/LoginForm/FormLayout.tsx +++ b/projects/app/src/pageComponents/login/LoginForm/FormLayout.tsx @@ -2,17 +2,16 @@ import { LoginPageTypeEnum } from '@/web/support/user/login/constants'; import { useSystemStore } from '@/web/common/system/useSystemStore'; import { Box, Flex, IconButton, Button } from '@chakra-ui/react'; import { LOGO_ICON } from '@fastgpt/global/common/system/constants'; -import { OAuthEnum } from '@fastgpt/global/support/user/constant'; import { useRouter } from 'next/router'; -import { type Dispatch, useCallback, useEffect, useMemo, useState } from 'react'; +import { type Dispatch, useCallback, useEffect, useMemo } from 'react'; import { useTranslation } from 'next-i18next'; import MyImage from '@fastgpt/web/components/common/Image/MyImage'; import { checkIsWecomTerminal } from '@fastgpt/global/support/user/login/constants'; -import { getNanoid } from '@fastgpt/global/common/string/tools'; import Avatar from '@fastgpt/web/components/common/Avatar'; import dynamic from 'next/dynamic'; -import { POST } from '@/web/common/api/request'; import MyTooltip from '@fastgpt/web/components/common/MyTooltip'; +import type { OAuthAccountVerificationProvider } from '@fastgpt/global/support/user/account/verification/type'; +import { createOauthLogin } from '@/web/support/user/api'; type Props = { children: React.ReactNode; @@ -22,10 +21,9 @@ type Props = { type OAuthItem = { label: string; - provider: OAuthEnum | LoginPageTypeEnum; + provider: OAuthAccountVerificationProvider | LoginPageTypeEnum; icon: any; pageType?: LoginPageTypeEnum; - redirectUrl?: string; }; const FormLayout = ({ children, setPageType, pageType }: Props) => { @@ -43,20 +41,20 @@ const FormLayout = ({ children, setPageType, pageType }: Props) => { return router.pathname === '/chat' ? router.asPath : lastRoute; }, [lastRoute, router.pathname, router.asPath]); - const [oauthState] = useState(() => getNanoid(8)); const redirectUri = `${location.origin}/login/provider`; const isWecomWorkTerminal = checkIsWecomTerminal(); const canWecomTerminalAutoRedirect = !isWecomWorkTerminal || feConfigs?.wecomLoginAutoRedirect === true; + const oauthVerificationV2 = feConfigs?.oauthVerificationV2 === true; - const oAuthList: OAuthItem[] = useMemo( + const oAuthList = useMemo( () => [ - ...(feConfigs?.sso?.url + ...(oauthVerificationV2 && feConfigs?.sso?.url ? [ { label: feConfigs.sso.title || 'Unknown', - provider: OAuthEnum.sso, + provider: 'sso' as const, icon: feConfigs.sso.icon } ] @@ -65,7 +63,7 @@ const FormLayout = ({ children, setPageType, pageType }: Props) => { ? [ { label: t('common:support.user.login.Wechat'), - provider: OAuthEnum.wechat, + provider: LoginPageTypeEnum.wechat, icon: 'common/wechatFill', pageType: LoginPageTypeEnum.wechat } @@ -81,96 +79,67 @@ const FormLayout = ({ children, setPageType, pageType }: Props) => { } ] : []), - ...(feConfigs?.oauth?.google + ...(oauthVerificationV2 && feConfigs?.oauth?.google ? [ { label: t('common:support.user.login.Google'), - provider: OAuthEnum.google, - icon: 'common/googleFill', - redirectUrl: `https://accounts.google.com/o/oauth2/v2/auth?client_id=${feConfigs?.oauth?.google}&redirect_uri=${redirectUri}&state=${oauthState}&response_type=code&scope=https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fuserinfo.profile%20https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fuserinfo.email%20openid&include_granted_scopes=true` + provider: 'google' as const, + icon: 'common/googleFill' } ] : []), - ...(feConfigs?.oauth?.github + ...(oauthVerificationV2 && feConfigs?.oauth?.github ? [ { label: t('common:support.user.login.Github'), - provider: OAuthEnum.github, - icon: 'common/gitFill', - redirectUrl: `https://github.com/login/oauth/authorize?client_id=${feConfigs?.oauth?.github}&redirect_uri=${redirectUri}&state=${oauthState}&scope=user:email%20read:user` + provider: 'github' as const, + icon: 'common/gitFill' } ] : []), - ...(feConfigs?.oauth?.microsoft + ...(oauthVerificationV2 && feConfigs?.oauth?.microsoft ? [ { label: feConfigs?.oauth?.microsoft?.customButton || t('common:support.user.login.Microsoft'), - provider: OAuthEnum.microsoft, - icon: 'common/microsoft', - redirectUrl: `https://login.microsoftonline.com/${feConfigs?.oauth?.microsoft?.tenantId || 'common'}/oauth2/v2.0/authorize?client_id=${feConfigs?.oauth?.microsoft?.clientId}&response_type=code&redirect_uri=${redirectUri}&response_mode=query&scope=https%3A%2F%2Fgraph.microsoft.com%2Fuser.read&state=${oauthState}` + provider: 'microsoft' as const, + icon: 'common/microsoft' } ] : []) ], - [feConfigs, oauthState, pageType, redirectUri, t] + [feConfigs, oauthVerificationV2, pageType, t] ); - const show_oauth = !!(feConfigs?.sso?.url || oAuthList.length > 0); + const show_oauth = oAuthList.length > 0; const onClickOauth = useCallback( async (item: OAuthItem) => { - if (item.provider === OAuthEnum.sso) { - const redirectUrl = await POST('/proApi/support/user/account/login/getAuthURL', { - redirectUri, - isWecomWorkTerminal - }); - setLoginStore({ - provider: item.provider as OAuthEnum, - lastRoute: computedLastRoute, - lastTmbId, - state: oauthState - }); - router.replace(redirectUrl, '_self'); + if (item.pageType) { + setPageType(item.pageType); return; } - if (item.provider === OAuthEnum.wecom) { - const redirectUrl = await POST( - '/proApi/support/user/account/login/wecom/getRedirectUrl', - { - redirectUri, - isWecomWorkTerminal, - state: oauthState - } - ); - setLoginStore({ - provider: item.provider as OAuthEnum, - lastRoute: computedLastRoute, - lastTmbId, - state: oauthState - }); - router.replace(redirectUrl, '_self'); - return; - } - - if (item.redirectUrl) { - setLoginStore({ - provider: item.provider as OAuthEnum, - lastRoute: computedLastRoute, - lastTmbId, - state: oauthState - }); - router.replace(item.redirectUrl, '_self'); - } - item.pageType && setPageType(item.pageType); + const provider = item.provider as OAuthAccountVerificationProvider; + const { state, url } = await createOauthLogin({ + provider, + callbackUrl: redirectUri, + isWecomWorkTerminal + }); + setLoginStore({ + provider, + lastRoute: computedLastRoute, + lastTmbId, + state, + callbackUrl: redirectUri + }); + router.replace(url, '_self'); }, [ computedLastRoute, isWecomWorkTerminal, lastTmbId, - oauthState, redirectUri, router, setLoginStore, @@ -181,20 +150,29 @@ const FormLayout = ({ children, setPageType, pageType }: Props) => { // Auto login useEffect(() => { if (rootLogin) return; - const sso = oAuthList.find((item) => item.provider === OAuthEnum.sso); + const sso = oAuthList.find((item) => item.provider === 'sso'); // sso auto login if (sso && canWecomTerminalAutoRedirect && (feConfigs?.sso?.autoLogin || isWecomWorkTerminal)) { - onClickOauth(sso); + void onClickOauth(sso); + return; } - if (feConfigs.oauth?.wecom && isWecomWorkTerminal && canWecomTerminalAutoRedirect) { - onClickOauth({ - provider: OAuthEnum.wecom - } as any); + if ( + oauthVerificationV2 && + feConfigs.oauth?.wecom && + isWecomWorkTerminal && + canWecomTerminalAutoRedirect + ) { + void onClickOauth({ + label: 'Wecom', + provider: 'wecom', + icon: 'common/wecom' + }); } }, [ rootLogin, canWecomTerminalAutoRedirect, feConfigs?.sso?.autoLogin, + oauthVerificationV2, isWecomWorkTerminal, onClickOauth, oAuthList, diff --git a/projects/app/src/pageComponents/login/LoginForm/WechatForm.tsx b/projects/app/src/pageComponents/login/LoginForm/WechatForm.tsx index 23f2e70910cf..f3c2bca8db5a 100644 --- a/projects/app/src/pageComponents/login/LoginForm/WechatForm.tsx +++ b/projects/app/src/pageComponents/login/LoginForm/WechatForm.tsx @@ -1,4 +1,4 @@ -import React, { type Dispatch } from 'react'; +import React, { type Dispatch, useEffect } from 'react'; import { LoginPageTypeEnum } from '@/web/support/user/login/constants'; import { Box, Center } from '@chakra-ui/react'; import { useQuery } from '@tanstack/react-query'; @@ -31,14 +31,27 @@ const WechatForm = ({ setPageType, loginSuccess }: Props) => { const { t, i18n } = useTranslation(); const { toast } = useToast(); - const { data: wechatInfo } = useQuery(['getWXLoginQR'], getWXLoginQR, { - onError(err) { - toast({ - status: 'warning', - title: getErrText(err, t('common:get_QR_failed')) - }); + const { data: wechatInfo, refetch: refetchWechatInfo } = useQuery( + ['getWXLoginQR'], + getWXLoginQR, + { + onError(err) { + toast({ + status: 'warning', + title: getErrText(err, t('common:get_QR_failed')) + }); + } } - }); + ); + + useEffect(() => { + if (!wechatInfo?.expiredAt) return; + const remainingMs = Math.max(new Date(wechatInfo.expiredAt).getTime() - Date.now(), 0); + const timer = window.setTimeout(() => { + void refetchWechatInfo(); + }, remainingMs); + return () => window.clearTimeout(timer); + }, [refetchWechatInfo, wechatInfo?.expiredAt]); useQuery( ['getWXLoginResult', wechatInfo?.code, i18n.language], diff --git a/projects/app/src/pageComponents/login/RegisterForm.tsx b/projects/app/src/pageComponents/login/RegisterForm.tsx index 3b1afc802e92..b466acf6bab0 100644 --- a/projects/app/src/pageComponents/login/RegisterForm.tsx +++ b/projects/app/src/pageComponents/login/RegisterForm.tsx @@ -18,21 +18,22 @@ import { import { checkPasswordRule } from '@fastgpt/global/common/string/password'; import type { LoginSuccessResponseType } from '@fastgpt/global/openapi/support/user/account/login/api'; import type { LangEnum } from '@fastgpt/global/common/i18n/type'; +import { AccountContactUsernameSchema } from '@fastgpt/global/support/user/account/verification/type'; import { getRegisterMethods } from '@/web/common/system/utils'; type LoginSuccessHandler = (res: LoginSuccessResponseType) => void | Promise; -interface Props { +type Props = { loginSuccess: LoginSuccessHandler; setPageType: Dispatch<`${LoginPageTypeEnum}`>; -} +}; -interface RegisterType { +type RegisterType = { username: string; password: string; password2: string; code: string; -} +}; const RegisterForm = ({ setPageType, loginSuccess }: Props) => { const { toast } = useToast(); @@ -119,11 +120,9 @@ const RegisterForm = ({ setPageType, loginSuccess }: Props) => { placeholder={placeholder} {...register('username', { required: t('user:password.email_phone_void'), - pattern: { - value: - /(^1[3456789]\d{9}$)|(^[A-Za-z0-9]+([_\.][A-Za-z0-9]+)*@([A-Za-z0-9\-]+\.)+[A-Za-z]{2,6}$)/, - message: t('user:password.email_phone_error') - } + validate: (value) => + AccountContactUsernameSchema.safeParse(value).success || + t('user:password.email_phone_error') })} > diff --git a/projects/app/src/pages/api/support/user/account/loginByPassword.ts b/projects/app/src/pages/api/support/user/account/loginByPassword.ts index 14ffca9a2979..89f6719841ac 100644 --- a/projects/app/src/pages/api/support/user/account/loginByPassword.ts +++ b/projects/app/src/pages/api/support/user/account/loginByPassword.ts @@ -1,18 +1,10 @@ -import { MongoUser } from '@fastgpt/service/support/user/schema'; -import { getUserDetail } from '@fastgpt/service/support/user/controller'; -import { UserStatusEnum } from '@fastgpt/global/support/user/constant'; import { NextAPI } from '@/service/middleware/entry'; import { useIPFrequencyLimit } from '@fastgpt/service/common/middle/reqFrequencyLimit'; import { pushTrack } from '@fastgpt/service/common/middle/tracks/utils'; -import { UserErrEnum } from '@fastgpt/global/common/error/code/user'; import { addAuditLog } from '@fastgpt/service/support/user/audit/util'; import { AuditEventEnum } from '@fastgpt/global/support/user/audit/constants'; import { serviceEnv } from '@fastgpt/service/env'; -import { UserAuthTypeEnum } from '@fastgpt/global/support/user/auth/constants'; -import { authCode } from '@fastgpt/service/support/user/auth/controller'; -import { createUserSession } from '@fastgpt/service/support/user/session'; import { setCookie } from '@fastgpt/service/support/permission/auth/common'; -import { UserError } from '@fastgpt/global/common/error/utils'; import { LoginByPasswordBodySchema, type LoginByPasswordBodyType, @@ -21,10 +13,8 @@ import { import type { ApiRequestProps, ApiResponseType } from '@fastgpt/next/type'; import { getClientIpFromRequest } from '@fastgpt/service/common/security/clientIp'; import { parseApiInput } from '@fastgpt/service/common/zod/requestParseError'; -import { - reportCRMVisitorIdentity, - resolveCRMVisitorId -} from '@fastgpt/service/support/marketing/attribution'; +import { passwordAccountVerification } from '@fastgpt/service/support/user/account/verification/password/service'; +import { loginLocalAccount } from '@/service/support/user/login/service'; async function handler( req: ApiRequestProps, @@ -35,81 +25,29 @@ async function handler( bodySchema: LoginByPasswordBodySchema }).body; - // Auth prelogin code - await authCode({ - key: username, - code, - type: UserAuthTypeEnum.login - }); - - const user = await MongoUser.findOne({ - username, - password - }); - - if (!user) { - return Promise.reject(UserErrEnum.account_psw_error); - } - if (user.status === UserStatusEnum.forbidden) { - return Promise.reject('Invalid account!'); - } - - if (user) { - if (user.username.startsWith('wecom-')) { - return Promise.reject(new UserError('Wecom user can not login with password')); - } - } - - const userDetail = await getUserDetail({ - tmbId: user?.lastLoginTmbId, - userId: user._id, - isRoot: username === 'root' - }); - - user.lastLoginTmbId = userDetail.team.tmbId; - user.language = language; - const visitorIdentity = resolveCRMVisitorId({ - storedFastgptSem: user.fastgpt_sem, - incomingVisitorId: fastgpt_sem?.visitor_id - }); - if (visitorIdentity.shouldPersist) { - user.fastgpt_sem = visitorIdentity.fastgptSem; - } - await user.save(); - - const token = await createUserSession({ - userId: user._id, - teamId: userDetail.team.teamId, - tmbId: userDetail.team.tmbId, - isRoot: username === 'root', + const identity = await passwordAccountVerification.consume({ username, password, code }); + const { user, token } = await loginLocalAccount({ + identity, + language, + fastgpt_sem, ip: getClientIpFromRequest(req) }); setCookie(res, token); - void reportCRMVisitorIdentity({ - visitorId: visitorIdentity.visitorId, - userId: String(user._id), - username: user.username, - contact: user.contact - }); - pushTrack.login({ type: 'password', uid: user._id, - teamId: userDetail.team.teamId, - tmbId: userDetail.team.tmbId + teamId: user.team.teamId, + tmbId: user.team.tmbId }); addAuditLog({ - tmbId: userDetail.team.tmbId, - teamId: userDetail.team.teamId, + tmbId: user.team.tmbId, + teamId: user.team.teamId, event: AuditEventEnum.LOGIN }); - return { - user: userDetail, - token - }; + return { user, token }; } const lockTime = serviceEnv.PASSWORD_LOGIN_LOCK_SECONDS; diff --git a/projects/app/src/pages/api/support/user/account/preLogin.ts b/projects/app/src/pages/api/support/user/account/preLogin.ts index 7589116c7e04..c26c5ee5f99d 100644 --- a/projects/app/src/pages/api/support/user/account/preLogin.ts +++ b/projects/app/src/pages/api/support/user/account/preLogin.ts @@ -1,33 +1,30 @@ -import type { ApiRequestProps, ApiResponseType } from '@fastgpt/next/type'; +import type { ApiRequestProps } from '@fastgpt/next/type'; import { NextAPI } from '@/service/middleware/entry'; -import { UserAuthTypeEnum } from '@fastgpt/global/support/user/auth/constants'; -import { getNanoid } from '@fastgpt/global/common/string/tools'; -import { addSeconds } from 'date-fns'; -import { addAuthCode } from '@fastgpt/service/support/user/auth/controller'; import { PreLoginQuerySchema, type PreLoginQueryType, type PreLoginResponseType } from '@fastgpt/global/openapi/support/user/account/login/api'; +import { parseApiInput } from '@fastgpt/service/common/zod/requestParseError'; +import { passwordAccountVerification } from '@fastgpt/service/support/user/account/verification/password/service'; +import { useIPFrequencyLimit } from '@fastgpt/service/common/middle/reqFrequencyLimit'; +import { authFrequencyLimit } from '@fastgpt/service/common/system/frequencyLimit/utils'; +import { hashStr } from '@fastgpt/global/common/string/tools'; +import { addMinutes } from 'date-fns'; async function handler( - req: ApiRequestProps, PreLoginQueryType>, - _res: ApiResponseType + req: ApiRequestProps, PreLoginQueryType> ): Promise { - const { username } = PreLoginQuerySchema.parse(req.query); - - const code = getNanoid(6); - - await addAuthCode({ - type: UserAuthTypeEnum.login, - key: username, - code, - expiredTime: addSeconds(new Date(), 30) + const { username } = parseApiInput({ req, querySchema: PreLoginQuerySchema }).query; + await authFrequencyLimit({ + eventId: `pre-login-username-${hashStr(username)}`, + maxAmount: 10, + expiredTime: addMinutes(new Date(), 1) }); - - return { - code - }; + return passwordAccountVerification.create({ username }); } -export default NextAPI(handler); +export default NextAPI( + useIPFrequencyLimit({ id: 'pre-login', seconds: 60, limit: 60, force: true }), + handler +); diff --git a/projects/app/src/pages/login/provider.tsx b/projects/app/src/pages/login/provider.tsx index 83cbff5b93f7..98eabd61d9d4 100644 --- a/projects/app/src/pages/login/provider.tsx +++ b/projects/app/src/pages/login/provider.tsx @@ -9,7 +9,6 @@ import Loading from '@fastgpt/web/components/common/MyLoading'; import { serviceSideProps } from '@/web/common/i18n/utils'; import { getErrText } from '@fastgpt/global/common/error/utils'; import { useTranslation } from 'next-i18next'; -import { OAuthEnum } from '@fastgpt/global/support/user/constant'; import { getBdVId, getFastGPTSem, @@ -31,7 +30,12 @@ const provider = () => { const { initd, loginStore, setLoginStore } = useSystemStore(); const { setUserInfo } = useUserStore(); const router = useRouter(); - const { state, error, ...props } = router.query as Record; + const { state, error, code, ...rawProps } = router.query; + const callbackProps = Object.fromEntries( + Object.entries(rawProps).filter( + (entry): entry is [string, string] => typeof entry[1] === 'string' + ) + ); const { toast } = useToast(); const resolveLoginRedirect = useLoginRedirectAfterLogin(); @@ -79,13 +83,24 @@ const provider = () => { [lastRoute, lastTmbId, resolveLoginRedirect, router, setUserInfo, t, toast] ); - const authProps = useCallback( - async (props: Record) => { + const completeOauthLogin = useCallback( + async ({ + code, + state, + props + }: { + code: string; + state: string; + props: Record; + }) => { + if (!loginStore) return; try { const res = await oauthLogin({ - type: loginStore?.provider || OAuthEnum.sso, + provider: loginStore.provider, + code, + state, props, - callbackUrl: `${location.origin}/login/provider`, + callbackUrl: loginStore.callbackUrl, inviterId: getInviterId(), bd_vid: getBdVId(), msclkid: getMsclkid(), @@ -98,9 +113,10 @@ const provider = () => { status: 'warning', title: t('common:support.user.login.error') }); - return setTimeout(() => { + setTimeout(() => { router.replace(errorRedirectPage); }, 1000); + return; } await onFastGPTLoginSuccess(loginSuccess, res); @@ -112,19 +128,11 @@ const provider = () => { setTimeout(() => { router.replace(errorRedirectPage); }, 1000); + } finally { + setLoginStore(undefined); } - setLoginStore(undefined); }, - [ - errorRedirectPage, - i18n.language, - loginStore?.provider, - loginSuccess, - router, - setLoginStore, - t, - toast - ] + [errorRedirectPage, i18n.language, loginStore, loginSuccess, router, setLoginStore, t, toast] ); useEffect(() => { @@ -137,17 +145,21 @@ const provider = () => { return; } - if (!props || !initd) return; + if (!router.isReady || !initd) return; if (isOauthLogging) return; isOauthLogging = true; (async () => { - await retryFn(async () => clearToken()); - router.prefetch('/dashboard/agent'); - - if (loginStore && loginStore.provider !== 'sso' && state !== loginStore.state) { + const currentCallbackUrl = `${location.origin}/login/provider`; + if ( + !loginStore || + typeof state !== 'string' || + typeof code !== 'string' || + state !== loginStore.state || + loginStore.callbackUrl !== currentCallbackUrl + ) { toast({ status: 'warning', title: t('common:support.user.login.security_failed') @@ -155,12 +167,28 @@ const provider = () => { setTimeout(() => { router.replace(errorRedirectPage); }, 1000); + setLoginStore(undefined); return; - } else { - authProps(props); } + + await retryFn(async () => clearToken()); + router.prefetch('/dashboard/agent'); + await completeOauthLogin({ code, state, props: callbackProps }); })(); - }, [initd, authProps, error, loginStore, router, state, t, toast, props, errorRedirectPage]); + }, [ + callbackProps, + code, + completeOauthLogin, + error, + errorRedirectPage, + initd, + loginStore, + router, + setLoginStore, + state, + t, + toast + ]); return ; }; diff --git a/projects/app/src/service/support/user/login/service.ts b/projects/app/src/service/support/user/login/service.ts new file mode 100644 index 000000000000..8a765d953c81 --- /dev/null +++ b/projects/app/src/service/support/user/login/service.ts @@ -0,0 +1,69 @@ +import { UserError } from '@fastgpt/global/common/error/utils'; +import type { LangEnum } from '@fastgpt/global/common/i18n/type'; +import type { FastGPTSemType } from '@fastgpt/global/support/marketing/type'; +import { + reportCRMVisitorIdentity, + resolveCRMVisitorId +} from '@fastgpt/service/support/marketing/attribution'; +import type { LocalAccountIdentity } from '@fastgpt/service/support/user/account/verification/service'; +import { getUserDetail } from '@fastgpt/service/support/user/controller'; +import { MongoUser } from '@fastgpt/service/support/user/schema'; +import { createUserSession } from '@fastgpt/service/support/user/session'; + +/** + * 把可信本地身份转换为登录态,并更新语言、最后访问团队及访客归因。 + * Cookie、埋点和审计保留在 API 成功分支,避免验证失败产生登录副作用。 + */ +export const loginLocalAccount = async ({ + identity, + language, + fastgpt_sem, + ip +}: { + identity: LocalAccountIdentity; + language?: `${LangEnum}`; + fastgpt_sem?: FastGPTSemType; + ip?: string | null; +}) => { + if (identity.username.startsWith('wecom-')) { + throw new UserError('Wecom user can not login with password'); + } + + const user = await getUserDetail({ + tmbId: identity.lastLoginTmbId, + userId: identity.userId, + isRoot: identity.isRoot + }); + + const account = await MongoUser.findById(identity.userId, 'fastgpt_sem').lean(); + const visitorIdentity = resolveCRMVisitorId({ + storedFastgptSem: account?.fastgpt_sem, + incomingVisitorId: fastgpt_sem?.visitor_id + }); + + await MongoUser.updateOne( + { _id: identity.userId }, + { + lastLoginTmbId: user.team.tmbId, + ...(language && { language }), + ...(visitorIdentity.shouldPersist && { fastgpt_sem: visitorIdentity.fastgptSem }) + } + ); + + const token = await createUserSession({ + userId: identity.userId, + teamId: user.team.teamId, + tmbId: user.team.tmbId, + isRoot: identity.isRoot, + ip + }); + + void reportCRMVisitorIdentity({ + visitorId: visitorIdentity.visitorId, + userId: identity.userId, + username: user.username, + contact: user.contact + }); + + return { user, token }; +}; diff --git a/projects/app/src/web/common/system/useSystemStore.ts b/projects/app/src/web/common/system/useSystemStore.ts index 91e4f89fd004..033942a26acb 100644 --- a/projects/app/src/web/common/system/useSystemStore.ts +++ b/projects/app/src/web/common/system/useSystemStore.ts @@ -1,6 +1,6 @@ import { create, devtools, persist, immer } from '@fastgpt/web/common/zustand'; import axios from 'axios'; -import type { OAuthEnum } from '@fastgpt/global/support/user/constant'; +import type { OAuthAccountVerificationProvider } from '@fastgpt/global/support/user/account/verification/type'; import type { TTSModelType, LLMModelItemType, @@ -22,7 +22,13 @@ import { } from '@fastgpt/global/core/ai/provider'; import { getMyModels, getOperationalAd } from './api'; -type LoginStoreType = { provider: OAuthEnum; lastRoute: string; state: string; lastTmbId?: string }; +type LoginStoreType = { + provider: OAuthAccountVerificationProvider; + lastRoute: string; + state: string; + callbackUrl: string; + lastTmbId?: string; +}; export type NotSufficientModalType = | TeamErrEnum.datasetSizeNotEnough diff --git a/projects/app/src/web/support/user/api.ts b/projects/app/src/web/support/user/api.ts index 8c739c26be7f..22742e5393c8 100644 --- a/projects/app/src/web/support/user/api.ts +++ b/projects/app/src/web/support/user/api.ts @@ -1,6 +1,5 @@ import { GET, POST, PUT } from '@/web/common/api/request'; import { hashStr } from '@fastgpt/global/common/string/tools'; -import type { UserAuthTypeEnum } from '@fastgpt/global/support/user/auth/constants'; import type { UserUpdateParams } from '@/types/user'; import type { UserType } from '@fastgpt/global/support/user/type'; import type { SearchResult } from '@fastgpt/global/support/user/api'; @@ -8,6 +7,8 @@ import type { PreLoginResponseType, LoginByPasswordBodyType, OauthLoginBodyType, + CreateOauthLoginBodyType, + CreateOauthLoginResponseType, FastLoginBodyType, WxLoginBodyType, GetWXLoginQRResponseType @@ -17,21 +18,20 @@ import type { UpdatePasswordByOldBodyType } from '@fastgpt/global/openapi/support/user/account/password/api'; import type { AccountRegisterBodyType } from '@fastgpt/global/openapi/support/user/account/register/api'; -import type { LangEnum } from '@fastgpt/global/common/i18n/type'; import type { LoginSuccessResponseType } from '@fastgpt/global/openapi/support/user/account/login/api'; +import type { + GetAccountCaptchaResponse, + SendAccountVerificationCodeBody, + SendAccountVerificationCodeResponse +} from '@fastgpt/global/openapi/support/user/account/verification/api'; /* ===== Auth code ===== */ -export const sendAuthCode = (data: { - username: string; - type: `${UserAuthTypeEnum}`; - googleToken: string; - captcha: string; - lang: `${LangEnum}`; -}) => POST(`/proApi/support/user/inform/sendAuthCode`, data); +export const sendAuthCode = (data: SendAccountVerificationCodeBody) => + POST(`/proApi/support/user/inform/sendAuthCode`, data); export const getCaptchaPic = (username: string) => - GET<{ - captchaImage: string; - }>('/proApi/support/user/account/captcha/getImgCaptcha', { username }); + GET('/proApi/support/user/account/captcha/getImgCaptcha', { + username + }); /* ===== login ===== */ export const getPreLogin = (username: string) => @@ -41,6 +41,8 @@ export const getTokenLogin = () => GET('/support/user/account/tokenLogin', {}, { maxQuantity: 1 }); export const oauthLogin = (params: OauthLoginBodyType) => POST('/proApi/support/user/account/login/oauth', params); +export const createOauthLogin = (params: CreateOauthLoginBodyType) => + POST('/proApi/support/user/account/login/oauth/create', params); export const postFastLogin = (params: FastLoginBodyType) => POST('/proApi/support/user/account/login/fastLogin', params); export const ssoLogin = (params: any) => diff --git a/projects/app/src/web/support/user/hooks/useSendCode.tsx b/projects/app/src/web/support/user/hooks/useSendCode.tsx index 504c249511cb..d518379d49ea 100644 --- a/projects/app/src/web/support/user/hooks/useSendCode.tsx +++ b/projects/app/src/web/support/user/hooks/useSendCode.tsx @@ -1,6 +1,5 @@ import { useState, useMemo } from 'react'; import { sendAuthCode } from '@/web/support/user/api'; -import type { UserAuthTypeEnum } from '@fastgpt/global/support/user/auth/constants'; import { useTranslation } from 'next-i18next'; import { useSystemStore } from '@/web/common/system/useSystemStore'; import { useRequest } from '@fastgpt/web/hooks/useRequest'; @@ -9,9 +8,10 @@ import SendCodeAuthModal from '@/components/support/user/safe/SendCodeAuthModal' import { useMemoizedFn } from 'ahooks'; import { useToast } from '@fastgpt/web/hooks/useToast'; import type { LangEnum } from '@fastgpt/global/common/i18n/type'; +import type { CodeAccountVerificationScene } from '@fastgpt/global/support/user/account/verification/type'; let timer: NodeJS.Timeout; -export const useSendCode = ({ type }: { type: `${UserAuthTypeEnum}` }) => { +export const useSendCode = ({ type }: { type: CodeAccountVerificationScene }) => { const { t, i18n } = useTranslation(); const { feConfigs } = useSystemStore(); const { toast } = useToast(); diff --git a/projects/app/test/api/support/user/account/loginByPassword.test.ts b/projects/app/test/api/support/user/account/loginByPassword.test.ts index 6f2185018cb7..f5b76568af0b 100644 --- a/projects/app/test/api/support/user/account/loginByPassword.test.ts +++ b/projects/app/test/api/support/user/account/loginByPassword.test.ts @@ -4,7 +4,6 @@ import { MongoUser } from '@fastgpt/service/support/user/schema'; import { UserStatusEnum } from '@fastgpt/global/support/user/constant'; import { MongoTeam } from '@fastgpt/service/support/user/team/teamSchema'; import { MongoTeamMember } from '@fastgpt/service/support/user/team/teamMemberSchema'; -import { authCode } from '@fastgpt/service/support/user/auth/controller'; import { setCookie } from '@fastgpt/service/support/permission/auth/common'; import { pushTrack } from '@fastgpt/service/common/middle/tracks/utils'; import { addAuditLog } from '@fastgpt/service/support/user/audit/util'; @@ -12,11 +11,13 @@ import { UserErrEnum } from '@fastgpt/global/common/error/code/user'; import type { LoginByPasswordBodyType } from '@fastgpt/global/openapi/support/user/account/login/api'; import { Call } from '@test/utils/request'; import { initTeamFreePlan } from '@fastgpt/service/support/wallet/sub/utils'; +import { passwordAccountVerification } from '@fastgpt/service/support/user/account/verification/password/service'; describe('loginByPassword API', () => { let testUser: any; let testTeam: any; let testTmb: any; + let preLoginCode: string; beforeEach(async () => { testUser = await MongoUser.create({ @@ -44,6 +45,7 @@ describe('loginByPassword API', () => { await MongoUser.findByIdAndUpdate(testUser._id, { lastLoginTmbId: testTmb._id }); + preLoginCode = (await passwordAccountVerification.create({ username: 'testuser' })).code; vi.clearAllMocks(); }); @@ -53,7 +55,7 @@ describe('loginByPassword API', () => { body: { username: 'testuser', password: 'testpassword', - code: '123456', + code: preLoginCode, language: 'zh-CN' } }); @@ -68,11 +70,6 @@ describe('loginByPassword API', () => { expect(typeof res.data.token).toBe('string'); expect(res.data.token.length).toBeGreaterThan(0); - expect(authCode).toHaveBeenCalledWith({ - key: 'testuser', - code: '123456', - type: expect.any(String) - }); expect(setCookie).toHaveBeenCalled(); expect(pushTrack.login).toHaveBeenCalledWith({ type: 'password', @@ -88,7 +85,7 @@ describe('loginByPassword API', () => { body: { username: '', password: 'testpassword', - code: '123456', + code: preLoginCode, language: 'zh-CN' } }); @@ -101,7 +98,7 @@ describe('loginByPassword API', () => { body: { username: 'testuser', password: '', - code: '123456', + code: preLoginCode, language: 'zh-CN' } }); @@ -112,8 +109,6 @@ describe('loginByPassword API', () => { }); it('should reject login when auth code verification fails', async () => { - vi.mocked(authCode).mockRejectedValueOnce(new Error('Invalid code')); - const res = await Call, any>(loginApi.default, { body: { username: 'testuser', @@ -128,11 +123,12 @@ describe('loginByPassword API', () => { }); it('should reject login when user does not exist', async () => { + const { code } = await passwordAccountVerification.create({ username: 'nonexistentuser' }); const res = await Call, any>(loginApi.default, { body: { username: 'nonexistentuser', password: 'testpassword', - code: '123456', + code, language: 'zh-CN' } }); @@ -150,7 +146,7 @@ describe('loginByPassword API', () => { body: { username: 'testuser', password: 'testpassword', - code: '123456', + code: preLoginCode, language: 'zh-CN' } }); @@ -164,7 +160,7 @@ describe('loginByPassword API', () => { body: { username: 'testuser', password: 'wrongpassword', - code: '123456', + code: preLoginCode, language: 'zh-CN' } }); @@ -178,7 +174,7 @@ describe('loginByPassword API', () => { body: { username: 'testuser', password: 'testpassword', - code: '123456', + code: preLoginCode, language: 'en' } }); @@ -195,7 +191,7 @@ describe('loginByPassword API', () => { body: { username: 'testuser', password: 'testpassword', - code: '123456', + code: preLoginCode, fastgpt_sem: { visitor_id: 'visitor-1' }, @@ -218,7 +214,7 @@ describe('loginByPassword API', () => { body: { username: 'testuser', password: 'testpassword', - code: '123456', + code: preLoginCode, fastgpt_sem: { visitor_id: 'incoming-visitor' }, @@ -258,12 +254,13 @@ describe('loginByPassword API', () => { await MongoUser.findByIdAndUpdate(rootUser._id, { lastLoginTmbId: rootTmb._id }); + const { code } = await passwordAccountVerification.create({ username: 'root' }); const res = await Call, any>(loginApi.default, { body: { username: 'root', password: 'rootpassword', - code: '123456', + code, language: 'zh-CN' } }); diff --git a/projects/app/test/service/support/user/login/service.test.ts b/projects/app/test/service/support/user/login/service.test.ts new file mode 100644 index 000000000000..63974d20b403 --- /dev/null +++ b/projects/app/test/service/support/user/login/service.test.ts @@ -0,0 +1,86 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { UserStatusEnum } from '@fastgpt/global/support/user/constant'; +import { MongoUser } from '@fastgpt/service/support/user/schema'; +import { MongoTeam } from '@fastgpt/service/support/user/team/teamSchema'; +import { MongoTeamMember } from '@fastgpt/service/support/user/team/teamMemberSchema'; +import { initTeamFreePlan } from '@fastgpt/service/support/wallet/sub/utils'; +import { loginLocalAccount } from '@/service/support/user/login/service'; + +describe('loginLocalAccount', () => { + let identity: { + kind: 'local'; + userId: string; + username: string; + lastLoginTmbId: string; + isRoot: boolean; + }; + + beforeEach(async () => { + const user = await MongoUser.create({ + username: 'user', + password: 'password', + status: UserStatusEnum.active + }); + const team = await MongoTeam.create({ name: 'Team', ownerId: user._id }); + await initTeamFreePlan({ teamId: String(team._id) }); + const tmb = await MongoTeamMember.create({ + teamId: team._id, + userId: user._id, + status: 'active', + role: 'owner' + }); + identity = { + kind: 'local', + userId: String(user._id), + username: user.username, + lastLoginTmbId: String(tmb._id), + isRoot: false + }; + }); + + it('loads the user, updates login preferences and creates a session', async () => { + const result = await loginLocalAccount({ identity, language: 'en', ip: '127.0.0.1' }); + + expect(result.user.team.tmbId).toBe(identity.lastLoginTmbId); + expect(result.token).toEqual(expect.any(String)); + await expect(MongoUser.findById(identity.userId).lean()).resolves.toMatchObject({ + language: 'en', + lastLoginTmbId: identity.lastLoginTmbId + }); + }); + + it('keeps the Wecom password-login restriction in the application layer', async () => { + await expect( + loginLocalAccount({ + identity: { ...identity, username: 'wecom-user' } + }) + ).rejects.toThrow('Wecom user can not login with password'); + }); + + it('persists an incoming visitor id when the user has no stored attribution', async () => { + await loginLocalAccount({ + identity, + fastgpt_sem: { visitor_id: 'visitor-1' } + }); + + await expect(MongoUser.findById(identity.userId).lean()).resolves.toMatchObject({ + fastgpt_sem: { visitor_id: 'visitor-1' } + }); + }); + + it('keeps the stored visitor id when login carries a different one', async () => { + await MongoUser.updateOne( + { _id: identity.userId }, + { fastgpt_sem: { visitor_id: 'stored-visitor' } } + ); + + await loginLocalAccount({ + identity, + fastgpt_sem: { visitor_id: 'incoming-visitor' } + }); + + await expect(MongoUser.findById(identity.userId).lean()).resolves.toMatchObject({ + fastgpt_sem: { visitor_id: 'stored-visitor' } + }); + }); +}); From 59215bbfe3e44488acf9eab5d4cdf56ac408bd80 Mon Sep 17 00:00:00 2001 From: shortlight5980 Date: Wed, 15 Jul 2026 17:45:59 +0800 Subject: [PATCH 02/10] account verification fix --- .../account- verification.md | 1595 +++++++++++++++++ .../login-register-find-password.md | 92 + 2 files changed, 1687 insertions(+) create mode 100644 .agents/design/account-verification/account- verification.md create mode 100644 .agents/design/account-verification/login-register-find-password.md diff --git a/.agents/design/account-verification/account- verification.md b/.agents/design/account-verification/account- verification.md new file mode 100644 index 000000000000..726fdb834224 --- /dev/null +++ b/.agents/design/account-verification/account- verification.md @@ -0,0 +1,1595 @@ +# 账号身份验证组件技术设计 + +状态:设计稿(按推荐默认方案收口) +日期:2026-07-13 +Mermaid 兼容基线:8.8.3 +关联需求:[requirements.md](./requirements.md) + +## 1. 结论 + +本方案不建立新的认证协议,而是把现有的短期验证材料收拢为统一组件: + +```text +create:创建验证材料 +consume:校验并消费验证材料,返回可信身份 +``` + +验证组件到“可信身份”为止。创建 FastGPT 用户、加载团队、创建 Session、写 Cookie、埋点、审计以及注册/改密/绑定/注销等业务仍在组件之外。 + +本设计采用以下默认决策: + +1. OAuth state 改由服务端生成、保存和一次性消费,但仍复用 `auth_codes` 集合。 +2. 材料消费必须显式检查过期时间并使用原子删除;TTL 只负责异步清理。 +3. `{ key, type }` 最终升级为唯一索引,保证同一验证场景只保留最新材料。 +4. 微信扫码、OAuth Provider 交换和验证码发送逻辑从 API 路由下沉到验证服务。 +5. `usernameLogin` 保留业务能力,但迁入专用登录应用服务,并只接收可信外部身份。 +6. 快速登录不实现验证类,按独立废弃计划移除。 +7. 前端展示与后端 create 分派共同使用 `resolveAccountVerificationByUsername`;后端在 create 时以持久化 username 和真实配置为最终依据,consume 沿用材料绑定。 + +## 2. 设计原则与边界 + +### 2.1 依赖方向 + +```mermaid +flowchart TB + subgraph API["API 边界"] + PasswordAPI["密码登录 API"] + CodeAPI["注册 / 改密 / 绑定 API"] + WechatAPI["微信 QR / callback / result API"] + OAuthAPI["OAuth create / consume API"] + end + + subgraph Application["应用编排层"] + LocalLogin["loginLocalAccount"] + ExternalLogin["loginExternalAccount"] + AccountBusiness["注册 / 改密 / 绑定 / 注销"] + end + + subgraph Verification["身份验证组件"] + Password["PasswordAccountVerification"] + Code["CodeAccountVerification"] + Wechat["WechatAccountVerification"] + OAuth["OAuthAccountVerification"] + end + + Material["VerificationMaterial entity"] + Mongo[("MongoDB auth_codes")] + User[("MongoDB users / teams")] + Session[("Redis session")] + + PasswordAPI --> Password --> LocalLogin + CodeAPI --> Code --> AccountBusiness + WechatAPI --> Wechat --> ExternalLogin + OAuthAPI --> OAuth --> ExternalLogin + Password --> Material + Code --> Material + Wechat --> Material + OAuth --> Material + Material --> Mongo + LocalLogin --> User + ExternalLogin --> User + LocalLogin --> Session + ExternalLogin --> Session + AccountBusiness --> User +``` + +依赖必须单向: + +- API 可以依赖验证组件和应用服务。 +- 应用服务可以依赖用户、团队、Session 服务,但不能反向被验证组件依赖。 +- 验证组件可以依赖短期材料实体和 Provider SDK/HTTP,不依赖 Cookie、埋点或具体账号业务。 +- `packages/global` 中的 username resolver 只依赖共享 schema、常量和纯函数,前端与服务端都可安全导入。 +- `packages/service` 不得导入 `pro/admin`;商业实现只能向下依赖共享抽象。 + +### 2.2 与相邻概念的区别 + +| 概念 | 本方案中的位置 | +| --- | --- | +| Account verification | 校验密码、验证码、微信扫码或 OAuth code,产出可信身份 | +| User provisioning | 根据可信外部身份查找或创建 FastGPT 用户,由登录应用服务负责 | +| Session authentication | 使用 Redis Session 校验后续请求,继续由 `authUserSession` / `parseHeaderCert` 负责 | +| Authorization | 团队、资源、API key 权限,不属于本组件 | +| Token login | 读取已有 Session,不是新的验证方式 | +| Fast login | 外部自定义快速登录协议,明确不纳入组件 | + +## 3. 核心契约 + +### 3.1 通用抽象 + +```ts +/** + * 统一账号验证方式的材料创建与消费模型。 + * 泛型允许不同方式保留自己的协议,不要求返回相同材料或身份结构。 + */ +export abstract class AccountVerification< + TCreateParams, + TCreateResult, + TConsumeParams, + TConsumeResult +> { + abstract create(params: TCreateParams): Promise; + abstract consume(params: TConsumeParams): Promise; +} +``` + +不增加统一 `verify()`、`login()` 或 Provider 大枚举分支。`create` / `consume` 是唯一公共模型,Provider 特有的回调写入可以作为实现类的附加方法。 + +### 3.2 可信身份 + +验证结果使用带判别字段的服务端内部类型,不直接返回 Mongoose Document: + +```ts +type LocalAccountIdentity = { + kind: 'local'; + userId: string; + username: string; + lastLoginTmbId?: string; + isRoot: boolean; +}; + +type VerifiedContactIdentity = { + kind: 'contact'; + account: string; + scene: 'register' | 'findPassword' | 'bindNotification'; +}; + +type ExternalAccountIdentity = { + kind: 'external'; + provider: 'github' | 'google' | 'microsoft' | 'wecom' | 'sso' | 'wechat'; + subject: string; + username: string; + avatar?: string; + notificationAccount?: string; + phonePrefix?: number; + teamName?: string; + memberName?: string; + organizationId?: string; +}; +``` + +约束: + +- `subject` 是 Provider 返回的稳定账号 ID;SSO 没有独立 subject 时退化为已验证 username。 +- `username` 映射必须保持当前兼容值:`git-*`、`google-*`、`microsoft-*`、`wecom-*`、`wechat-*`;SSO 继续使用其返回值。 +- 受“不新增身份绑定表”约束,实际用户查找仍使用兼容 username;`subject` 用于响应校验和后续演进,不能宣称已解决 Provider 账号改名问题。 +- `organizationId` 只描述已验证的外部组织,不在验证组件内查询 FastGPT 团队或创建用户。 +- 类型只在服务端使用,不放入前端 API schema。 + +### 3.3 实例与工厂 + +```mermaid +classDiagram + class AccountVerification { + <> + +create(params) + +consume(params) + } + class PasswordAccountVerification + class CodeAccountVerification + class WechatAccountVerification { + +recordCallback(params) + } + class OAuthAccountVerification { + <> + #buildAuthorizationUrl(params) + #exchangeCode(params) + } + class GithubAccountVerification + class GoogleAccountVerification + class MicrosoftAccountVerification + class WecomAccountVerification + class SsoAccountVerification + + AccountVerification <|-- PasswordAccountVerification + AccountVerification <|-- CodeAccountVerification + AccountVerification <|-- WechatAccountVerification + AccountVerification <|-- OAuthAccountVerification + OAuthAccountVerification <|-- GithubAccountVerification + OAuthAccountVerification <|-- GoogleAccountVerification + OAuthAccountVerification <|-- MicrosoftAccountVerification + OAuthAccountVerification <|-- WecomAccountVerification + OAuthAccountVerification <|-- SsoAccountVerification +``` + +OAuth 路由通过 `getOAuthAccountVerification(provider)` 取得实例。工厂必须穷举支持的五个 Provider;`wechat` 虽仍存在于当前 `OAuthEnum`,但只能走微信扫码实现,不能进入 OAuth 工厂。 + +### 3.4 根据用户名推导验证方式 + +#### 3.4.1 设计目标 + +新增前后端共享纯函数: + +```ts +resolveAccountVerificationByUsername(params): AccountVerificationResolution +``` + +它只负责把 username 分类,并结合部署能力自动选中唯一验证方式。只有没有可展示的验证码或 Provider 方式时才返回旧密码。它不读取数据库、全局配置或浏览器状态,也不创建验证材料。这样前端和后端使用的是同一套分支逻辑,但后端仍以自己的输入为准。 + +共享数据结构使用 Zod schema 推导类型。验证方式采用图中约定的稳定字符串,避免对象比较、前后端序列化差异以及额外的 `isSameMethod`: + +```ts +export const AccountVerificationMethodSchema = z.enum([ + 'code', + 'oldPassword', + 'wechat', + 'oauth/github', + 'oauth/google', + 'oauth/microsoft', + 'oauth/wecom', + 'oauth/sso' +]); +export type AccountVerificationMethod = z.infer; + +export const AccountEmailUsernameSchema = z.email().max(254); +export const AccountPhoneUsernameSchema = z.string().regex(/^1[3456789]\d{9}$/); + +export const AccountVerificationCapabilitiesSchema = z.object({ + emailCode: z.boolean(), + phoneCode: z.boolean(), + wechat: z.boolean(), + oauth: z.object({ + github: z.boolean(), + google: z.boolean(), + microsoft: z.boolean(), + wecom: z.boolean(), + sso: z.boolean() + }) +}); +export type AccountVerificationCapabilities = z.infer< + typeof AccountVerificationCapabilitiesSchema +>; + +export const RecognizedAccountKindSchema = z.enum([ + 'email', + 'phone', + 'local', + 'wechat', + 'github', + 'google', + 'microsoft', + 'wecom', + 'sso' +]); +export const AccountKindSchema = z.union([RecognizedAccountKindSchema, z.literal('invalid')]); + +export const AccountVerificationUnsupportedReasonSchema = z.literal('empty_username'); + +export const AccountVerificationResolutionSchema = z.discriminatedUnion('status', [ + z.object({ + status: z.literal('supported'), + accountKind: RecognizedAccountKindSchema, + method: AccountVerificationMethodSchema, + unsupportedReason: z.undefined().optional() + }), + z.object({ + status: z.literal('unsupported'), + accountKind: z.literal('invalid'), + method: z.undefined().optional(), + unsupportedReason: AccountVerificationUnsupportedReasonSchema + }) +]); +export type AccountVerificationResolution = z.infer< + typeof AccountVerificationResolutionSchema +>; +``` + +`capabilities` 只描述部署实际具备哪些验证码或 Provider 能力,不能藏在 resolver 内读取全局变量。旧密码不是可关闭的业务策略:`users.password` 在当前模型中必填,因此没有其它可展示方式的非空 username 最终都能得到 `oldPassword`。 + +返回值由 resolver 保证三条契约:非空 username 一定为 `status=supported`;支持结果只有一个 `method`;`oldPassword` 不会与验证码或 Provider 方式一起返回。只有空 username 返回 `unsupported/empty_username`。 + +#### 3.4.2 推导函数 + +```ts +/** + * 根据 username 和部署能力推导唯一验证方式。 + * 前端只能展示该方式;后端必须重新调用,不能信任前端结果。 + */ +export const resolveAccountVerificationByUsername = ({ + username, + capabilities +}: { + username: string; + capabilities: AccountVerificationCapabilities; +}): AccountVerificationResolution => { + const normalizedUsername = username.trim(); + if (!normalizedUsername) { + return { + status: 'unsupported', + accountKind: 'invalid', + unsupportedReason: 'empty_username' + }; + } + + /** 前缀必须完整匹配 `${prefix}-`,并且分隔符后至少有一个字符。 */ + const hasPrefix = (prefix: string) => + normalizedUsername.startsWith(`${prefix}-`) && + normalizedUsername.length > prefix.length + 1; + + // 客户 SSO 前缀不做枚举:第一个分隔符前后均非空即可。 + const firstSeparatorIndex = normalizedUsername.indexOf('-'); + const hasSsoPrefix = + firstSeparatorIndex > 0 && firstSeparatorIndex < normalizedUsername.length - 1; + + // 邮箱/手机号优先,避免 user-name@example.com 被通用连字符规则识别为 SSO。 + const accountKind = (() => { + if (AccountEmailUsernameSchema.safeParse(normalizedUsername).success) return 'email'; + if (AccountPhoneUsernameSchema.safeParse(normalizedUsername).success) return 'phone'; + if (hasPrefix('wechat')) return 'wechat'; + if (hasPrefix('git')) return 'github'; + if (hasPrefix('google')) return 'google'; + if (hasPrefix('microsoft')) return 'microsoft'; + if (hasPrefix('wecom')) return 'wecom'; + if (capabilities.oauth.sso && hasSsoPrefix) return 'sso'; + return 'local'; + })() satisfies z.infer; + + type ConfiguredAccountVerificationMethod = Exclude< + AccountVerificationMethod, + 'oldPassword' + >; + + // 候选顺序只供 resolver 内部自动降级,不能作为可选列表返回给客户端。 + const candidateMethods: readonly ConfiguredAccountVerificationMethod[] = (() => { + switch (accountKind) { + case 'email': + case 'phone': + return ['code'] as const; + case 'local': + return []; + case 'wechat': + return ['wechat'] as const; + case 'github': + return ['oauth/github'] as const; + case 'google': + return ['oauth/google'] as const; + case 'microsoft': + return ['oauth/microsoft'] as const; + case 'sso': + return ['oauth/sso'] as const; + case 'wecom': + return ['oauth/sso', 'oauth/wecom'] as const; + default: { + const exhaustiveAccountKind: never = accountKind; + return exhaustiveAccountKind; + } + } + })(); + + const isMethodAvailable = (method: ConfiguredAccountVerificationMethod) => { + switch (method) { + case 'code': + return accountKind === 'email' ? capabilities.emailCode : capabilities.phoneCode; + case 'wechat': + return capabilities.wechat; + case 'oauth/github': + return capabilities.oauth.github; + case 'oauth/google': + return capabilities.oauth.google; + case 'oauth/microsoft': + return capabilities.oauth.microsoft; + case 'oauth/wecom': + return capabilities.oauth.wecom; + case 'oauth/sso': + return capabilities.oauth.sso; + default: { + const exhaustiveMethod: never = method; + return exhaustiveMethod; + } + } + }; + + const method = candidateMethods.find(isMethodAvailable) ?? 'oldPassword'; + return { + status: 'supported', + accountKind, + method + }; +}; +``` + +`AccountEmailUsernameSchema` 与 `AccountPhoneUsernameSchema` 同样放在 `type.ts`。邮箱统一使用本地 Zod 4 的 `z.email()` 并限制总长度,不再沿用服务端 `includes('@')` 或两个前端表单中重复的窄正则。邮箱 local-part 和域名标签都允许出现在合法位置的 `-`,例如 `user-name@example-domain.com`,因此邮箱判定必须先于通用 SSO 连字符判定。确切语法只由共享 schema 和 fixture 决定,resolver 不再解释邮箱字符规则。 + +手机号沿用注册/找回密码表单当前的 `^1[3456789]\d{9}$` 规则;实现时同样把散落正则迁到共享 schema。resolver 只在去除首尾空白的副本上分类,不改写大小写,也不修改用于数据库查询、发送验证码或身份归属比较的原始 username。 + +明确的直连 Provider 前缀必须按大小写精确匹配 `${prefix}-` 且后缀非空。其余账号只判断第一个 `-` 前后是否均非空,不枚举 SSO 客户前缀: + +| Username | `accountKind` | resolver 唯一返回结果 | +| --- | --- | --- | +| 邮箱 | `email` | 邮件能力可用时为 `code`,否则为 `oldPassword` | +| 手机号 | `phone` | 短信能力可用时为 `code`,否则为 `oldPassword` | +| 普通账号,或 SSO 未配置时的未知连字符账号 | `local` | `oldPassword` | +| `wechat-*` | `wechat` | 微信能力可用时为 `wechat`,否则为 `oldPassword` | +| `git-*` | `github` | GitHub 能力可用时为 `oauth/github`,否则为 `oldPassword` | +| `google-*` | `google` | Google 能力可用时为 `oauth/google`,否则为 `oldPassword` | +| `microsoft-*` | `microsoft` | Microsoft 能力可用时为 `oauth/microsoft`,否则为 `oldPassword` | +| `wecom-*` | `wecom` | SSO 可用时为 `oauth/sso`,否则内部 Wecom 可用时为 `oauth/wecom`,再否则为 `oldPassword` | +| 其它 `prefix-suffix` 且 SSO 已配置 | `sso` | `oauth/sso` | + +`accountKind=sso` 只表示“交给统一 SSO 中转”,不表示 Feishu、DingTalk 或任何具体 Provider。`RecognizedAccountKindSchema` 枚举的是 resolver 的有限输出类型,不是客户 SSO prefix 枚举。 + +除 Wecom 外,明确的直连 Provider 绝不降级为 SSO。例如 `git-*` 在 GitHub OAuth 未配置时唯一得到 `method=oldPassword`,不能改走 SSO。Wecom 是唯一跨两条企业身份链路的特例,由 resolver 固定按 SSO、内部 Wecom、旧密码顺序自动选中第一个可用方式。只有“不属于明确直连 Provider”的连字符账号受 SSO capability 控制:SSO 可用时唯一返回 `oauth/sso`,不可用时按本地账号返回 `oldPassword`。 + +启用 SSO 会占用所有未知 `prefix-suffix` 命名空间。上线前必须扫描已有本地连字符账号并迁移冲突;不通过 Admin 前缀白名单制造另一套长期规则。 + +#### 3.4.3 能力输入 + +resolver 不直接理解 `feConfigs` 或 `global.systemConfig`。前后端分别用适配函数生成同一个 `AccountVerificationCapabilities`,避免把环境读取混入纯函数: + +| 能力 | 前端公开配置来源 | 后端权威判断 | +| --- | --- | --- | +| `emailCode` / `phoneCode` | 当前业务 scene 对应的公开 method 列表 | 邮件/SMS 发送配置、scene 模板和业务开关均可用 | +| `wechat` | `feConfigs.oauth.wechat` | 微信 `appID`、`appSecret` 等必填配置完整 | +| `oauth.github` / `google` / `microsoft` | 对应 `feConfigs.oauth` 客户端配置存在 | 对应 client id 与 secret 等服务端配置完整 | +| `oauth.wecom` | 服务端下发的可用布尔值,不由浏览器只看按钮配置推断 | 企业微信内部应用/套件配置完整;与 SSO 同时启用时还需确认 `wecom-*` 身份命名空间兼容 | +| `oauth.sso` | `feConfigs.sso.url` | SSO URL、License 与服务端访问配置均有效 | + +账号注销等新增 scene 不能误用 `bind_notification_method` 猜测验证码能力,应在服务端配置归一化时增加该 scene 的公开 capability。SSO 只需要已有的可用性 capability,不新增 prefix 列表或 Admin 配置;client id、secret、短信密钥等敏感值绝不进入 resolver 输入。 + +本地代码中 `pro/sso/src/provider/wecom.ts` 使用 `wecom-{userid}`,内部套件 `pro/admin/src/service/support/wecom/auth.ts` 使用 `wecom-{open_userid}`。两者并非天然相等。因此 `oauth.wecom=true` 在双入口场景必须表示“配置可用且部署已通过迁移或映射确认两条链路产出同一持久化 username”,不能只表示按钮已配置。未完成对齐时只开放账号原始来源对应的入口;即使配置误报为可用,consume 后的精确 username 校验仍必须拒绝不一致身份。 + +```mermaid +flowchart TD + U["username + capabilities"] --> E{"username 为空?"} + E -- "是" --> X0["unsupported / invalid / empty_username"] + E -- "否" --> F{"邮箱 -> 手机号 -> 明确直连 Provider"} + F -- "邮箱 / 手机号" --> P["生成首选候选 code"] + F -- "wechat-*" --> P2["生成首选候选 wechat"] + F -- "git/google/microsoft-*" --> P3["生成对应 OAuth 首选候选"] + F -- "wecom-*" --> P4["生成 SSO -> Wecom 首选候选"] + F -- "其它" --> SC{"SSO 已配置且第一个 - 前后非空?"} + SC -- "是" --> P5["生成首选候选 oauth/sso"] + SC -- "否" --> P6["无额外首选候选"] + + P --> C["按 capabilities 查找第一个可用候选"] + P2 --> C + P3 --> C + P4 --> C + P5 --> C + P6 --> C + C --> D{"找到候选?"} + D -- "是" --> M["返回该唯一 method"] + D -- "否" --> O["唯一 method = oldPassword"] + M --> R["AccountVerificationResolution"] + O --> R + + R --> FE["前端只渲染唯一入口"] + R --> BE["后端按唯一 method 分派 verification"] +``` + +#### 3.4.4 前端使用 + +前端从当前用户信息取得 username,将公开的 `feConfigs` 转成 `AccountVerificationCapabilities`: + +```ts +const resolution = resolveAccountVerificationByUsername({ + username: userInfo.username, + capabilities: getAccountVerificationCapabilitiesFromFeConfig(feConfigs) +}); + +if (resolution.status === 'unsupported') { + return renderUnsupported(resolution.unsupportedReason); +} + +switch (resolution.method) { + case 'code': + return renderCodeForm({ channel: resolution.accountKind }); + case 'oldPassword': + return renderOldPasswordForm(); + case 'wechat': + return renderWechatPanel(); + default: + return renderOAuthButton(resolution.method); +} +``` + +页面只渲染 `resolution.method` 对应的一个入口,不提供“使用旧密码”或其它验证方式切换入口。仅当 resolver 判断当前账号没有可展示的验证码或 Provider 方式时,页面才会得到并展示 `oldPassword`。前端结果只控制 UI,不作为后端授权依据。 + +#### 3.4.5 后端使用 + +后端只在敏感验证流程的 create 入口做一次权威推导。请求中的 method 是 Zod 判别字段,不是用户可选策略;服务端先通过 Session 得到 userId,再读取数据库中的 username 和真实 Provider 配置并精确比对: + +```ts +const { body } = parseApiInput({ + req, + bodySchema: SensitiveAccountVerificationCreateBodySchema +}); +const { userId } = await parseHeaderCert({ req, authToken: true }); +const user = await MongoUser.findById(userId, { username: 1 }).lean(); +if (!user) { + throw new UserError('User not found'); +} + +const resolution = resolveAccountVerificationByUsername({ + username: user.username, + capabilities: getAccountVerificationCapabilitiesFromServerConfig() +}); + +if (resolution.status === 'unsupported' || body.method !== resolution.method) { + throw new UserError('Verification method is not allowed for this account'); +} + +const result = await createAccountVerificationForUser({ + method: resolution.method, + payload: body.payload, + user, + scene: 'accountCancellation' +}); +``` + +create 必须把 `method + userId + scene` 绑定到现有验证材料的 key/type/provider 命名空间。后续 action/consume 请求仍以 method 作为 payload 判别字段,但不再读取 capabilities 或调用 resolver,只校验请求 method 与材料绑定一致: + +```ts +const { body } = parseApiInput({ + req, + bodySchema: SensitiveAccountVerificationBodySchema +}); +const { userId } = await parseHeaderCert({ req, authToken: true }); +const user = await MongoUser.findById(userId, { username: 1 }).lean(); +if (!user) { + throw new UserError('User not found'); +} +const identity = await consumeAccountVerificationForUser({ + verification: body, + user, + scene: 'accountCancellation' +}); +await assertVerifiedIdentityMatchesUser({ user, identity }); +``` + +敏感业务请求 schema 根本不包含 username,而不是“接收后忽略”。method 虽由客户端随当前唯一入口回传,但 create 会与服务端 resolver 结果精确比较,consume 会与服务端材料绑定精确比较,因此客户端不能借此选择其它方式。`consumeAccountVerificationForUser` 位于服务端应用层,按绑定后的 method 调用: + +| method | 后端调用 | 归属校验 | +| --- | --- | --- | +| `code` | `codeVerification.consume({ account: user.username, scene, code })` | `identity.account === user.username` | +| `oldPassword` | `passwordVerification.consume({ username: user.username, password, code: preLoginCode })` | `identity.userId === user._id` | +| `wechat` | `wechatVerification.consume({ code })` | `identity.username === user.username` | +| `oauth/*` | `getOAuthAccountVerification(provider).consume(...)` | `identity.username === user.username` | + +所有比较都使用持久化原值,不使用 resolver 的小写副本。resolver 只确定 create 时的唯一方式,不替代材料绑定或身份归属校验。验证码只能发往当前账号,OAuth state 绑定当前 userId 与业务 scene。流程建立后即使 capability 配置变化也不切换 method;对应 Provider 在 create 或 consume 时不可用,直接按该实现的正常错误处理。 + +## 4. 验证材料模型 + +### 4.1 存储兼容 + +继续使用集合 `auth_codes`,不新增表和 verification token。目标模型改名为 `MongoAccountVerificationMaterial`,但 collection name 保持不变。 + +| 字段 | 用途 | 调整 | +| --- | --- | --- | +| `key` | 按场景构造的材料键 | 保留 | +| `code` | 预登录、图片或短信/邮件验证码 | 保留六位限制 | +| `openid` | 微信回调写入的 openid | 保留,仅微信使用 | +| `type` | 验证场景 | 保留现有值,增加 `oauthLogin` | +| `createTime` | 创建时间 | 每次 upsert 必须刷新 | +| `expiredTime` | 业务过期时间 | 每次创建必须显式传入 | + +材料 key 规则: + +| 场景 | `type` | `key` | 典型有效期 | +| --- | --- | --- | --- | +| 密码预登录 | `login` | 原始 username | 30 秒 | +| 图片验证码 | `captcha` | 原始 account | 5 分钟 | +| 注册验证码 | `register` | 原始 account | 5 分钟 | +| 找回密码 | `findPassword` | 原始 account | 5 分钟 | +| 绑定联系方式 | `bindNotification` | 原始 account | 5 分钟 | +| 微信扫码 | `wxLogin` | scene code | 与二维码 1 小时有效期一致 | +| OAuth state | `oauthLogin` | `oauth:{purpose}:{subjectHash}:{provider}:{callbackHash}:{state}` | 10 分钟 | + +`callbackHash` 使用规范化 callback URL 的 SHA-256 摘要。登录场景的 `purpose=login`、`subjectHash=anonymous`;敏感业务使用固定 scene 作为 purpose,并用当前 Session userId 的 SHA-256 摘要作为 subjectHash。这样可在不增加字段的前提下把 state 绑定到用途、当前用户、Provider 和回调地址。 + +敏感业务的 code、oldPassword 和 wechat 材料同样在现有 key 命名空间中绑定 `purpose + subjectHash + method`;OAuth 则由 key 中已有的 provider 段绑定具体 method。consume 使用请求 method 只查询同一 userId、scene 和 method 下的有效材料。该绑定不新增字段或 verification token,但可以让 consume 沿用 create 已确认的方式,而不必再次读取 capabilities。 + +### 4.2 索引与过期 + +目标索引: + +```ts +schema.index({ key: 1, type: 1 }, { unique: true }); +schema.index({ expiredTime: 1 }, { expireAfterSeconds: 0 }); +``` + +TTL 删除不是实时保证。所有读取和消费都必须包含: + +```ts +expiredTime: { $gt: now } +``` + +升级唯一索引前必须先运行重复数据 dry-run 和清理,不能直接依赖启动时 `syncIndexes()`,否则历史重复记录会导致建索引失败。 + +### 4.3 材料实体 API + +`entity.ts` 只负责数据库原子操作,并按代码规范接受可选 `session`: + +```ts +createVerificationMaterial(data, session?) +upsertVerificationMaterial(data, session?) +findValidVerificationMaterial(query) +consumeVerificationMaterial(query, session?) +updateWechatMaterialIdentity(data, session?) +deleteVerificationMaterialIfMatch(query, session?) +``` + +关键语义: + +- `upsert` 使用 `$set` 刷新材料和过期时间,重发即使旧材料失效。 +- OAuth state 和微信 scene 等随机键使用 `create`;发生极低概率碰撞时失败并重新生成,不能覆盖已有流程。 +- 普通消费使用单次 `findOneAndDelete`,不使用“先 find 再 delete”的事务。 +- 验证码大小写兼容必须对输入做转义后再进行锚定匹配,不能把用户输入直接拼成正则。 +- 微信回调只更新 `create()` 已建立、尚未过期的 scene 占位记录,不允许 callback 自行 upsert 任意 scene。 +- OAuth 在 Provider 交换成功后原子删除 state;删除不到记录时丢弃已取得的身份,不进入登录业务。 +- 清理发送失败的验证码时必须同时匹配本次生成的 code,避免删除稍后创建的新材料。 + +### 4.4 生命周期 + +```mermaid +stateDiagram-v2 + [*] --> Created: create / upsert + Created --> Ready: code、password、oauth state + Created --> Pending: 微信等待扫码 + Pending --> Ready: callback 写入 openid + Created --> Expired: expiredTime 到期 + Pending --> Expired: expiredTime 到期 + Ready --> Expired: expiredTime 到期 + Ready --> Consumed: 原子 findOneAndDelete + Consumed --> [*] + Expired --> Deleted: TTL 异步清理 + Deleted --> [*] +``` + +数据库不新增 `status` 字段。`Pending`、`Ready` 等状态由记录是否存在、是否过期及微信 `openid` 是否存在推导。 + +## 5. 各验证方式 + +### 5.1 密码验证 + +职责: + +- `create({ username })`:生成六位预登录 code,以 `login + username` upsert,30 秒后过期并返回 code。 +- `consume({ username, password, code })`:先消费预登录 code,再按 `username + password` 查询用户并检查禁用状态,返回 `LocalAccountIdentity`;不因账号来自第三方而拒绝正确密码。 +- 不加载团队,不修改语言或 `lastLoginTmbId`,不创建 Session。 + +```mermaid +sequenceDiagram + participant Browser + participant PreAPI as preLogin API + participant Verify as PasswordVerification + participant Material as Material Entity + participant LoginAPI as loginByPassword API + participant Login as Local Login Service + participant Redis as Redis Session + + Browser->>PreAPI: GET username + PreAPI->>Verify: create(username) + Verify->>Material: upsert(login, username, code, 30s) + Material-->>Verify: stored + Verify-->>Browser: code + Browser->>LoginAPI: username + password hash + code + LoginAPI->>Verify: consume(...) + Verify->>Material: findOneAndDelete(valid code) + Material-->>Verify: consumed + Verify->>Verify: 查询用户、密码、状态、账号类型 + Verify-->>LoginAPI: LocalAccountIdentity + LoginAPI->>Login: loginLocalAccount(identity, language, ip) + Login->>Login: getUserDetail + 更新登录信息 + Login->>Redis: createUserSession + Login-->>LoginAPI: user + token + LoginAPI-->>Browser: Set-Cookie + user + token +``` + +密码错误继续返回统一账号/密码错误,避免区分“用户不存在”和“密码错误”。当前 `loginByPassword.ts` 的 Wecom 密码登录禁令移到密码登录应用服务:登录 purpose 仍拒绝 Wecom,账号注销等敏感业务只调用密码验证组件,因此可以使用同一账号的旧密码兜底。IP 频率限制保留在 API 边界;`preLogin` 另加轻量 IP/username 限流,避免无限写入。 + +### 5.2 图片验证码与短信/邮件验证码 + +图片验证码是发送验证码前的人机挑战,不产出账号身份,因此由 `CaptchaChallengeService` 管理,不强行实现 `AccountVerification`。 + +`CodeAccountVerification`: + +- `create` 仅允许 `register`、`findPassword`、`bindNotification` 三种 scene。 +- 依次消费图片验证码、校验配置存在时的 reCAPTCHA、获取一分钟发送锁、生成六位数字码、upsert 材料、调用现有 `sendMessage`。 +- 发送失败时删除本次 code 并释放发送锁,允许用户立即重试。 +- `consume` 按 `account + scene + code + expiredTime` 原子删除并返回 `VerifiedContactIdentity`。 +- 不查询用户是否存在,不执行注册、改密或绑定。 + +```mermaid +sequenceDiagram + participant Browser + participant CaptchaAPI as Captcha API + participant Captcha as CaptchaChallengeService + participant SendAPI as sendAuthCode API + participant Code as CodeVerification + participant Guard as reCAPTCHA + TimerLock + participant Material as Material Entity + participant Message as Email / SMS + participant Business as Register / Reset / Bind API + + Browser->>CaptchaAPI: account + CaptchaAPI->>Captcha: create(account) + Captcha->>Material: upsert(captcha, answer, 5m) + Captcha-->>Browser: image + Browser->>SendAPI: account + scene + captcha + googleToken + SendAPI->>Code: create(...) + Code->>Captcha: consume(account, captcha) + Code->>Guard: verify human + acquire send lock + Code->>Material: upsert(scene, account, code, 5m) + Code->>Message: send(code) + Message-->>Browser: success + Browser->>Business: account + scene-specific code + business data + Business->>Code: consume(account, scene, code) + Code->>Material: findOneAndDelete(valid code) + Code-->>Business: VerifiedContactIdentity + Business->>Business: 注册 / 改密 / 绑定 +``` + +外部消息发送不能与 Mongo 事务形成真正原子操作,因此不再把网络发送包进 Mongo transaction。采用“先持久化、再发送、失败时条件清理”的补偿策略。 + +### 5.3 微信扫码验证 + +`WechatAccountVerification` 提供: + +- `create()`:生成 scene 并创建占位材料,再取得微信 access token 和临时二维码,返回 `{ code, codeUrl, expiredAt? }`;上游失败时条件删除占位材料。 +- `recordCallback({ code, openid })`:微信签名和 XML 解析仍在 callback API;验证通过后只更新有效占位材料。 +- `consume({ code })`:无记录返回 `expired`,没有 `openid` 返回 `pending`;有 `openid` 时原子删除记录,调用微信用户信息 API 并返回外部身份。 +- callback 消息校验 token 继续使用既有源码常量 `WX_AUTH_TOKEN`,不新增后台配置入口;AppID 和 AppSecret 仍由现有后台配置提供。 + +```mermaid +sequenceDiagram + participant Browser + participant QRAPI as wx/getQR + participant Verify as WechatVerification + participant Wechat as WeChat API + participant Material as Material Entity + participant Callback as wx/callback + participant Result as wx/getResult + participant Login as External Login Service + + Browser->>QRAPI: create QR + QRAPI->>Verify: create() + Verify->>Material: create(scene placeholder, 1h) + Verify->>Wechat: create temporary QR + Wechat-->>Verify: ticket + expires + Verify-->>Browser: code + codeUrl + Wechat->>Callback: signed SCAN / subscribe event + Callback->>Callback: verify signature + parse XML + Callback->>Verify: recordCallback(code, openid) + Verify->>Material: update valid placeholder + loop every 3 seconds + Browser->>Result: code + Result->>Verify: consume(code) + alt not scanned + Verify-->>Result: pending + Result-->>Browser: current empty result + else scanned + Verify->>Material: findOneAndDelete(openid exists) + Verify->>Wechat: fetch verified user profile + Verify-->>Result: ExternalAccountIdentity + Result->>Login: loginExternalAccount(identity) + Login-->>Browser: Set-Cookie + user + token + end + end +``` + +只有取得并删除记录的一个轮询请求能继续登录,解决当前同一 scene 重复创建 Session 的问题。Provider 查询失败时记录已消费,用户重新扫码;这是避免已确认扫码材料被无限重试的安全取舍。 + +### 5.4 OAuth 验证 + +公共流程位于 `OAuthAccountVerification`: + +```ts +abstract class OAuthAccountVerification extends AccountVerification< + CreateOAuthParams, + OAuthRedirectResult, + ConsumeOAuthParams, + ExternalAccountIdentity +> { + async create(params) { + // 校验 provider 配置和 callback URL + // 生成高熵 state,以 purpose + subjectHash + provider + callback 绑定后 create + // 调用子类构造授权 URL + } + + async consume(params) { + // 只读校验 purpose + subjectHash + provider + callback + state + expiredTime + // 子类使用 code 换取并校验 Provider 身份 + // 原子删除 state;删除失败则拒绝身份 + } + + protected abstract buildAuthorizationUrl(params): Promise; + protected abstract exchangeCode(params): Promise; +} +``` + +```mermaid +sequenceDiagram + participant Browser + participant CreateAPI as oauth/create API + participant Verify as Provider Verification + participant Material as Material Entity + participant IdP as OAuth / SSO Provider + participant Callback as /login/provider + participant ConsumeAPI as oauth API + participant Login as External Login Service + + Browser->>CreateAPI: provider + callbackUrl + terminal info + CreateAPI->>Verify: create(...) + Verify->>Verify: validate config and callbackUrl + Verify->>Material: create(purpose + subjectHash + provider + callbackHash + state, 10m) + Verify->>IdP: build authorization URL + Verify-->>Browser: state + url + Browser->>IdP: redirect + IdP-->>Callback: code + state + Callback->>ConsumeAPI: provider + code + state + callbackUrl + ConsumeAPI->>Verify: consume(...) + Verify->>Material: assert valid state + Verify->>IdP: exchange code and fetch identity + IdP-->>Verify: validated provider identity + Verify->>Material: findOneAndDelete state + alt state consumed by another request + Verify-->>ConsumeAPI: reject + else consumed successfully + Verify-->>ConsumeAPI: ExternalAccountIdentity + ConsumeAPI->>Login: loginExternalAccount(identity, context) + Login-->>Browser: Set-Cookie + user + token + end +``` + +先交换 code、再原子消费 state,沿用基准逻辑并允许 Provider 临时失败时重试。并发请求即使都完成交换,也只有一个能删除 state 并进入登录业务。 + +callback URL 规则: + +- 使用 `adminEnv.FE_DOMAIN` 生成或校验允许的 origin; +- path 必须精确为 `/login/provider`,不得含用户名、密码或 hash; +- 开发环境可以显式允许 localhost,生产环境只允许 HTTPS; +- 不直接信任客户端传入的任意 `callbackUrl`; +- state key 同时绑定 callback URL 摘要,避免跨回调地址使用。 + +### 5.5 Provider 适配 + +| Provider | create | consume 与身份映射 | 必要校验 | +| --- | --- | --- | --- | +| GitHub | 服务端构造 authorize URL | code 换 token,读取 `/user`,映射 `git-{login}` | token 使用 form body,响应 Zod 校验,不在 URL/日志暴露 secret | +| Google | 服务端构造 authorize URL | code 换 token,验证 id token,映射 `google-{sub}` | 校验签名、`aud`、`iss`、`exp`,需要直接依赖官方验证库 | +| Microsoft | 服务端构造 tenant authorize URL | code 换 token,读取 Graph `/me`,映射 `microsoft-{id}` | tenant/client 配置、token/user 响应 Zod 校验 | +| Wecom | 服务端构造企业微信 URL | code 换 `open_userid + corpid`,映射 `wecom-{open_userid}` | 验证企业微信 errcode;不在验证类创建 FastGPT 用户 | +| SSO | 调用配置的 SSO 服务获取授权 URL,并传入 state | 固定 SSO base URL 下用 code 获取身份 | 必须透传/校验 state;固定同源 URL;限制 props 数量和长度 | + +Wecom 当前 `authWecom()` 会预先创建 FastGPT 用户。迁移后该副作用移动到登录应用服务:验证结果只返回 `organizationId=corpid`,登录服务再查找关联团队并按现有规则设置 `defaultTeamIdList`、`forbidCreateDefaultTeam` 和 Wecom tag。 + +SSO 服务本身也需要满足 V2 契约。当前 `tcl`、`aecc` 等定制适配器不会把 state 带回 FastGPT,`oauth2` 和 `aecc` 还使用进程级变量缓存 redirect URI,存在多实例和并发串线风险。迁移要求: + +- 每个 `RedirectFn` 都必须让最终 FastGPT callback 恢复原始 state;OAuth 可用 state,SAML 使用 RelayState,CAS 或不支持 state 的协议需要服务端一次性 flow 记录。 +- 不再使用 `cache_redirect_uri`、`aecc_redirect_uri` 等进程全局变量保存单次请求上下文。 +- SSO 返回给 FastGPT 的 code 必须短期、一次性并绑定对应 flow。 +- 未升级的定制 Provider 不声明 `oauthVerificationV2`,不能通过跳过 state 校验维持兼容。 + +## 6. 登录与业务编排 + +### 6.1 总调用关系 + +```mermaid +flowchart LR + Create["verification.create"] --> Material["短期材料"] + Material --> Consume["verification.consume"] + Consume --> Identity{"可信身份类型"} + Identity -- local --> LocalLogin["loginLocalAccount"] + Identity -- external --> ExternalLogin["loginExternalAccount / usernameLogin"] + Identity -- contact --> Business["注册 / 改密 / 绑定 / 注销"] + LocalLogin --> Session["user + Session"] + ExternalLogin --> Provision["查找或创建用户 / 团队"] + Provision --> Session + Business --> DomainResult["具体业务结果"] +``` + +### 6.2 本地账号登录服务 + +`projects/app/src/service/support/user/login/service.ts` 提供 `loginLocalAccount`: + +1. 接收 `LocalAccountIdentity`、语言和客户端 IP; +2. 通过 userId 加载用户详情及默认/最后团队; +3. 更新 `lastLoginTmbId` 和语言; +4. 创建现有 Redis Session; +5. 返回 `{ user, token }`。 + +Cookie、`pushTrack.login` 和 LOGIN 审计由 API 成功分支显式执行,验证失败不能触发。 + +### 6.3 外部账号登录服务 + +`pro/admin/src/service/support/user/login/service.ts`: + +- `loginExternalAccount` 只接收 `ExternalAccountIdentity` 和注册上下文; +- 内部保留并迁移 `usernameLogin` 的查找、自动注册、团队创建、联系方式同步、语言更新和 Session 创建规则; +- 已存在但状态为 `forbidden` 的用户必须拒绝登录,补齐当前外部登录绕过禁用状态的问题; +- Wecom 的组织到团队映射在本层处理; +- 新用户并发登录依赖 `users.username` 唯一索引收敛;捕获重复键后重新读取用户,不能把可恢复竞争直接返回为登录失败; +- 第三方身份字段不能由 API body 直接构造,只能来自 Provider `consume()` 返回值。 + +登录成功后 API 再执行 Cookie、登录埋点和广告转化追踪。微信和 OAuth 共用该应用服务,但保留各自 track type。 + +### 6.4 验证码业务消费者 + +| 业务 | 调用顺序 | 验证组件之外的动作 | +| --- | --- | --- | +| 注册 | `code.consume(register)` | 重名/License 检查、创建用户团队、Session、Cookie、推广与转化追踪 | +| 找回密码 | `code.consume(findPassword)` | 更新密码和语言、创建新 Session、撤销其他 Session | +| 用户联系方式 | 先校验当前 Session,再 `code.consume(bindNotification)` | 更新用户 contact,按现有规则补团队通知账号 | +| 团队通知账号 | 先校验 owner 权限,再 `code.consume(bindNotification)` | 更新团队账号、按现有规则补 owner contact、写审计 | +| 账号注销 | 由存在该功能的分支增加独立 scene 后复用 | 注销资格、等待期和资源清理由注销业务负责 | + +验证码实现只证明对目标邮箱/手机号的控制权,不证明 FastGPT 用户存在。是否允许该身份执行具体业务由调用方判断。 + +### 6.5 敏感业务的验证方式分派 + +账号注销等敏感业务按“resolver 唯一确定方式、后端校验并创建材料、业务 API 直接消费并执行动作”的顺序使用共享 resolver。前端不能提供方式选择器,每次只展示 resolver 返回的一种入口。create 请求携带 method 作为协议判别字段,服务端使用持久化 username 和真实 capabilities 推导一次并精确比对,随后把 method 绑定到验证材料。consume 不再重新推导,只验证请求 method、当前 userId、scene 与材料绑定。不能先在通用接口消费身份再返回布尔值,否则没有 verification token 就无法把验证结果安全传递给后续请求;也不能新增本方案明确排除的 verification token。 + +| 服务端确认的 method | create 请求(均不含 username) | create 结果 | action 请求中的 consume payload | +| --- | --- | --- | --- | +| `code` | `{ method: 'code', payload: captcha/reCAPTCHA }` | 验证码发送结果 | `{ method: 'code', payload: { code } }` | +| `oldPassword` | `{ method: 'oldPassword', payload: {} }`;服务端用 Session username 调用 password create | `{ preLoginCode }` | `{ method: 'oldPassword', payload: { password, preLoginCode } }` | +| `wechat` | `{ method: 'wechat', payload: {} }` | `{ code, codeUrl, expiredAt? }` | `{ method: 'wechat', payload: { code } }`;未扫码时 action 返回 pending | +| `oauth/*` | `{ method, payload: { callbackUrl, isWecomWorkTerminal? } }` | `{ state, url }` | `{ method, payload: { state, code, callbackUrl } }`;SSO 可额外带受限 props | + +```mermaid +sequenceDiagram + participant User + participant UI as Frontend + participant Resolver as Shared Username Resolver + participant CreateAPI as Verification Create API + participant Session as Session Auth + participant DB as MongoUser + participant API as Sensitive Business API + participant Dispatcher as Verification Dispatcher + participant Verification as AccountVerification + participant Business as Account Deletion / Sensitive Action + + UI->>Resolver: username + public capabilities + Resolver-->>UI: status + unique method + alt supported + UI-->>User: 只展示唯一验证入口 + User->>UI: 开始当前验证流程 + UI->>CreateAPI: unique method + create payload + CreateAPI->>Session: 校验 Session + Session-->>CreateAPI: userId + CreateAPI->>DB: 按 userId 查询 username + DB-->>CreateAPI: persisted username + CreateAPI->>Resolver: username + server capabilities + Resolver-->>CreateAPI: authoritative unique method + CreateAPI->>CreateAPI: 精确比对请求 method + CreateAPI->>Verification: create and bind method + userId + scene + Verification-->>UI: code sent / preLogin code / QR / OAuth URL + User->>API: bound method + consume payload + API->>Session: 校验 Session + Session-->>API: userId + API->>DB: 按 userId 查询 username + DB-->>API: persisted username + API->>Dispatcher: method + typed payload + persisted user + Dispatcher->>Dispatcher: 校验 method + userId + scene 材料绑定 + Dispatcher->>Verification: consume(payload) + Verification-->>Dispatcher: verified identity / pending / expired + Dispatcher-->>API: verified identity / pending / expired + alt verified and identity belongs to user + API->>Business: 执行注销或敏感操作 + else pending / expired / identity mismatch + API-->>UI: 等待或拒绝,不执行业务 + end + else unsupported + UI-->>User: 展示稳定不可用原因 + end +``` + +唯一 method 只在 create 接收前端请求时由后端推导和校验一次。consume 不重新读取 capabilities,也不因为流程中途配置变化改走其它方式;它只接受与服务端材料绑定相同的 method,并继续完成材料校验和身份归属校验。如果对应渠道在 create 或 consume 时实际不可用,当前流程按正常上游失败返回,不隐式切换到旧密码或另一 Provider。 + +## 7. API 契约与兼容 + +### 7.1 路由 + +| 路由 | 处理方式 | +| --- | --- | +| `GET /support/user/account/preLogin` | 路径和响应不变,改调 `passwordVerification.create` | +| `POST /support/user/account/loginByPassword` | 路径和请求不变,改调 password consume + local login | +| `GET /proApi/.../captcha/getImgCaptcha` | 路径不变,改调 captcha service | +| `POST /proApi/.../inform/sendAuthCode` | 路径不变,改调 code create | +| 注册、改密、绑定路由 | 路径和响应不变,改调 code consume | +| `GET /proApi/.../login/wx/getQR` | 路径不变,改调 wechat create | +| `POST /proApi/.../login/wx/getResult` | 路径不变,改调 wechat consume + external login | +| `POST /proApi/.../login/oauth/create` | 新增统一 OAuth create,返回 `{ state, url }` | +| `POST /proApi/.../login/oauth` | 路径不变,请求增加必填 `state`,执行 consume | +| 旧 `getAuthURL` / `wecom/getRedirectUrl` | 新前端切换后删除,不保留转发文件 | +| 敏感业务的 verification create 路由 | 校验 Session,用持久化 username 推导一次并精确比对请求 method,创建绑定 method、userId 与 scene 的材料;不接收 username | +| 敏感业务 action 路由 | 不重算 method 或 capabilities;校验请求 method 与材料绑定后,在同一请求内 consume、校验身份归属并执行业务;不接收 username,不新增通用“验证成功”接口或 verification token | +| `fastLogin` | 不接入组件,按单独阶段废弃 | + +主应用和 Pro 服务需要协调发布。若支持独立版本滚动升级,应在初始化配置暴露 `oauthVerificationV2` capability;新前端只有在 Pro 支持 create API 时启用 V2,切换完成后再关闭旧路径。不得长期保留“state 可选”的不安全 consume。 + +本地 `pro` 是独立 Git submodule。实现时需要先形成可独立校验的 Pro commit,再由主仓提交共享包、主应用改动和 submodule 指针;两边的 schema/capability 必须在同一发布单元中配套,不能只移动其中一侧。 + +### 7.2 OpenAPI schema + +新增或调整 schema: + +```ts +const OAuthVerificationProviderSchema = z.enum([ + OAuthEnum.github, + OAuthEnum.google, + OAuthEnum.microsoft, + OAuthEnum.wecom, + OAuthEnum.sso +]); + +const CreateOAuthVerificationBodySchema = z.object({ + type: OAuthVerificationProviderSchema, + callbackUrl: UrlSchema, + isWecomWorkTerminal: z.boolean().optional() +}); + +const CreateOAuthVerificationResponseSchema = z.object({ + state: z.string(), + url: UrlSchema +}); + +const OauthLoginBodySchema = TrackRegisterParamsSchema.extend({ + type: OAuthVerificationProviderSchema, + state: z.string(), + callbackUrl: UrlSchema, + props: z.record(z.string(), z.string()), + language: LanguageSchema.optional() +}); + +const CodeVerificationPayloadSchema = z.object({ + code: z.string().length(6) +}); + +const OldPasswordVerificationPayloadSchema = z.object({ + password: z.string().min(1).max(512), + preLoginCode: z.string().length(6) +}); + +const WechatVerificationPayloadSchema = z.object({ + code: z.string().min(16).max(128) +}); + +const OAuthVerificationPayloadSchema = z.object({ + state: z.string().min(32).max(256), + code: z.string().min(1).max(4096), + callbackUrl: UrlSchema +}); + +const reservedSsoCallbackProps = new Set([ + 'method', + 'username', + 'state', + 'code', + 'callbackUrl' +]); +const SsoCallbackPropsSchema = z + .record(z.string().regex(/^[A-Za-z0-9_.-]+$/).max(64), z.string().max(4096)) + .refine( + (props) => + Object.keys(props).length <= 20 && + Object.keys(props).every((key) => !reservedSsoCallbackProps.has(key)), + 'Invalid SSO callback properties' + ); + +const SsoVerificationPayloadSchema = OAuthVerificationPayloadSchema.extend({ + props: SsoCallbackPropsSchema.default({}) +}); + +const SensitiveAccountVerificationBodySchema = z.discriminatedUnion('method', [ + z.object({ + method: z.literal('code'), + payload: CodeVerificationPayloadSchema + }), + z.object({ + method: z.literal('oldPassword'), + payload: OldPasswordVerificationPayloadSchema + }), + z.object({ + method: z.literal('wechat'), + payload: WechatVerificationPayloadSchema + }), + z.object({ + method: z.literal('oauth/github'), + payload: OAuthVerificationPayloadSchema + }), + z.object({ + method: z.literal('oauth/google'), + payload: OAuthVerificationPayloadSchema + }), + z.object({ + method: z.literal('oauth/microsoft'), + payload: OAuthVerificationPayloadSchema + }), + z.object({ + method: z.literal('oauth/wecom'), + payload: OAuthVerificationPayloadSchema + }), + z.object({ + method: z.literal('oauth/sso'), + payload: SsoVerificationPayloadSchema + }) +]); +export type SensitiveAccountVerificationBody = z.infer< + typeof SensitiveAccountVerificationBodySchema +>; +``` + +敏感业务请求不包含 username;method 是严格 union 的协议判别字段,不是用户可选策略。create schema 使用相同的 `method + payload` 结构:后端在该入口用 resolver 校验 method 一次,校验成功后将其绑定到材料。consume 直接解析上面的判别 union,并要求 method 命中同一 userId 和 scene 下的材料绑定,不再检查当前 capabilities。`code` 的邮件/手机通道沿用 create 时绑定的 `accountKind`。SSO adapter 只把受限 props 作为附加字段,并始终以顶层 `state`、`code`、`callbackUrl` 为准,禁止 props 覆盖安全字段。所有对象在生产 schema 中使用 `.strict()` 并补齐 OpenAPI `description`、`example`;callback URL 还要做服务端 allowlist 校验。所有改动路由统一使用 `parseApiInput`;第三方 Provider 响应使用普通 `Schema.parse`,失败应作为内部/上游异常记录。 + +### 7.3 微信轮询兼容 + +组件内部返回: + +```ts +type WechatConsumeResult = + | { status: 'pending' } + | { status: 'expired' } + | { status: 'verified'; identity: ExternalAccountIdentity }; +``` + +第一阶段 API 将 `pending` 映射为现有空结果。QR create 响应可向后兼容增加 `expiredAt`,前端到期后重新获取二维码。若暂不扩展响应,`expired` 也映射为空结果以维持旧轮询协议,但服务端不得继续接受过期材料。 + +## 8. 目标目录 + +```text +packages/global/support/user/ +└── account/ + └── verification/ + ├── constants.ts # 验证场景、方式常量和稳定原因码 + ├── type.ts # username/method/payload/capabilities/resolution Zod schema + └── utils.ts # resolveAccountVerificationByUsername 纯函数 + +packages/global/common/system/types/ +└── index.ts # 公开 accountVerification capability + +packages/global/test/support/user/account/verification/ +└── utils.test.ts # 前后端共享 username 推导 fixture + +packages/global/openapi/support/user/account/verification/ +├── api.ts # create/consume 请求响应 schema +└── index.ts # OpenAPI 路由声明 + +packages/service/support/user/ +├── account/ +│ └── verification/ +│ ├── index.ts # 统一导出真实实现 +│ ├── schema.ts # auth_codes Mongoose schema/model +│ ├── entity.ts # 原子 upsert/find/consume/update +│ ├── service.ts # AccountVerification 抽象与内部身份类型 +│ ├── utils.ts # key、callback hash、code 匹配纯函数 +│ └── password/ +│ └── service.ts # PasswordAccountVerification +└── session.ts # 保持现有位置,不属于验证组件 + +projects/app/src/service/support/user/login/ +└── service.ts # loginLocalAccount + +projects/app/src/web/support/user/account/verification/ +├── api.ts # OAuth create/consume、微信和验证码 API 封装 +├── utils.ts # feConfigs -> capabilities 纯适配 +├── useAccountVerificationMethod.ts # 调用共享 resolver 并映射 UI 入口 +└── useOAuthVerification.ts # 创建授权、保存回跳上下文、发起跳转 + +pro/admin/src/service/support/user/ +├── account/ +│ └── verification/ +│ ├── service.ts # 敏感业务 method 分派与身份归属校验 +│ ├── utils.ts # server config -> capabilities 纯适配 +│ ├── captcha/ +│ │ └── service.ts # 图片验证码 challenge +│ ├── code/ +│ │ └── service.ts # CodeAccountVerification +│ ├── wechat/ +│ │ └── service.ts # QR、callback material、openid exchange +│ └── oauth/ +│ ├── service.ts # OAuth 基类、工厂和公共 state 流程 +│ ├── github.ts # GitHub Provider +│ ├── google.ts # Google Provider +│ ├── microsoft.ts # Microsoft Provider +│ ├── wecom.ts # 企业微信 Provider,仅返回身份 +│ ├── sso.ts # 通用 SSO Provider +│ └── utils.ts # callback 校验与 Provider 公共纯函数 +└── login/ + └── service.ts # usernameLogin / loginExternalAccount +``` + +OAuth Provider 文件是同一 `oauth` 子功能下的策略实现,不再为每个 Provider 增加一层目录,避免超过仓库约定的子功能嵌套深度。 + +### 8.1 旧文件迁移映射 + +| 当前文件 | 目标 | +| --- | --- | +| `packages/global/support/user/auth/constants.ts` | `.../account/verification/constants.ts` | +| `packages/global/support/user/auth/type.ts` | DB 类型移入新 `schema.ts`,不再放 global | +| 散落的 username 前缀/配置判断 | `account/verification/type.ts` + `utils.ts` 统一推导 | +| `FastGPTFeConfigsType` 与 Pro auth 配置 | 增加规范化 capability 输入,前端不暴露 secret,也不增加 SSO prefix 配置 | +| `packages/service/support/user/auth/schema.ts` | `.../account/verification/schema.ts` | +| `packages/service/support/user/auth/controller.ts` | 拆到 `entity.ts`、`service.ts`、`utils.ts` | +| `projects/app/.../preLogin.ts` 中的业务 | `password/service.ts#create` | +| `projects/app/.../loginByPassword.ts` 中的验证 | `password/service.ts#consume` | +| 密码登录后的用户/Session 编排 | `projects/app/.../login/service.ts` | +| `projects/app/src/web/support/user/api.ts` 中的验证 API | `web/support/user/account/verification/api.ts` | +| 前端各验证面板中的 username 分支 | `useAccountVerificationMethod.ts` 调用共享 resolver | +| 前后端直接读取原始配置判断 Provider | 各自 `account/verification/utils.ts` 适配成共享 capabilities | +| `FormLayout.tsx` 中的 OAuth URL/state 构造 | `useOAuthVerification.ts` | +| `pages/login/provider.tsx` 中的回调验证 | OAuth callback 调用新 consume API,页面只负责结果跳转 | +| `pro/admin/.../sendAuthCode.ts` 中的业务 | `code/service.ts#create` | +| `pro/admin/.../captcha/getImgCaptcha.ts` 中的业务 | `captcha/service.ts` | +| `pro/admin/.../login/wx/*` 中的 Provider 业务 | `wechat/service.ts` | +| `pro/admin/.../login/oauth.ts` 中的 Provider 函数 | `oauth/*.ts` | +| `pro/admin/.../login/getAuthURL.ts` | 统一 OAuth create 后删除 | +| `pro/admin/.../login/wecom/getRedirectUrl.ts` | 统一 OAuth create 后删除 | +| `pro/admin/src/service/support/wecom/auth.ts` | 身份交换到 `oauth/wecom.ts`,建用户逻辑到 login service | +| `pro/admin/src/service/support/user/login/wx.ts` | 配置/请求能力合并到 `wechat/service.ts` | +| `pro/admin/src/service/support/user/controller.ts#usernameLogin` | `login/service.ts` | + +迁移完成后直接修改全部 import 并删除旧文件,不创建旧路径 re-export 转发文件。 + +## 9. 安全与异常语义 + +### 9.1 必须修正 + +1. 每次 consume 显式判断 `expiredTime`。 +2. 验证材料使用原子 `findOneAndDelete`,配合唯一索引实现一次性语义。 +3. 用户输入 code 正则必须转义;优先逐步归一化后精确匹配。 +4. OAuth state 至少 32 个高熵字符,并绑定 purpose、当前用户(登录场景为 anonymous)、Provider 与 callback URL。 +5. OAuth create/consume 做 IP 频率限制;Provider code/state 不写日志。 +6. Provider access token、refresh token、id token 和 client secret 不落库、不返回前端、不进入 URL 或结构化日志。 +7. Google token 完整校验;所有 Provider 响应使用 Zod 收窄。 +8. 外部登录同样拒绝 `forbidden` 用户。 +9. SSO 请求只能访问配置的固定 base origin,callback props 设置数量和长度上限。 +10. 微信 callback 必须先使用既有源码常量 `WX_AUTH_TOKEN` 验证签名,再允许写入已存在 scene;为兼容现有部署,不新增后台 token 配置入口。 +11. SSO 适配器必须回传 state,移除单请求上下文的进程全局缓存。 +12. username resolver 的前端结果用于展示和填写协议 method;后端只在 create 入口用 Session userId 对应的持久化 username 和服务端能力推导一次并精确比对。 +13. create 将确认后的 method 绑定到材料;consume 校验请求 method 与 userId、scene、材料绑定一致,并在成功后校验外部身份 username 与当前用户一致。 +14. 通用 SSO 只使用“第一个 `-` 前后非空”的格式规则,并以 SSO capability 为开关;不维护客户前缀枚举或 Admin 白名单。 +15. 敏感业务的 create 与 action 请求都不接受 username;method 仅作为严格判别字段。OAuth state 绑定 userId 和 scene,action 必须在同一请求中消费身份并执行业务。 +16. `oldPassword` 只在没有可展示的验证码或 Provider 方式时成为唯一 method;密码验证组件接受第三方账号的正确密码,Wecom 禁止密码登录的规则只留在登录应用服务。 + +### 9.2 失败处理 + +| 失败 | 组件行为 | +| --- | --- | +| 材料不存在、过期或已消费 | 统一验证失败,不透露具体原因;微信轮询内部可返回 expired | +| 密码错误/用户不存在 | 统一账号密码错误 | +| Provider capability 缺失 | resolver 在 create 前唯一返回 `oldPassword`,不调用 Provider,也不同时向用户展示两种入口 | +| create 请求 method 与服务端推导不一致 | 拒绝创建材料,前端刷新配置后重新渲染唯一入口 | +| 流程建立后 Provider 暂时不可用 | 当前 create/consume 正常返回上游失败;不重算 capabilities,也不隐式切换 method | +| 授权 URL 构建失败 | 条件删除本次 state | +| Provider code 交换失败 | state 在有效期内可重试,并受频率限制 | +| Provider 交换成功但 state 删除失败 | 丢弃身份,不创建用户或 Session | +| 短信/邮件发送失败 | 条件删除本次 code、释放锁并返回失败 | +| 微信 profile 获取失败 | scene 已消费,要求重新扫码 | +| 用户/团队/Session 业务失败 | 不回滚已消费材料,沿用当前“重新验证后重试”语义 | + +## 10. 可观测性 + +验证组件记录结构化但不含敏感值的事件: + +- `verificationType`、`scene`、`provider`; +- `operation=create|consume|callback`; +- `outcome=success|pending|expired|invalid|upstream_error`; +- 耗时和上游 HTTP 状态; +- 可选的材料 key 哈希前缀,用于关联但不能反推账号/state。 + +不得记录 username、手机号、邮箱、code、state、openid、OAuth token、Provider 原始响应或 client secret。登录埋点仍在应用层,不能把验证成功等同于登录成功。 + +### 10.1 残余风险 + +| 风险 | 本轮处理 | +| --- | --- | +| GitHub login 可改名,而兼容 username 使用 `git-{login}` | 保持既有账号映射;彻底解决需要独立 Provider subject 绑定表,超出“不新增存储”边界 | +| SSO 可返回与本地账号相同的 username | SSO 返回的用户名必须有前缀,否则失败 | +| 部分定制 SSO Provider 无 state 能力 | V2 上线前逐个升级;不以 state 可选方式降级安全性 | +| OAuth 未采用 PKCE | 本轮使用机密客户端、服务端 code 交换和一次性 state;PKCE 属于后续协议增强 | +| 消息发送与数据库无法分布式原子提交 | 使用条件补偿并覆盖故障测试,仍可能出现“消息已发但客户端收到失败”的可接受窗口 | +| 企业微信 SSO 使用 `userid`、内部套件使用 `open_userid` | 双入口 capability 以前置身份映射/迁移为条件;未对齐时只开放单入口,最终 username 仍精确校验 | + +## 11. 测试设计 + +### 11.1 测试分层与代码落点 + +测试按依赖边界放入对应 workspace,不把 Pro 实现的测试放进开源包,也不通过前端测试替代服务端授权测试。 + +| 层级 | 建议目录 | 主要职责 | 外部依赖策略 | +| --- | --- | --- | --- | +| Global 纯函数与 schema | `packages/global/test/support/user/account/verification/` | username resolver、method/capabilities/resolution schema、OpenAPI 合约 | 无网络、无数据库;共享固定 fixture | +| Service 材料与密码 | `packages/service/test/support/user/account/verification/` | `auth_codes` entity、过期与原子消费、密码验证 | `mongodb-memory-server`、fake timers、固定随机数 | +| App API 与 Web | `projects/app/test/api/support/user/account/`、`projects/app/test/web/support/user/account/verification/` | 密码 API、Session/Cookie、前端唯一入口与回调上下文 | mock Pro API 和浏览器跳转 | +| Admin 验证与应用服务 | `pro/admin/test/service/support/user/account/verification/`、`pro/admin/test/api/support/user/account/verification/` | captcha/code/wechat/OAuth、method 分派、外部登录服务 | mock Provider HTTP、消息发送、Redis Session | +| SSO V2 协议 | `pro/sso/test/` | state/RelayState/flow 往返、一次性 code、多实例隔离 | 本地 HTTP fixture;禁止访问真实客户 IdP | +| 数据迁移 | 迁移脚本同目录的 `*.test.ts` | dry-run 统计、重复清理、幂等、唯一索引前置检查 | 独立测试库和可重复 fixture | + +`pro/sso` 当前没有标准 `test` script。开始修改 SSO 协议前必须补齐 Vitest 配置和 `pnpm --filter @fastgpt/sso test`,否则 SSO V2 不能进入发布阶段。 + +### 11.2 必测矩阵 + +#### 11.2.1 Username resolver 与 capability adapter + +同一组 canonical fixture 由 global resolver 测试直接消费;前端和后端 adapter 只测试“配置 -> capabilities”映射,不复制 username 分支。 + +| 场景 | 输入重点 | 期望结果 | +| --- | --- | --- | +| 非法账号 | 空字符串、全空白 | `unsupported/invalid/empty_username` | +| 邮箱优先 | 标准邮箱、local-part/domain 含合法 `-` | 先识别 `email`;邮件可用为 `code`,否则为 `oldPassword` | +| 手机号 | 合法手机号、相邻非法长度或号段 | 合法值识别 `phone`;非法值继续进入后续规则 | +| 普通本地账号 | 无前缀账号、首尾为 `-` 的账号 | 唯一 method 为 `oldPassword` | +| 明确直连 Provider | `wechat-*`、`git-*`、`google-*`、`microsoft-*` | capability 可用时返回对应方式;不可用时只返回 `oldPassword`,即使 SSO 可用也不能转 SSO | +| Wecom | SSO/内部 Wecom 的开关组合 | 按 SSO、内部 Wecom、`oldPassword` 顺序自动返回一个 method | +| 通用 SSO | 未命中直连 Provider 的 `prefix-suffix` | SSO 可用时为 `oauth/sso`,否则按 `local/oldPassword` | +| 匹配边界 | 空后缀、大小写变体、多个 `-` | 前缀精确且后缀非空;未命中的合法连字符账号再进入通用 SSO | +| 结果不变量 | 所有非空 username | `status=supported`、只有一个 method,不返回候选列表 | + +Adapter 测试必须覆盖缺 client id、缺 secret、scene 未启用、SSO URL 缺失、License 不可用,以及 Wecom 身份命名空间未对齐时关闭对应 capability。前端 adapter 不得读取或暴露 secret;后端 adapter 以完整服务端配置为准。 + +#### 11.2.2 Verification material entity + +| 能力 | 必测场景 | 断言 | +| --- | --- | --- | +| 创建与覆盖 | 同一 `{ key, type }` 连续 upsert | 只保留最新 code、`createTime` 和 `expiredTime` | +| 过期判断 | 有效、刚好到期、已过期但 TTL 尚未删除 | 只有 `expiredTime > now` 可读取或消费 | +| 单次消费 | 串行重复消费、`Promise.all` 并发消费 | 最多一个调用得到材料,其余统一失败 | +| 随机键创建 | OAuth state、微信 scene 碰撞 | 不覆盖已有流程,重新生成或明确失败 | +| 条件更新 | 微信 callback 更新不存在、过期、已带身份的 scene | 只允许更新仍有效的 create 占位记录 | +| 条件清理 | 消息/授权 URL 创建失败后发生同 key 重试 | 只删除本次材料,不误删后来创建的记录 | +| 敏感业务绑定 | method、userId、scene、provider/callback 任一不一致 | 查询和消费均失败,不跨业务复用材料 | +| 兼容数据 | 迁移前已存在验证码和微信记录 | 在原有效期内仍可按旧 key/type 规则消费 | +| 唯一索引 | 清理前存在重复、清理后建索引、重复执行脚本 | dry-run 能阻止建索引;清理幂等;最终索引可创建 | + +#### 11.2.3 验证实现与应用编排 + +| 模块 | 成功路径 | 失败、安全与兼容路径 | +| --- | --- | --- | +| Password | create 30 秒材料;正确密码返回 `LocalAccountIdentity` | code 错误/过期/重复、用户不存在/禁用、密码错误;第三方账号可做敏感验证,但 Wecom 密码登录仍被应用服务拒绝 | +| Captcha/Code | 图片码 -> reCAPTCHA -> 锁 -> upsert -> 发送;consume 返回 contact identity | 图片码大小写、reCAPTCHA 失败、锁冲突、发送失败条件补偿、scene 串用、重发旧码失效 | +| WeChat | create 占位、callback 写入、pending 轮询、扫码后返回 external identity | 签名失败、伪造/过期 scene、并发轮询仅一个成功、profile 上游失败、现有 pending 响应兼容 | +| OAuth base | create state 和 URL;consume 交换身份并原子删除 state | state 熵、过期、缺失、provider/user/purpose/callback 不匹配、build 失败清理、交换失败保留、并发 consume 仅一个成功 | +| GitHub | authorize、token、user 映射 `git-*` | secret 不进 URL/日志;token/user 响应 Zod 错误 | +| Google | authorize、官方库验证 id token、映射 `google-*` | 签名、`aud`、`iss`、`exp` 任一失败即拒绝 | +| Microsoft | tenant authorize、token、Graph user 映射 | tenant/client 配置缺失;token/user 响应校验失败 | +| Wecom | 返回 `wecom-*` 外部身份,不创建 FastGPT 用户 | errcode、corpid、username 归属不一致;`userid/open_userid` 未对齐时不开放双 capability | +| SSO | 固定 base URL、state 往返、身份映射 | 非同源 URL、props 越界、缺 state、一次性 code 重用、未升级 Provider 不声明 V2 | +| Login services | 本地/外部已有用户与新用户、团队映射、Session 创建 | forbidden 用户、并发首次登录重复键恢复、验证失败不写 Cookie/埋点/审计 | + +#### 11.2.4 API、前端与敏感业务 + +| 边界 | 必测场景 | +| --- | --- | +| API schema | 所有新增/修改路由使用 OpenAPI Zod schema 和 `parseApiInput`;额外字段、method/payload 错配、非法 callback/props 返回请求错误 | +| Create 授权 | 服务端从 Session 加载持久化 username;请求 method 与唯一 resolver 结果不一致时不创建材料 | +| Consume 授权 | 不重新读取 capabilities;请求 method 必须命中同一 userId、scene 和 method 的材料,并在成功后校验身份归属 | +| 敏感业务原子边界 | consume 与注销/改密等具体动作在同一业务请求内编排;不存在通用“验证成功”布尔接口或 verification token | +| 前端入口 | 每个 resolver 结果只渲染一个入口;不存在方式选择器或“切换旧密码”;Provider 缺失时直接渲染 `oldPassword` | +| OAuth 前端 | create API 返回后才跳转;回调提交 state;刷新、返回和多标签页不会复用错误 Provider 上下文 | +| 微信前端 | pending 继续轮询,过期重新取二维码,动态内容不改变布局 | +| 兼容行为 | 既有路由、成功响应、Cookie、Session、username 映射、团队创建和成功后埋点保持不变 | + +### 11.3 并发、故障与敏感信息检查 + +- 时间相关测试统一使用 fake timers 或注入 `now`,明确覆盖边界时刻,不使用真实 sleep。 +- code、state、scene 和随机碰撞通过可注入生成器固定,不让测试依赖概率。 +- Provider、消息发送、Redis 和 Mongo 故障分别注入在“调用前、上游成功后、材料消费前后”,验证补偿和不可回滚边界。 +- 原子消费至少使用两组并发测试:同进程 `Promise.all` 和两个独立 service 实例共享同一测试库。 +- Provider 单元测试禁止访问真实外网;请求 URL、headers、form body 和响应解析都由本地 mock 断言。 +- 日志测试捕获结构化事件,确认 username、邮箱、手机号、code、state、openid、token、Provider 原始响应和 secret 均未出现。 + +### 11.4 执行命令与门禁 + +开发中只运行当前阶段涉及的 workspace 和文件;阶段完成时运行该 workspace 全量测试,全部迁移完成后再运行仓库全量测试。 + +```bash +# 定向测试 +pnpm --filter @fastgpt/global test -- test/support/user/account/verification +pnpm --filter @fastgpt/service test -- test/support/user/account/verification +pnpm --filter @fastgpt/app test -- test/api/support/user/account test/web/support/user/account/verification +pnpm --filter @fastgpt/admin test -- test/service/support/user/account/verification test/api/support/user/account/verification + +# SSO 增加 test script 后执行 +pnpm --filter @fastgpt/sso test + +# 应用类型检查 +pnpm --filter @fastgpt/app typecheck +pnpm --filter @fastgpt/admin typecheck + +# 最终门禁 +pnpm lint +pnpm test +git diff --check +``` + +不设置脱离风险面的统一覆盖率数字。resolver 全分支、材料并发与过期、method 绑定、Provider 响应校验、登录副作用顺序必须有直接断言;只通过行覆盖率不能视为完成。 + +## 12. 分阶段迁移 TODO + +迁移按依赖顺序推进。每一阶段都必须保持仓库可构建、可局部测试和可回滚;不得先删除旧实现,再等待后续阶段补齐调用方。 + +```mermaid +flowchart TD + P0["阶段 0 基线与发布约束"] --> P1["阶段 1 共享契约与 resolver"] + P1 --> P2["阶段 2 材料实体与数据安全"] + P2 --> P3["阶段 3 密码验证与本地登录"] + P3 --> P4["阶段 4 Captcha 与消息验证码"] + P4 --> P5["阶段 5 微信扫码与外部登录"] + P5 --> P6["阶段 6 直连 OAuth"] + P6 --> P7["阶段 7 SSO V2 与 Wecom"] + P7 --> P8["阶段 8 敏感业务与前端单入口"] + P8 --> P9["阶段 9 灰度、清理与下线"] +``` + +### 阶段 0:基线、数据盘点与发布约束 + +- [ ] 固化密码、验证码、微信、OAuth 的现有路由、请求、响应、Cookie、Session、username 映射、团队创建和埋点回归用例。 +- [ ] 枚举主仓和 Pro 中所有 `auth_codes` 读写点,标注 key/type、过期时间、写入方式和消费方式,确认没有遗漏调用方。 +- [ ] 编写重复材料 dry-run,按 type 输出重复组数、记录数、保留记录和脱敏样本;本阶段只读,不直接清理。 +- [ ] 扫描启用 SSO 的部署中已有 `prefix-suffix` 本地账号,形成冲突迁移与回滚清单。 +- [ ] 核对 Wecom SSO 的 `userid` 与内部 Wecom 的 `open_userid`,明确哪些部署可以同时声明两项 capability。 +- [ ] 明确主应用、`pro/admin`、`pro/sso` 和 Pro submodule 指针的提交/发布顺序,以及版本不匹配时 `oauthVerificationV2` 的关闭行为。 +- [ ] 为 `pro/sso` 增加 Vitest 配置和 test script,先覆盖当前 redirect/code 行为。 + +完成门槛:现有行为回归测试通过;数据和账号冲突报告可重复生成;每个发布单元都有明确回滚点。 + +### 阶段 1:共享契约、resolver 与 API schema + +- [ ] 在 `packages/global/support/user/account/verification/` 增加 method、account kind、capabilities、resolution 和稳定原因码 schema。 +- [ ] 实现 `resolveAccountVerificationByUsername`,建立 canonical fixture,覆盖邮箱/手机号优先、直连 Provider、Wecom 自动顺序、通用 SSO 和旧密码兜底。 +- [ ] 在前端和服务端分别实现配置 adapter;resolver 内不读取 `window`、数据库、`global.systemConfig` 或服务端 SDK。 +- [ ] 在 `packages/global/openapi/support/user/account/verification/` 定义 create/consume 判别 union、响应和路由文档,补齐 `description`、`example` 与 `.strict()`。 +- [ ] 统一导出前后端共享的 method、capabilities 和 resolution 类型,不在 global 放服务端身份实现。 +- [ ] 增加 `oauthVerificationV2` 和敏感业务公开 capability,但不切换现有路由。 + +完成门槛:global fixture 与 adapter 测试通过;前后端对同一输入得到同一唯一 method;现有生产行为没有变化。 + +### 阶段 2:材料实体、过期与唯一性 + +- [ ] 在 `packages/service/support/user/account/verification/` 建立 `schema.ts`、`entity.ts`、`service.ts`、`utils.ts` 和统一导出。 +- [ ] 在 `service.ts` 定义 `AccountVerification` 抽象和三类可信身份类型;接口不出现 Session、Cookie 或具体业务返回值。 +- [ ] 保持 collection 为 `auth_codes`,保留现有 type 值;新增 OAuth state scene 和敏感业务 key 命名空间。 +- [ ] 实现 create、upsert、有效读取、原子 consume、微信条件更新和条件删除;全部查询显式包含 `expiredTime > now`。 +- [ ] 按场景把普通验证码写入改为 upsert,把 OAuth state/微信 scene 改为随机键 create;碰撞不能覆盖已有流程。 +- [ ] 在 key/type/provider 命名空间绑定敏感业务的 method、userId、scene 和 callback,不新增字段或 verification token。 +- [ ] 保证迁移前已存在的验证码和微信记录在原有效期内仍可消费。 +- [ ] 将全部旧读写方切到新 entity 后运行重复材料清理;先预览、再执行、再复核,脚本必须幂等。 +- [ ] 仅在重复为零且所有写入方已兼容后创建 `{ key, type }` 唯一索引;失败时不修改现有索引。 + +完成门槛:材料 entity 的过期、并发、绑定、补偿和兼容测试通过;清理复核无重复;唯一索引可重复验证。 + +### 阶段 3:密码验证与本地登录应用服务 + +- [ ] 实现 `PasswordAccountVerification.create/consume`,迁移 `preLogin` 和 `loginByPassword`,保持客户端 hash 协议与响应不变。 +- [ ] consume 先原子消费预登录 code,再校验用户、密码和禁用状态,只返回 `LocalAccountIdentity`。 +- [ ] 拆出 `loginLocalAccount`,迁移用户详情、语言、最后团队和 Redis Session 编排;Cookie、埋点和审计留在 API 成功分支。 +- [ ] 密码验证组件允许第三方来源账号验证正确密码;密码登录应用服务继续拒绝 Wecom 创建 Session。 +- [ ] 更新全部 import 并删除已无引用的旧密码验证代码,不保留旧路径转发文件。 + +完成门槛:现有 `preLogin`、密码登录和 Session 回归测试全部通过;敏感密码验证与 Wecom 密码登录边界均有测试。 + +### 阶段 4:Captcha、消息验证码与业务消费者 + +- [ ] 拆出 `CaptchaChallengeService` 与 `CodeAccountVerification`,限定 register/findPassword/bindNotification 及新增敏感 scene。 +- [ ] 把图片验证码、reCAPTCHA、发送锁、材料 upsert 和消息发送按第 5.2 节顺序编排。 +- [ ] 将消息网络请求移出 Mongo transaction;发送失败时按 code 条件清理材料并释放锁。 +- [ ] 迁移注册、找回密码、用户联系方式和团队通知账号消费者,保持业务校验、Session、Cookie 和审计行为。 +- [ ] 所有相关 API 改用 global OpenAPI schema 与 `parseApiInput`,删除直接解析 `req.body/query` 的写法。 +- [ ] 删除旧验证码 controller 前确认重发旧码失效、scene 隔离和迁移前记录兼容。 + +完成门槛:captcha/code、四类消费者和补偿故障测试通过;不存在继续向旧 controller 写入的调用方。 + +### 阶段 5:微信扫码与外部登录应用服务 + +- [ ] `WechatAccountVerification.create` 先创建有效期一致的 scene 占位,再请求临时二维码;上游失败按 scene 条件清理。 +- [ ] callback 校验签名和请求尺寸后,只调用 `recordCallback` 更新有效占位,禁止任意 upsert。 +- [ ] 保留微信 callback token 的既有源码常量,不新增后台配置入口;callback 仍必须先验签。 +- [ ] `consume` 区分 pending/expired/verified,扫码成功时原子删除;保持现有 pending 对外响应。 +- [ ] 微信 profile 请求和响应进入 service 并用 Zod 校验;上游失败按既定语义要求重新扫码。 +- [ ] 建立 `loginExternalAccount`,迁入 `usernameLogin` 的已有用户、新用户、团队、联系方式、Session 和 forbidden 检查。 +- [ ] 微信 API 成功后再写 Cookie 和埋点;并发轮询只能创建一个 Session。 + +完成门槛:微信签名、占位、轮询并发和登录回归测试通过;验证 service 中不存在用户创建或 Session 调用。 + +### 阶段 6:OAuth 基类与直连 Provider + +- [ ] 实现 OAuth create API、服务端高熵 state、callback allowlist,以及 purpose/user/provider/callback 绑定。 +- [ ] 实现 OAuth 基类的“只读校验 state -> Provider 交换 -> 原子删除 state”流程和并发保护。 +- [ ] 迁移 GitHub、Google、Microsoft Provider,分别补齐 form body、官方 token 验证和所有上游响应 Zod schema。 +- [ ] 前端改为调用 create API 获取 URL,不再本地拼接授权地址或生成安全 state;回调请求必须提交 state。 +- [ ] OAuth 登录统一进入 `loginExternalAccount`,保持 username 映射、Cookie、Session 和 track type。 +- [ ] Provider code/state/token/secret 不进入 URL、响应或结构化日志。 + +完成门槛:三个直连 Provider 的 URL、交换、身份映射、state 并发与失败测试通过;关闭 V2 capability 可回到发布前行为。 + +### 阶段 7:SSO V2、Wecom 与跨服务协调 + +- [ ] 升级 `pro/sso` 的 OAuth/SAML/CAS/定制 flow,使最终 callback 恢复 state 或 RelayState,并生成短期一次性 code。 +- [ ] 删除 `cache_redirect_uri`、`aecc_redirect_uri` 等进程级单请求缓存,验证多实例和并发 flow 不串线。 +- [ ] SSO 仅访问固定 base origin,限制 callback props 的键、数量、长度和保留字段。 +- [ ] 实现 SSO 与 Wecom OAuth adapter;验证类只返回身份,不创建 FastGPT 用户。 +- [ ] 把 Wecom `corpid` 到团队的映射和用户 provisioning 留在 `loginExternalAccount`。 +- [ ] 只有完成 state 契约的 SSO Provider 才声明 `oauthVerificationV2`;未升级 Provider 不允许跳过 state。 +- [ ] 对齐或迁移 `userid/open_userid` 命名空间;未对齐部署只开放来源明确的一项 capability。 + +完成门槛:SSO 协议 fixture、一次性 code、并发隔离和 Wecom 团队映射测试通过;主应用/Admin/SSO 的版本组合符合 capability 门控。 + +### 阶段 8:敏感业务分派与前端唯一入口 + +- [ ] 实现前端 `useAccountVerificationMethod` 和 capabilities adapter,每个账号只渲染 resolver 返回的一种入口。 +- [ ] 删除方式选择器和“切换旧密码”;只有无可展示验证码或 Provider 时才渲染 oldPassword。 +- [ ] 实现敏感业务 create dispatcher:从 Session 加载持久化 username,推导一次并精确校验请求 method,再创建绑定材料。 +- [ ] 实现 consume dispatcher:不重算 capabilities,只校验 method/userId/scene 材料绑定并调用对应验证实现。 +- [ ] consume 后必须校验 contact/userId/username 与当前用户一致,再在同一请求内执行具体敏感业务。 +- [ ] 请求 schema 不接受 username,method/payload 使用严格判别 union;篡改 method、跨 user/scene/state 和额外字段均被拒绝。 +- [ ] 在实际存在的敏感业务中接入 dispatcher;账号注销仅由包含该功能的分支接入,不在当前分支虚构业务实现。 + +完成门槛:前端单入口、create 单次推导、consume 材料绑定和身份归属测试通过;不存在通用验证成功接口或 verification token。 + +### 阶段 9:灰度发布、旧代码清理与快速登录下线 + +- [ ] 按主应用、Admin、SSO 和 Pro submodule 计划完成灰度,观察 create/consume outcome、上游错误和登录成功率;日志不得含敏感值。 +- [ ] 演练关闭 `oauthVerificationV2`、回退应用版本和回滚唯一索引之外代码的流程;数据脚本保留 dry-run 和复核能力。 +- [ ] 扫描并移除重复 username 分支、旧授权 URL/state 构造、旧 Provider route 函数和无引用 `support/user/auth` 文件。 +- [ ] 确认目标实现均位于 `account/verification`,不存在 `accountVerification` 目录或旧路径 re-export。 +- [ ] 统计 fastLogin 配置和路由使用,完成弃用窗口后删除 schema、OpenAPI、web API、页面、Pro handler 和管理配置;保留可信身份使用的 `usernameLogin` 业务能力。 +- [ ] 运行各 workspace 测试、App/Admin typecheck、lint、仓库全量测试、`git diff --check` 和数据迁移复核。 +- [ ] 使用 Mermaid 8.8.3 解析全部设计图,并同步更新 OpenAPI、运维说明和必要的用户文案。 + +完成门槛:灰度与回滚演练完成;旧路径和快速登录入口清零;第 13 章所有验收项均有可审计证据。 + +## 13. 验收标准 + +验收以“可观察行为 + 自动化测试或迁移输出”为准,不能只以文件已移动或代码已编译作为完成证据。 + +### 13.1 架构与契约 + +| ID | 验收条件 | 证据 | +| --- | --- | --- | +| A-01 | 通用契约位于 `packages/global/support/user/account/verification/`,服务实现位于各自 `account/verification/`;不存在 `accountVerification` 目录 | 目录扫描、import 扫描 | +| A-02 | `packages/service` 不导入 `pro/admin`;前端不导入 service-only 实现 | 依赖扫描、构建 | +| A-03 | 验证组件只创建/消费材料并返回可信身份,不创建用户、Session、Cookie,不写登录埋点或审计 | 单元测试、调用扫描 | +| A-04 | Local/contact/external identity 均为服务端判别类型;第三方身份字段只能来自 Provider consume | 类型检查、API 负例 | +| A-05 | 所有新增或修改 API 有 global OpenAPI schema,并通过 `parseApiInput` 校验边界输入 | OpenAPI 测试、路由扫描 | +| A-06 | collection 仍为 `auth_codes`,没有 verification token、OAuth token 或身份绑定新表 | schema diff、数据库检查 | + +### 13.2 Resolver、前端与敏感业务 + +| ID | 验收条件 | 证据 | +| --- | --- | --- | +| R-01 | 前后端共用同一 resolver 和 canonical fixture;resolver 不读取运行环境 | Global/adapter 测试、依赖扫描 | +| R-02 | 邮箱和手机号优先于连字符规则;含合法 `-` 的邮箱不会进入 SSO | Resolver fixture | +| R-03 | WeChat/GitHub/Google/Microsoft capability 缺失时只降级 oldPassword,不转 SSO | Resolver fixture | +| R-04 | Wecom 按 SSO、内部 Wecom、oldPassword 自动单选;通用连字符账号仅受 SSO capability 控制 | Resolver fixture | +| R-05 | 每个非空 username 只得到一个 method;前端只展示一个入口,不存在方式选择器或旧密码切换 | Resolver 与组件测试、页面扫描 | +| R-06 | create 使用持久化 username 和服务端 capabilities 推导一次并校验 method;consume 不重算 capabilities | API/service 测试 | +| R-07 | consume 只接受同一 method、userId、scene 的材料,验证身份必须归属当前用户 | API 安全测试、并发测试 | +| R-08 | 敏感业务请求不接受 username,不签发通用验证成功结果或 verification token | OpenAPI schema、API 负例、路由扫描 | + +### 13.3 材料、安全与并发 + +| ID | 验收条件 | 证据 | +| --- | --- | --- | +| M-01 | 每次读取/消费显式要求 `expiredTime > now`,TTL 延迟不会让过期材料成功 | 边界时间测试 | +| M-02 | 消费使用原子删除;并发请求最多一个得到可信身份或进入登录/敏感业务 | Mongo 并发测试、API 并发测试 | +| M-03 | 同一 `{ key, type }` 只保留最新材料;重复清理完成后唯一索引存在 | 迁移报告、索引检查 | +| M-04 | 迁移前已有验证码和微信记录在原有效期内仍可消费 | 兼容 fixture | +| M-05 | OAuth state 高熵、短期、一次性,并绑定 purpose、subject/provider、callback;SSO 不例外 | OAuth/SSO 测试 | +| M-06 | 微信 callback 只能更新有效占位;扫码成功只能被一个轮询消费 | 微信 service/API 测试 | +| M-07 | 消息或授权 URL 上游失败只条件清理本次材料,不误删并发重试产生的新材料 | 故障注入测试 | +| M-08 | 日志和响应不含 username、联系方式、code、state、openid、token、Provider 原始响应或 secret | 日志捕获测试、静态扫描 | + +### 13.4 验证方式与登录兼容 + +| ID | 验收条件 | 证据 | +| --- | --- | --- | +| V-01 | 密码预登录协议、密码摘要、成功响应和 Session/Cookie 保持兼容 | App API 回归测试 | +| V-02 | 第三方来源账号可用正确旧密码完成敏感验证;Wecom 仍不能通过密码登录创建 Session | Password/service 测试 | +| V-03 | Captcha/code 的人机校验、发送锁、重发覆盖、scene 隔离和失败补偿符合第 5.2 节 | Admin service/API 测试 | +| V-04 | 微信 pending 对外行为兼容,扫码后单次登录,签名和上游失败按第 5.3 节处理 | 微信回归与并发测试 | +| V-05 | GitHub、Google、Microsoft、Wecom、SSO 均由服务端 create/consume,响应经过 Zod 或官方库验证 | Provider 测试 | +| V-06 | 外部登录拒绝 forbidden 用户,并保持既有自动注册、默认团队、Wecom 团队映射、联系方式同步和 track type | 登录应用服务回归测试 | +| V-07 | 既有公开路由、成功响应、username 映射、Redis Session 规则和成功后埋点无非预期变化 | API contract diff、回归测试 | + +### 13.5 迁移与交付 + +| ID | 验收条件 | 证据 | +| --- | --- | --- | +| D-01 | 重复材料、SSO 前缀冲突和 Wecom 命名空间均完成 dry-run、处理和复核,脚本重复执行结果稳定 | 脱敏迁移报告 | +| D-02 | 主应用、Admin、SSO 和 Pro submodule 的兼容矩阵通过;未升级 SSO 不声明 V2 | 版本组合测试、发布记录 | +| D-03 | 旧 auth/controller、前端 URL/state 构造、route 内 Provider 逻辑和旧路径 re-export 已清除 | `rg`/依赖扫描、diff | +| D-04 | fastLogin 完成弃用窗口后从 schema、API、页面、handler 和管理配置移除,但 `usernameLogin` 可信身份能力保留 | 使用统计、路由与配置扫描 | +| D-05 | 定向测试、各 workspace 测试、App/Admin typecheck、lint、`pnpm test` 和 `git diff --check` 全部通过 | CI/本地命令输出 | +| D-06 | 全部 Mermaid 图由 8.8.3 解析通过,OpenAPI 和运维说明与最终实现一致 | Mermaid 校验输出、文档 diff | +| D-07 | 灰度指标无异常,关闭 capability 和应用回滚演练成功,未产生不可恢复的短期材料或 Session 行为 | 监控截图、演练记录 | diff --git a/.agents/design/account-verification/login-register-find-password.md b/.agents/design/account-verification/login-register-find-password.md new file mode 100644 index 000000000000..f2c82b40e17a --- /dev/null +++ b/.agents/design/account-verification/login-register-find-password.md @@ -0,0 +1,92 @@ +# 身份验证组件首轮接入开发文档 + +状态:已完成(本轮范围) +上游方案:`/Users/sealos/Desktop/docs/账号注销/身份验证组件技术方案.md` +范围:登录、注册、找回密码 + +## 1. 本轮目标 + +按上游方案把现有认证代码拆成“验证材料 create/consume、可信身份、业务编排”三层,并在不改变公开成功响应的前提下接入: + +- 密码登录; +- 微信扫码登录; +- GitHub、Google、Microsoft、Wecom、SSO 登录; +- 邮箱或手机号验证码注册; +- 邮箱或手机号验证码找回密码。 + +## 2. 明确不做 + +- 修改密码、过期密码重置; +- 用户或团队联系方式绑定; +- 账号注销及其它敏感业务验证分派; +- fastLogin 下线; +- `{ key, type }` 唯一索引上线和生产数据清理; +- 尚无发布条件的 SSO Provider 协议升级。 + +未纳入范围的旧调用方继续使用旧入口。只有全部旧调用方迁移完成后,后续需求才能删除旧 `support/user/auth` 路径。 + +## 3. 兼容约束 + +1. `auth_codes` collection、已有 type 值和公开路由保持不变。 +2. 登录成功响应仍为 `{ user, token }`,Cookie、Session、推广转化和登录埋点保持原成功时序。 +3. 注册和找回密码的请求及成功响应保持不变。 +4. 迁移后的材料读取必须显式检查 `expiredTime > now`,消费使用 `findOneAndDelete`。 +5. 普通验证码采用 upsert,使同一账号和 scene 只有最新验证码有效;本轮不直接创建唯一索引。 +6. 验证组件不创建用户、团队、Session、Cookie,也不写业务埋点。 +7. API 边界统一使用 global Zod schema 和 `parseApiInput`;Provider 响应在内部用普通 schema 解析。 +8. 主仓和 `pro` 子模块配套改动;保留用户已移动的子模块基线,不回退指针。 +9. 微信 callback token 沿用既有源码常量 `WX_AUTH_TOKEN`,后台只配置 AppID 和 AppSecret,不新增 token 配置入口。 + +## 4. 目标调用关系 + +```text +API + -> AccountVerification.create/consume + -> Local/External/Contact identity + -> 登录、注册或找回密码应用编排 + -> Session/Cookie/track +``` + +材料层位于 `packages/service/support/user/account/verification/`,前后端共享契约位于 `packages/global/support/user/account/verification/`。Pro Provider 实现位于 `pro/admin/src/service/support/user/account/verification/`。 + +## 5. 发布策略 + +- 先提交并验证 Pro 代码,再更新主仓共享包、App 和子模块指针。 +- OAuth V2 需要服务端 create state 与前端 callback 同步切换,不允许长期兼容可选 state。 +- Admin 调用 SSO 获取授权地址时始终传入服务端生成的 state,回调仍必须携带并消费该 state。 +- SSO 授权响应缺少 `oauthVerificationV2` 时按已能透传 state 的旧协议兼容;仅当该字段显式为 `false` 时拒绝登录。无法回传 state 的 Provider 仍会在回调校验阶段失败,不放宽安全校验。 +- 唯一索引必须在生产重复数据 dry-run/清理后单独发布,本轮 schema 只保留非唯一索引。 + +## 6. TODO + +- [x] 阅读上游技术方案、仓库规范并盘点主仓/Pro 现有调用方。 +- [x] 增加共享 method、capabilities、identity scene schema 与 username resolver。 +- [x] 增加 verification material schema/entity/service,覆盖显式过期与原子消费。 +- [x] 实现 PasswordAccountVerification 和 loginLocalAccount,迁移密码登录 API。 +- [x] 实现 CaptchaChallengeService、CodeAccountVerification,迁移发送验证码、注册和找回密码 API。 +- [x] 实现 WechatAccountVerification、OAuth 基类与 Provider adapter。 +- [x] 实现 loginExternalAccount,迁移微信/OAuth 登录 API。 +- [x] 前端 OAuth 改为服务端 create state,callback 必填 state。 +- [x] 补 resolver、材料、密码、验证码、Provider 和 API 定向测试。 +- [x] 运行 Global/Service/App/Admin 定向测试与 App/Admin typecheck。 +- [x] 最终运行 lint、仓库全量测试和 `git diff --check`。 + +## 7. 验收重点 + +- 验证材料过期后即使 TTL 尚未清理也不能消费;并发消费最多一个成功。 +- 密码错误和用户不存在保持统一错误;Wecom 仍不能通过密码登录创建 Session。 +- 注册与找回密码验证码 scene 不可串用,重发后旧码失效。 +- 微信同一 scene 最多创建一个登录 Session。 +- OAuth state 短期、一次性,并绑定 Provider 与 callback;第三方 token/secret 不进入日志或响应。 +- 外部登录拒绝 forbidden 用户,同时保持既有用户创建、团队和联系方式行为。 + +## 8. 最终验证 + +- `TURBO_CONCURRENCY=1 pnpm test`:App、Global、Admin、Service 四个 workspace 全部通过,共 6312 个测试通过;Service 按既有配置跳过 35 个测试。 +- `pnpm --filter @fastgpt/sso test`:5 个测试文件、9 个测试全部通过。 +- `pnpm --filter @fastgpt/app typecheck`、`pnpm --filter @fastgpt/admin typecheck`:通过。 +- `pnpm --filter @fastgpt/sso build`:通过;仅保留第三方 `@protobufjs/inquire` 的 direct-eval 构建警告。 +- 本次变更文件定向 ESLint:0 error、13 warning;warning 均为既有 React Hook Form、未使用变量或表达式风格告警。 +- `git diff --check`、`git -C pro diff --check`:通过。 + +仓库级 `pnpm lint` 仍受既有门禁问题阻断:`@fastgpt/marketplace` 使用当前 Next.js 已不支持的 `next lint` 命令;单独执行 App/Admin 全量 lint 还会分别命中 176/230 个范围外历史错误。本轮没有扩大范围修复这些基线问题,以定向 ESLint 结果作为本次改动的 lint 证据。 From eb7b08c89b06cdd87b4e67a7f8232ad7b5dfe8cd Mon Sep 17 00:00:00 2001 From: shortlight5980 Date: Thu, 16 Jul 2026 13:58:43 +0800 Subject: [PATCH 03/10] remove oauthVerificationV2 --- .../account- verification.md | 128 ++++++++++-------- .../login-register-find-password.md | 26 ++-- packages/global/common/system/types/index.ts | 1 - .../openapi/support/user/account/login/api.ts | 38 +++++- .../account/verification/oauthApi.test.ts | 18 ++- .../user/account/verification/service.ts | 17 +++ .../user/account/verification/service.test.ts | 29 ++++ .../login/LoginForm/FormLayout.tsx | 19 +-- projects/app/src/pages/login/provider.tsx | 30 ++-- .../user/account/verification/oauth.ts | 66 +++++++++ .../user/account/verification/oauth.test.ts | 97 +++++++++++++ 11 files changed, 368 insertions(+), 101 deletions(-) create mode 100644 packages/service/test/support/user/account/verification/service.test.ts create mode 100644 projects/app/src/web/support/user/account/verification/oauth.ts create mode 100644 projects/app/test/web/support/user/account/verification/oauth.test.ts diff --git a/.agents/design/account-verification/account- verification.md b/.agents/design/account-verification/account- verification.md index 726fdb834224..728bc777e8e6 100644 --- a/.agents/design/account-verification/account- verification.md +++ b/.agents/design/account-verification/account- verification.md @@ -1,7 +1,7 @@ # 账号身份验证组件技术设计 -状态:设计稿(按推荐默认方案收口) -日期:2026-07-13 +状态:设计稿(统一身份验证接入并保持旧 SSO 兼容)
+日期:2026-07-16
Mermaid 兼容基线:8.8.3 关联需求:[requirements.md](./requirements.md) @@ -18,13 +18,14 @@ consume:校验并消费验证材料,返回可信身份 本设计采用以下默认决策: -1. OAuth state 改由服务端生成、保存和一次性消费,但仍复用 `auth_codes` 集合。 +1. OAuth state 改由服务端生成并传给所有 Provider,仍复用 `auth_codes` 集合;直连 Provider 和返回 state 的 SSO 完整校验并一次性消费,只有旧 SSO 回调完全不返回 state 时允许 code-only 兼容。 2. 材料消费必须显式检查过期时间并使用原子删除;TTL 只负责异步清理。 3. `{ key, type }` 最终升级为唯一索引,保证同一验证场景只保留最新材料。 4. 微信扫码、OAuth Provider 交换和验证码发送逻辑从 API 路由下沉到验证服务。 5. `usernameLogin` 保留业务能力,但迁入专用登录应用服务,并只接收可信外部身份。 6. 快速登录不实现验证类,按独立废弃计划移除。 7. 前端展示与后端 create 分派共同使用 `resolveAccountVerificationByUsername`;后端在 create 时以持久化 username 和真实配置为最终依据,consume 沿用材料绑定。 +8. 本期范围是接入统一身份验证并保持旧 SSO 兼容,不修改 `pro/sso`,不引入兼容开关,也不解决旧 SSO 缺少 state 的登录 CSRF 和协议降级风险。 ## 2. 设计原则与边界 @@ -787,6 +788,10 @@ abstract class OAuthAccountVerification extends AccountVerification< } async consume(params) { + // 旧 SSO 回调完全没有 state 时,仅按旧协议使用 code 换取身份 + if (provider === 'sso' && params.state === undefined) { + return exchangeCode(params); + } // 只读校验 purpose + subjectHash + provider + callback + state + expiredTime // 子类使用 code 换取并校验 Provider 身份 // 原子删除 state;删除失败则拒绝身份 @@ -815,23 +820,31 @@ sequenceDiagram Verify->>IdP: build authorization URL Verify-->>Browser: state + url Browser->>IdP: redirect - IdP-->>Callback: code + state - Callback->>ConsumeAPI: provider + code + state + callbackUrl + IdP-->>Callback: code + state(旧 SSO 可能不返回 state) + Callback->>ConsumeAPI: provider + code + optional state + callbackUrl ConsumeAPI->>Verify: consume(...) - Verify->>Material: assert valid state - Verify->>IdP: exchange code and fetch identity - IdP-->>Verify: validated provider identity - Verify->>Material: findOneAndDelete state - alt state consumed by another request - Verify-->>ConsumeAPI: reject - else consumed successfully + alt provider 为 SSO 且 callback 完全没有 state + Verify->>IdP: exchange code only and fetch identity + IdP-->>Verify: validated provider identity Verify-->>ConsumeAPI: ExternalAccountIdentity ConsumeAPI->>Login: loginExternalAccount(identity, context) Login-->>Browser: Set-Cookie + user + token + else state 存在,或 Provider 不是 SSO + Verify->>Material: assert valid state + Verify->>IdP: exchange code and fetch identity + IdP-->>Verify: validated provider identity + Verify->>Material: findOneAndDelete state + alt state invalid, expired or consumed + Verify-->>ConsumeAPI: reject + else consumed successfully + Verify-->>ConsumeAPI: ExternalAccountIdentity + ConsumeAPI->>Login: loginExternalAccount(identity, context) + Login-->>Browser: Set-Cookie + user + token + end end ``` -先交换 code、再原子消费 state,沿用基准逻辑并允许 Provider 临时失败时重试。并发请求即使都完成交换,也只有一个能删除 state 并进入登录业务。 +state 存在时先交换 code、再原子消费 state,沿用基准逻辑并允许 Provider 临时失败时重试。并发请求即使都完成交换,也只有一个能删除 state 并进入登录业务。旧 SSO code-only 路径不读取或消费本地 state,create 时生成的记录由短期过期机制清理;该路径只依赖 SSO 一次性 code,并保留第 10.1 节记录的残余风险。 callback URL 规则: @@ -849,16 +862,13 @@ callback URL 规则: | Google | 服务端构造 authorize URL | code 换 token,验证 id token,映射 `google-{sub}` | 校验签名、`aud`、`iss`、`exp`,需要直接依赖官方验证库 | | Microsoft | 服务端构造 tenant authorize URL | code 换 token,读取 Graph `/me`,映射 `microsoft-{id}` | tenant/client 配置、token/user 响应 Zod 校验 | | Wecom | 服务端构造企业微信 URL | code 换 `open_userid + corpid`,映射 `wecom-{open_userid}` | 验证企业微信 errcode;不在验证类创建 FastGPT 用户 | -| SSO | 调用配置的 SSO 服务获取授权 URL,并传入 state | 固定 SSO base URL 下用 code 获取身份 | 必须透传/校验 state;固定同源 URL;限制 props 数量和长度 | +| SSO | 调用配置的 SSO 服务获取授权 URL,并始终传入 state | 固定 SSO base URL 下用 code 获取身份 | callback 带 state 时完整校验;完全无 state 时 code-only;固定同源 URL;限制 props 数量和长度 | Wecom 当前 `authWecom()` 会预先创建 FastGPT 用户。迁移后该副作用移动到登录应用服务:验证结果只返回 `organizationId=corpid`,登录服务再查找关联团队并按现有规则设置 `defaultTeamIdList`、`forbidCreateDefaultTeam` 和 Wecom tag。 -SSO 服务本身也需要满足 V2 契约。当前 `tcl`、`aecc` 等定制适配器不会把 state 带回 FastGPT,`oauth2` 和 `aecc` 还使用进程级变量缓存 redirect URI,存在多实例和并发串线风险。迁移要求: +本期范围是“接入统一身份验证并保持旧 SSO 兼容”。`pro/admin` 获取 SSO 授权地址时始终传入服务端生成的 state,但不要求现有 SSO 必须返回;回调带 state 时执行完整的错误、过期和一次性消费校验,回调完全没有 state 时仅对 `provider=sso` 使用旧 code-only 协议。GitHub、Google、Microsoft 和 Wecom 等非 SSO Provider 缺少 state 时直接拒绝,不允许 fallback。 -- 每个 `RedirectFn` 都必须让最终 FastGPT callback 恢复原始 state;OAuth 可用 state,SAML 使用 RelayState,CAS 或不支持 state 的协议需要服务端一次性 flow 记录。 -- 不再使用 `cache_redirect_uri`、`aecc_redirect_uri` 等进程全局变量保存单次请求上下文。 -- SSO 返回给 FastGPT 的 code 必须短期、一次性并绑定对应 flow。 -- 未升级的定制 Provider 不声明 `oauthVerificationV2`,不能通过跳过 state 校验维持兼容。 +本期不修改 `pro/sso` 的协议实现、进程级回调缓存或多实例行为,不新增 SSO 响应 capability 或其它兼容开关。Pro Admin 不再声明、读取或依赖历史 capability 字段,旧 SSO 即使继续返回该额外字段也会被忽略。统一组件只限制 SSO base URL、callback props 和返回身份结构;敏感业务即使走 code-only,也必须把 SSO 返回的持久化 username 与当前 Session 用户精确比较。 ## 6. 登录与业务编排 @@ -990,13 +1000,13 @@ sequenceDiagram | `GET /proApi/.../login/wx/getQR` | 路径不变,改调 wechat create | | `POST /proApi/.../login/wx/getResult` | 路径不变,改调 wechat consume + external login | | `POST /proApi/.../login/oauth/create` | 新增统一 OAuth create,返回 `{ state, url }` | -| `POST /proApi/.../login/oauth` | 路径不变,请求增加必填 `state`,执行 consume | +| `POST /proApi/.../login/oauth` | 路径不变;直连 Provider 的 `state` 必填,SSO 可省略;state 存在时执行完整 consume,无 state 时仅 SSO code-only | | 旧 `getAuthURL` / `wecom/getRedirectUrl` | 新前端切换后删除,不保留转发文件 | | 敏感业务的 verification create 路由 | 校验 Session,用持久化 username 推导一次并精确比对请求 method,创建绑定 method、userId 与 scene 的材料;不接收 username | | 敏感业务 action 路由 | 不重算 method 或 capabilities;校验请求 method 与材料绑定后,在同一请求内 consume、校验身份归属并执行业务;不接收 username,不新增通用“验证成功”接口或 verification token | | `fastLogin` | 不接入组件,按单独阶段废弃 | -主应用和 Pro 服务需要协调发布。若支持独立版本滚动升级,应在初始化配置暴露 `oauthVerificationV2` capability;新前端只有在 Pro 支持 create API 时启用 V2,切换完成后再关闭旧路径。不得长期保留“state 可选”的不安全 consume。 +主应用和 Pro Admin 需要协调发布统一 create/consume schema,但不增加前端门控、SSO 响应声明或兼容开关。OAuth/SSO 配置存在时前端直接使用统一入口;只有 `provider=sso && state===undefined` 命中旧协议,其它请求都进入 required-state 流程。 本地 `pro` 是独立 Git submodule。实现时需要先形成可独立校验的 Pro commit,再由主仓提交共享包、主应用改动和 submodule 指针;两边的 schema/capability 必须在同一发布单元中配套,不能只移动其中一侧。 @@ -1024,14 +1034,24 @@ const CreateOAuthVerificationResponseSchema = z.object({ url: UrlSchema }); -const OauthLoginBodySchema = TrackRegisterParamsSchema.extend({ - type: OAuthVerificationProviderSchema, - state: z.string(), +const OauthLoginCommonBodySchema = TrackRegisterParamsSchema.extend({ callbackUrl: UrlSchema, + code: z.string().min(1).max(4096), props: z.record(z.string(), z.string()), language: LanguageSchema.optional() }); +const OauthLoginBodySchema = z.discriminatedUnion('provider', [ + OauthLoginCommonBodySchema.extend({ + provider: z.literal(OAuthEnum.sso), + state: z.string().min(32).max(128).optional() + }), + OauthLoginCommonBodySchema.extend({ + provider: OAuthVerificationProviderSchema.exclude([OAuthEnum.sso]), + state: z.string().min(32).max(128) + }) +]); + const CodeVerificationPayloadSchema = z.object({ code: z.string().length(6) }); @@ -1067,7 +1087,8 @@ const SsoCallbackPropsSchema = z 'Invalid SSO callback properties' ); -const SsoVerificationPayloadSchema = OAuthVerificationPayloadSchema.extend({ +const SsoVerificationPayloadSchema = OAuthVerificationPayloadSchema.omit({ state: true }).extend({ + state: z.string().min(32).max(256).optional(), props: SsoCallbackPropsSchema.default({}) }); @@ -1110,7 +1131,7 @@ export type SensitiveAccountVerificationBody = z.infer< >; ``` -敏感业务请求不包含 username;method 是严格 union 的协议判别字段,不是用户可选策略。create schema 使用相同的 `method + payload` 结构:后端在该入口用 resolver 校验 method 一次,校验成功后将其绑定到材料。consume 直接解析上面的判别 union,并要求 method 命中同一 userId 和 scene 下的材料绑定,不再检查当前 capabilities。`code` 的邮件/手机通道沿用 create 时绑定的 `accountKind`。SSO adapter 只把受限 props 作为附加字段,并始终以顶层 `state`、`code`、`callbackUrl` 为准,禁止 props 覆盖安全字段。所有对象在生产 schema 中使用 `.strict()` 并补齐 OpenAPI `description`、`example`;callback URL 还要做服务端 allowlist 校验。所有改动路由统一使用 `parseApiInput`;第三方 Provider 响应使用普通 `Schema.parse`,失败应作为内部/上游异常记录。 +敏感业务请求不包含 username;method 是严格 union 的协议判别字段,不是用户可选策略。create schema 使用相同的 `method + payload` 结构:后端在该入口用 resolver 校验 method 一次,校验成功后将其绑定到材料。consume 直接解析上面的判别 union,不再检查当前 capabilities。除旧 SSO code-only 例外外,请求必须命中同一 userId 和 scene 下的材料绑定;旧 SSO 无 state 时无法用 state 绑定流程,因此必须依赖当前 Session、一次性 code 和消费后的持久化 username 精确归属校验。`code` 的邮件/手机通道沿用 create 时绑定的 `accountKind`。SSO adapter 只把受限 props 作为附加字段,并始终以顶层 `state`、`code`、`callbackUrl` 为准,禁止 props 覆盖安全字段。所有对象在生产 schema 中使用 `.strict()` 并补齐 OpenAPI `description`、`example`;callback URL 还要做服务端 allowlist 校验。所有改动路由统一使用 `parseApiInput`;第三方 Provider 响应使用普通 `Schema.parse`,失败应作为内部/上游异常记录。 ### 7.3 微信轮询兼容 @@ -1228,14 +1249,14 @@ OAuth Provider 文件是同一 `oauth` 子功能下的策略实现,不再为 1. 每次 consume 显式判断 `expiredTime`。 2. 验证材料使用原子 `findOneAndDelete`,配合唯一索引实现一次性语义。 3. 用户输入 code 正则必须转义;优先逐步归一化后精确匹配。 -4. OAuth state 至少 32 个高熵字符,并绑定 purpose、当前用户(登录场景为 anonymous)、Provider 与 callback URL。 +4. OAuth state 至少 32 个高熵字符,并绑定 purpose、当前用户(登录场景为 anonymous)、Provider 与 callback URL;仅旧 SSO 回调完全无 state 时走明确的 code-only 兼容分支。 5. OAuth create/consume 做 IP 频率限制;Provider code/state 不写日志。 6. Provider access token、refresh token、id token 和 client secret 不落库、不返回前端、不进入 URL 或结构化日志。 7. Google token 完整校验;所有 Provider 响应使用 Zod 收窄。 8. 外部登录同样拒绝 `forbidden` 用户。 9. SSO 请求只能访问配置的固定 base origin,callback props 设置数量和长度上限。 10. 微信 callback 必须先使用既有源码常量 `WX_AUTH_TOKEN` 验证签名,再允许写入已存在 scene;为兼容现有部署,不新增后台 token 配置入口。 -11. SSO 适配器必须回传 state,移除单请求上下文的进程全局缓存。 +11. Pro 始终向 SSO 传 state;SSO 回调带 state 时必须完整校验,完全无 state 时才可 code-only,且该例外不得扩展到非 SSO Provider。 12. username resolver 的前端结果用于展示和填写协议 method;后端只在 create 入口用 Session userId 对应的持久化 username 和服务端能力推导一次并精确比对。 13. create 将确认后的 method 绑定到材料;consume 校验请求 method 与 userId、scene、材料绑定一致,并在成功后校验外部身份 username 与当前用户一致。 14. 通用 SSO 只使用“第一个 `-` 前后非空”的格式规则,并以 SSO capability 为开关;不维护客户前缀枚举或 Admin 白名单。 @@ -1254,6 +1275,7 @@ OAuth Provider 文件是同一 `oauth` 子功能下的策略实现,不再为 | 授权 URL 构建失败 | 条件删除本次 state | | Provider code 交换失败 | state 在有效期内可重试,并受频率限制 | | Provider 交换成功但 state 删除失败 | 丢弃身份,不创建用户或 Session | +| SSO callback 完全无 state | 仅 SSO 按旧协议用一次性 code 换取身份;敏感业务继续精确校验当前用户,非 SSO 直接拒绝 | | 短信/邮件发送失败 | 条件删除本次 code、释放锁并返回失败 | | 微信 profile 获取失败 | scene 已消费,要求重新扫码 | | 用户/团队/Session 业务失败 | 不回滚已消费材料,沿用当前“重新验证后重试”语义 | @@ -1276,11 +1298,13 @@ OAuth Provider 文件是同一 `oauth` 子功能下的策略实现,不再为 | --- | --- | | GitHub login 可改名,而兼容 username 使用 `git-{login}` | 保持既有账号映射;彻底解决需要独立 Provider subject 绑定表,超出“不新增存储”边界 | | SSO 可返回与本地账号相同的 username | SSO 返回的用户名必须有前缀,否则失败 | -| 部分定制 SSO Provider 无 state 能力 | V2 上线前逐个升级;不以 state 可选方式降级安全性 | +| 部分定制 SSO Provider 无 state 能力 | 本期显式保留 SSO code-only 兼容;不把该 fallback 扩展到其它 Provider | | OAuth 未采用 PKCE | 本轮使用机密客户端、服务端 code 交换和一次性 state;PKCE 属于后续协议增强 | | 消息发送与数据库无法分布式原子提交 | 使用条件补偿并覆盖故障测试,仍可能出现“消息已发但客户端收到失败”的可接受窗口 | | 企业微信 SSO 使用 `userid`、内部套件使用 `open_userid` | 双入口 capability 以前置身份映射/迁移为条件;未对齐时只开放单入口,最终 username 仍精确校验 | +为兼容现有不支持 state 的 SSO,本期允许 SSO 回调在缺少 state 时按旧协议仅使用一次性 code 完成身份验证。该兼容路径不解决登录 CSRF 和协议降级风险;SSO state 强制校验、PKCE 或等价的流程绑定能力留待后续专项改造。 + ## 11. 测试设计 ### 11.1 测试分层与代码落点 @@ -1292,11 +1316,10 @@ OAuth Provider 文件是同一 `oauth` 子功能下的策略实现,不再为 | Global 纯函数与 schema | `packages/global/test/support/user/account/verification/` | username resolver、method/capabilities/resolution schema、OpenAPI 合约 | 无网络、无数据库;共享固定 fixture | | Service 材料与密码 | `packages/service/test/support/user/account/verification/` | `auth_codes` entity、过期与原子消费、密码验证 | `mongodb-memory-server`、fake timers、固定随机数 | | App API 与 Web | `projects/app/test/api/support/user/account/`、`projects/app/test/web/support/user/account/verification/` | 密码 API、Session/Cookie、前端唯一入口与回调上下文 | mock Pro API 和浏览器跳转 | -| Admin 验证与应用服务 | `pro/admin/test/service/support/user/account/verification/`、`pro/admin/test/api/support/user/account/verification/` | captcha/code/wechat/OAuth、method 分派、外部登录服务 | mock Provider HTTP、消息发送、Redis Session | -| SSO V2 协议 | `pro/sso/test/` | state/RelayState/flow 往返、一次性 code、多实例隔离 | 本地 HTTP fixture;禁止访问真实客户 IdP | +| Admin 验证与应用服务 | `pro/admin/test/service/support/user/account/verification/`、`pro/admin/test/api/support/user/account/verification/` | captcha/code/wechat/OAuth/SSO 兼容、method 分派、外部登录服务 | mock Provider HTTP、消息发送、Redis Session | | 数据迁移 | 迁移脚本同目录的 `*.test.ts` | dry-run 统计、重复清理、幂等、唯一索引前置检查 | 独立测试库和可重复 fixture | -`pro/sso` 当前没有标准 `test` script。开始修改 SSO 协议前必须补齐 Vitest 配置和 `pnpm --filter @fastgpt/sso test`,否则 SSO V2 不能进入发布阶段。 +`pro/sso` 不在本期修改和测试范围内。SSO 兼容测试在 Pro Admin 边界模拟“正确 state、错误 state、完全无 state”三类现有服务响应。 ### 11.2 必测矩阵 @@ -1339,12 +1362,12 @@ Adapter 测试必须覆盖缺 client id、缺 secret、scene 未启用、SSO URL | Password | create 30 秒材料;正确密码返回 `LocalAccountIdentity` | code 错误/过期/重复、用户不存在/禁用、密码错误;第三方账号可做敏感验证,但 Wecom 密码登录仍被应用服务拒绝 | | Captcha/Code | 图片码 -> reCAPTCHA -> 锁 -> upsert -> 发送;consume 返回 contact identity | 图片码大小写、reCAPTCHA 失败、锁冲突、发送失败条件补偿、scene 串用、重发旧码失效 | | WeChat | create 占位、callback 写入、pending 轮询、扫码后返回 external identity | 签名失败、伪造/过期 scene、并发轮询仅一个成功、profile 上游失败、现有 pending 响应兼容 | -| OAuth base | create state 和 URL;consume 交换身份并原子删除 state | state 熵、过期、缺失、provider/user/purpose/callback 不匹配、build 失败清理、交换失败保留、并发 consume 仅一个成功 | +| OAuth base | create state 和 URL;required-state consume 交换身份并原子删除 state | state 熵、过期、provider/user/purpose/callback 不匹配、build 失败清理、交换失败保留、并发 consume 仅一个成功;非 SSO 缺 state 拒绝 | | GitHub | authorize、token、user 映射 `git-*` | secret 不进 URL/日志;token/user 响应 Zod 错误 | | Google | authorize、官方库验证 id token、映射 `google-*` | 签名、`aud`、`iss`、`exp` 任一失败即拒绝 | | Microsoft | tenant authorize、token、Graph user 映射 | tenant/client 配置缺失;token/user 响应校验失败 | | Wecom | 返回 `wecom-*` 外部身份,不创建 FastGPT 用户 | errcode、corpid、username 归属不一致;`userid/open_userid` 未对齐时不开放双 capability | -| SSO | 固定 base URL、state 往返、身份映射 | 非同源 URL、props 越界、缺 state、一次性 code 重用、未升级 Provider 不声明 V2 | +| SSO | 固定 base URL、始终发送 state、身份映射 | 正确 state 成功、错误/过期/已消费 state 拒绝、完全无 state code-only 成功、props 越界;敏感业务身份不匹配拒绝 | | Login services | 本地/外部已有用户与新用户、团队映射、Session 创建 | forbidden 用户、并发首次登录重复键恢复、验证失败不写 Cookie/埋点/审计 | #### 11.2.4 API、前端与敏感业务 @@ -1356,7 +1379,7 @@ Adapter 测试必须覆盖缺 client id、缺 secret、scene 未启用、SSO URL | Consume 授权 | 不重新读取 capabilities;请求 method 必须命中同一 userId、scene 和 method 的材料,并在成功后校验身份归属 | | 敏感业务原子边界 | consume 与注销/改密等具体动作在同一业务请求内编排;不存在通用“验证成功”布尔接口或 verification token | | 前端入口 | 每个 resolver 结果只渲染一个入口;不存在方式选择器或“切换旧密码”;Provider 缺失时直接渲染 `oldPassword` | -| OAuth 前端 | create API 返回后才跳转;回调提交 state;刷新、返回和多标签页不会复用错误 Provider 上下文 | +| OAuth 前端 | create API 返回后才跳转;要求 loginStore、非空 code 和相同 callback URL;仅 SSO 可缺 state,state 存在时必须与 loginStore 精确相等 | | 微信前端 | pending 继续轮询,过期重新取二维码,动态内容不改变布局 | | 兼容行为 | 既有路由、成功响应、Cookie、Session、username 映射、团队创建和成功后埋点保持不变 | @@ -1380,9 +1403,6 @@ pnpm --filter @fastgpt/service test -- test/support/user/account/verification pnpm --filter @fastgpt/app test -- test/api/support/user/account test/web/support/user/account/verification pnpm --filter @fastgpt/admin test -- test/service/support/user/account/verification test/api/support/user/account/verification -# SSO 增加 test script 后执行 -pnpm --filter @fastgpt/sso test - # 应用类型检查 pnpm --filter @fastgpt/app typecheck pnpm --filter @fastgpt/admin typecheck @@ -1407,7 +1427,7 @@ flowchart TD P3 --> P4["阶段 4 Captcha 与消息验证码"] P4 --> P5["阶段 5 微信扫码与外部登录"] P5 --> P6["阶段 6 直连 OAuth"] - P6 --> P7["阶段 7 SSO V2 与 Wecom"] + P6 --> P7["阶段 7 SSO 兼容与 Wecom"] P7 --> P8["阶段 8 敏感业务与前端单入口"] P8 --> P9["阶段 9 灰度、清理与下线"] ``` @@ -1419,8 +1439,7 @@ flowchart TD - [ ] 编写重复材料 dry-run,按 type 输出重复组数、记录数、保留记录和脱敏样本;本阶段只读,不直接清理。 - [ ] 扫描启用 SSO 的部署中已有 `prefix-suffix` 本地账号,形成冲突迁移与回滚清单。 - [ ] 核对 Wecom SSO 的 `userid` 与内部 Wecom 的 `open_userid`,明确哪些部署可以同时声明两项 capability。 -- [ ] 明确主应用、`pro/admin`、`pro/sso` 和 Pro submodule 指针的提交/发布顺序,以及版本不匹配时 `oauthVerificationV2` 的关闭行为。 -- [ ] 为 `pro/sso` 增加 Vitest 配置和 test script,先覆盖当前 redirect/code 行为。 +- [ ] 明确主应用、`pro/admin` 和 Pro submodule 指针的提交/发布顺序,并验证新旧 SSO 回调组合;`pro/sso` 保持不变。 完成门槛:现有行为回归测试通过;数据和账号冲突报告可重复生成;每个发布单元都有明确回滚点。 @@ -1431,7 +1450,7 @@ flowchart TD - [ ] 在前端和服务端分别实现配置 adapter;resolver 内不读取 `window`、数据库、`global.systemConfig` 或服务端 SDK。 - [ ] 在 `packages/global/openapi/support/user/account/verification/` 定义 create/consume 判别 union、响应和路由文档,补齐 `description`、`example` 与 `.strict()`。 - [ ] 统一导出前后端共享的 method、capabilities 和 resolution 类型,不在 global 放服务端身份实现。 -- [ ] 增加 `oauthVerificationV2` 和敏感业务公开 capability,但不切换现有路由。 +- [ ] 增加敏感业务公开 capability,但不增加 OAuth/SSO 兼容开关。 完成门槛:global fixture 与 adapter 测试通过;前后端对同一输入得到同一唯一 method;现有生产行为没有变化。 @@ -1487,23 +1506,24 @@ flowchart TD - [ ] 实现 OAuth create API、服务端高熵 state、callback allowlist,以及 purpose/user/provider/callback 绑定。 - [ ] 实现 OAuth 基类的“只读校验 state -> Provider 交换 -> 原子删除 state”流程和并发保护。 - [ ] 迁移 GitHub、Google、Microsoft Provider,分别补齐 form body、官方 token 验证和所有上游响应 Zod schema。 -- [ ] 前端改为调用 create API 获取 URL,不再本地拼接授权地址或生成安全 state;回调请求必须提交 state。 +- [ ] 前端改为调用 create API 获取 URL,不再本地拼接授权地址或生成安全 state;直连 Provider 回调必须提交 state,SSO 仅在 callback 完全无 state 时允许兼容。 - [ ] OAuth 登录统一进入 `loginExternalAccount`,保持 username 映射、Cookie、Session 和 track type。 - [ ] Provider code/state/token/secret 不进入 URL、响应或结构化日志。 -完成门槛:三个直连 Provider 的 URL、交换、身份映射、state 并发与失败测试通过;关闭 V2 capability 可回到发布前行为。 +完成门槛:三个直连 Provider 的 URL、交换、身份映射、state 并发与失败测试通过;非 SSO 缺 state 的请求全部被拒绝。 -### 阶段 7:SSO V2、Wecom 与跨服务协调 +### 阶段 7:SSO 兼容、Wecom 与跨服务协调 -- [ ] 升级 `pro/sso` 的 OAuth/SAML/CAS/定制 flow,使最终 callback 恢复 state 或 RelayState,并生成短期一次性 code。 -- [ ] 删除 `cache_redirect_uri`、`aecc_redirect_uri` 等进程级单请求缓存,验证多实例和并发 flow 不串线。 +- [ ] Pro Admin 获取 SSO 授权地址时始终传 state,但不要求旧 SSO 必须返回,也不修改 `pro/sso`。 +- [ ] 实现唯一兼容分支:`provider=sso && state===undefined` 时 code-only;state 存在时统一进入 required-state consume。 - [ ] SSO 仅访问固定 base origin,限制 callback props 的键、数量、长度和保留字段。 - [ ] 实现 SSO 与 Wecom OAuth adapter;验证类只返回身份,不创建 FastGPT 用户。 - [ ] 把 Wecom `corpid` 到团队的映射和用户 provisioning 留在 `loginExternalAccount`。 -- [ ] 只有完成 state 契约的 SSO Provider 才声明 `oauthVerificationV2`;未升级 Provider 不允许跳过 state。 +- [ ] 前端仅允许 SSO 缺 state;state 存在时必须与 loginStore.state 相同,非 SSO 缺 state 必须拒绝。 +- [ ] 敏感业务的 SSO code-only 结果必须与当前 Session 用户的持久化 username 精确一致。 - [ ] 对齐或迁移 `userid/open_userid` 命名空间;未对齐部署只开放来源明确的一项 capability。 -完成门槛:SSO 协议 fixture、一次性 code、并发隔离和 Wecom 团队映射测试通过;主应用/Admin/SSO 的版本组合符合 capability 门控。 +完成门槛:SSO 正确 state、错误 state、无 state code-only、直连 Provider 无 state、敏感业务身份不匹配和 Wecom 团队映射测试通过。 ### 阶段 8:敏感业务分派与前端唯一入口 @@ -1519,8 +1539,8 @@ flowchart TD ### 阶段 9:灰度发布、旧代码清理与快速登录下线 -- [ ] 按主应用、Admin、SSO 和 Pro submodule 计划完成灰度,观察 create/consume outcome、上游错误和登录成功率;日志不得含敏感值。 -- [ ] 演练关闭 `oauthVerificationV2`、回退应用版本和回滚唯一索引之外代码的流程;数据脚本保留 dry-run 和复核能力。 +- [ ] 按主应用、Admin 和 Pro submodule 计划完成灰度,观察 required-state/code-only consume outcome、上游错误和登录成功率;日志不得含敏感值。 +- [ ] 演练应用版本回退和回滚唯一索引之外代码的流程;数据脚本保留 dry-run 和复核能力。 - [ ] 扫描并移除重复 username 分支、旧授权 URL/state 构造、旧 Provider route 函数和无引用 `support/user/auth` 文件。 - [ ] 确认目标实现均位于 `account/verification`,不存在 `accountVerification` 目录或旧路径 re-export。 - [ ] 统计 fastLogin 配置和路由使用,完成弃用窗口后删除 schema、OpenAPI、web API、页面、Pro handler 和管理配置;保留可信身份使用的 `usernameLogin` 业务能力。 @@ -1565,7 +1585,7 @@ flowchart TD | M-02 | 消费使用原子删除;并发请求最多一个得到可信身份或进入登录/敏感业务 | Mongo 并发测试、API 并发测试 | | M-03 | 同一 `{ key, type }` 只保留最新材料;重复清理完成后唯一索引存在 | 迁移报告、索引检查 | | M-04 | 迁移前已有验证码和微信记录在原有效期内仍可消费 | 兼容 fixture | -| M-05 | OAuth state 高熵、短期、一次性,并绑定 purpose、subject/provider、callback;SSO 不例外 | OAuth/SSO 测试 | +| M-05 | OAuth state 高熵、短期、一次性,并绑定 purpose、subject/provider、callback;仅旧 SSO 完全无 state 时走显式 code-only 兼容 | OAuth/SSO 测试 | | M-06 | 微信 callback 只能更新有效占位;扫码成功只能被一个轮询消费 | 微信 service/API 测试 | | M-07 | 消息或授权 URL 上游失败只条件清理本次材料,不误删并发重试产生的新材料 | 故障注入测试 | | M-08 | 日志和响应不含 username、联系方式、code、state、openid、token、Provider 原始响应或 secret | 日志捕获测试、静态扫描 | @@ -1587,9 +1607,9 @@ flowchart TD | ID | 验收条件 | 证据 | | --- | --- | --- | | D-01 | 重复材料、SSO 前缀冲突和 Wecom 命名空间均完成 dry-run、处理和复核,脚本重复执行结果稳定 | 脱敏迁移报告 | -| D-02 | 主应用、Admin、SSO 和 Pro submodule 的兼容矩阵通过;未升级 SSO 不声明 V2 | 版本组合测试、发布记录 | +| D-02 | 主应用、Admin 和 Pro submodule 的兼容矩阵通过;旧 SSO 无 state 可 code-only,新 SSO 带 state 必须完整校验,`pro/sso` 无本期改动 | 版本组合测试、发布记录 | | D-03 | 旧 auth/controller、前端 URL/state 构造、route 内 Provider 逻辑和旧路径 re-export 已清除 | `rg`/依赖扫描、diff | | D-04 | fastLogin 完成弃用窗口后从 schema、API、页面、handler 和管理配置移除,但 `usernameLogin` 可信身份能力保留 | 使用统计、路由与配置扫描 | | D-05 | 定向测试、各 workspace 测试、App/Admin typecheck、lint、`pnpm test` 和 `git diff --check` 全部通过 | CI/本地命令输出 | | D-06 | 全部 Mermaid 图由 8.8.3 解析通过,OpenAPI 和运维说明与最终实现一致 | Mermaid 校验输出、文档 diff | -| D-07 | 灰度指标无异常,关闭 capability 和应用回滚演练成功,未产生不可恢复的短期材料或 Session 行为 | 监控截图、演练记录 | +| D-07 | 灰度指标无异常,应用回滚演练成功,未产生不可恢复的短期材料或 Session 行为 | 监控截图、演练记录 | diff --git a/.agents/design/account-verification/login-register-find-password.md b/.agents/design/account-verification/login-register-find-password.md index f2c82b40e17a..c1c30ba146ea 100644 --- a/.agents/design/account-verification/login-register-find-password.md +++ b/.agents/design/account-verification/login-register-find-password.md @@ -1,7 +1,7 @@ # 身份验证组件首轮接入开发文档 -状态:已完成(本轮范围) -上游方案:`/Users/sealos/Desktop/docs/账号注销/身份验证组件技术方案.md` +状态:已完成(本轮范围)
+上游方案:`/Users/sealos/Desktop/docs/账号注销/身份验证组件技术方案.md`
范围:登录、注册、找回密码 ## 1. 本轮目标 @@ -21,7 +21,7 @@ - 账号注销及其它敏感业务验证分派; - fastLogin 下线; - `{ key, type }` 唯一索引上线和生产数据清理; -- 尚无发布条件的 SSO Provider 协议升级。 +- `pro/sso` 协议、进程级回调缓存、多实例行为和 PKCE 等专项安全升级。 未纳入范围的旧调用方继续使用旧入口。只有全部旧调用方迁移完成后,后续需求才能删除旧 `support/user/auth` 路径。 @@ -36,6 +36,8 @@ 7. API 边界统一使用 global Zod schema 和 `parseApiInput`;Provider 响应在内部用普通 schema 解析。 8. 主仓和 `pro` 子模块配套改动;保留用户已移动的子模块基线,不回退指针。 9. 微信 callback token 沿用既有源码常量 `WX_AUTH_TOKEN`,后台只配置 AppID 和 AppSecret,不新增 token 配置入口。 +10. Pro 始终向 SSO 传 state;SSO 回调带 state 时完整校验,完全无 state 时按旧协议 code-only;非 SSO Provider 缺 state 时拒绝。 +11. 不增加 OAuth/SSO 版本 capability 或其它兼容开关,不要求 SSO 响应声明能力,也不修改 `pro/sso`。 ## 4. 目标调用关系 @@ -52,9 +54,10 @@ API ## 5. 发布策略 - 先提交并验证 Pro 代码,再更新主仓共享包、App 和子模块指针。 -- OAuth V2 需要服务端 create state 与前端 callback 同步切换,不允许长期兼容可选 state。 -- Admin 调用 SSO 获取授权地址时始终传入服务端生成的 state,回调仍必须携带并消费该 state。 -- SSO 授权响应缺少 `oauthVerificationV2` 时按已能透传 state 的旧协议兼容;仅当该字段显式为 `false` 时拒绝登录。无法回传 state 的 Provider 仍会在回调校验阶段失败,不放宽安全校验。 +- 主应用与 Pro Admin 同步发布统一 OAuth create/consume schema,不增加前端 capability 门控。 +- Admin 调用 SSO 获取授权地址时始终传入服务端生成的 state,但不要求旧 SSO 必须返回。 +- SSO 回调带 state 时执行完整校验;错误、过期或已消费时拒绝。回调完全无 state 时仅 SSO code-only;所有直连 Provider 缺 state 时拒绝。 +- 前端仍要求 loginStore、非空 code 和相同 callback URL;仅 SSO 可缺 state,state 存在时必须与 loginStore.state 相同。 - 唯一索引必须在生产重复数据 dry-run/清理后单独发布,本轮 schema 只保留非唯一索引。 ## 6. TODO @@ -66,7 +69,7 @@ API - [x] 实现 CaptchaChallengeService、CodeAccountVerification,迁移发送验证码、注册和找回密码 API。 - [x] 实现 WechatAccountVerification、OAuth 基类与 Provider adapter。 - [x] 实现 loginExternalAccount,迁移微信/OAuth 登录 API。 -- [x] 前端 OAuth 改为服务端 create state,callback 必填 state。 +- [x] 前端 OAuth 改为服务端 create state;直连 Provider callback 必填 state,旧 SSO callback 可完全省略 state。 - [x] 补 resolver、材料、密码、验证码、Provider 和 API 定向测试。 - [x] 运行 Global/Service/App/Admin 定向测试与 App/Admin typecheck。 - [x] 最终运行 lint、仓库全量测试和 `git diff --check`。 @@ -82,11 +85,12 @@ API ## 8. 最终验证 -- `TURBO_CONCURRENCY=1 pnpm test`:App、Global、Admin、Service 四个 workspace 全部通过,共 6312 个测试通过;Service 按既有配置跳过 35 个测试。 -- `pnpm --filter @fastgpt/sso test`:5 个测试文件、9 个测试全部通过。 +- Global、Service、App workspace 全量测试均通过;Admin 全量为 73 个测试文件、400 个测试通过。 - `pnpm --filter @fastgpt/app typecheck`、`pnpm --filter @fastgpt/admin typecheck`:通过。 -- `pnpm --filter @fastgpt/sso build`:通过;仅保留第三方 `@protobufjs/inquire` 的 direct-eval 构建警告。 -- 本次变更文件定向 ESLint:0 error、13 warning;warning 均为既有 React Hook Form、未使用变量或表达式风格告警。 +- 旧 SSO 兼容调整覆盖:正确 state 成功、错误 state 拒绝、无 state code-only 成功、直连 Provider 无 state 拒绝;`pro/sso` 无本期改动。 +- 本次 App/Admin 变更文件定向 ESLint:0 error;Admin 保留 2 条既有表达式风格 warning。 - `git diff --check`、`git -C pro diff --check`:通过。 仓库级 `pnpm lint` 仍受既有门禁问题阻断:`@fastgpt/marketplace` 使用当前 Next.js 已不支持的 `next lint` 命令;单独执行 App/Admin 全量 lint 还会分别命中 176/230 个范围外历史错误。本轮没有扩大范围修复这些基线问题,以定向 ESLint 结果作为本次改动的 lint 证据。 + +为兼容现有不支持 state 的 SSO,本期允许 SSO 回调在缺少 state 时按旧协议仅使用一次性 code 完成身份验证。该兼容路径不解决登录 CSRF 和协议降级风险;SSO state 强制校验、PKCE 或等价的流程绑定能力留待后续专项改造。 diff --git a/packages/global/common/system/types/index.ts b/packages/global/common/system/types/index.ts index 7512015725d1..71b4a3ba4812 100644 --- a/packages/global/common/system/types/index.ts +++ b/packages/global/common/system/types/index.ts @@ -110,7 +110,6 @@ export type FastGPTFeConfigsType = { url?: string; autoLogin?: boolean; }; - oauthVerificationV2?: boolean; oauth?: { github?: string; google?: string; diff --git a/packages/global/openapi/support/user/account/login/api.ts b/packages/global/openapi/support/user/account/login/api.ts index b1e65818066f..c7501b3e82f6 100644 --- a/packages/global/openapi/support/user/account/login/api.ts +++ b/packages/global/openapi/support/user/account/login/api.ts @@ -90,7 +90,13 @@ export const LoginByPasswordBodySchema = TrackRegisterParamsSchema.extend({ .strict(); export type LoginByPasswordBodyType = z.infer; -// ===== OAuth Login V2 ===== +/* ============================================================================ + * API: 创建 OAuth 登录 + * Route: POST /proApi/support/user/account/login/oauth/create + * Method: POST + * Description: 创建 OAuth/SSO 登录 state 并返回 Provider 授权地址 + * Tags: ['Account Verification', 'User', 'Write'] + * ============================================================================ */ export const CreateOauthLoginBodySchema = z .object({ provider: OAuthAccountVerificationProviderSchema.meta({ description: 'OAuth Provider' }), @@ -112,6 +118,10 @@ export type CreateOauthLoginResponseType = z.infer; // ===== Fast Login ===== diff --git a/packages/global/test/support/user/account/verification/oauthApi.test.ts b/packages/global/test/support/user/account/verification/oauthApi.test.ts index e94f853effb3..f9d2d82571c2 100644 --- a/packages/global/test/support/user/account/verification/oauthApi.test.ts +++ b/packages/global/test/support/user/account/verification/oauthApi.test.ts @@ -5,7 +5,7 @@ import { } from '@fastgpt/global/openapi/support/user/account/login/api'; describe('OAuth login API contracts', () => { - it('accepts only OAuth V2 providers and a callback URL', () => { + it('accepts only supported OAuth providers and a callback URL', () => { expect( CreateOauthLoginBodySchema.parse({ provider: 'github', @@ -24,7 +24,7 @@ describe('OAuth login API contracts', () => { ).toBe(false); }); - it('requires provider, code and server-generated state when consuming OAuth', () => { + it('requires state for direct OAuth providers and allows legacy SSO without it', () => { const state = 'abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG'; expect( OauthLoginBodySchema.safeParse({ @@ -34,6 +34,20 @@ describe('OAuth login API contracts', () => { state }).success ).toBe(true); + expect( + OauthLoginBodySchema.safeParse({ + provider: 'github', + callbackUrl: 'https://fastgpt.example.com/login/provider', + code: 'provider-code' + }).success + ).toBe(false); + expect( + OauthLoginBodySchema.safeParse({ + provider: 'sso', + callbackUrl: 'https://fastgpt.example.com/login/provider', + code: 'provider-code' + }).success + ).toBe(true); expect( OauthLoginBodySchema.safeParse({ type: 'github', diff --git a/packages/service/support/user/account/verification/service.ts b/packages/service/support/user/account/verification/service.ts index 851a77f654cc..197bae7c6ca9 100644 --- a/packages/service/support/user/account/verification/service.ts +++ b/packages/service/support/user/account/verification/service.ts @@ -1,4 +1,5 @@ import type { CodeAccountVerificationScene } from '@fastgpt/global/support/user/account/verification/type'; +import { UserError } from '@fastgpt/global/common/error/utils'; /** 统一账号验证方式的材料创建与消费模型。 */ export abstract class AccountVerification< @@ -37,3 +38,19 @@ export type ExternalAccountIdentity = { memberName?: string; organizationId?: string; }; + +/** + * 敏感业务必须用持久化 username 精确校验外部身份归属。 + * 该校验同样适用于旧 SSO 的无 state code-only 兼容路径。 + */ +export const assertExternalAccountIdentityMatchesUsername = ({ + identity, + username +}: { + identity: ExternalAccountIdentity; + username: string; +}) => { + if (identity.username !== username) { + throw new UserError('Verified external identity does not match the current user'); + } +}; diff --git a/packages/service/test/support/user/account/verification/service.test.ts b/packages/service/test/support/user/account/verification/service.test.ts new file mode 100644 index 000000000000..d9a61c9d9b4b --- /dev/null +++ b/packages/service/test/support/user/account/verification/service.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest'; +import { assertExternalAccountIdentityMatchesUsername } from '@fastgpt/service/support/user/account/verification/service'; + +describe('assertExternalAccountIdentityMatchesUsername', () => { + const ssoIdentity = { + kind: 'external' as const, + provider: 'sso' as const, + subject: 'customer-user', + username: 'customer-user' + }; + + it('accepts an exact SSO identity match', () => { + expect(() => + assertExternalAccountIdentityMatchesUsername({ + identity: ssoIdentity, + username: 'customer-user' + }) + ).not.toThrow(); + }); + + it('rejects a code-only SSO identity that does not belong to the current user', () => { + expect(() => + assertExternalAccountIdentityMatchesUsername({ + identity: ssoIdentity, + username: 'customer-other-user' + }) + ).toThrow('Verified external identity does not match the current user'); + }); +}); diff --git a/projects/app/src/pageComponents/login/LoginForm/FormLayout.tsx b/projects/app/src/pageComponents/login/LoginForm/FormLayout.tsx index ae3c5cc5c269..e01d81e48192 100644 --- a/projects/app/src/pageComponents/login/LoginForm/FormLayout.tsx +++ b/projects/app/src/pageComponents/login/LoginForm/FormLayout.tsx @@ -46,11 +46,10 @@ const FormLayout = ({ children, setPageType, pageType }: Props) => { const isWecomWorkTerminal = checkIsWecomTerminal(); const canWecomTerminalAutoRedirect = !isWecomWorkTerminal || feConfigs?.wecomLoginAutoRedirect === true; - const oauthVerificationV2 = feConfigs?.oauthVerificationV2 === true; const oAuthList = useMemo( () => [ - ...(oauthVerificationV2 && feConfigs?.sso?.url + ...(feConfigs?.sso?.url ? [ { label: feConfigs.sso.title || 'Unknown', @@ -79,7 +78,7 @@ const FormLayout = ({ children, setPageType, pageType }: Props) => { } ] : []), - ...(oauthVerificationV2 && feConfigs?.oauth?.google + ...(feConfigs?.oauth?.google ? [ { label: t('common:support.user.login.Google'), @@ -88,7 +87,7 @@ const FormLayout = ({ children, setPageType, pageType }: Props) => { } ] : []), - ...(oauthVerificationV2 && feConfigs?.oauth?.github + ...(feConfigs?.oauth?.github ? [ { label: t('common:support.user.login.Github'), @@ -97,7 +96,7 @@ const FormLayout = ({ children, setPageType, pageType }: Props) => { } ] : []), - ...(oauthVerificationV2 && feConfigs?.oauth?.microsoft + ...(feConfigs?.oauth?.microsoft ? [ { label: @@ -109,7 +108,7 @@ const FormLayout = ({ children, setPageType, pageType }: Props) => { ] : []) ], - [feConfigs, oauthVerificationV2, pageType, t] + [feConfigs, pageType, t] ); const show_oauth = oAuthList.length > 0; @@ -156,12 +155,7 @@ const FormLayout = ({ children, setPageType, pageType }: Props) => { void onClickOauth(sso); return; } - if ( - oauthVerificationV2 && - feConfigs.oauth?.wecom && - isWecomWorkTerminal && - canWecomTerminalAutoRedirect - ) { + if (feConfigs.oauth?.wecom && isWecomWorkTerminal && canWecomTerminalAutoRedirect) { void onClickOauth({ label: 'Wecom', provider: 'wecom', @@ -172,7 +166,6 @@ const FormLayout = ({ children, setPageType, pageType }: Props) => { rootLogin, canWecomTerminalAutoRedirect, feConfigs?.sso?.autoLogin, - oauthVerificationV2, isWecomWorkTerminal, onClickOauth, oAuthList, diff --git a/projects/app/src/pages/login/provider.tsx b/projects/app/src/pages/login/provider.tsx index 98eabd61d9d4..e6fc38644e0d 100644 --- a/projects/app/src/pages/login/provider.tsx +++ b/projects/app/src/pages/login/provider.tsx @@ -22,6 +22,10 @@ import { validateRedirectUrl } from '@/web/common/utils/uri'; import type { LoginSuccessResponseType } from '@fastgpt/global/openapi/support/user/account/login/api'; import { useLoginRedirectAfterLogin } from '@/web/support/user/loginRedirect'; import type { LangEnum } from '@fastgpt/global/common/i18n/type'; +import { + resolveOAuthLoginCallback, + type ResolvedOAuthLoginCallback +} from '@/web/support/user/account/verification/oauth'; let isOauthLogging = false; @@ -85,20 +89,16 @@ const provider = () => { const completeOauthLogin = useCallback( async ({ - code, - state, + callback, props }: { - code: string; - state: string; + callback: ResolvedOAuthLoginCallback; props: Record; }) => { if (!loginStore) return; try { const res = await oauthLogin({ - provider: loginStore.provider, - code, - state, + ...callback, props, callbackUrl: loginStore.callbackUrl, inviterId: getInviterId(), @@ -153,13 +153,13 @@ const provider = () => { (async () => { const currentCallbackUrl = `${location.origin}/login/provider`; - if ( - !loginStore || - typeof state !== 'string' || - typeof code !== 'string' || - state !== loginStore.state || - loginStore.callbackUrl !== currentCallbackUrl - ) { + const callback = resolveOAuthLoginCallback({ + loginStore, + code, + state, + currentCallbackUrl + }); + if (!callback) { toast({ status: 'warning', title: t('common:support.user.login.security_failed') @@ -173,7 +173,7 @@ const provider = () => { await retryFn(async () => clearToken()); router.prefetch('/dashboard/agent'); - await completeOauthLogin({ code, state, props: callbackProps }); + await completeOauthLogin({ callback, props: callbackProps }); })(); }, [ callbackProps, diff --git a/projects/app/src/web/support/user/account/verification/oauth.ts b/projects/app/src/web/support/user/account/verification/oauth.ts new file mode 100644 index 000000000000..83067e1398b1 --- /dev/null +++ b/projects/app/src/web/support/user/account/verification/oauth.ts @@ -0,0 +1,66 @@ +import type { OAuthAccountVerificationProvider } from '@fastgpt/global/support/user/account/verification/type'; + +type OAuthLoginCallbackStore = { + provider: OAuthAccountVerificationProvider; + state: string; + callbackUrl: string; +}; + +type OAuthCallbackQueryValue = string | string[] | undefined; + +export type ResolvedOAuthLoginCallback = + | { + provider: 'sso'; + code: string; + state?: string; + } + | { + provider: Exclude; + code: string; + state: string; + }; + +/** + * 校验 OAuth 回调与发起登录时的本地上下文是否一致。 + * 仅旧 SSO 回调可以缺少 state;任何已返回的 state 都必须精确匹配。 + */ +export const resolveOAuthLoginCallback = ({ + loginStore, + code, + state, + currentCallbackUrl +}: { + loginStore?: OAuthLoginCallbackStore; + code: OAuthCallbackQueryValue; + state: OAuthCallbackQueryValue; + currentCallbackUrl: string; +}): ResolvedOAuthLoginCallback | undefined => { + if ( + !loginStore || + typeof code !== 'string' || + code.length === 0 || + loginStore.callbackUrl !== currentCallbackUrl + ) { + return; + } + + if (loginStore.provider === 'sso') { + if (state === undefined) { + return { provider: 'sso', code }; + } + if (typeof state !== 'string' || state !== loginStore.state) { + return; + } + return { provider: 'sso', code, state }; + } + + if (typeof state !== 'string' || state !== loginStore.state) { + return; + } + + return { + provider: loginStore.provider, + code, + state + }; +}; diff --git a/projects/app/test/web/support/user/account/verification/oauth.test.ts b/projects/app/test/web/support/user/account/verification/oauth.test.ts new file mode 100644 index 000000000000..81c772e31c80 --- /dev/null +++ b/projects/app/test/web/support/user/account/verification/oauth.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from 'vitest'; +import { resolveOAuthLoginCallback } from '@/web/support/user/account/verification/oauth'; + +const callbackUrl = 'https://fastgpt.example.com/login/provider'; +const state = 'abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG'; + +describe('resolveOAuthLoginCallback', () => { + it('accepts SSO with the matching state', () => { + expect( + resolveOAuthLoginCallback({ + loginStore: { provider: 'sso', state, callbackUrl }, + code: 'sso-code', + state, + currentCallbackUrl: callbackUrl + }) + ).toEqual({ provider: 'sso', code: 'sso-code', state }); + }); + + it('rejects SSO with a mismatched state', () => { + expect( + resolveOAuthLoginCallback({ + loginStore: { provider: 'sso', state, callbackUrl }, + code: 'sso-code', + state: 'different-state', + currentCallbackUrl: callbackUrl + }) + ).toBeUndefined(); + }); + + it('accepts legacy SSO without state', () => { + expect( + resolveOAuthLoginCallback({ + loginStore: { provider: 'sso', state, callbackUrl }, + code: 'sso-code', + state: undefined, + currentCallbackUrl: callbackUrl + }) + ).toEqual({ provider: 'sso', code: 'sso-code' }); + }); + + it('rejects a direct OAuth Provider without state', () => { + expect( + resolveOAuthLoginCallback({ + loginStore: { provider: 'github', state, callbackUrl }, + code: 'github-code', + state: undefined, + currentCallbackUrl: callbackUrl + }) + ).toBeUndefined(); + }); + + it('accepts a direct OAuth Provider with the matching state', () => { + expect( + resolveOAuthLoginCallback({ + loginStore: { provider: 'github', state, callbackUrl }, + code: 'github-code', + state, + currentCallbackUrl: callbackUrl + }) + ).toEqual({ provider: 'github', code: 'github-code', state }); + }); + + it('requires login context, a non-empty scalar code and the same callback URL', () => { + expect( + resolveOAuthLoginCallback({ + loginStore: undefined, + code: 'sso-code', + state: undefined, + currentCallbackUrl: callbackUrl + }) + ).toBeUndefined(); + expect( + resolveOAuthLoginCallback({ + loginStore: { provider: 'sso', state, callbackUrl }, + code: '', + state: undefined, + currentCallbackUrl: callbackUrl + }) + ).toBeUndefined(); + expect( + resolveOAuthLoginCallback({ + loginStore: { provider: 'sso', state, callbackUrl }, + code: ['sso-code'], + state: undefined, + currentCallbackUrl: callbackUrl + }) + ).toBeUndefined(); + expect( + resolveOAuthLoginCallback({ + loginStore: { provider: 'sso', state, callbackUrl }, + code: 'sso-code', + state: undefined, + currentCallbackUrl: `${callbackUrl}?changed=1` + }) + ).toBeUndefined(); + }); +}); From e65b82912f10b57b8765a536c48ee10b44725fac Mon Sep 17 00:00:00 2001 From: shortlight5980 Date: Fri, 17 Jul 2026 12:10:04 +0800 Subject: [PATCH 04/10] fix(account-verification): code verify frequency limit --- .../account- verification.md | 22 ++++++-- .../login-register-find-password.md | 5 +- .../user/account/verification/utils.ts | 29 +++++++++++ .../service/support/user/auth/controller.ts | 3 ++ .../user/account/verification/utils.test.ts | 50 ++++++++++++++++++- .../test/support/user/auth/controller.test.ts | 33 ++++++++++++ packages/web/i18n/en/common.json | 1 + packages/web/i18n/zh-CN/common.json | 1 + packages/web/i18n/zh-Hant/common.json | 1 + 9 files changed, 138 insertions(+), 7 deletions(-) create mode 100644 packages/service/test/support/user/auth/controller.test.ts diff --git a/.agents/design/account-verification/account- verification.md b/.agents/design/account-verification/account- verification.md index 728bc777e8e6..0fca6bdd5b32 100644 --- a/.agents/design/account-verification/account- verification.md +++ b/.agents/design/account-verification/account- verification.md @@ -1,7 +1,7 @@ # 账号身份验证组件技术设计 状态:设计稿(统一身份验证接入并保持旧 SSO 兼容)
-日期:2026-07-16
+日期:2026-07-17
Mermaid 兼容基线:8.8.3 关联需求:[requirements.md](./requirements.md) @@ -26,6 +26,7 @@ consume:校验并消费验证材料,返回可信身份 6. 快速登录不实现验证类,按独立废弃计划移除。 7. 前端展示与后端 create 分派共同使用 `resolveAccountVerificationByUsername`;后端在 create 时以持久化 username 和真实配置为最终依据,consume 沿用材料绑定。 8. 本期范围是接入统一身份验证并保持旧 SSO 兼容,不修改 `pro/sso`,不引入兼容开关,也不解决旧 SSO 缺少 state 的登录 CSRF 和协议降级风险。 +9. 短信/邮件验证码的每次 `consume` 都在材料查询前按 Redis `account + scene` 累加固定窗口频控;同一键 1 分钟最多提交 10 次,第 11 次起返回“验证过于频繁,请稍后再试”。 ## 2. 设计原则与边界 @@ -687,7 +688,7 @@ sequenceDiagram - `create` 仅允许 `register`、`findPassword`、`bindNotification` 三种 scene。 - 依次消费图片验证码、校验配置存在时的 reCAPTCHA、获取一分钟发送锁、生成六位数字码、upsert 材料、调用现有 `sendMessage`。 - 发送失败时删除本次 code 并释放发送锁,允许用户立即重试。 -- `consume` 按 `account + scene + code + expiredTime` 原子删除并返回 `VerifiedContactIdentity`。 +- `consume` 先按 `account + scene` 执行 Redis 固定窗口频控,60 秒内每次提交都累加,前 10 次允许,第 11 次起返回“验证过于频繁,请稍后再试”;通过频控后再按 `account + scene + code + expiredTime` 原子删除并返回 `VerifiedContactIdentity`。 - 不查询用户是否存在,不执行注册、改密或绑定。 ```mermaid @@ -698,6 +699,7 @@ sequenceDiagram participant SendAPI as sendAuthCode API participant Code as CodeVerification participant Guard as reCAPTCHA + TimerLock + participant ConsumeLimit as Redis Consume Limit participant Material as Material Entity participant Message as Email / SMS participant Business as Register / Reset / Bind API @@ -715,6 +717,8 @@ sequenceDiagram Message-->>Browser: success Browser->>Business: account + scene-specific code + business data Business->>Code: consume(account, scene, code) + Code->>ConsumeLimit: INCR(account + scene), EXPIRE NX 60s + ConsumeLimit-->>Code: allow when count <= 10 Code->>Material: findOneAndDelete(valid code) Code-->>Business: VerifiedContactIdentity Business->>Business: 注册 / 改密 / 绑定 @@ -925,6 +929,8 @@ Cookie、`pushTrack.login` 和 LOGIN 审计由 API 成功分支显式执行, 验证码实现只证明对目标邮箱/手机号的控制权,不证明 FastGPT 用户存在。是否允许该身份执行具体业务由调用方判断。 +迁移完成前,仍调用旧 `authCode` 的用户联系方式和团队通知账号入口必须复用同一个 Redis 提交频控断言,不能因新旧消费路径并存而留下无限尝试入口。注册场景使用请求中的 `username` 作为 account;其它场景使用实际接收验证码的邮箱或手机号。 + ### 6.5 敏感业务的验证方式分派 账号注销等敏感业务按“resolver 唯一确定方式、后端校验并创建材料、业务 API 直接消费并执行动作”的顺序使用共享 resolver。前端不能提供方式选择器,每次只展示 resolver 返回的一种入口。create 请求携带 method 作为协议判别字段,服务端使用持久化 username 和真实 capabilities 推导一次并精确比对,随后把 method 绑定到验证材料。consume 不再重新推导,只验证请求 method、当前 userId、scene 与材料绑定。不能先在通用接口消费身份再返回布尔值,否则没有 verification token 就无法把验证结果安全传递给后续请求;也不能新增本方案明确排除的 verification token。 @@ -1262,12 +1268,14 @@ OAuth Provider 文件是同一 `oauth` 子功能下的策略实现,不再为 14. 通用 SSO 只使用“第一个 `-` 前后非空”的格式规则,并以 SSO capability 为开关;不维护客户前缀枚举或 Admin 白名单。 15. 敏感业务的 create 与 action 请求都不接受 username;method 仅作为严格判别字段。OAuth state 绑定 userId 和 scene,action 必须在同一请求中消费身份并执行业务。 16. `oldPassword` 只在没有可展示的验证码或 Provider 方式时成为唯一 method;密码验证组件接受第三方账号的正确密码,Wecom 禁止密码登录的规则只留在登录应用服务。 +17. 短信/邮件验证码 consume 在读取材料前按 Redis `account + scene` 累加 60 秒固定窗口计数;错误和成功提交都计数,前 10 次允许,第 11 次起拒绝,新旧消费入口必须使用同一策略。 ### 9.2 失败处理 | 失败 | 组件行为 | | --- | --- | | 材料不存在、过期或已消费 | 统一验证失败,不透露具体原因;微信轮询内部可返回 expired | +| 同一 `account + scene` 在 60 秒内提交验证码超过 10 次 | 在查询材料前拒绝,返回“验证过于频繁,请稍后再试”;窗口从首次提交起固定 60 秒,超限请求继续累加但不延长窗口 | | 密码错误/用户不存在 | 统一账号密码错误 | | Provider capability 缺失 | resolver 在 create 前唯一返回 `oldPassword`,不调用 Provider,也不同时向用户展示两种入口 | | create 请求 method 与服务端推导不一致 | 拒绝创建材料,前端刷新配置后重新渲染唯一入口 | @@ -1286,7 +1294,7 @@ OAuth Provider 文件是同一 `oauth` 子功能下的策略实现,不再为 - `verificationType`、`scene`、`provider`; - `operation=create|consume|callback`; -- `outcome=success|pending|expired|invalid|upstream_error`; +- `outcome=success|pending|expired|invalid|rate_limited|upstream_error`; - 耗时和上游 HTTP 状态; - 可选的材料 key 哈希前缀,用于关联但不能反推账号/state。 @@ -1302,6 +1310,7 @@ OAuth Provider 文件是同一 `oauth` 子功能下的策略实现,不再为 | OAuth 未采用 PKCE | 本轮使用机密客户端、服务端 code 交换和一次性 state;PKCE 属于后续协议增强 | | 消息发送与数据库无法分布式原子提交 | 使用条件补偿并覆盖故障测试,仍可能出现“消息已发但客户端收到失败”的可接受窗口 | | 企业微信 SSO 使用 `userid`、内部套件使用 `open_userid` | 双入口 capability 以前置身份映射/迁移为条件;未对齐时只开放单入口,最终 username 仍精确校验 | +| 账号级验证码提交频控可被外部请求触发短时锁定 | 固定窗口 60 秒后自动恢复,并按 scene 隔离;发送侧人机校验和 API IP 频控作为补充,但不能完全消除针对特定账号的短时拒绝服务 | 为兼容现有不支持 state 的 SSO,本期允许 SSO 回调在缺少 state 时按旧协议仅使用一次性 code 完成身份验证。该兼容路径不解决登录 CSRF 和协议降级风险;SSO state 强制校验、PKCE 或等价的流程绑定能力留待后续专项改造。 @@ -1354,13 +1363,14 @@ Adapter 测试必须覆盖缺 client id、缺 secret、scene 未启用、SSO URL | 敏感业务绑定 | method、userId、scene、provider/callback 任一不一致 | 查询和消费均失败,不跨业务复用材料 | | 兼容数据 | 迁移前已存在验证码和微信记录 | 在原有效期内仍可按旧 key/type 规则消费 | | 唯一索引 | 清理前存在重复、清理后建索引、重复执行脚本 | dry-run 能阻止建索引;清理幂等;最终索引可创建 | +| 验证码提交频控 | 同账号同场景连续提交、不同账号、不同 scene、新旧消费入口 | 前 10 次允许,第 11 次起返回频控错误;账号和 scene 独立计数;旧 `authCode` 与统一 `consume` 使用同一策略 | #### 11.2.3 验证实现与应用编排 | 模块 | 成功路径 | 失败、安全与兼容路径 | | --- | --- | --- | | Password | create 30 秒材料;正确密码返回 `LocalAccountIdentity` | code 错误/过期/重复、用户不存在/禁用、密码错误;第三方账号可做敏感验证,但 Wecom 密码登录仍被应用服务拒绝 | -| Captcha/Code | 图片码 -> reCAPTCHA -> 锁 -> upsert -> 发送;consume 返回 contact identity | 图片码大小写、reCAPTCHA 失败、锁冲突、发送失败条件补偿、scene 串用、重发旧码失效 | +| Captcha/Code | 图片码 -> reCAPTCHA -> 锁 -> upsert -> 发送;consume 先做 Redis 提交频控,再返回 contact identity | 图片码大小写、reCAPTCHA 失败、锁冲突、发送失败条件补偿、scene 串用、重发旧码失效、同账号场景第 11 次提交被拒绝、旧 `authCode` 不绕过频控 | | WeChat | create 占位、callback 写入、pending 轮询、扫码后返回 external identity | 签名失败、伪造/过期 scene、并发轮询仅一个成功、profile 上游失败、现有 pending 响应兼容 | | OAuth base | create state 和 URL;required-state consume 交换身份并原子删除 state | state 熵、过期、provider/user/purpose/callback 不匹配、build 失败清理、交换失败保留、并发 consume 仅一个成功;非 SSO 缺 state 拒绝 | | GitHub | authorize、token、user 映射 `git-*` | secret 不进 URL/日志;token/user 响应 Zod 错误 | @@ -1482,6 +1492,7 @@ flowchart TD - [ ] 拆出 `CaptchaChallengeService` 与 `CodeAccountVerification`,限定 register/findPassword/bindNotification 及新增敏感 scene。 - [ ] 把图片验证码、reCAPTCHA、发送锁、材料 upsert 和消息发送按第 5.2 节顺序编排。 +- [ ] 在统一 `CodeAccountVerification.consume` 和迁移期旧 `authCode` 中复用 Redis `account + scene` 固定窗口频控,覆盖 60 秒 10 次边界、账号与 scene 隔离及超限错误文案。 - [ ] 将消息网络请求移出 Mongo transaction;发送失败时按 code 条件清理材料并释放锁。 - [ ] 迁移注册、找回密码、用户联系方式和团队通知账号消费者,保持业务校验、Session、Cookie 和审计行为。 - [ ] 所有相关 API 改用 global OpenAPI schema 与 `parseApiInput`,删除直接解析 `req.body/query` 的写法。 @@ -1589,6 +1600,7 @@ flowchart TD | M-06 | 微信 callback 只能更新有效占位;扫码成功只能被一个轮询消费 | 微信 service/API 测试 | | M-07 | 消息或授权 URL 上游失败只条件清理本次材料,不误删并发重试产生的新材料 | 故障注入测试 | | M-08 | 日志和响应不含 username、联系方式、code、state、openid、token、Provider 原始响应或 secret | 日志捕获测试、静态扫描 | +| M-09 | 验证码提交按 Redis `account + scene` 在 60 秒固定窗口内累计,前 10 次允许、第 11 次起统一返回频控错误,且新旧消费入口策略一致 | Service/Admin 频控边界与兼容测试 | ### 13.4 验证方式与登录兼容 @@ -1596,7 +1608,7 @@ flowchart TD | --- | --- | --- | | V-01 | 密码预登录协议、密码摘要、成功响应和 Session/Cookie 保持兼容 | App API 回归测试 | | V-02 | 第三方来源账号可用正确旧密码完成敏感验证;Wecom 仍不能通过密码登录创建 Session | Password/service 测试 | -| V-03 | Captcha/code 的人机校验、发送锁、重发覆盖、scene 隔离和失败补偿符合第 5.2 节 | Admin service/API 测试 | +| V-03 | Captcha/code 的人机校验、发送锁、重发覆盖、scene 隔离、提交频控和失败补偿符合第 5.2 节 | Admin service/API 测试 | | V-04 | 微信 pending 对外行为兼容,扫码后单次登录,签名和上游失败按第 5.3 节处理 | 微信回归与并发测试 | | V-05 | GitHub、Google、Microsoft、Wecom、SSO 均由服务端 create/consume,响应经过 Zod 或官方库验证 | Provider 测试 | | V-06 | 外部登录拒绝 forbidden 用户,并保持既有自动注册、默认团队、Wecom 团队映射、联系方式同步和 track type | 登录应用服务回归测试 | diff --git a/.agents/design/account-verification/login-register-find-password.md b/.agents/design/account-verification/login-register-find-password.md index c1c30ba146ea..75dafd50cdc7 100644 --- a/.agents/design/account-verification/login-register-find-password.md +++ b/.agents/design/account-verification/login-register-find-password.md @@ -23,7 +23,7 @@ - `{ key, type }` 唯一索引上线和生产数据清理; - `pro/sso` 协议、进程级回调缓存、多实例行为和 PKCE 等专项安全升级。 -未纳入范围的旧调用方继续使用旧入口。只有全部旧调用方迁移完成后,后续需求才能删除旧 `support/user/auth` 路径。 +未纳入范围的旧调用方继续使用旧入口。只有全部旧调用方迁移完成后,后续需求才能删除旧 `support/user/auth` 路径;迁移期间旧验证码消费入口仍必须复用统一的 Redis 提交频控,不能保留无限尝试路径。 ## 3. 兼容约束 @@ -38,6 +38,7 @@ 9. 微信 callback token 沿用既有源码常量 `WX_AUTH_TOKEN`,后台只配置 AppID 和 AppSecret,不新增 token 配置入口。 10. Pro 始终向 SSO 传 state;SSO 回调带 state 时完整校验,完全无 state 时按旧协议 code-only;非 SSO Provider 缺 state 时拒绝。 11. 不增加 OAuth/SSO 版本 capability 或其它兼容开关,不要求 SSO 响应声明能力,也不修改 `pro/sso`。 +12. 短信/邮件验证码提交在读取材料前按 Redis `account + scene` 累加 60 秒固定窗口计数;前 10 次允许,第 11 次起返回“验证过于频繁,请稍后再试”。统一 `CodeAccountVerification.consume` 与迁移期旧 `authCode` 使用同一策略。 ## 4. 目标调用关系 @@ -71,6 +72,7 @@ API - [x] 实现 loginExternalAccount,迁移微信/OAuth 登录 API。 - [x] 前端 OAuth 改为服务端 create state;直连 Provider callback 必填 state,旧 SSO callback 可完全省略 state。 - [x] 补 resolver、材料、密码、验证码、Provider 和 API 定向测试。 +- [x] 为统一验证码 consume 和旧 `authCode` 增加 Redis `account + scene` 提交频控,覆盖 1 分钟 10 次边界与场景隔离。 - [x] 运行 Global/Service/App/Admin 定向测试与 App/Admin typecheck。 - [x] 最终运行 lint、仓库全量测试和 `git diff --check`。 @@ -79,6 +81,7 @@ API - 验证材料过期后即使 TTL 尚未清理也不能消费;并发消费最多一个成功。 - 密码错误和用户不存在保持统一错误;Wecom 仍不能通过密码登录创建 Session。 - 注册与找回密码验证码 scene 不可串用,重发后旧码失效。 +- 验证码提交按账号与 scene 独立计数,前 10 次进入校验,第 11 次起返回频控错误;旧绑定入口不能绕过该限制。 - 微信同一 scene 最多创建一个登录 Session。 - OAuth state 短期、一次性,并绑定 Provider 与 callback;第三方 token/secret 不进入日志或响应。 - 外部登录拒绝 forbidden 用户,同时保持既有用户创建、团队和联系方式行为。 diff --git a/packages/service/support/user/account/verification/utils.ts b/packages/service/support/user/account/verification/utils.ts index 896f041bd1d1..0a180cf3d7f3 100644 --- a/packages/service/support/user/account/verification/utils.ts +++ b/packages/service/support/user/account/verification/utils.ts @@ -1,3 +1,10 @@ +import { UserError } from '@fastgpt/global/common/error/utils'; +import { i18nT } from '@fastgpt/global/common/i18n/utils'; +import { checkFixedWindowQpmLimit } from '../../../../common/system/frequencyLimit/redisFixedWindow'; + +const CodeVerificationConsumeQpm = 10; +const CodeVerificationConsumeWindowSeconds = 60; + /** 将用户输入转成可安全用于锚定正则的字面量。 */ export const escapeVerificationCodeForRegExp = (code: string) => code.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); @@ -13,3 +20,25 @@ export const buildVerificationCodeFilter = ({ caseInsensitive ? { $regex: new RegExp(`^${escapeVerificationCodeForRegExp(code)}$`, 'i') } : code; + +/** + * 按账号和场景累计验证码提交次数,限制同一固定分钟窗口内最多验证 10 次。 + * 该检查必须在查询验证码前执行,使错误和成功提交都占用尝试次数。 + */ +export const assertCodeVerificationConsumeFrequency = async ({ + account, + scene +}: { + account: string; + scene: string; +}) => { + const allowed = await checkFixedWindowQpmLimit({ + key: `account-verification:code:consume:${scene}:${account}`, + limit: CodeVerificationConsumeQpm, + seconds: CodeVerificationConsumeWindowSeconds + }); + + if (!allowed) { + throw new UserError(i18nT('common:error.verify_code_too_frequently')); + } +}; diff --git a/packages/service/support/user/auth/controller.ts b/packages/service/support/user/auth/controller.ts index 101aa62d4fca..b37eadb6cbf5 100644 --- a/packages/service/support/user/auth/controller.ts +++ b/packages/service/support/user/auth/controller.ts @@ -4,6 +4,7 @@ import { i18nT } from '@fastgpt/global/common/i18n/utils'; import { mongoSessionRun } from '../../../common/mongo/sessionRun'; import { UserError } from '@fastgpt/global/common/error/utils'; import { z } from 'zod'; +import { assertCodeVerificationConsumeFrequency } from '../account/verification/utils'; export const addAuthCode = async ({ key, @@ -41,6 +42,8 @@ const authCodeSchema = z.object({ }); export const authCode = async (props: z.infer) => { const { key, type, code } = authCodeSchema.parse(props); + await assertCodeVerificationConsumeFrequency({ account: key, scene: type }); + return mongoSessionRun(async (session) => { const result = await MongoUserAuth.findOne( { diff --git a/packages/service/test/support/user/account/verification/utils.test.ts b/packages/service/test/support/user/account/verification/utils.test.ts index 83a3958e862d..7da4464d583e 100644 --- a/packages/service/test/support/user/account/verification/utils.test.ts +++ b/packages/service/test/support/user/account/verification/utils.test.ts @@ -1,8 +1,11 @@ -import { describe, expect, it } from 'vitest'; +import { beforeEach, describe, expect, it } from 'vitest'; import { + assertCodeVerificationConsumeFrequency, buildVerificationCodeFilter, escapeVerificationCodeForRegExp } from '@fastgpt/service/support/user/account/verification/utils'; +import { getGlobalRedisConnection } from '@fastgpt/service/common/redis'; +import { i18nT } from '@fastgpt/global/common/i18n/utils'; describe('escapeVerificationCodeForRegExp', () => { it('escapes every regular expression metacharacter', () => { @@ -23,3 +26,48 @@ describe('buildVerificationCodeFilter', () => { expect(filter.$regex.test('xa.b[c]')).toBe(false); }); }); + +describe('assertCodeVerificationConsumeFrequency', () => { + const account = 'verification-rate-limit@example.com'; + + beforeEach(async () => { + await getGlobalRedisConnection().del( + `account-verification:code:consume:register:${account}`, + `account-verification:code:consume:findPassword:${account}`, + 'account-verification:code:consume:register:other@example.com' + ); + }); + + it('allows 10 attempts per account and scene, then returns the frequency error', async () => { + const params = { account, scene: 'register' }; + + for (let index = 0; index < 10; index++) { + await expect(assertCodeVerificationConsumeFrequency(params)).resolves.toBeUndefined(); + } + + await expect(assertCodeVerificationConsumeFrequency(params)).rejects.toThrow( + i18nT('common:error.verify_code_too_frequently') + ); + }); + + it('counts accounts and scenes independently', async () => { + const registerParams = { account, scene: 'register' }; + + for (let index = 0; index < 10; index++) { + await assertCodeVerificationConsumeFrequency(registerParams); + } + + await expect( + assertCodeVerificationConsumeFrequency({ + account: 'other@example.com', + scene: 'register' + }) + ).resolves.toBeUndefined(); + await expect( + assertCodeVerificationConsumeFrequency({ + account, + scene: 'findPassword' + }) + ).resolves.toBeUndefined(); + }); +}); diff --git a/packages/service/test/support/user/auth/controller.test.ts b/packages/service/test/support/user/auth/controller.test.ts new file mode 100644 index 000000000000..329703a31c74 --- /dev/null +++ b/packages/service/test/support/user/auth/controller.test.ts @@ -0,0 +1,33 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { i18nT } from '@fastgpt/global/common/i18n/utils'; +import { UserAuthTypeEnum } from '@fastgpt/global/support/user/auth/constants'; +import { getGlobalRedisConnection } from '@fastgpt/service/common/redis'; +import { authCode } from '@fastgpt/service/support/user/auth/controller'; + +vi.unmock('@fastgpt/service/support/user/auth/controller'); + +describe('authCode', () => { + const account = 'legacy-rate-limit@example.com'; + + beforeEach(async () => { + await getGlobalRedisConnection().del( + `account-verification:code:consume:${UserAuthTypeEnum.bindNotification}:${account}` + ); + }); + + it('applies the shared account and scene frequency limit before legacy code validation', async () => { + const params = { + key: account, + type: UserAuthTypeEnum.bindNotification, + code: 'wrong-code' + }; + + for (let index = 0; index < 10; index++) { + await authCode(params).catch(() => undefined); + } + + await expect(authCode(params)).rejects.toThrow( + i18nT('common:error.verify_code_too_frequently') + ); + }); +}); diff --git a/packages/web/i18n/en/common.json b/packages/web/i18n/en/common.json index c701ac5aa9ea..5dbee20df0a4 100644 --- a/packages/web/i18n/en/common.json +++ b/packages/web/i18n/en/common.json @@ -794,6 +794,7 @@ "error.missingParams": "Insufficient parameters", "error.s3_upload_invalid_file_type": "Unsupported file content or file extension does not match", "error.send_auth_code_too_frequently": "Please do not obtain verification code frequently", + "error.verify_code_too_frequently": "Too many verification attempts. Please try again later.", "error.too_many_request": "Too many request", "error.tool_not_exist": "Tool deleted", "error.unAuthFile": "Unauthorized to read this file", diff --git a/packages/web/i18n/zh-CN/common.json b/packages/web/i18n/zh-CN/common.json index 22629c871425..56892ae3a679 100644 --- a/packages/web/i18n/zh-CN/common.json +++ b/packages/web/i18n/zh-CN/common.json @@ -794,6 +794,7 @@ "error.missingParams": "参数缺失", "error.s3_upload_invalid_file_type": "文件内容不受支持,或文件后缀与内容不匹配", "error.send_auth_code_too_frequently": "请勿频繁获取验证码", + "error.verify_code_too_frequently": "验证过于频繁,请稍后再试", "error.too_many_request": "请求太频繁了,请稍后重试", "error.tool_not_exist": "工具已删除", "error.unAuthFile": "无权读取该文件", diff --git a/packages/web/i18n/zh-Hant/common.json b/packages/web/i18n/zh-Hant/common.json index 10b7e4e230b1..378260db2914 100644 --- a/packages/web/i18n/zh-Hant/common.json +++ b/packages/web/i18n/zh-Hant/common.json @@ -788,6 +788,7 @@ "error.missingParams": "參數不足", "error.s3_upload_invalid_file_type": "文件內容不受支援,或副檔名與內容不匹配", "error.send_auth_code_too_frequently": "請勿頻繁取得驗證碼", + "error.verify_code_too_frequently": "驗證過於頻繁,請稍後再試", "error.too_many_request": "請求太頻繁了,請稍後重試", "error.tool_not_exist": "工具已刪除", "error.unAuthFile": "無權讀取該文件", From 6fb83771828599f36f4c4aa799bd5863d9494c2b Mon Sep 17 00:00:00 2001 From: shortlight5980 Date: Fri, 17 Jul 2026 10:57:24 +0800 Subject: [PATCH 05/10] feat: unsubscribe --- packages/global/common/error/code/team.ts | 7 +- packages/global/common/error/code/user.ts | 7 +- packages/global/common/system/types/index.ts | 8 + .../support/user/account/cancellation/api.ts | 238 ++++++++++ .../user/account/cancellation/index.ts | 73 +++ .../openapi/support/user/account/index.ts | 4 +- .../user/account/cancellation/constants.ts | 45 ++ .../user/account/cancellation/index.ts | 4 + .../user/account/cancellation/resolver.ts | 41 ++ .../support/user/account/cancellation/type.ts | 111 +++++ .../user/account/cancellation/utils.ts | 168 +++++++ .../user/account/verification/constants.ts | 14 +- .../support/user/account/verification/type.ts | 4 +- .../global/support/user/inform/constants.ts | 5 + packages/global/support/user/team/type.ts | 10 +- .../user/account/cancellation/api.test.ts | 54 +++ .../account/cancellation/resolver.test.ts | 56 +++ .../user/account/cancellation/utils.test.ts | 55 +++ packages/service/common/bullmq/index.ts | 1 + .../service/core/dataset/delete/processor.ts | 9 +- .../service/support/outLink/runtime/utils.ts | 16 + .../service/support/permission/auth/common.ts | 37 +- .../support/permission/publish/authLink.ts | 6 + packages/service/support/permission/type.ts | 2 + .../user/account/cancellation/access.ts | 117 +++++ .../user/account/cancellation/formatter.ts | 63 +++ .../user/account/cancellation/guard.ts | 81 ++++ .../user/account/cancellation/index.ts | 6 + .../support/user/account/cancellation/read.ts | 67 +++ .../user/account/cancellation/schema.ts | 53 +++ .../user/account/cancellation/service.ts | 142 ++++++ .../user/account/verification/entity.ts | 64 ++- .../user/account/verification/schema.ts | 11 + packages/service/support/user/controller.ts | 6 +- packages/service/support/user/session.ts | 105 +++++ .../service/support/user/team/controller.ts | 18 +- .../support/user/team/delete/processor.ts | 283 +++++++----- .../service/support/user/team/delete/utils.ts | 13 +- .../service/support/user/team/fallback.ts | 54 +++ .../account/cancellation/formatter.test.ts | 28 ++ .../web/components/common/Icon/constants.ts | 4 + .../Icon/icons/common/quickActionBook.svg | 3 + .../Icon/icons/common/quickActionFeedback.svg | 4 + .../Icon/icons/common/quickActionPhone.svg | 3 + .../Icon/icons/common/quickActionUserX.svg | 3 + packages/web/i18n/en/account_info.json | 56 +++ packages/web/i18n/en/common.json | 2 + packages/web/i18n/zh-CN/account_info.json | 56 +++ packages/web/i18n/zh-CN/common.json | 2 + packages/web/i18n/zh-Hant/account_info.json | 56 +++ packages/web/i18n/zh-Hant/common.json | 2 + .../app/src/components/Layout/SupportBot.tsx | 6 +- projects/app/src/components/Layout/index.tsx | 13 + .../account/AccountContainer.tsx | 2 +- .../AccountCancellationConfirmModal.tsx | 170 +++++++ .../cancel/AccountCancellationPageLayout.tsx | 97 ++++ .../account/cancel/CancelAccountPage.tsx | 148 ++++++ .../account/cancel/CancelPendingPanel.tsx | 89 ++++ .../account/cancel/MemberPendingPanel.tsx | 57 +++ .../account/cancel/VerificationPanel.tsx | 430 ++++++++++++++++++ .../app/src/pages/account/cancel/index.tsx | 16 + projects/app/src/pages/account/info/index.tsx | 105 ++++- .../api/support/user/account/tokenLogin.ts | 6 +- projects/app/src/pages/login/provider.tsx | 40 +- projects/app/src/service/support/mcp/utils.ts | 22 +- .../src/web/common/system/useSystemStore.ts | 1 + .../support/user/account/cancellation/api.ts | 29 ++ 67 files changed, 3328 insertions(+), 180 deletions(-) create mode 100644 packages/global/openapi/support/user/account/cancellation/api.ts create mode 100644 packages/global/openapi/support/user/account/cancellation/index.ts create mode 100644 packages/global/support/user/account/cancellation/constants.ts create mode 100644 packages/global/support/user/account/cancellation/index.ts create mode 100644 packages/global/support/user/account/cancellation/resolver.ts create mode 100644 packages/global/support/user/account/cancellation/type.ts create mode 100644 packages/global/support/user/account/cancellation/utils.ts create mode 100644 packages/global/test/openapi/support/user/account/cancellation/api.test.ts create mode 100644 packages/global/test/support/user/account/cancellation/resolver.test.ts create mode 100644 packages/global/test/support/user/account/cancellation/utils.test.ts create mode 100644 packages/service/support/user/account/cancellation/access.ts create mode 100644 packages/service/support/user/account/cancellation/formatter.ts create mode 100644 packages/service/support/user/account/cancellation/guard.ts create mode 100644 packages/service/support/user/account/cancellation/index.ts create mode 100644 packages/service/support/user/account/cancellation/read.ts create mode 100644 packages/service/support/user/account/cancellation/schema.ts create mode 100644 packages/service/support/user/account/cancellation/service.ts create mode 100644 packages/service/support/user/team/fallback.ts create mode 100644 packages/service/test/support/user/account/cancellation/formatter.test.ts create mode 100644 packages/web/components/common/Icon/icons/common/quickActionBook.svg create mode 100644 packages/web/components/common/Icon/icons/common/quickActionFeedback.svg create mode 100644 packages/web/components/common/Icon/icons/common/quickActionPhone.svg create mode 100644 packages/web/components/common/Icon/icons/common/quickActionUserX.svg create mode 100644 projects/app/src/pageComponents/account/cancel/AccountCancellationConfirmModal.tsx create mode 100644 projects/app/src/pageComponents/account/cancel/AccountCancellationPageLayout.tsx create mode 100644 projects/app/src/pageComponents/account/cancel/CancelAccountPage.tsx create mode 100644 projects/app/src/pageComponents/account/cancel/CancelPendingPanel.tsx create mode 100644 projects/app/src/pageComponents/account/cancel/MemberPendingPanel.tsx create mode 100644 projects/app/src/pageComponents/account/cancel/VerificationPanel.tsx create mode 100644 projects/app/src/pages/account/cancel/index.tsx create mode 100644 projects/app/src/web/support/user/account/cancellation/api.ts diff --git a/packages/global/common/error/code/team.ts b/packages/global/common/error/code/team.ts index 92ee7ad6eaea..809e1fac0076 100644 --- a/packages/global/common/error/code/team.ts +++ b/packages/global/common/error/code/team.ts @@ -35,7 +35,8 @@ export enum TeamErrEnum { invitationLinkInvalid = 'invitationLinkInvalid', youHaveBeenInTheTeam = 'youHaveBeenInTheTeam', tooManyInvitations = 'tooManyInvitations', - unPermission = 'unPermission' + unPermission = 'unPermission', + accountCancellationPending = 'accountCancellationPending' } const teamErr = [ @@ -47,6 +48,10 @@ const teamErr = [ statusText: TeamErrEnum.unPermission, message: i18nT('common:error_un_permission') }, + { + statusText: TeamErrEnum.accountCancellationPending, + message: i18nT('common:code_error.team_error.account_cancellation_pending') + }, { statusText: TeamErrEnum.teamOverSize, message: i18nT('common:code_error.team_error.over_size') diff --git a/packages/global/common/error/code/user.ts b/packages/global/common/error/code/user.ts index 00b0408d2f0b..82fcc9541586 100644 --- a/packages/global/common/error/code/user.ts +++ b/packages/global/common/error/code/user.ts @@ -6,7 +6,8 @@ export enum UserErrEnum { userExist = 'userExist', unAuthRole = 'unAuthRole', account_psw_error = 'account_psw_error', - unAuthSso = 'unAuthSso' + unAuthSso = 'unAuthSso', + accountCancellationPending = 'accountCancellationPending' } const errList = [ { @@ -24,6 +25,10 @@ const errList = [ { statusText: UserErrEnum.unAuthSso, message: i18nT('user:sso_auth_failed') + }, + { + statusText: UserErrEnum.accountCancellationPending, + message: i18nT('common:code_error.account_cancellation_pending') } ]; export default errList.reduce((acc, cur, index) => { diff --git a/packages/global/common/system/types/index.ts b/packages/global/common/system/types/index.ts index 71b4a3ba4812..93ebfcbdadf5 100644 --- a/packages/global/common/system/types/index.ts +++ b/packages/global/common/system/types/index.ts @@ -1,4 +1,5 @@ import type { SubPlanType } from '../../../support/wallet/sub/type'; +import type { AccountVerificationCapabilities } from '../../../support/user/account/verification/type'; import type { LLMModelItemType, EmbeddingModelItemType, @@ -76,6 +77,13 @@ export type FastGPTFeConfigsType = { show_enterprise_auth?: boolean; showWecomConfig?: boolean; wecomLoginAutoRedirect?: boolean; + accountCancellation?: { + enabled?: boolean; + }; + /** 仅暴露注销验证的布尔能力,不包含任何 Provider 密钥。 */ + accountVerification?: { + accountCancellation?: AccountVerificationCapabilities; + }; show_dataset_feishu?: boolean; show_dataset_yuque?: boolean; diff --git a/packages/global/openapi/support/user/account/cancellation/api.ts b/packages/global/openapi/support/user/account/cancellation/api.ts new file mode 100644 index 000000000000..209260d97217 --- /dev/null +++ b/packages/global/openapi/support/user/account/cancellation/api.ts @@ -0,0 +1,238 @@ +import { z } from 'zod'; +import { + AccountCancellationAllowedMethodSchema, + AccountCancellationUnavailableReasonSchema +} from '../../../../../support/user/account/cancellation/type'; + +/* ============================================================================ + * API: 账号注销 + * Route: /proApi/support/user/account/cancellation/* + * Method: GET/POST/DELETE + * Description: 查询、验证、提交和取消当前登录账号的注销申请 + * Tags: ['Account Cancellation', 'Account Verification'] + * ============================================================================ */ + +const DateTimeSchema = z.iso.datetime({ offset: true }); + +export const AccountCancellationStatusResponseSchema = z + .discriminatedUnion('status', [ + z + .object({ + status: z.literal('none').meta({ description: '当前没有注销申请', example: 'none' }), + canRequestCancellation: z.boolean().meta({ + description: '是否允许发起注销申请', + example: true + }), + maskedAccount: z + .string() + .meta({ description: '当前账号脱敏值', example: 'us***@example.com' }), + unavailableReason: AccountCancellationUnavailableReasonSchema.optional().meta({ + description: '不可申请原因', + example: 'password_verification_not_allowed' + }) + }) + .strict(), + z + .object({ + status: z + .literal('pending') + .meta({ description: '等待期或最终清理中', example: 'pending' }), + maskedAccount: z + .string() + .meta({ description: '当前账号脱敏值', example: 'us***@example.com' }), + requestedAt: DateTimeSchema.meta({ + description: '注销申请时间(UTC)', + example: '2026-07-01T10:00:00.000Z' + }), + scheduledCancelAt: DateTimeSchema.optional().meta({ + description: '派生的计划清理时间(UTC)', + example: '2026-07-16T16:00:00.000Z' + }), + canCancelCancellation: z.boolean().meta({ description: '当前是否允许取消', example: true }) + }) + .strict() + ]) + .meta({ description: '账号注销公开状态' }); +export type AccountCancellationStatusResponse = z.infer< + typeof AccountCancellationStatusResponseSchema +>; + +const CodeVerificationCreateSchema = z + .object({ + method: z.literal('code').meta({ description: '邮箱或手机验证码', example: 'code' }), + payload: z + .object({ + captcha: z + .string() + .min(1) + .max(64) + .meta({ description: '图片验证码答案', example: 'A1B2C3' }), + googleToken: z.string().max(4096).optional().meta({ description: 'reCAPTCHA token' }) + }) + .strict() + }) + .strict(); + +const WechatVerificationCreateSchema = z + .object({ + method: z.literal('wechat').meta({ description: '微信扫码验证', example: 'wechat' }), + payload: z.object({}).strict() + }) + .strict(); + +const OAuthCreatePayloadSchema = z + .object({ + callbackUrl: z + .url() + .max(2048) + .meta({ description: 'OAuth 回调地址', example: 'https://example.com/login/provider' }), + isWecomWorkTerminal: z.boolean().optional().meta({ description: '是否来自企业微信工作台' }) + }) + .strict(); + +const OAuthCreateMethodSchemas = [ + 'oauth/github', + 'oauth/google', + 'oauth/microsoft', + 'oauth/wecom', + 'oauth/sso' +] as const; + +export const CreateAccountCancellationVerificationBodySchema = z.discriminatedUnion('method', [ + CodeVerificationCreateSchema, + WechatVerificationCreateSchema, + ...OAuthCreateMethodSchemas.map((method) => + z + .object({ + method: z.literal(method).meta({ description: 'OAuth 验证方式', example: method }), + payload: OAuthCreatePayloadSchema + }) + .strict() + ) +] as [typeof CodeVerificationCreateSchema, typeof WechatVerificationCreateSchema, ...any[]]); +export type CreateAccountCancellationVerificationBody = z.infer< + typeof CreateAccountCancellationVerificationBodySchema +>; + +export const CreateAccountCancellationVerificationResponseSchema = z.discriminatedUnion('method', [ + z + .object({ + method: z.literal('code'), + sent: z.literal(true), + maskedTarget: z + .string() + .meta({ description: '验证码接收目标脱敏值', example: 'us***@example.com' }) + }) + .strict(), + z + .object({ + method: z.literal('wechat'), + code: z.string().min(16).meta({ description: '微信扫码 scene', example: 'scene-code' }), + codeUrl: z + .url() + .meta({ description: '微信二维码地址', example: 'https://mp.weixin.qq.com/...' }), + expiredAt: DateTimeSchema.optional().meta({ description: '二维码过期时间' }) + }) + .strict(), + ...OAuthCreateMethodSchemas.map((method) => + z + .object({ + method: z.literal(method), + state: z.string().min(16).meta({ description: '一次性 OAuth state', example: 'state' }), + url: z + .url() + .meta({ description: 'Provider 授权地址', example: 'https://provider.example/authorize' }) + }) + .strict() + ) +] as [any, any, ...any[]]); +export type CreateAccountCancellationVerificationResponse = z.infer< + typeof CreateAccountCancellationVerificationResponseSchema +>; + +const CodeSubmitSchema = z + .object({ + method: z.literal('code'), + payload: z + .object({ + code: z.string().min(1).max(32).meta({ description: '验证码', example: '123456' }) + }) + .strict() + }) + .strict(); +const WechatSubmitSchema = z + .object({ + method: z.literal('wechat'), + payload: z + .object({ + code: z + .string() + .min(1) + .max(128) + .meta({ description: '微信扫码 scene', example: 'scene-code' }) + }) + .strict() + }) + .strict(); +const OAuthPropsSchema = z + .record( + z + .string() + .regex(/^[A-Za-z0-9_.-]+$/) + .max(64), + z.string().max(4096) + ) + .refine((props) => Object.keys(props).length <= 20, { + message: 'OAuth props contain too many keys' + }); +const OAuthSubmitPayloadSchema = z.object({ + callbackUrl: z + .url() + .max(2048) + .meta({ description: 'OAuth 回调地址', example: 'https://example.com/login/provider' }), + code: z + .string() + .min(1) + .max(4096) + .meta({ description: 'Provider 授权 code', example: 'provider-code' }), + state: z + .string() + .min(16) + .max(256) + .optional() + .meta({ description: '一次性 state;仅旧 SSO 可省略' }), + props: OAuthPropsSchema.optional().meta({ description: 'SSO 附加属性' }) +}); + +export const SubmitAccountCancellationBodySchema = z.discriminatedUnion('method', [ + CodeSubmitSchema, + WechatSubmitSchema, + ...OAuthCreateMethodSchemas.map((method) => + z.object({ method: z.literal(method), payload: OAuthSubmitPayloadSchema }).strict() + ) +] as [typeof CodeSubmitSchema, typeof WechatSubmitSchema, ...any[]]); +export type SubmitAccountCancellationBody = z.infer; + +export const SubmitAccountCancellationResponseSchema = z.discriminatedUnion('status', [ + z.object({ status: z.literal('verificationPending') }).strict(), + z + .object({ + status: z.literal('pending'), + requestedAt: DateTimeSchema, + scheduledCancelAt: DateTimeSchema, + canCancelCancellation: z.literal(true) + }) + .strict() +]); +export type SubmitAccountCancellationResponse = z.infer< + typeof SubmitAccountCancellationResponseSchema +>; + +export const CancelAccountCancellationResponseSchema = z + .undefined() + .meta({ description: '取消成功' }); +export type CancelAccountCancellationResponse = z.infer< + typeof CancelAccountCancellationResponseSchema +>; + +export { AccountCancellationAllowedMethodSchema }; diff --git a/packages/global/openapi/support/user/account/cancellation/index.ts b/packages/global/openapi/support/user/account/cancellation/index.ts new file mode 100644 index 000000000000..1e4cc39dd17f --- /dev/null +++ b/packages/global/openapi/support/user/account/cancellation/index.ts @@ -0,0 +1,73 @@ +import type { OpenAPIPath } from '../../../../type'; +import { DevApiTagsMap } from '../../../../tag'; +import { + AccountCancellationStatusResponseSchema, + CancelAccountCancellationResponseSchema, + CreateAccountCancellationVerificationBodySchema, + CreateAccountCancellationVerificationResponseSchema, + SubmitAccountCancellationBodySchema, + SubmitAccountCancellationResponseSchema +} from './api'; + +export const AccountCancellationPath: OpenAPIPath = { + '/proApi/support/user/account/cancellation/status': { + get: { + summary: '获取账号注销状态', + description: '获取当前登录账号的注销状态和申请资格', + tags: [DevApiTagsMap.userLogin, 'Account Cancellation'], + responses: { + 200: { + description: '注销状态', + content: { 'application/json': { schema: AccountCancellationStatusResponseSchema } } + } + } + } + }, + '/proApi/support/user/account/cancellation/verification/create': { + post: { + summary: '创建账号注销验证材料', + description: '创建绑定当前登录账号和 accountCancellation scene 的短期验证材料', + tags: [DevApiTagsMap.userLogin, 'Account Verification', 'Account Cancellation'], + requestBody: { + content: { 'application/json': { schema: CreateAccountCancellationVerificationBodySchema } } + }, + responses: { + 200: { + description: '验证材料已创建', + content: { + 'application/json': { schema: CreateAccountCancellationVerificationResponseSchema } + } + } + } + } + }, + '/proApi/support/user/account/cancellation/submit': { + post: { + summary: '提交账号注销申请', + description: '在同一请求中消费注销验证材料并创建注销等待期记录', + tags: [DevApiTagsMap.userLogin, 'Account Cancellation'], + requestBody: { + content: { 'application/json': { schema: SubmitAccountCancellationBodySchema } } + }, + responses: { + 200: { + description: '验证进行中或已进入注销等待期', + content: { 'application/json': { schema: SubmitAccountCancellationResponseSchema } } + } + } + } + }, + '/proApi/support/user/account/cancellation/cancel': { + delete: { + summary: '取消账号注销', + description: '在最终清理开始前取消当前账号的注销申请', + tags: [DevApiTagsMap.userLogin, 'Account Cancellation'], + responses: { + 200: { + description: '取消成功', + content: { 'application/json': { schema: CancelAccountCancellationResponseSchema } } + } + } + } + } +}; diff --git a/packages/global/openapi/support/user/account/index.ts b/packages/global/openapi/support/user/account/index.ts index 8d4f533bf969..508048e19ed7 100644 --- a/packages/global/openapi/support/user/account/index.ts +++ b/packages/global/openapi/support/user/account/index.ts @@ -3,10 +3,12 @@ import { LoginPath } from './login'; import { RegisterPath } from './register'; import { PasswordPath } from './password'; import { AccountVerificationPath } from './verification'; +import { AccountCancellationPath } from './cancellation'; export const UserAccountPath: OpenAPIPath = { ...LoginPath, ...RegisterPath, ...PasswordPath, - ...AccountVerificationPath + ...AccountVerificationPath, + ...AccountCancellationPath }; diff --git a/packages/global/support/user/account/cancellation/constants.ts b/packages/global/support/user/account/cancellation/constants.ts new file mode 100644 index 000000000000..a02045bec5d5 --- /dev/null +++ b/packages/global/support/user/account/cancellation/constants.ts @@ -0,0 +1,45 @@ +export const accountCancellationWaitDays = 15; +export const accountCancellationTimezone = 'Asia/Shanghai'; + +export enum AccountCancellationStatusEnum { + pending = 'pending', + finalizing = 'finalizing', + completed = 'completed' +} + +export const accountCancellationActiveStatuses = [ + AccountCancellationStatusEnum.pending, + AccountCancellationStatusEnum.finalizing +] as const; + +export const accountCancellationAllowedMethods = [ + 'code', + 'wechat', + 'oauth/github', + 'oauth/google', + 'oauth/microsoft', + 'oauth/wecom', + 'oauth/sso' +] as const; + +export enum AccountCancellationReminderEnum { + sevenDays = '7d', + oneDay = '1d', + today = 'today' +} + +export enum AccountCancellationUnavailableReasonEnum { + featureDisabled = 'feature_disabled', + unsupportedTeamMode = 'unsupported_team_mode', + rootAccount = 'root_account', + accountForbidden = 'account_forbidden', + emptyUsername = 'empty_username', + verificationUnavailable = 'verification_unavailable', + passwordVerificationNotAllowed = 'password_verification_not_allowed' +} + +export const accountCancellationStatusMap = { + [AccountCancellationStatusEnum.pending]: { label: 'Pending' }, + [AccountCancellationStatusEnum.finalizing]: { label: 'Finalizing' }, + [AccountCancellationStatusEnum.completed]: { label: 'Completed' } +}; diff --git a/packages/global/support/user/account/cancellation/index.ts b/packages/global/support/user/account/cancellation/index.ts new file mode 100644 index 000000000000..828c52e9b735 --- /dev/null +++ b/packages/global/support/user/account/cancellation/index.ts @@ -0,0 +1,4 @@ +export * from './constants'; +export * from './type'; +export * from './utils'; +export * from './resolver'; diff --git a/packages/global/support/user/account/cancellation/resolver.ts b/packages/global/support/user/account/cancellation/resolver.ts new file mode 100644 index 000000000000..ef1360ac4634 --- /dev/null +++ b/packages/global/support/user/account/cancellation/resolver.ts @@ -0,0 +1,41 @@ +import { resolveAccountVerificationByUsername } from '../verification/utils'; +import { AccountVerificationMethodEnum } from '../verification/constants'; +import type { AccountCancellationResolveResult, AccountCancellationResolverInput } from './type'; + +/** 将统一 resolver 的结果收窄为注销允许的非密码验证方式。 */ +export const resolveAccountCancellationByUsername = ({ + username, + capabilities +}: AccountCancellationResolverInput): AccountCancellationResolveResult => { + const account = username ?? ''; + if (!account.trim()) { + return { + status: 'unsupported', + accountKind: 'invalid', + unsupportedReason: 'empty_username' + }; + } + + const result = resolveAccountVerificationByUsername({ username: account, capabilities }); + if (result.status === 'unsupported') { + return { + status: 'unsupported', + accountKind: result.accountKind, + unsupportedReason: 'verification_unavailable' + }; + } + + if (result.method === AccountVerificationMethodEnum.oldPassword) { + return { + status: 'unsupported', + accountKind: result.accountKind, + unsupportedReason: 'password_verification_not_allowed' + }; + } + + return { + status: 'supported', + accountKind: result.accountKind, + method: result.method + }; +}; diff --git a/packages/global/support/user/account/cancellation/type.ts b/packages/global/support/user/account/cancellation/type.ts new file mode 100644 index 000000000000..0867f051cd9a --- /dev/null +++ b/packages/global/support/user/account/cancellation/type.ts @@ -0,0 +1,111 @@ +import { z } from 'zod'; +import type { + AccountVerificationCapabilities, + AccountVerificationMethod +} from '../verification/type'; +import { + AccountCancellationStatusEnum, + AccountCancellationUnavailableReasonEnum, + accountCancellationActiveStatuses, + accountCancellationAllowedMethods, + accountCancellationTimezone +} from './constants'; + +export const AccountCancellationStatusSchema = z.enum([ + AccountCancellationStatusEnum.pending, + AccountCancellationStatusEnum.finalizing, + AccountCancellationStatusEnum.completed +]); +export type AccountCancellationStatus = z.infer; + +export const AccountCancellationPublicStatusSchema = z.enum(['none', 'pending']); +export type AccountCancellationPublicStatus = z.infer; + +export const TeamAccountCancellationStatusSchema = AccountCancellationStatusSchema.exclude([ + AccountCancellationStatusEnum.completed +]); +export type TeamAccountCancellationStatus = z.infer; + +export const AccountCancellationAllowedMethodSchema = z.enum(accountCancellationAllowedMethods); +export type AccountCancellationAllowedMethod = z.infer< + typeof AccountCancellationAllowedMethodSchema +>; + +export const AccountCancellationUnavailableReasonSchema = z.enum([ + AccountCancellationUnavailableReasonEnum.featureDisabled, + AccountCancellationUnavailableReasonEnum.unsupportedTeamMode, + AccountCancellationUnavailableReasonEnum.rootAccount, + AccountCancellationUnavailableReasonEnum.accountForbidden, + AccountCancellationUnavailableReasonEnum.emptyUsername, + AccountCancellationUnavailableReasonEnum.verificationUnavailable, + AccountCancellationUnavailableReasonEnum.passwordVerificationNotAllowed +]); +export type AccountCancellationUnavailableReason = z.infer< + typeof AccountCancellationUnavailableReasonSchema +>; + +export type AccountCancellationRecordType = { + _id: string; + userId: string; + status: AccountCancellationStatus; + requestedAt: Date; +}; + +export type AccountCancellationSchedule = { + requestedAt: Date; + waitEndsAt: Date; + cleanupLocalDate: string; + sevenDayReminderAt: Date; + oneDayReminderAt: Date; + finalNoticeAt: Date; + scheduledCancelAt: Date; + timezone: string; +}; + +export type AccountCancellationUserState = { + status: 'pending'; + requestedAt: Date; + scheduledCancelAt?: Date; + canCancelCancellation: boolean; +}; + +export type TeamAccountCancellationSummary = { + status: TeamAccountCancellationStatus; + scheduledCancelAt?: Date | string; +}; + +export type AccountCancellationResolverInput = { + username?: string | null; + capabilities: AccountVerificationCapabilities; +}; + +export type AccountCancellationResolveResult = + | { + status: 'supported'; + method: AccountCancellationAllowedMethod; + accountKind: string; + unsupportedReason?: undefined; + } + | { + status: 'unsupported'; + method?: undefined; + accountKind: 'invalid' | string; + unsupportedReason: + | 'empty_username' + | 'password_verification_not_allowed' + | 'verification_unavailable'; + }; + +export type AccountCancellationAccessPreset = + | 'normal' + | 'selfCancellation' + | 'teamEscape' + | 'tokenLogin'; + +export type AccountCancellationVerificationMethod = Exclude< + AccountVerificationMethod, + 'oldPassword' +>; + +export const accountCancellationDefaultTimezone = accountCancellationTimezone; +export const accountCancellationActiveStatusValues = accountCancellationActiveStatuses; diff --git a/packages/global/support/user/account/cancellation/utils.ts b/packages/global/support/user/account/cancellation/utils.ts new file mode 100644 index 000000000000..aa55942522c3 --- /dev/null +++ b/packages/global/support/user/account/cancellation/utils.ts @@ -0,0 +1,168 @@ +import { + accountCancellationTimezone, + accountCancellationWaitDays, + AccountCancellationReminderEnum +} from './constants'; +import type { AccountCancellationSchedule } from './type'; + +const dayInMilliseconds = 24 * 60 * 60 * 1000; + +type LocalDateParts = { + year: number; + month: number; + day: number; + hour: number; + minute: number; + second: number; +}; + +const getFormatter = (timeZone: string) => + new Intl.DateTimeFormat('en-US', { + timeZone, + calendar: 'gregory', + numberingSystem: 'latn', + hourCycle: 'h23', + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit' + }); + +const parseDateParts = (date: Date, timeZone: string): LocalDateParts => { + const values = Object.fromEntries( + getFormatter(timeZone) + .formatToParts(date) + .filter(({ type }) => type !== 'literal') + .map(({ type, value }) => [type, Number(value)]) + ) as Record; + + return { + year: values.year, + month: values.month, + day: values.day, + hour: values.hour === 24 ? 0 : values.hour, + minute: values.minute, + second: values.second + }; +}; + +const assertValidTimeZone = (timeZone: string) => { + try { + getFormatter(timeZone).format(); + } catch { + throw new Error(`Invalid account cancellation timezone: ${timeZone}`); + } +}; + +const getTimeZoneOffset = (date: Date, timeZone: string) => { + const parts = parseDateParts(date, timeZone); + const localAsUtc = Date.UTC( + parts.year, + parts.month - 1, + parts.day, + parts.hour, + parts.minute, + parts.second + ); + return localAsUtc - Math.floor(date.getTime() / 1000) * 1000; +}; + +/** 将指定时区的墙上时间转换为 UTC,避免依赖进程机器时区。 */ +const localDateTimeToUtc = ( + parts: Omit & { second?: number }, + timeZone: string +) => { + const localAsUtc = Date.UTC( + parts.year, + parts.month - 1, + parts.day, + parts.hour, + parts.minute, + parts.second ?? 0 + ); + let candidate = localAsUtc; + + for (let attempt = 0; attempt < 3; attempt++) { + const offset = getTimeZoneOffset(new Date(candidate), timeZone); + const next = localAsUtc - offset; + if (next === candidate) break; + candidate = next; + } + + return new Date(candidate); +}; + +const addLocalDays = ({ year, month, day }: LocalDateParts, days: number) => { + const date = new Date(Date.UTC(year, month - 1, day + days)); + return { + year: date.getUTCFullYear(), + month: date.getUTCMonth() + 1, + day: date.getUTCDate() + }; +}; + +const formatLocalDate = ({ year, month, day }: LocalDateParts) => + `${String(year).padStart(4, '0')}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`; + +const atLocalTime = (date: ReturnType, hour: number, timeZone: string) => + localDateTimeToUtc({ ...date, hour, minute: 0, second: 0 }, timeZone); + +/** + * 从唯一持久化时间推导注销等待期的全部时间点。 + * waitEndsAt 使用完整的 UTC 24 小时周期,提醒和最终清理则使用显式配置时区的自然日。 + */ +export const deriveAccountCancellationSchedule = ( + requestedAt: Date, + timeZone = accountCancellationTimezone +): AccountCancellationSchedule => { + if (!(requestedAt instanceof Date) || Number.isNaN(requestedAt.getTime())) { + throw new Error('Invalid account cancellation requestedAt'); + } + assertValidTimeZone(timeZone); + + const normalizedRequestedAt = new Date(requestedAt.getTime()); + const waitEndsAt = new Date( + normalizedRequestedAt.getTime() + accountCancellationWaitDays * dayInMilliseconds + ); + const waitEndsLocal = parseDateParts(waitEndsAt, timeZone); + const cleanupDate = { + year: waitEndsLocal.year, + month: waitEndsLocal.month, + day: waitEndsLocal.day + }; + const cleanupLocalDate = formatLocalDate(waitEndsLocal); + + return { + requestedAt: normalizedRequestedAt, + waitEndsAt, + cleanupLocalDate, + sevenDayReminderAt: atLocalTime(addLocalDays(waitEndsLocal, -7), 10, timeZone), + oneDayReminderAt: atLocalTime(addLocalDays(waitEndsLocal, -1), 10, timeZone), + finalNoticeAt: atLocalTime(cleanupDate, 10, timeZone), + scheduledCancelAt: atLocalTime(addLocalDays(waitEndsLocal, 1), 0, timeZone), + timezone: timeZone + }; +}; + +export const getAccountCancellationReminderAt = ({ + requestedAt, + reminder, + timeZone = accountCancellationTimezone +}: { + requestedAt: Date; + reminder: AccountCancellationReminderEnum; + timeZone?: string; +}) => { + const schedule = deriveAccountCancellationSchedule(requestedAt, timeZone); + if (reminder === AccountCancellationReminderEnum.sevenDays) return schedule.sevenDayReminderAt; + if (reminder === AccountCancellationReminderEnum.oneDay) return schedule.oneDayReminderAt; + return schedule.finalNoticeAt; +}; + +export const isAccountCancellationCancelable = (requestedAt: Date, now = new Date()) => + now.getTime() < deriveAccountCancellationSchedule(requestedAt).scheduledCancelAt.getTime(); + +export const isAccountCancellationMethod = (method: string) => + method === 'code' || method === 'wechat' || method.startsWith('oauth/'); diff --git a/packages/global/support/user/account/verification/constants.ts b/packages/global/support/user/account/verification/constants.ts index c139a06cf256..66adef00f613 100644 --- a/packages/global/support/user/account/verification/constants.ts +++ b/packages/global/support/user/account/verification/constants.ts @@ -5,7 +5,19 @@ export enum AccountVerificationMaterialTypeEnum { bindNotification = 'bindNotification', captcha = 'captcha', login = 'login', - oauthLogin = 'oauthLogin' + oauthLogin = 'oauthLogin', + accountCancellation = 'accountCancellation' +} + +export enum AccountVerificationMethodEnum { + code = 'code', + oldPassword = 'oldPassword', + wechat = 'wechat', + oauthGithub = 'oauth/github', + oauthGoogle = 'oauth/google', + oauthMicrosoft = 'oauth/microsoft', + oauthWecom = 'oauth/wecom', + oauthSso = 'oauth/sso' } export const accountVerificationMethods = [ diff --git a/packages/global/support/user/account/verification/type.ts b/packages/global/support/user/account/verification/type.ts index 6fc3a2eaed77..e23a0e60d9f8 100644 --- a/packages/global/support/user/account/verification/type.ts +++ b/packages/global/support/user/account/verification/type.ts @@ -18,6 +18,7 @@ export const AccountContactUsernameSchema = z.union([ export const AccountVerificationCapabilitiesSchema = z.object({ emailCode: z.boolean(), phoneCode: z.boolean(), + accountCancellation: z.boolean().optional(), wechat: z.boolean(), oauth: z.object({ github: z.boolean(), @@ -56,7 +57,8 @@ export type AccountVerificationResolution = z.infer; diff --git a/packages/global/support/user/inform/constants.ts b/packages/global/support/user/inform/constants.ts index 206628a927ee..94ff76cbd5cc 100644 --- a/packages/global/support/user/inform/constants.ts +++ b/packages/global/support/user/inform/constants.ts @@ -20,6 +20,11 @@ export enum SendInformTemplateCodeEnum { REGISTER = 'REGISTER', // 注册 RESET_PASSWORD = 'RESET_PASSWORD', // 重置密码 BIND_NOTIFICATION = 'BIND_NOTIFICATION', // 绑定通知 + ACCOUNT_CANCELLATION_CODE = 'ACCOUNT_CANCELLATION_CODE', // 账号注销验证码 + ACCOUNT_CANCELLATION_TEAM_SUBMITTED = 'ACCOUNT_CANCELLATION_TEAM_SUBMITTED', // 团队注销通知 + ACCOUNT_CANCELLATION_7D = 'ACCOUNT_CANCELLATION_7D', + ACCOUNT_CANCELLATION_1D = 'ACCOUNT_CANCELLATION_1D', + ACCOUNT_CANCELLATION_TODAY = 'ACCOUNT_CANCELLATION_TODAY', EXPIRE_SOON = 'EXPIRE_SOON', // 即将过期 EXPIRED = 'EXPIRED', // 已过期 diff --git a/packages/global/support/user/team/type.ts b/packages/global/support/user/team/type.ts index edb15a644689..32dd8c1a3769 100644 --- a/packages/global/support/user/team/type.ts +++ b/packages/global/support/user/team/type.ts @@ -3,6 +3,7 @@ import { TeamMemberRoleEnum, TeamMemberStatusEnum } from './constant'; import type { GroupMemberRole } from '../../permission/memberGroup/constant'; import { TeamPermission } from '../../permission/user/controller'; import { z } from 'zod'; +import { TeamAccountCancellationStatusSchema } from '../account/cancellation/type'; export const OpenaiAccountSchema = z.object({ key: z.string(), @@ -62,7 +63,14 @@ export const TeamTmbItemSchema = ThidPartyAccountSchema.extend({ status: z.enum(TeamMemberStatusEnum), notificationAccount: z.string().optional(), permission: z.instanceof(TeamPermission), - isWecomTeam: z.boolean().optional() + isWecomTeam: z.boolean().optional(), + accountCancellation: z + .object({ + status: TeamAccountCancellationStatusSchema, + scheduledCancelAt: z.union([z.date(), z.iso.datetime({ offset: true })]).optional() + }) + .strict() + .optional() }); export type TeamTmbItemType = z.infer; diff --git a/packages/global/test/openapi/support/user/account/cancellation/api.test.ts b/packages/global/test/openapi/support/user/account/cancellation/api.test.ts new file mode 100644 index 000000000000..1a7a202789ea --- /dev/null +++ b/packages/global/test/openapi/support/user/account/cancellation/api.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest'; +import { + CreateAccountCancellationVerificationBodySchema, + SubmitAccountCancellationBodySchema +} from '@fastgpt/global/openapi/support/user/account/cancellation/api'; + +describe('account cancellation API contracts', () => { + it('accepts only the non-password create methods', () => { + expect( + CreateAccountCancellationVerificationBodySchema.parse({ + method: 'code', + payload: { captcha: 'A1B2C3' } + }).method + ).toBe('code'); + expect(() => + CreateAccountCancellationVerificationBodySchema.parse({ + method: 'oldPassword', + payload: { password: 'secret' } + }) + ).toThrow(); + }); + + it('does not accept username, scene, or extra submit fields', () => { + expect(() => + SubmitAccountCancellationBodySchema.parse({ + method: 'code', + payload: { code: '123456' }, + username: 'user@example.com' + }) + ).toThrow(); + expect(() => + SubmitAccountCancellationBodySchema.parse({ + method: 'oldPassword', + payload: { password: 'secret' } + }) + ).toThrow(); + }); + + it('bounds SSO callback props', () => { + const props = Object.fromEntries( + Array.from({ length: 21 }, (_, index) => [`key${index}`, 'value']) + ); + expect(() => + SubmitAccountCancellationBodySchema.parse({ + method: 'oauth/sso', + payload: { + callbackUrl: 'https://fastgpt.example.com/login/provider', + code: 'provider-code', + props + } + }) + ).toThrow(); + }); +}); diff --git a/packages/global/test/support/user/account/cancellation/resolver.test.ts b/packages/global/test/support/user/account/cancellation/resolver.test.ts new file mode 100644 index 000000000000..567daa334b0e --- /dev/null +++ b/packages/global/test/support/user/account/cancellation/resolver.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest'; +import type { AccountVerificationCapabilities } from '@fastgpt/global/support/user/account/verification/type'; +import { resolveAccountCancellationByUsername } from '@fastgpt/global/support/user/account/cancellation/resolver'; + +const capabilities: AccountVerificationCapabilities = { + emailCode: true, + phoneCode: true, + wechat: true, + oauth: { + github: true, + google: true, + microsoft: true, + wecom: true, + sso: true + } +}; + +describe('resolveAccountCancellationByUsername', () => { + it('allows the shared non-password resolver result', () => { + expect( + resolveAccountCancellationByUsername({ + username: 'user@example.com', + capabilities + }) + ).toEqual({ status: 'supported', accountKind: 'email', method: 'code' }); + }); + + it('turns the shared old-password fallback into an unsupported result', () => { + expect( + resolveAccountCancellationByUsername({ + username: 'local', + capabilities + }) + ).toEqual({ + status: 'unsupported', + accountKind: 'local', + unsupportedReason: 'password_verification_not_allowed' + }); + }); + + it('does not expose a provider that is unavailable', () => { + expect( + resolveAccountCancellationByUsername({ + username: 'git-octocat', + capabilities: { + ...capabilities, + oauth: { ...capabilities.oauth, github: false } + } + }) + ).toMatchObject({ + status: 'unsupported', + accountKind: 'github', + unsupportedReason: 'password_verification_not_allowed' + }); + }); +}); diff --git a/packages/global/test/support/user/account/cancellation/utils.test.ts b/packages/global/test/support/user/account/cancellation/utils.test.ts new file mode 100644 index 000000000000..231a714a0906 --- /dev/null +++ b/packages/global/test/support/user/account/cancellation/utils.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest'; +import { AccountCancellationReminderEnum } from '@fastgpt/global/support/user/account/cancellation/constants'; +import { + deriveAccountCancellationSchedule, + getAccountCancellationReminderAt, + isAccountCancellationCancelable +} from '@fastgpt/global/support/user/account/cancellation/utils'; + +describe('deriveAccountCancellationSchedule', () => { + it('derives a complete wait period and local-day reminders', () => { + const requestedAt = new Date('2026-07-01T10:20:00.000Z'); + const schedule = deriveAccountCancellationSchedule(requestedAt); + + expect(schedule.waitEndsAt.toISOString()).toBe('2026-07-16T10:20:00.000Z'); + expect(schedule.cleanupLocalDate).toBe('2026-07-16'); + expect(schedule.sevenDayReminderAt.toISOString()).toBe('2026-07-09T02:00:00.000Z'); + expect(schedule.oneDayReminderAt.toISOString()).toBe('2026-07-15T02:00:00.000Z'); + expect(schedule.finalNoticeAt.toISOString()).toBe('2026-07-16T02:00:00.000Z'); + expect(schedule.scheduledCancelAt.toISOString()).toBe('2026-07-16T16:00:00.000Z'); + }); + + it('uses the configured timezone for a DST transition', () => { + const requestedAt = new Date('2026-03-01T17:00:00.000Z'); + const schedule = deriveAccountCancellationSchedule(requestedAt, 'America/New_York'); + + expect(schedule.cleanupLocalDate).toBe('2026-03-16'); + expect(schedule.finalNoticeAt.toISOString()).toBe('2026-03-16T14:00:00.000Z'); + expect(schedule.scheduledCancelAt.toISOString()).toBe('2026-03-17T04:00:00.000Z'); + }); + + it('shares reminder calculation with the schedule helper', () => { + const requestedAt = new Date('2026-07-01T00:00:00.000Z'); + const schedule = deriveAccountCancellationSchedule(requestedAt); + expect( + getAccountCancellationReminderAt({ + requestedAt, + reminder: AccountCancellationReminderEnum.today + }) + ).toEqual(schedule.finalNoticeAt); + }); + + it('allows cancellation only before the derived local midnight', () => { + const requestedAt = new Date('2026-07-01T10:20:00.000Z'); + const schedule = deriveAccountCancellationSchedule(requestedAt); + expect(isAccountCancellationCancelable(requestedAt, new Date('2026-07-16T15:59:59.999Z'))).toBe( + true + ); + expect(isAccountCancellationCancelable(requestedAt, schedule.scheduledCancelAt)).toBe(false); + }); + + it('rejects invalid dates and timezones', () => { + expect(() => deriveAccountCancellationSchedule(new Date('invalid'))).toThrow(); + expect(() => deriveAccountCancellationSchedule(new Date(), 'invalid/zone')).toThrow(); + }); +}); diff --git a/packages/service/common/bullmq/index.ts b/packages/service/common/bullmq/index.ts index b7bd34936e23..57418ed6919a 100644 --- a/packages/service/common/bullmq/index.ts +++ b/packages/service/common/bullmq/index.ts @@ -34,6 +34,7 @@ export enum QueueNames { appDelete = 'appDelete', agentSkillDelete = 'agentSkillDelete', teamDelete = 'teamDelete', + accountCancellation = 'accountCancellation', // Publish wechatPoll = 'wechatPoll', diff --git a/packages/service/core/dataset/delete/processor.ts b/packages/service/core/dataset/delete/processor.ts index ef39910739f8..5ecefd126468 100644 --- a/packages/service/core/dataset/delete/processor.ts +++ b/packages/service/core/dataset/delete/processor.ts @@ -44,6 +44,11 @@ export const deleteTeamAllDatasets = async (teamId: string) => { datasetIds: datasets.map((d) => d._id) }); + const datasetIdSet = new Set(datasets.map((dataset) => String(dataset._id))); + const deleteRootDatasets = datasets.filter( + (dataset) => !dataset.parentId || !datasetIdSet.has(String(dataset.parentId)) + ); + await mongoSessionRun(async (session) => { await MongoDataset.updateMany( { @@ -59,9 +64,7 @@ export const deleteTeamAllDatasets = async (teamId: string) => { } ); await Promise.all( - datasets.map((dataset) => { - // 有 parentId 的忽略,只需要删 root 下的即可。 - if (dataset.parentId) return; + deleteRootDatasets.map((dataset) => { return addDatasetDeleteJob({ teamId, datasetId: dataset._id diff --git a/packages/service/support/outLink/runtime/utils.ts b/packages/service/support/outLink/runtime/utils.ts index 11c0a945856c..97ae76734b3f 100644 --- a/packages/service/support/outLink/runtime/utils.ts +++ b/packages/service/support/outLink/runtime/utils.ts @@ -41,6 +41,7 @@ import { MongoChat } from '../../../core/chat/chatSchema'; import { buildChatSourceQuery, type ChatSourceParams } from '../../../core/chat/source'; import { getNanoid } from '@fastgpt/global/common/string/tools'; import { MongoChatItem } from '../../../core/chat/chatItemSchema'; +import { assertAccountUsable } from '../../user/account/cancellation/guard'; const logger = getLogger(LogCategories.MODULE.OUTLINK); @@ -113,6 +114,17 @@ const DEFAULT_REPLY = 'This is default reply'; export const STREAM_END_FLAG = '[DONE]'; export const STREAM_CACHE_KEY_PREFIX = 'streamResponse:'; +/** 分享链接没有用户 Session,使用发布链接绑定的 tmb/team 作为业务执行身份。 */ +export const assertOutLinkTeamUsable = async ({ + teamId, + tmbId +}: { + teamId: string; + tmbId: string; +}) => { + await assertAccountUsable({ teamId, tmbId }); +}; + export async function outlinkInvokeChat({ outLinkConfig, chatId, @@ -123,6 +135,10 @@ export async function outlinkInvokeChat({ onStreamChunk, streamId }: outLinkInvokeChatProps) { + await assertOutLinkTeamUsable({ + teamId: String(outLinkConfig.teamId), + tmbId: String(outLinkConfig.tmbId) + }); const streamResKey = `${STREAM_CACHE_KEY_PREFIX}${streamId}`; const roundState = { preparedRound: undefined as PreChatRoundResult | undefined, diff --git a/packages/service/support/permission/auth/common.ts b/packages/service/support/permission/auth/common.ts index 83dd1dee9201..5655980e6c46 100644 --- a/packages/service/support/permission/auth/common.ts +++ b/packages/service/support/permission/auth/common.ts @@ -4,10 +4,12 @@ import { SERVICE_LOCAL_HOST } from '../../../common/system/tools'; import type { NodeHttpRequest, NodeHttpResponse } from '../../../types/http'; import Cookie from 'cookie'; import { ERROR_ENUM } from '@fastgpt/global/common/error/errorCode'; -import { authUserSession } from '../../../support/user/session'; +import { authUserSession, resolveUserSessionTeam } from '../../../support/user/session'; import { authOpenApiKey } from '../../../support/openapi/auth'; import { AuthUserTypeEnum } from '@fastgpt/global/support/permission/constant'; import { serviceEnv } from '../../../env'; +import { resolveAccountCancellationAccess } from '../../user/account/cancellation/access'; +import { assertAccountUsable } from '../../user/account/cancellation/guard'; export const authCert = async (props: AuthModeType) => { const result = await parseHeaderCert(props); @@ -30,7 +32,8 @@ export async function parseHeaderCert({ req, authToken = false, authRoot = false, - authApiKey = false + authApiKey = false, + accountCancellationAccess = 'normal' }: AuthModeType) { // parse jwt async function authCookieToken(cookie?: string, token?: string) { @@ -152,14 +155,38 @@ export async function parseHeaderCert({ return Promise.reject(ERROR_ENUM.unAuthorization); })(); - if (!authRoot && (!teamId || !tmbId)) { + let resolvedTeamId = teamId; + let resolvedTmbId = tmbId; + if (uid && sessionId && teamId && tmbId) { + const sessionTeam = await resolveUserSessionTeam({ + userId: String(uid), + teamId: String(teamId), + tmbId: String(tmbId), + sessionId + }); + resolvedTeamId = sessionTeam.teamId; + resolvedTmbId = sessionTeam.tmbId; + } + + if (!authRoot && (!resolvedTeamId || !resolvedTmbId)) { return Promise.reject(ERROR_ENUM.unAuthorization); } + const accountCancellationGuard = resolveAccountCancellationAccess({ + req, + accountCancellationAccess + }); + await assertAccountUsable({ + userId: uid ? String(uid) : undefined, + teamId: resolvedTeamId ? String(resolvedTeamId) : undefined, + tmbId: resolvedTmbId ? String(resolvedTmbId) : undefined, + ...accountCancellationGuard + }); + return { userId: String(uid), - teamId: String(teamId), - tmbId: String(tmbId), + teamId: String(resolvedTeamId), + tmbId: String(resolvedTmbId), appId, authType, sourceName, diff --git a/packages/service/support/permission/publish/authLink.ts b/packages/service/support/permission/publish/authLink.ts index 0e0688990049..bca09639b0e0 100644 --- a/packages/service/support/permission/publish/authLink.ts +++ b/packages/service/support/permission/publish/authLink.ts @@ -6,6 +6,7 @@ import { OwnerPermissionVal } from '@fastgpt/global/support/permission/constant' import { authAppByTmbId } from '../app/auth'; import { type AuthModeType, type AuthResponseType } from '../type'; import { parseHeaderCert } from '../auth/common'; +import { assertOutLinkTeamUsable } from '../../outLink/runtime/utils'; /* crud outlink permission */ export async function authOutLinkCrud({ @@ -68,6 +69,11 @@ export async function authOutLinkValid({ return Promise.reject(OutLinkErrEnum.linkUnInvalid); } + await assertOutLinkTeamUsable({ + teamId: String(outLinkConfig.teamId), + tmbId: String(outLinkConfig.tmbId) + }); + return { appId: outLinkConfig.appId, outLinkConfig: outLinkConfig diff --git a/packages/service/support/permission/type.ts b/packages/service/support/permission/type.ts index 7b680c0bac35..ca76699c387c 100644 --- a/packages/service/support/permission/type.ts +++ b/packages/service/support/permission/type.ts @@ -3,6 +3,7 @@ import type { PermissionValueType } from '@fastgpt/global/support/permission/typ import type { RequireAtLeastOne } from '@fastgpt/global/common/type/utils'; import type { AuthUserTypeEnum } from '@fastgpt/global/support/permission/constant'; import type { NodeHttpRequest } from '../../types/http'; +import type { AccountCancellationAccessPreset } from '@fastgpt/global/support/user/account/cancellation/type'; export type ReqHeaderAuthType = { cookie?: string; @@ -19,6 +20,7 @@ type authModeType = { authRoot?: boolean; authApiKey?: boolean; per?: PermissionValueType; + accountCancellationAccess?: AccountCancellationAccessPreset; }; export type AuthModeType = RequireAtLeastOne; diff --git a/packages/service/support/user/account/cancellation/access.ts b/packages/service/support/user/account/cancellation/access.ts new file mode 100644 index 000000000000..733f391ae84b --- /dev/null +++ b/packages/service/support/user/account/cancellation/access.ts @@ -0,0 +1,117 @@ +import { ERROR_ENUM } from '@fastgpt/global/common/error/errorCode'; +import type { AccountCancellationAccessPreset } from '@fastgpt/global/support/user/account/cancellation/type'; +import type { AssertAccountUsableProps } from './guard'; + +type AccessRequest = { + method?: string; + url?: string; +}; + +type AccessPreset = { + apis: string[]; + options: Required< + Pick< + AssertAccountUsableProps, + | 'allowUserAccountCancellationPending' + | 'allowCurrentUserOwnedTeamAccountCancellationPending' + | 'allowCurrentSessionTeamAccountCancellationPending' + > + >; +}; + +export const accountCancellationAccessPresets: Record< + AccountCancellationAccessPreset, + AccessPreset +> = { + normal: { + apis: [], + options: { + allowUserAccountCancellationPending: false, + allowCurrentUserOwnedTeamAccountCancellationPending: false, + allowCurrentSessionTeamAccountCancellationPending: false + } + }, + selfCancellation: { + apis: [ + 'GET /proApi/support/user/account/cancellation/status', + 'POST /proApi/support/user/account/cancellation/verification/create', + 'POST /proApi/support/user/account/cancellation/submit', + 'DELETE /proApi/support/user/account/cancellation/cancel' + ], + options: { + allowUserAccountCancellationPending: true, + allowCurrentUserOwnedTeamAccountCancellationPending: true, + allowCurrentSessionTeamAccountCancellationPending: true + } + }, + teamEscape: { + apis: [ + 'GET /proApi/support/user/team/list', + 'POST /proApi/support/user/team/switch', + 'PUT /proApi/support/user/team/switch' + ], + options: { + allowUserAccountCancellationPending: false, + allowCurrentUserOwnedTeamAccountCancellationPending: false, + allowCurrentSessionTeamAccountCancellationPending: true + } + }, + tokenLogin: { + apis: [ + 'GET /api/support/user/account/tokenLogin', + 'GET /proApi/support/user/account/tokenLogin', + 'GET /api/support/user/team/plan/getTeamPlanStatus' + ], + options: { + allowUserAccountCancellationPending: true, + allowCurrentUserOwnedTeamAccountCancellationPending: true, + allowCurrentSessionTeamAccountCancellationPending: true + } + } +}; + +const requestKeys = ({ method, url }: AccessRequest) => { + const normalizedMethod = method?.toUpperCase(); + if (!normalizedMethod || !url) return []; + let pathname = url; + try { + pathname = new URL(url, 'http://fastgpt.local').pathname; + } catch { + pathname = url.split('?')[0] ?? ''; + } + + const paths = new Set([pathname]); + if (pathname.startsWith('/api/proApi/')) paths.add(pathname.replace('/api/proApi', '/proApi')); + if (pathname.startsWith('/api/') && !pathname.startsWith('/api/proApi/')) { + paths.add(pathname.replace('/api', '/proApi')); + } + return Array.from(paths, (path) => `${normalizedMethod} ${path}`); +}; + +/** 将访问 preset 收窄为 guard flags,并强制校验当前请求路径。 */ +export const resolveAccountCancellationAccess = ({ + req, + accountCancellationAccess = 'normal' +}: { + req?: AccessRequest; + accountCancellationAccess?: AccountCancellationAccessPreset; +}) => { + const preset = accountCancellationAccessPresets[accountCancellationAccess]; + if (accountCancellationAccess !== 'normal') { + const allowed = requestKeys(req ?? {}).some((key) => preset.apis.includes(key)); + if (!allowed) throw new Error(ERROR_ENUM.unAuthorization); + } + if ( + accountCancellationAccess === 'selfCancellation' && + !requestKeys(req ?? {}).some((key) => + key.endsWith(' /proApi/support/user/account/cancellation/status') + ) + ) { + // 成员等待页需要读取本人 status,但不能借任意 pending 团队绕过停服提交注销。 + return { + ...preset.options, + allowCurrentSessionTeamAccountCancellationPending: false + }; + } + return preset.options; +}; diff --git a/packages/service/support/user/account/cancellation/formatter.ts b/packages/service/support/user/account/cancellation/formatter.ts new file mode 100644 index 000000000000..0d6c1f9246d0 --- /dev/null +++ b/packages/service/support/user/account/cancellation/formatter.ts @@ -0,0 +1,63 @@ +import { AccountCancellationStatusEnum } from '@fastgpt/global/support/user/account/cancellation/constants'; +import { deriveAccountCancellationSchedule } from '@fastgpt/global/support/user/account/cancellation/utils'; +import type { TeamAccountCancellationSummary } from '@fastgpt/global/support/user/account/cancellation/type'; +import type { AccountCancellationSchemaType } from './schema'; + +export const getAccountCancellationAuthKey = (userId: string) => + `accountCancellation:${String(userId)}`; + +export const maskAccount = (account?: string) => { + if (!account) return ''; + const at = account.indexOf('@'); + if (at > 1) return `${account.slice(0, 2)}***${account.slice(at)}`; + if (/^1\d{10}$/.test(account)) return `${account.slice(0, 3)}****${account.slice(-4)}`; + if (account.length <= 4) return `${account.slice(0, 1)}***`; + return `${account.slice(0, 2)}***${account.slice(-2)}`; +}; + +/** 将内部 pending/finalizing 记录转换为公开注销状态。 */ +export const formatAccountCancellationPendingResponse = ( + record: Pick, + now = new Date() +) => { + if ( + !record.requestedAt || + ![AccountCancellationStatusEnum.pending, AccountCancellationStatusEnum.finalizing].includes( + record.status + ) + ) { + throw new Error('Invalid account cancellation active record'); + } + + const schedule = deriveAccountCancellationSchedule(record.requestedAt); + const isPending = record.status === AccountCancellationStatusEnum.pending; + return { + status: 'pending' as const, + requestedAt: record.requestedAt, + ...(isPending && now < schedule.scheduledCancelAt + ? { scheduledCancelAt: schedule.scheduledCancelAt } + : {}), + canCancelCancellation: isPending && now < schedule.scheduledCancelAt + }; +}; + +export const formatTeamAccountCancellationSummary = ( + record: Pick +): TeamAccountCancellationSummary => { + formatAccountCancellationPendingResponse(record); + + if (record.status === AccountCancellationStatusEnum.pending) { + return { + status: AccountCancellationStatusEnum.pending, + scheduledCancelAt: deriveAccountCancellationSchedule(record.requestedAt).scheduledCancelAt + }; + } + + if (record.status === AccountCancellationStatusEnum.finalizing) { + return { + status: AccountCancellationStatusEnum.finalizing + }; + } + + throw new Error('Invalid team account cancellation active record'); +}; diff --git a/packages/service/support/user/account/cancellation/guard.ts b/packages/service/support/user/account/cancellation/guard.ts new file mode 100644 index 000000000000..36c7f2102bd4 --- /dev/null +++ b/packages/service/support/user/account/cancellation/guard.ts @@ -0,0 +1,81 @@ +import { TeamErrEnum } from '@fastgpt/global/common/error/code/team'; +import { UserErrEnum } from '@fastgpt/global/common/error/code/user'; +import { ERROR_ENUM } from '@fastgpt/global/common/error/errorCode'; +import { MongoTeamMember } from '../../team/teamMemberSchema'; +import { MongoTeam } from '../../team/teamSchema'; +import { getActiveAccountCancellationByTeamId, getActiveAccountCancellationByUserId } from './read'; + +export type AssertAccountUsableProps = { + userId?: string; + teamId?: string; + tmbId?: string; + allowUserAccountCancellationPending?: boolean; + allowCurrentUserOwnedTeamAccountCancellationPending?: boolean; + allowCurrentSessionTeamAccountCancellationPending?: boolean; +}; + +/** + * 统一阻断用户本人和当前团队 owner 的 pending/finalizing 业务访问。 + * owner 关联始终实时读取团队 ownerId,不依赖注销记录中的团队快照。 + */ +export const assertAccountUsable = async ({ + userId, + teamId, + tmbId, + allowUserAccountCancellationPending = false, + allowCurrentUserOwnedTeamAccountCancellationPending = false, + allowCurrentSessionTeamAccountCancellationPending = false +}: AssertAccountUsableProps) => { + const tmb = tmbId && !teamId ? await MongoTeamMember.findById(tmbId).lean() : null; + const currentUserId = userId || (tmb?.userId ? String(tmb.userId) : undefined); + const currentTeamId = teamId || (tmb?.teamId ? String(tmb.teamId) : undefined); + const [userCancellation, teamCancellation] = await Promise.all([ + allowUserAccountCancellationPending + ? null + : getActiveAccountCancellationByUserId(currentUserId), + getActiveAccountCancellationByTeamId(currentTeamId) + ]); + + if (!userCancellation && !teamCancellation) return; + + if (tmbId && currentTeamId) { + const [activeMember, activeTeam] = await Promise.all([ + MongoTeamMember.findOne( + { + _id: tmbId, + teamId: currentTeamId, + ...(currentUserId ? { userId: currentUserId } : {}), + status: 'active' + }, + { _id: 1 } + ).lean(), + MongoTeam.findOne( + { + _id: currentTeamId, + $or: [{ deleteTime: { $exists: false } }, { deleteTime: null }] + }, + { _id: 1 } + ).lean() + ]); + if (!activeMember || !activeTeam) throw new Error(ERROR_ENUM.unAuthorization); + } + + if (userCancellation) throw new Error(UserErrEnum.accountCancellationPending); + + if (teamCancellation) { + const isOwnTeam = + allowCurrentUserOwnedTeamAccountCancellationPending && + !!currentUserId && + String(teamCancellation.userId) === String(currentUserId); + const isCurrentSessionTeam = allowCurrentSessionTeamAccountCancellationPending; + if (!isOwnTeam && !isCurrentSessionTeam) { + throw new Error(TeamErrEnum.accountCancellationPending); + } + } +}; + +/** 创建团队或转让 owner 前调用,避免注销中的用户重新获得 owner 资源。 */ +export const assertAccountCancellationUserCanOwnTeam = async (userId?: string) => { + const cancellation = await getActiveAccountCancellationByUserId(userId); + if (cancellation) throw new Error(UserErrEnum.accountCancellationPending); +}; diff --git a/packages/service/support/user/account/cancellation/index.ts b/packages/service/support/user/account/cancellation/index.ts new file mode 100644 index 000000000000..ff4709a8abb2 --- /dev/null +++ b/packages/service/support/user/account/cancellation/index.ts @@ -0,0 +1,6 @@ +export * from './schema'; +export * from './read'; +export * from './formatter'; +export * from './service'; +export * from './guard'; +export * from './access'; diff --git a/packages/service/support/user/account/cancellation/read.ts b/packages/service/support/user/account/cancellation/read.ts new file mode 100644 index 000000000000..69a6d3ca9ac8 --- /dev/null +++ b/packages/service/support/user/account/cancellation/read.ts @@ -0,0 +1,67 @@ +import { + AccountCancellationStatusEnum, + accountCancellationActiveStatuses +} from '@fastgpt/global/support/user/account/cancellation/constants'; +import { Types } from 'mongoose'; +import { MongoTeam } from '../../team/teamSchema'; +import { MongoAccountCancellation } from './schema'; + +export const accountCancellationActiveStatusFilter = { + $in: accountCancellationActiveStatuses +} as const; + +export const getAccountCancellationByUserId = (userId?: string) => { + if (!userId) return null; + return MongoAccountCancellation.findOne({ userId }).lean(); +}; + +export const getActiveAccountCancellationByUserId = (userId?: string) => { + if (!userId) return null; + return MongoAccountCancellation.findOne({ + userId, + status: accountCancellationActiveStatusFilter + }).lean(); +}; + +/** + * 通过团队当前 owner 动态关联注销记录,生命周期集合不保存 ownerTeamIds 快照。 + */ +export const getActiveAccountCancellationByTeamId = async (teamId?: string) => { + if (!teamId || !Types.ObjectId.isValid(teamId)) return null; + const team = await MongoTeam.findById(teamId, { ownerId: 1 }).lean(); + if (!team?.ownerId) return null; + + return MongoAccountCancellation.findOne({ + userId: team.ownerId, + status: accountCancellationActiveStatusFilter + }).lean(); +}; + +/** 批量读取团队 owner 的注销状态,避免团队列表产生逐条查询。 */ +export const getActiveAccountCancellationsByTeamIds = async (teamIds: string[]) => { + const uniqueTeamIds = Array.from(new Set(teamIds.filter(Boolean))); + if (uniqueTeamIds.length === 0) return []; + + const teams = await MongoTeam.find( + { _id: { $in: uniqueTeamIds } }, + { _id: 1, ownerId: 1 } + ).lean(); + const teamsWithOwners = teams.filter((team) => team.ownerId); + const ownerIds = Array.from(new Set(teamsWithOwners.map((team) => String(team.ownerId)))); + if (ownerIds.length === 0) return []; + + const records = await MongoAccountCancellation.find({ + userId: { $in: ownerIds }, + status: accountCancellationActiveStatusFilter + }).lean(); + const recordsByOwnerId = new Map(records.map((record) => [String(record.userId), record])); + + return teamsWithOwners.flatMap((team) => { + const record = recordsByOwnerId.get(String(team.ownerId)); + return record ? [{ teamId: String(team._id), record }] : []; + }); +}; + +export const isAccountCancellationActiveStatus = (status?: string) => + status === AccountCancellationStatusEnum.pending || + status === AccountCancellationStatusEnum.finalizing; diff --git a/packages/service/support/user/account/cancellation/schema.ts b/packages/service/support/user/account/cancellation/schema.ts new file mode 100644 index 000000000000..abfa876696c6 --- /dev/null +++ b/packages/service/support/user/account/cancellation/schema.ts @@ -0,0 +1,53 @@ +import { + AccountCancellationStatusEnum, + accountCancellationStatusMap +} from '@fastgpt/global/support/user/account/cancellation/constants'; +import type { AccountCancellationStatus } from '@fastgpt/global/support/user/account/cancellation/type'; +import { connectionMongo, getMongoModel } from '../../../../common/mongo'; +import type { Types } from 'mongoose'; +import { userCollectionName } from '../../schema'; + +const { Schema } = connectionMongo; + +export const accountCancellationCollectionName = 'account_cancellation'; + +export type AccountCancellationSchemaType = { + _id: Types.ObjectId; + userId: Types.ObjectId; + status: AccountCancellationStatus; + requestedAt: Date; +}; + +const AccountCancellationSchema = new Schema( + { + userId: { + type: Schema.Types.ObjectId, + ref: userCollectionName, + required: true + }, + status: { + type: String, + enum: Object.keys(accountCancellationStatusMap), + required: true + }, + requestedAt: { + type: Date, + required: true + } + }, + { + collection: accountCancellationCollectionName, + timestamps: false, + versionKey: false + } +); + +AccountCancellationSchema.index({ userId: 1 }, { unique: true }); +AccountCancellationSchema.index({ status: 1, requestedAt: 1 }); + +export const MongoAccountCancellation = getMongoModel( + accountCancellationCollectionName, + AccountCancellationSchema +); + +export { AccountCancellationStatusEnum }; diff --git a/packages/service/support/user/account/cancellation/service.ts b/packages/service/support/user/account/cancellation/service.ts new file mode 100644 index 000000000000..94088f58868f --- /dev/null +++ b/packages/service/support/user/account/cancellation/service.ts @@ -0,0 +1,142 @@ +import { UserErrEnum } from '@fastgpt/global/common/error/code/user'; +import { + AccountCancellationStatusEnum, + accountCancellationActiveStatuses +} from '@fastgpt/global/support/user/account/cancellation/constants'; +import { isAccountCancellationMethod } from '@fastgpt/global/support/user/account/cancellation/utils'; +import { deriveAccountCancellationSchedule } from '@fastgpt/global/support/user/account/cancellation/utils'; +import type { ClientSession } from '../../../../common/mongo'; +import { checkTimerLock, deleteTimerLock } from '../../../../common/system/timerLock/utils'; +import { MongoUser } from '../../schema'; +import { getAccountCancellationAuthKey } from './formatter'; +import { getActiveAccountCancellationByUserId } from './read'; +import { MongoAccountCancellation } from './schema'; + +const accountCancellationLockMinutes = 10; +const accountCancellationTeamLockMinutes = 10; + +/** + * 在注销用户维度串行化 submit、cancel、cron 和管理员删除,释放锁由 finally 保证。 + */ +export const withAccountCancellationUserLock = async (userId: string, fn: () => Promise) => { + const timerId = getAccountCancellationAuthKey(userId); + const locked = await checkTimerLock({ + timerId, + lockMinuted: accountCancellationLockMinutes + }); + if (!locked) throw new Error('Account cancellation operation is busy'); + + try { + return await fn(); + } finally { + await deleteTimerLock({ timerId }).catch(() => undefined); + } +}; + +/** + * 串行化团队删除、owner 转让和注销 finalizer 的团队部分。 + * 团队锁独立于用户锁,调用方需遵循“用户锁后团队锁”的顺序避免交叉等待。 + */ +export const withAccountCancellationTeamLock = async (teamId: string, fn: () => Promise) => { + const timerId = `accountCancellation:team:${String(teamId)}`; + const locked = await checkTimerLock({ + timerId, + lockMinuted: accountCancellationTeamLockMinutes + }); + if (!locked) throw new Error('Account cancellation team operation is busy'); + + try { + return await fn(); + } finally { + await deleteTimerLock({ timerId }).catch(() => undefined); + } +}; + +export const assertAccountCancellationMethod = (method: string) => { + if (!isAccountCancellationMethod(method) || method === 'oldPassword') { + throw new Error('Password verification is not allowed for account cancellation'); + } +}; + +/** + * 以唯一 userId 索引幂等创建 pending。记录只写 userId、status、requestedAt 三个业务字段。 + */ +export const createPendingAccountCancellation = async ({ + userId, + requestedAt = new Date(), + session +}: { + userId: string; + requestedAt?: Date; + session?: ClientSession; +}) => + withAccountCancellationUserLock(userId, async () => { + const user = await MongoUser.findById(userId, { username: 1, status: 1 }).lean(); + if (!user) throw new Error(UserErrEnum.notUser); + if (user.username === 'root') throw new Error('Root account can not be cancelled'); + if (user.status !== 'active') throw new Error('Account is not active'); + + const existing = await getActiveAccountCancellationByUserId(userId); + if (existing) return { record: existing, created: false }; + + await MongoAccountCancellation.updateOne( + { userId }, + { + $setOnInsert: { + userId, + status: AccountCancellationStatusEnum.pending, + requestedAt + } + }, + { upsert: true, session } + ); + + const record = await getActiveAccountCancellationByUserId(userId); + if (!record) throw new Error('Account cancellation record was not created'); + return { record, created: String(record.requestedAt) === String(requestedAt) }; + }); + +/** 条件删除 pending;finalizing/completed 永远不会被取消。 */ +export const cancelPendingAccountCancellation = async ({ + userId, + now = new Date() +}: { + userId: string; + now?: Date; +}) => + withAccountCancellationUserLock(userId, async () => { + const record = await getActiveAccountCancellationByUserId(userId); + if (!record) return { cancelled: false as const, record: null }; + if (record.status !== AccountCancellationStatusEnum.pending) { + throw new Error('Account cancellation is already finalizing'); + } + + const scheduledCancelAt = deriveAccountCancellationSchedule( + record.requestedAt + ).scheduledCancelAt; + if (now >= scheduledCancelAt) + throw new Error('Account cancellation can no longer be cancelled'); + + const result = await MongoAccountCancellation.deleteOne({ + _id: record._id, + userId, + status: AccountCancellationStatusEnum.pending + }); + return { + cancelled: result.deletedCount === 1, + record + } as const; + }); + +/** finalizer 的原子认领入口;调用方必须先复核派生 scheduledCancelAt。 */ +export const claimAccountCancellationForFinalizing = async (userId: string) => + withAccountCancellationUserLock(userId, async () => { + const result = await MongoAccountCancellation.findOneAndUpdate( + { userId, status: AccountCancellationStatusEnum.pending }, + { $set: { status: AccountCancellationStatusEnum.finalizing } }, + { new: true } + ).lean(); + return result; + }); + +export const getActiveAccountCancellationStatuses = () => [...accountCancellationActiveStatuses]; diff --git a/packages/service/support/user/account/verification/entity.ts b/packages/service/support/user/account/verification/entity.ts index a2f469a7c99a..2b8df5c73f02 100644 --- a/packages/service/support/user/account/verification/entity.ts +++ b/packages/service/support/user/account/verification/entity.ts @@ -1,5 +1,6 @@ import type { ClientSession, FilterQuery } from 'mongoose'; import { AccountVerificationMaterialTypeEnum } from '@fastgpt/global/support/user/account/verification/constants'; +import type { CodeAccountVerificationScene } from '@fastgpt/global/support/user/account/verification/type'; import { MongoAccountVerificationMaterial, type AccountVerificationMaterialSchemaType @@ -9,11 +10,17 @@ import { buildVerificationCodeFilter } from './utils'; type MaterialIdentity = { key: string; type: `${AccountVerificationMaterialTypeEnum}`; + scene?: CodeAccountVerificationScene; + userIdHash?: string; + purpose?: 'login' | 'accountCancellation'; + provider?: string; + callbackHash?: string; }; type CreateVerificationMaterialData = MaterialIdentity & { code?: string; openid?: string; + scene?: string; expiredTime: Date; createTime?: Date; }; @@ -49,7 +56,19 @@ export const upsertVerificationMaterial = ( data: CreateVerificationMaterialData, session?: ClientSession ) => { - const { key, type, code, openid, expiredTime, createTime = new Date() } = data; + const { + key, + type, + code, + openid, + scene, + expiredTime, + createTime = new Date(), + userIdHash, + purpose, + provider, + callbackHash + } = data; return MongoAccountVerificationMaterial.updateOne( { key, type }, @@ -57,6 +76,11 @@ export const upsertVerificationMaterial = ( $set: { code, openid, + scene, + userIdHash, + purpose, + provider, + callbackHash, createTime, expiredTime } @@ -71,6 +95,11 @@ const buildValidMaterialFilter = ({ code, caseInsensitiveCode, requireOpenid, + scene, + userIdHash, + purpose, + provider, + callbackHash, now = new Date() }: QueryValidVerificationMaterialData): FilterQuery => ({ key, @@ -79,7 +108,12 @@ const buildValidMaterialFilter = ({ ...(code !== undefined && { code: buildVerificationCodeFilter({ code, caseInsensitive: caseInsensitiveCode }) }), - ...(requireOpenid && { openid: { $exists: true, $ne: '' } }) + ...(requireOpenid && { openid: { $exists: true, $ne: '' } }), + ...(scene !== undefined && { scene }), + ...(userIdHash !== undefined && { userIdHash }), + ...(purpose !== undefined && { purpose }), + ...(provider !== undefined && { provider }), + ...(callbackHash !== undefined && { callbackHash }) }); /** 查询仍在业务有效期内的材料,不依赖 TTL 清理时机。 */ @@ -105,10 +139,16 @@ export const updateWechatMaterialIdentity = ( { key, openid, + materialType = AccountVerificationMaterialTypeEnum.wxLogin, + userIdHash, + purpose, now = new Date() }: { key: string; openid: string; + materialType?: `${AccountVerificationMaterialTypeEnum}`; + userIdHash?: string; + purpose?: 'login' | 'accountCancellation'; now?: Date; }, session?: ClientSession @@ -116,9 +156,11 @@ export const updateWechatMaterialIdentity = ( MongoAccountVerificationMaterial.findOneAndUpdate( { key, - type: AccountVerificationMaterialTypeEnum.wxLogin, + type: materialType, expiredTime: { $gt: now }, - openid: { $exists: false } + openid: { $exists: false }, + ...(userIdHash !== undefined && { userIdHash }), + ...(purpose !== undefined && { purpose }) }, { $set: { openid } }, { new: true, session } @@ -129,8 +171,13 @@ export const deleteVerificationMaterialIfMatch = ( { key, type, + scene, code, - openid + openid, + userIdHash, + purpose, + provider, + callbackHash }: MaterialIdentity & { code?: string; openid?: string; @@ -141,8 +188,13 @@ export const deleteVerificationMaterialIfMatch = ( { key, type, + ...(scene !== undefined && { scene }), ...(code !== undefined && { code }), - ...(openid !== undefined && { openid }) + ...(openid !== undefined && { openid }), + ...(userIdHash !== undefined && { userIdHash }), + ...(purpose !== undefined && { purpose }), + ...(provider !== undefined && { provider }), + ...(callbackHash !== undefined && { callbackHash }) }, { session } ); diff --git a/packages/service/support/user/account/verification/schema.ts b/packages/service/support/user/account/verification/schema.ts index 3d220c8dc0cb..0c9195896a50 100644 --- a/packages/service/support/user/account/verification/schema.ts +++ b/packages/service/support/user/account/verification/schema.ts @@ -1,4 +1,5 @@ import { AccountVerificationMaterialTypeEnum } from '@fastgpt/global/support/user/account/verification/constants'; +import type { CodeAccountVerificationScene } from '@fastgpt/global/support/user/account/verification/type'; import { connectionMongo, getMongoModel } from '../../../../common/mongo'; const { Schema } = connectionMongo; @@ -8,6 +9,11 @@ export type AccountVerificationMaterialSchemaType = { type: `${AccountVerificationMaterialTypeEnum}`; code?: string; openid?: string; + userIdHash?: string; + purpose?: 'login' | 'accountCancellation'; + scene?: CodeAccountVerificationScene; + provider?: string; + callbackHash?: string; createTime: Date; expiredTime: Date; }; @@ -23,6 +29,11 @@ const AccountVerificationMaterialSchema = new Schema => { + const [member, team] = await Promise.all([ + MongoTeamMember.findOne( + { + _id: tmbId, + teamId, + userId, + status: TeamMemberStatusEnum.active + }, + { _id: 1 } + ).lean(), + MongoTeam.findOne( + { + _id: teamId, + $or: [{ deleteTime: { $exists: false } }, { deleteTime: null }] + }, + { _id: 1 } + ).lean() + ]); + + if (member && team) return { teamId: String(teamId), tmbId: String(tmbId) }; + + const fallback = await getUserFallbackTeam({ userId, excludedTeamId: teamId }); + if (!fallback || !sessionId) { + if (sessionId) await delSession(sessionId); + throw new Error(ERROR_ENUM.unAuthorization); + } + + const redis = getGlobalRedisConnection(); + await redis.hmset(getSessionKey(sessionId), { + teamId: String(fallback.teamId), + tmbId: String(fallback.tmbId) + }); + return fallback; +}; + /* Session manager */ const setSession = async ({ key, @@ -98,6 +157,52 @@ export const delUserAllSession = async (userId: string, whiteList?: (string | un } }; +export const getUserSessionCount = async (userId: string) => { + const redis = getGlobalRedisConnection(); + const keys = await getAllKeysByPrefix(`${redisPrefix}${String(userId)}`); + return keys.length; +}; + +/** + * 仅处理指向已删除团队的会话。找到 fallback 时更新这部分会话,找不到时删除它们, + * 这样成员在其它团队的会话不会因为某一个团队被删除而失效。 + */ +export const migrateUserSessionsFromTeam = async ({ + userId, + deletedTeamId, + fallback +}: { + userId: string; + deletedTeamId: string; + fallback?: UserSessionTeamFallback; +}) => { + const redis = getGlobalRedisConnection(); + const keys = await getAllKeysByPrefix(`${redisPrefix}${String(userId)}`); + const deletedTeam = String(deletedTeamId); + const affectedKeys: string[] = []; + + await Promise.all( + keys.map(async (key) => { + const data = await redis.hgetall(key); + if (!data || data.teamId !== deletedTeam) return; + affectedKeys.push(key); + + if (fallback) { + await redis.hmset(key, { + teamId: String(fallback.teamId), + tmbId: String(fallback.tmbId) + }); + } + }) + ); + + if (!fallback && affectedKeys.length > 0) { + await redis.del(affectedKeys); + } + + return { affectedCount: affectedKeys.length }; +}; + // 会根据创建时间,删除超出客户端登录限制的 session const delRedundantSession = async (userId: string) => { // 至少为 1,默认为 10 diff --git a/packages/service/support/user/team/controller.ts b/packages/service/support/user/team/controller.ts index 5d7980e931f4..84a8d19f6988 100644 --- a/packages/service/support/user/team/controller.ts +++ b/packages/service/support/user/team/controller.ts @@ -19,15 +19,21 @@ import { getAIApi } from '../../../core/ai/config'; import { createRootOrg } from '../../permission/org/controllers'; import { getS3AvatarSource } from '../../../common/s3/sources/avatar'; import { getLogger, LogCategories } from '../../../common/logger'; +import { + formatTeamAccountCancellationSummary, + getActiveAccountCancellationsByTeamIds +} from '../account/cancellation'; const logger = getLogger(LogCategories.MODULE.USER.TEAM); async function getTeamMember(match: Record): Promise { const tmb = await MongoTeamMember.findOne(match).populate<{ team: TeamSchema }>('team').lean(); - if (!tmb) { + if (!tmb || !tmb.team || tmb.team.deleteTime) { return Promise.reject('member not exist'); } + const [cancellation] = await getActiveAccountCancellationsByTeamIds([String(tmb.teamId)]); + const role = (await getTmbPermission({ resourceType: PerResourceTypeEnum.team, @@ -54,7 +60,12 @@ async function getTeamMember(match: Record): Promise = async (job) => { - const { teamId } = job.data; - const startTime = Date.now(); - - logger.info('Team delete started', { teamId }); - - try { - // 1. 检查团队是否存在 - const team = await MongoTeam.findById(teamId); - if (!team) { - logger.warn('Team not found for deletion', { teamId }); - return; +export const teamDeleteProcessor: Processor = async (job) => + withAccountCancellationTeamLock(job.data.teamId, async () => { + const { teamId } = job.data; + const startTime = Date.now(); + + logger.info('Team delete started', { teamId }); + + try { + // 1. 检查团队是否存在 + const team = await MongoTeam.findById(teamId); + if (!team) { + logger.warn('Team not found for deletion', { teamId }); + return; + } + + // 2. 先删除知识库和应用(它们内部有自己的队列) + await deleteTeamAllDatasets(teamId); + await onDelAllApp(teamId); + // 删除评估 + await MongoEvaluation.deleteMany({ + teamId + }); + // 删除评估项 + await MongoEvalItem.deleteMany({ + teamId + }); + + // 删除图片(旧的了) + await MongoImage.deleteMany({ + teamId: teamId + }); + + // 3. 删除门户 + await MongoChatSetting.deleteMany({ + teamId + }); + await MongoChatFavouriteApp.deleteMany({ + teamId + }); + + // 4. 删除独立资源 + // 删除 API key + await MongoOpenApi.deleteMany({ + teamId + }); + // 分享链接直接绑定团队;不能只依赖 app delete 队列清理,避免队列延迟期间继续可访问。 + await MongoOutLink.deleteMany({ + teamId + }); + // 删除 MCP + await MongoMcpKey.deleteMany({ + teamId + }); + // 审计日志 + await MongoTeamAudit.deleteMany({ + teamId + }); + + // 5. 删除财务相关 + // 删除优惠券 + await MongoDiscountCoupon.deleteMany({ + teamId + }); + + await MongoTeamSub.deleteMany({ + teamId + }); + // 删除使用记录(不删除,等待自动过期) + // 充值记录不删除 + + const [remainingApps, remainingDatasets] = await Promise.all([ + MongoApp.countDocuments({ teamId }), + MongoDataset.countDocuments({ teamId }) + ]); + if (remainingApps > 0 || remainingDatasets > 0) { + // App/Dataset worker 必须先完成,否则删除团队后 finalizer 无法再按 teamId 观察残留。 + throw new Error( + `Team resources are still being deleted: apps=${remainingApps}, datasets=${remainingDatasets}` + ); + } + + // 6. 删除团队信息 + // 删除权限 + await MongoResourcePermission.deleteMany({ + teamId + }); + + // 删除群组 + const groups = await MongoMemberGroupModel.find({ teamId }); + await MongoGroupMemberModel.deleteMany({ + groupId: { $in: groups.map((item) => item._id) } + }); + await MongoMemberGroupModel.deleteMany({ + teamId + }); + + // 删除组织 + await MongoOrgModel.deleteMany({ + teamId + }); + await MongoOrgMemberModel.deleteMany({ + teamId + }); + + // 7. 删除成员 session 和成员信息 + const members = await MongoTeamMember.find({ + teamId + }); + + // 仅迁移/删除指向本团队的会话,保留成员在其它团队的登录态。 + await Promise.all( + members.map(async (member) => { + try { + const fallback = await getUserFallbackTeam({ + userId: String(member.userId), + excludedTeamId: teamId + }); + await migrateUserSessionsFromTeam({ + userId: String(member.userId), + deletedTeamId: teamId, + fallback: fallback ?? undefined + }); + await MongoUser.updateOne( + { _id: member.userId, lastLoginTmbId: member._id }, + fallback + ? { $set: { lastLoginTmbId: fallback.tmbId } } + : { $unset: { lastLoginTmbId: 1 } } + ); + } catch (error) { + // Session 迁移失败不阻塞团队删除;旧会话由下次鉴权的 fallback 收口。 + logger.warn('Team delete session fallback failed', { + teamId, + userId: String(member.userId), + error + }); + } + }) + ); + + await MongoTeamMember.deleteMany({ + teamId + }); + + // 8. 清理团队敏感信息 + team.notificationAccount = ''; + team.openaiAccount = undefined; + team.externalWorkflowVariables = undefined; + team.meta = undefined; + await team.save(); + + await MongoTeam.deleteOne({ _id: teamId }); + + logger.info('Team delete completed', { + teamId, + durationMs: Date.now() - startTime + }); + } catch (error: any) { + logger.error('Team delete failed', { teamId, error }); + throw error; } - - // 2. 先删除知识库和应用(它们内部有自己的队列) - await deleteTeamAllDatasets(teamId); - await onDelAllApp(teamId); - // 删除评估 - await MongoEvaluation.deleteMany({ - teamId - }); - // 删除评估项 - await MongoEvalItem.deleteMany({ - teamId - }); - - // 删除图片(旧的了) - await MongoImage.deleteMany({ - teamId: teamId - }); - - // 3. 删除门户 - await MongoChatSetting.deleteMany({ - teamId - }); - await MongoChatFavouriteApp.deleteMany({ - teamId - }); - - // 4. 删除独立资源 - // 删除 API key - await MongoOpenApi.deleteMany({ - teamId - }); - // 删除 MCP - await MongoMcpKey.deleteMany({ - teamId - }); - // 审计日志 - await MongoTeamAudit.deleteMany({ - teamId - }); - - // 5. 删除财务相关 - // 删除优惠券 - await MongoDiscountCoupon.deleteMany({ - teamId - }); - - await MongoTeamSub.deleteMany({ - teamId - }); - // 删除使用记录(不删除,等待自动过期) - // 充值记录不删除 - - // 6. 删除团队信息 - // 删除权限 - await MongoResourcePermission.deleteMany({ - teamId - }); - - // 删除群组 - const groups = await MongoMemberGroupModel.find({ teamId }); - await MongoGroupMemberModel.deleteMany({ - groupId: { $in: groups.map((item) => item._id) } - }); - await MongoMemberGroupModel.deleteMany({ - teamId - }); - - // 删除组织 - await MongoOrgModel.deleteMany({ - teamId - }); - await MongoOrgMemberModel.deleteMany({ - teamId - }); - - // 7. 删除成员 session 和成员信息 - const members = await MongoTeamMember.find({ - teamId - }); - - // 删除所有成员的 session - await Promise.all(members.map((member) => delUserAllSession(member.userId))); - - await MongoTeamMember.deleteMany({ - teamId - }); - - // 8. 清理团队敏感信息 - team.notificationAccount = ''; - team.openaiAccount = undefined; - team.externalWorkflowVariables = undefined; - team.meta = undefined; - await team.save(); - - logger.info('Team delete completed', { - teamId, - durationMs: Date.now() - startTime - }); - } catch (error: any) { - logger.error('Team delete failed', { teamId, error }); - throw error; - } -}; + }); diff --git a/packages/service/support/user/team/delete/utils.ts b/packages/service/support/user/team/delete/utils.ts index 62e9ccc81795..4be050b174c3 100644 --- a/packages/service/support/user/team/delete/utils.ts +++ b/packages/service/support/user/team/delete/utils.ts @@ -3,14 +3,15 @@ import { deleteAppsImmediate } from '../../../../core/app/controller'; import { addAppDeleteJob } from '../../../../core/app/delete'; export const onDelAllApp = async (teamId: string) => { - // 取根目录所有应用 + // 正常只投递根应用;如果历史数据留下孤立子应用,则把孤立应用作为自己的根补偿投递。 const apps = await MongoApp.find( { - teamId, - parentId: null + teamId }, - '_id' + '_id parentId' ); + const appIdSet = new Set(apps.map((app) => String(app._id))); + const deleteRootApps = apps.filter((app) => !app.parentId || !appIdSet.has(String(app.parentId))); const appIds = apps.map((app) => app._id); // Stop background tasks immediately @@ -32,10 +33,10 @@ export const onDelAllApp = async (teamId: string) => { ); // 添加到删除队列 - for (const appId of appIds) { + for (const app of deleteRootApps) { await addAppDeleteJob({ teamId, - appId + appId: String(app._id) }); } }; diff --git a/packages/service/support/user/team/fallback.ts b/packages/service/support/user/team/fallback.ts new file mode 100644 index 000000000000..314388084e52 --- /dev/null +++ b/packages/service/support/user/team/fallback.ts @@ -0,0 +1,54 @@ +import { TeamMemberStatusEnum } from '@fastgpt/global/support/user/team/constant'; +import { getActiveAccountCancellationsByTeamIds } from '../account/cancellation/read'; +import { MongoTeamMember } from './teamMemberSchema'; +import { MongoTeam } from './teamSchema'; + +/** + * 找到用户可继续使用的团队。已删除团队、无效成员关系和注销中的 owner 团队都不能作为 fallback。 + */ +export const getUserFallbackTeam = async ({ + userId, + excludedTeamId +}: { + userId: string; + excludedTeamId?: string; +}) => { + const members = await MongoTeamMember.find( + { + userId, + status: TeamMemberStatusEnum.active, + ...(excludedTeamId ? { teamId: { $ne: excludedTeamId } } : {}) + }, + { _id: 1, teamId: 1 } + ) + .sort({ createTime: 1 }) + .lean(); + + if (members.length === 0) return null; + + const teams = await MongoTeam.find( + { + _id: { $in: members.map((member) => member.teamId) }, + $or: [{ deleteTime: { $exists: false } }, { deleteTime: null }] + }, + { _id: 1, ownerId: 1 } + ).lean(); + if (teams.length === 0) return null; + + const cancellationTeams = await getActiveAccountCancellationsByTeamIds( + teams.map((team) => String(team._id)) + ); + const blockedTeamIds = new Set(cancellationTeams.map(({ teamId }) => teamId)); + const validTeams = new Map(teams.map((team) => [String(team._id), team])); + + return ( + members + .map((member) => { + const teamId = String(member.teamId); + return blockedTeamIds.has(teamId) || !validTeams.has(teamId) + ? null + : { teamId, tmbId: String(member._id) }; + }) + .find(Boolean) ?? null + ); +}; diff --git a/packages/service/test/support/user/account/cancellation/formatter.test.ts b/packages/service/test/support/user/account/cancellation/formatter.test.ts new file mode 100644 index 000000000000..893e38ea74c7 --- /dev/null +++ b/packages/service/test/support/user/account/cancellation/formatter.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest'; +import { AccountCancellationStatusEnum } from '@fastgpt/global/support/user/account/cancellation/constants'; +import { formatTeamAccountCancellationSummary } from '@fastgpt/service/support/user/account/cancellation/formatter'; + +describe('formatTeamAccountCancellationSummary', () => { + it('keeps pending status and exposes the derived scheduled cleanup time', () => { + const summary = formatTeamAccountCancellationSummary({ + status: AccountCancellationStatusEnum.pending, + requestedAt: new Date('2026-07-01T10:20:00.000Z') + }); + + expect(summary).toEqual({ + status: AccountCancellationStatusEnum.pending, + scheduledCancelAt: new Date('2026-07-16T16:00:00.000Z') + }); + }); + + it('keeps finalizing status and hides the scheduled cleanup time', () => { + const summary = formatTeamAccountCancellationSummary({ + status: AccountCancellationStatusEnum.finalizing, + requestedAt: new Date('2026-07-01T10:20:00.000Z') + }); + + expect(summary).toEqual({ + status: AccountCancellationStatusEnum.finalizing + }); + }); +}); diff --git a/packages/web/components/common/Icon/constants.ts b/packages/web/components/common/Icon/constants.ts index f1e9a12471b7..390d7053f199 100644 --- a/packages/web/components/common/Icon/constants.ts +++ b/packages/web/components/common/Icon/constants.ts @@ -82,6 +82,10 @@ export const iconPaths = { 'common/model': () => import('./icons/common/model.svg'), 'common/openai': () => import('./icons/common/openai.svg'), 'common/overviewLight': () => import('./icons/common/overviewLight.svg'), + 'common/quickActionBook': () => import('./icons/common/quickActionBook.svg'), + 'common/quickActionFeedback': () => import('./icons/common/quickActionFeedback.svg'), + 'common/quickActionPhone': () => import('./icons/common/quickActionPhone.svg'), + 'common/quickActionUserX': () => import('./icons/common/quickActionUserX.svg'), 'common/refresh': () => import('./icons/common/refresh.svg'), 'common/refreshLight': () => import('./icons/common/refreshLight.svg'), 'common/retryLight': () => import('./icons/common/retryLight.svg'), diff --git a/packages/web/components/common/Icon/icons/common/quickActionBook.svg b/packages/web/components/common/Icon/icons/common/quickActionBook.svg new file mode 100644 index 000000000000..ee6d4fbab46b --- /dev/null +++ b/packages/web/components/common/Icon/icons/common/quickActionBook.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/web/components/common/Icon/icons/common/quickActionFeedback.svg b/packages/web/components/common/Icon/icons/common/quickActionFeedback.svg new file mode 100644 index 000000000000..1b1ab1e044f9 --- /dev/null +++ b/packages/web/components/common/Icon/icons/common/quickActionFeedback.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/web/components/common/Icon/icons/common/quickActionPhone.svg b/packages/web/components/common/Icon/icons/common/quickActionPhone.svg new file mode 100644 index 000000000000..3c8f620a09a4 --- /dev/null +++ b/packages/web/components/common/Icon/icons/common/quickActionPhone.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/web/components/common/Icon/icons/common/quickActionUserX.svg b/packages/web/components/common/Icon/icons/common/quickActionUserX.svg new file mode 100644 index 000000000000..f17316b131f5 --- /dev/null +++ b/packages/web/components/common/Icon/icons/common/quickActionUserX.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/web/i18n/en/account_info.json b/packages/web/i18n/en/account_info.json index 3482895539b7..04f28dac39d4 100644 --- a/packages/web/i18n/en/account_info.json +++ b/packages/web/i18n/en/account_info.json @@ -1,5 +1,61 @@ { "account_knowledge_base_cleanup_warning": "When the free version team does not log in to the system for 30 consecutive days, the system will automatically clean up the account knowledge base.", + "account_cancellation": "Delete account", + "account_cancellation_account": "Account to delete", + "account_cancellation_cancel": "Cancel deletion", + "account_cancellation_cancel_error": "Could not cancel account deletion", + "account_cancellation_cancel_success": "Account deletion canceled", + "account_cancellation_code_countdown": "Resend ({{seconds}})", + "account_cancellation_code_resend": "Resend", + "account_cancellation_code_send_failed": "Could not send the verification code. Try again.", + "account_cancellation_code_sending": "Sending", + "account_cancellation_code_sent": "Verification code sent", + "account_cancellation_confirm": "Confirm deletion", + "account_cancellation_confirm_backup": "Backed up important data, configuration, and business materials", + "account_cancellation_confirm_before_continue": "Before continuing, confirm that you have completed the following:", + "account_cancellation_confirm_cancel_during_wait": "During the 15-day waiting period, you can sign in again and cancel account deletion.", + "account_cancellation_confirm_completion_intro": "After the waiting period, account deletion will be completed. At that point:", + "account_cancellation_confirm_intro": "Before deleting your account, confirm the following:", + "account_cancellation_confirm_leave_team_impact": "The account will automatically leave all other teams it has joined", + "account_cancellation_confirm_order_refund": "Resolved outstanding orders, refunds, and related matters", + "account_cancellation_confirm_owned_team_impact": "teams created by this account will be deleted", + "account_cancellation_confirm_owned_team_prefix": "All ", + "account_cancellation_confirm_personal_data_impact": "The account's personal information will be deleted or anonymized", + "account_cancellation_confirm_reregister": "If you register again with the same account after deletion, a new account will be created and none of the original data can be restored.", + "account_cancellation_confirm_service_impact": "channels that provide external services through this account will stop working", + "account_cancellation_confirm_service_stop": "Confirmed that stopping related services will not affect production workloads", + "account_cancellation_confirm_team_data_impact": "Apps, data, members, and configuration in those teams will be deleted, and team members will lose access", + "account_cancellation_confirm_team_transfer": "Transferred team ownership or handled team data", + "account_cancellation_confirm_title": "Account deletion notice", + "account_cancellation_confirm_verification_effect": "The deletion request takes effect after identity verification is complete.", + "account_cancellation_confirm_waiting_prefix": "After you submit the request, the account enters a 15-day waiting period. During this period, the account cannot be used normally and all ", + "account_cancellation_confirm_waiting_suffix": ", including API keys, shared links, and external APIs. System notifications will remain available.", + "account_cancellation_continue": "I understand, continue", + "account_cancellation_finalizing_desc": "Your account is being deleted and its related data is being cleaned up.", + "account_cancellation_finalizing_no_estimate": "Deletion can no longer be canceled, and an estimated completion time is not shown at this stage.", + "account_cancellation_in_progress_title": "Deleting account", + "account_cancellation_oauth_start": "Verify with {{provider}}", + "account_cancellation_pending_cancel_desc": "If you did not request this, or you want to keep using the account, cancel deletion before the scheduled deletion time. The account will return to normal after cancellation.", + "account_cancellation_pending_desc": "Your account deletion request has been submitted and is in the 15-day waiting period.", + "account_cancellation_pending_service_desc": "During the waiting period, the account cannot be used normally and all external service channels that depend on it are disabled.", + "account_cancellation_requested_at": "Requested: {{time}}", + "account_cancellation_scheduled_at": "Scheduled deletion: {{time}}", + "account_cancellation_send_code": "Get code", + "account_cancellation_switch_team": "Switch team", + "account_cancellation_team_finalizing_desc": "This team is being deleted. Contact the team owner for an update.", + "account_cancellation_team_pending_desc": "The team owner requested deletion and the team is in its 15-day waiting period. Contact the owner to cancel deletion.", + "account_cancellation_team_scheduled_at": "Scheduled cleanup: {{time}}", + "account_cancellation_team_title": "Team deletion in progress", + "account_cancellation_title": "Delete account", + "account_cancellation_unavailable_desc": "This account has no supported non-password verification method.", + "account_cancellation_verification_failed": "Identity verification failed. Try again.", + "account_cancellation_verification_success": "Identity verified", + "account_cancellation_verifying": "Verifying", + "account_cancellation_wechat_expired": "The QR code expired. Get a new one.", + "account_cancellation_wechat_load_failed": "Could not load the QR code. Try again.", + "account_cancellation_wechat_qr": "WeChat QR code", + "account_cancellation_wechat_refresh": "Get a new QR code", + "account_cancellation_wechat_scan": "Scan with WeChat", "active": "Taking effect", "ai_points": "AI points", "ai_points_calculation_standard": "AI points", diff --git a/packages/web/i18n/en/common.json b/packages/web/i18n/en/common.json index 5dbee20df0a4..9e945cc0a93c 100644 --- a/packages/web/i18n/en/common.json +++ b/packages/web/i18n/en/common.json @@ -131,6 +131,7 @@ "code_editor": "Code Editor", "code_error.account_error": "Incorrect account name or password", "code_error.account_exist": "Account has been registered", + "code_error.account_cancellation_pending": "Account deletion is in progress. This account is temporarily unavailable.", "code_error.account_not_found": "User is not registered", "code_error.app_error.can_not_edit_admin_permission": "Can not edit admin permission", "code_error.app_error.invalid_app_type": "Invalid Application Type", @@ -192,6 +193,7 @@ "code_error.team_error.ai_points_not_enough": "Insufficient AI Points", "code_error.team_error.app_amount_not_enough": "Application Limit Reached", "code_error.team_error.app_folder_amount_not_enough": "Folder Limit Reached", + "code_error.team_error.account_cancellation_pending": "The team owner is deleting their account. This team is temporarily unavailable.", "code_error.team_error.cannot_delete_default_group": "Cannot delete default group", "code_error.team_error.cannot_delete_non_empty_org": "Cannot delete non-empty organization", "code_error.team_error.cannot_modify_root_org": "Cannot modify root organization", diff --git a/packages/web/i18n/zh-CN/account_info.json b/packages/web/i18n/zh-CN/account_info.json index 0cef97893c26..cead3f31f318 100644 --- a/packages/web/i18n/zh-CN/account_info.json +++ b/packages/web/i18n/zh-CN/account_info.json @@ -1,5 +1,61 @@ { "account_knowledge_base_cleanup_warning": "免费版团队连续 30 天未登录系统时,系统会自动清理账号知识库。", + "account_cancellation": "账号注销", + "account_cancellation_account": "注销账号", + "account_cancellation_cancel": "取消注销", + "account_cancellation_cancel_error": "取消失败", + "account_cancellation_cancel_success": "已取消注销", + "account_cancellation_code_countdown": "重新获取({{seconds}})", + "account_cancellation_code_resend": "重新获取", + "account_cancellation_code_send_failed": "验证码发送失败,请重试", + "account_cancellation_code_sending": "发送中", + "account_cancellation_code_sent": "验证码已发送", + "account_cancellation_confirm": "确认注销", + "account_cancellation_confirm_backup": "已备份重要数据、配置和业务资料", + "account_cancellation_confirm_before_continue": "在继续前,请确认你已处理好以下事项:", + "account_cancellation_confirm_cancel_during_wait": "在 15 天等待期内,你可以重新登录账号并取消注销。", + "account_cancellation_confirm_completion_intro": "等待期结束后,账号注销将正式完成。届时:", + "account_cancellation_confirm_intro": "注销账号前,请确认以下事项:", + "account_cancellation_confirm_leave_team_impact": "该账号加入的其他团队将自动退出", + "account_cancellation_confirm_order_refund": "已处理未完成订单、退款等事项", + "account_cancellation_confirm_owned_team_impact": "创建的团队将被删除", + "account_cancellation_confirm_owned_team_prefix": "该账号下", + "account_cancellation_confirm_personal_data_impact": "该账号的个人信息将被删除或匿名化处理", + "account_cancellation_confirm_reregister": "账号注销完成后,如果你再次使用该账号注册,将会创建一个全新的账号,原账号数据无法恢复。", + "account_cancellation_confirm_service_impact": "依赖该账号对外提供服务的渠道将停止生效", + "account_cancellation_confirm_service_stop": "已确认相关服务停用不会影响线上业务", + "account_cancellation_confirm_team_data_impact": "团队内的应用、数据、成员、配置等信息将被删除,团队成员无法进入团队", + "account_cancellation_confirm_team_transfer": "已完成团队归属转移或团队数据处理", + "account_cancellation_confirm_title": "注销提示", + "account_cancellation_confirm_verification_effect": "完成身份验证后,注销申请将正式生效。", + "account_cancellation_confirm_waiting_prefix": "提交注销申请后,账号将进入 15 天等待期。等待期内,该账号将无法正常使用,所有", + "account_cancellation_confirm_waiting_suffix": ",包括但不限于 API Key、分享链接和对外调用接口。系统通知信息仍可正常接收。", + "account_cancellation_continue": "已知晓,下一步", + "account_cancellation_finalizing_desc": "你的账号已进入注销处理阶段,系统正在清理账号及相关数据。", + "account_cancellation_finalizing_no_estimate": "该阶段无法取消注销,预计完成时间不再展示。", + "account_cancellation_in_progress_title": "注销中", + "account_cancellation_oauth_start": "前往 {{provider}} 验证", + "account_cancellation_pending_cancel_desc": "若这不是你本人操作,或你希望继续使用该账号,请在预计注销时间前取消注销。取消后,账号将恢复正常状态。", + "account_cancellation_pending_desc": "你的账号已提交注销申请,目前处于 15 天注销等待期。", + "account_cancellation_pending_service_desc": "等待期内,该账号将无法正常使用,所有依赖该账号对外提供服务的渠道已停止生效。", + "account_cancellation_requested_at": "申请时间:{{time}}", + "account_cancellation_scheduled_at": "预计注销时间:{{time}}", + "account_cancellation_send_code": "获取验证码", + "account_cancellation_switch_team": "切换团队", + "account_cancellation_team_finalizing_desc": "团队已进入注销清理阶段。您可联系团队所有者了解处理进度。", + "account_cancellation_team_pending_desc": "团队已由团队所有者提交注销申请,目前处于 15 天注销等待期。您可联系团队所有者取消注销。", + "account_cancellation_team_scheduled_at": "预计清理时间:{{time}}", + "account_cancellation_team_title": "团队注销中", + "account_cancellation_title": "注销账号", + "account_cancellation_unavailable_desc": "当前账号没有可用的非密码验证方式。", + "account_cancellation_verification_failed": "身份验证失败,请重试", + "account_cancellation_verification_success": "身份验证成功", + "account_cancellation_verifying": "验证中", + "account_cancellation_wechat_expired": "二维码已过期,请重新获取。", + "account_cancellation_wechat_load_failed": "二维码加载失败,请重试。", + "account_cancellation_wechat_qr": "微信二维码", + "account_cancellation_wechat_refresh": "重新获取二维码", + "account_cancellation_wechat_scan": "微信扫码登录", "active": "生效中", "ai_points": "AI 积分", "ai_points_calculation_standard": "AI 积分", diff --git a/packages/web/i18n/zh-CN/common.json b/packages/web/i18n/zh-CN/common.json index 56892ae3a679..88d42a26e335 100644 --- a/packages/web/i18n/zh-CN/common.json +++ b/packages/web/i18n/zh-CN/common.json @@ -131,6 +131,7 @@ "code_editor": "代码编辑", "code_error.account_error": "账号名或密码错误", "code_error.account_exist": "账号已注册", + "code_error.account_cancellation_pending": "账号注销处理中,当前账号暂不可使用", "code_error.account_not_found": "用户未注册", "code_error.app_error.can_not_edit_admin_permission": "不能编辑管理员权限", "code_error.app_error.invalid_app_type": "错误的应用类型", @@ -192,6 +193,7 @@ "code_error.team_error.ai_points_not_enough": "AI 积分不足", "code_error.team_error.app_amount_not_enough": "应用数量已达上限~", "code_error.team_error.app_folder_amount_not_enough": "文件夹数量已达上限~", + "code_error.team_error.account_cancellation_pending": "团队所有者正在注销,当前团队暂不可使用", "code_error.team_error.cannot_delete_default_group": "不能删除默认群组", "code_error.team_error.cannot_delete_non_empty_org": "不能删除非空部门", "code_error.team_error.cannot_modify_root_org": "不能修改根部门", diff --git a/packages/web/i18n/zh-Hant/account_info.json b/packages/web/i18n/zh-Hant/account_info.json index fa0eef499a90..710a68ea2954 100644 --- a/packages/web/i18n/zh-Hant/account_info.json +++ b/packages/web/i18n/zh-Hant/account_info.json @@ -1,5 +1,61 @@ { "account_knowledge_base_cleanup_warning": "免費版團隊連續 30 天未登入系統時,系統會自動清理帳號知識庫。", + "account_cancellation": "帳號註銷", + "account_cancellation_account": "註銷帳號", + "account_cancellation_cancel": "取消註銷", + "account_cancellation_cancel_error": "取消失敗", + "account_cancellation_cancel_success": "已取消註銷", + "account_cancellation_code_countdown": "重新取得({{seconds}})", + "account_cancellation_code_resend": "重新取得", + "account_cancellation_code_send_failed": "驗證碼傳送失敗,請重試", + "account_cancellation_code_sending": "傳送中", + "account_cancellation_code_sent": "驗證碼已傳送", + "account_cancellation_confirm": "確認註銷", + "account_cancellation_confirm_backup": "已備份重要資料、設定和業務資料", + "account_cancellation_confirm_before_continue": "繼續前,請確認你已處理好以下事項:", + "account_cancellation_confirm_cancel_during_wait": "在 15 天等待期內,你可以重新登入帳號並取消註銷。", + "account_cancellation_confirm_completion_intro": "等待期結束後,帳號註銷將正式完成。屆時:", + "account_cancellation_confirm_intro": "註銷帳號前,請確認以下事項:", + "account_cancellation_confirm_leave_team_impact": "該帳號加入的其他團隊將自動退出", + "account_cancellation_confirm_order_refund": "已處理未完成訂單、退款等事項", + "account_cancellation_confirm_owned_team_impact": "建立的團隊將被刪除", + "account_cancellation_confirm_owned_team_prefix": "該帳號下", + "account_cancellation_confirm_personal_data_impact": "該帳號的個人資訊將被刪除或匿名化處理", + "account_cancellation_confirm_reregister": "帳號註銷完成後,如果你再次使用該帳號註冊,將建立一個全新帳號,原帳號資料無法恢復。", + "account_cancellation_confirm_service_impact": "依賴該帳號對外提供服務的管道將停止生效", + "account_cancellation_confirm_service_stop": "已確認相關服務停用不會影響線上業務", + "account_cancellation_confirm_team_data_impact": "團隊內的應用、資料、成員、設定等資訊將被刪除,團隊成員無法進入團隊", + "account_cancellation_confirm_team_transfer": "已完成團隊歸屬轉移或團隊資料處理", + "account_cancellation_confirm_title": "註銷提示", + "account_cancellation_confirm_verification_effect": "完成身分驗證後,註銷申請將正式生效。", + "account_cancellation_confirm_waiting_prefix": "提交註銷申請後,帳號將進入 15 天等待期。等待期內,該帳號將無法正常使用,所有", + "account_cancellation_confirm_waiting_suffix": ",包括但不限於 API Key、分享連結和對外呼叫介面。系統通知資訊仍可正常接收。", + "account_cancellation_continue": "已知悉,下一步", + "account_cancellation_finalizing_desc": "你的帳號已進入註銷處理階段,系統正在清理帳號及相關資料。", + "account_cancellation_finalizing_no_estimate": "該階段無法取消註銷,預計完成時間不再顯示。", + "account_cancellation_in_progress_title": "註銷中", + "account_cancellation_oauth_start": "前往 {{provider}} 驗證", + "account_cancellation_pending_cancel_desc": "若這不是你本人操作,或你希望繼續使用該帳號,請在預計註銷時間前取消註銷。取消後,帳號將恢復正常狀態。", + "account_cancellation_pending_desc": "你的帳號已提交註銷申請,目前處於 15 天註銷等待期。", + "account_cancellation_pending_service_desc": "等待期內,該帳號將無法正常使用,所有依賴該帳號對外提供服務的管道已停止生效。", + "account_cancellation_requested_at": "申請時間:{{time}}", + "account_cancellation_scheduled_at": "預計註銷時間:{{time}}", + "account_cancellation_send_code": "取得驗證碼", + "account_cancellation_switch_team": "切換團隊", + "account_cancellation_team_finalizing_desc": "團隊已進入註銷清理階段。你可聯絡團隊擁有者了解處理進度。", + "account_cancellation_team_pending_desc": "團隊已由團隊擁有者提交註銷申請,目前處於 15 天註銷等待期。你可聯絡團隊擁有者取消註銷。", + "account_cancellation_team_scheduled_at": "預計清理時間:{{time}}", + "account_cancellation_team_title": "團隊註銷中", + "account_cancellation_title": "註銷帳號", + "account_cancellation_unavailable_desc": "目前帳號沒有可用的非密碼驗證方式。", + "account_cancellation_verification_failed": "身分驗證失敗,請重試", + "account_cancellation_verification_success": "身分驗證成功", + "account_cancellation_verifying": "驗證中", + "account_cancellation_wechat_expired": "QR Code 已過期,請重新取得。", + "account_cancellation_wechat_load_failed": "QR Code 載入失敗,請重試。", + "account_cancellation_wechat_qr": "微信 QR Code", + "account_cancellation_wechat_refresh": "重新取得 QR Code", + "account_cancellation_wechat_scan": "微信掃碼登入", "active": "生效中", "ai_points": "AI 積分", "ai_points_calculation_standard": "AI 積分", diff --git a/packages/web/i18n/zh-Hant/common.json b/packages/web/i18n/zh-Hant/common.json index 378260db2914..78e4f0ce32ca 100644 --- a/packages/web/i18n/zh-Hant/common.json +++ b/packages/web/i18n/zh-Hant/common.json @@ -130,6 +130,7 @@ "code_editor": "程式碼編輯器", "code_error.account_error": "帳號名稱或密碼錯誤", "code_error.account_exist": "賬號已註冊", + "code_error.account_cancellation_pending": "帳號註銷處理中,目前帳號暫不可使用", "code_error.account_not_found": "使用者未註冊", "code_error.app_error.can_not_edit_admin_permission": "不能編輯管理員權限", "code_error.app_error.invalid_app_type": "無效的應用程式類型", @@ -190,6 +191,7 @@ "code_error.team_error.ai_points_not_enough": "AI 點數不足", "code_error.team_error.app_amount_not_enough": "已達應用程式數量上限", "code_error.team_error.app_folder_amount_not_enough": "已達資料夾數量上限", + "code_error.team_error.account_cancellation_pending": "團隊擁有者正在註銷,目前團隊暫不可使用", "code_error.team_error.cannot_delete_default_group": "無法刪除預設群組", "code_error.team_error.cannot_delete_non_empty_org": "無法刪除非空組織", "code_error.team_error.cannot_modify_root_org": "無法修改根組織", diff --git a/projects/app/src/components/Layout/SupportBot.tsx b/projects/app/src/components/Layout/SupportBot.tsx index 8a1ddd6443a0..9b132b97909e 100644 --- a/projects/app/src/components/Layout/SupportBot.tsx +++ b/projects/app/src/components/Layout/SupportBot.tsx @@ -78,7 +78,7 @@ const SupportBot = () => { {showChat && open && ( { {open && ( diff --git a/projects/app/src/components/Layout/index.tsx b/projects/app/src/components/Layout/index.tsx index 070b1334a14d..1facb21b9b63 100644 --- a/projects/app/src/components/Layout/index.tsx +++ b/projects/app/src/components/Layout/index.tsx @@ -58,6 +58,7 @@ const pcUnShowLayoutRoute: Record = { '/login': true, '/login/provider': true, '/login/fastlogin': true, + '/account/cancel': true, '/chat/share': true, '/app/edit': true, '/chat': true, @@ -70,6 +71,7 @@ const phoneUnShowLayoutRoute: Record = { '/login': true, '/login/provider': true, '/login/fastlogin': true, + '/account/cancel': true, '/chat': true, '/chat/share': true, '/tools/price': true, @@ -164,6 +166,17 @@ const Layout = ({ children }: { children: JSX.Element }) => { setLastRoute(router.pathname); }, [router.pathname, setLastRoute]); + useEffect(() => { + if ( + userInfo?.team?.accountCancellation && + router.pathname !== '/account/cancel' && + router.pathname !== '/login' && + router.pathname !== '/login/provider' + ) { + router.replace('/account/cancel?view=team'); + } + }, [router, router.pathname, userInfo?.team?.accountCancellation]); + return ( <> diff --git a/projects/app/src/pageComponents/account/AccountContainer.tsx b/projects/app/src/pageComponents/account/AccountContainer.tsx index 324b1391f445..04ed39dff7a3 100644 --- a/projects/app/src/pageComponents/account/AccountContainer.tsx +++ b/projects/app/src/pageComponents/account/AccountContainer.tsx @@ -153,7 +153,7 @@ const AccountContainer = ({ ); return ( - + {isPc ? ( void; + onConfirm: () => void; +}) => { + const { t } = useTranslation(); + + return ( + + + + + } + > + + + + {t('account_info:account_cancellation_confirm_intro', '注销账号前,请确认以下事项:')} + + + + {t( + 'account_info:account_cancellation_confirm_waiting_prefix', + '提交注销申请后,账号将进入 15 天等待期。等待期内,该账号将无法正常使用,所有' + )} + + {t( + 'account_info:account_cancellation_confirm_service_impact', + '依赖该账号对外提供服务的渠道将停止生效' + )} + + {t( + 'account_info:account_cancellation_confirm_waiting_suffix', + ',包括但不限于 API Key、分享链接和对外调用接口。系统通知信息仍可正常接收。' + )} + + + + + {t( + 'account_info:account_cancellation_confirm_completion_intro', + '等待期结束后,账号注销将正式完成。届时:' + )} + + + + {t('account_info:account_cancellation_confirm_owned_team_prefix', '该账号下')} + + {t( + 'account_info:account_cancellation_confirm_owned_team_impact', + '创建的团队将被删除' + )} + + + + {t( + 'account_info:account_cancellation_confirm_team_data_impact', + '团队内的应用、数据、成员、配置等信息将被删除,团队成员无法进入团队' + )} + + + {t( + 'account_info:account_cancellation_confirm_personal_data_impact', + '该账号的个人信息将被删除或匿名化处理' + )} + + + {t( + 'account_info:account_cancellation_confirm_leave_team_impact', + '该账号加入的其他团队将自动退出' + )} + + + + + + + {t( + 'account_info:account_cancellation_confirm_before_continue', + '在继续前,请确认你已处理好以下事项:' + )} + + + + {t( + 'account_info:account_cancellation_confirm_team_transfer', + '已完成团队归属转移或团队数据处理' + )} + + + {t( + 'account_info:account_cancellation_confirm_order_refund', + '已处理未完成订单、退款等事项' + )} + + + {t( + 'account_info:account_cancellation_confirm_backup', + '已备份重要数据、配置和业务资料' + )} + + + {t( + 'account_info:account_cancellation_confirm_service_stop', + '已确认相关服务停用不会影响线上业务' + )} + + + + + + + {t( + 'account_info:account_cancellation_confirm_verification_effect', + '完成身份验证后,注销申请将正式生效。' + )} + + + {t( + 'account_info:account_cancellation_confirm_cancel_during_wait', + '在 15 天等待期内,你可以重新登录账号并取消注销。' + )} + + + {t( + 'account_info:account_cancellation_confirm_reregister', + '账号注销完成后,如果你再次使用该账号注册,将会创建一个全新的账号,原账号数据无法恢复。' + )} + + + + + + ); +}; diff --git a/projects/app/src/pageComponents/account/cancel/AccountCancellationPageLayout.tsx b/projects/app/src/pageComponents/account/cancel/AccountCancellationPageLayout.tsx new file mode 100644 index 000000000000..97530975add1 --- /dev/null +++ b/projects/app/src/pageComponents/account/cancel/AccountCancellationPageLayout.tsx @@ -0,0 +1,97 @@ +import { Box, Button, Flex, type FlexProps } from '@chakra-ui/react'; +import MyIcon from '@fastgpt/web/components/common/Icon'; +import { useTranslation } from 'next-i18next'; + +/** 注销流程独立页骨架,复用登录页背景但不渲染账号导航和语言切换。 */ +export const AccountCancellationPageLayout = ({ + children, + showBack = false, + onBack, + cardProps +}: { + children: React.ReactNode; + showBack?: boolean; + onBack?: () => void; + cardProps?: FlexProps; +}) => { + const { t } = useTranslation(); + + return ( + + {showBack && ( + + )} + + + + + + {children} + + + + ); +}; diff --git a/projects/app/src/pageComponents/account/cancel/CancelAccountPage.tsx b/projects/app/src/pageComponents/account/cancel/CancelAccountPage.tsx new file mode 100644 index 000000000000..034f06e7d5d7 --- /dev/null +++ b/projects/app/src/pageComponents/account/cancel/CancelAccountPage.tsx @@ -0,0 +1,148 @@ +import { Spinner } from '@chakra-ui/react'; +import { useCallback, useEffect, useState } from 'react'; +import { useRouter } from 'next/router'; +import { useTranslation } from 'next-i18next'; +import type { + AccountCancellationStatusResponse, + SubmitAccountCancellationResponse +} from '@fastgpt/global/openapi/support/user/account/cancellation/api'; +import { useToast } from '@fastgpt/web/hooks/useToast'; +import { useUserStore } from '@/web/support/user/useUserStore'; +import { + cancelAccountCancellation, + getAccountCancellationStatus +} from '@/web/support/user/account/cancellation/api'; +import { AccountCancellationPageLayout } from './AccountCancellationPageLayout'; +import { CancelPendingPanel } from './CancelPendingPanel'; +import { MemberPendingPanel } from './MemberPendingPanel'; +import { VerificationPanel } from './VerificationPanel'; + +const CancelAccountPage = () => { + const { t } = useTranslation(); + const router = useRouter(); + const { toast } = useToast(); + const { userInfo, initUserInfo, setUserInfo } = useUserStore(); + const [status, setStatus] = useState(); + const [submittedResult, setSubmittedResult] = + useState>(); + const [loading, setLoading] = useState(true); + const [canceling, setCanceling] = useState(false); + + const loadStatus = useCallback(async () => { + setLoading(true); + try { + setStatus(await getAccountCancellationStatus()); + } catch { + await router.replace('/account/info'); + } finally { + setLoading(false); + } + }, [router]); + + useEffect(() => { + void initUserInfo().then(loadStatus); + }, [initUserInfo, loadStatus]); + + const memberCancellation = userInfo?.team?.accountCancellation; + const isMemberView = status?.status === 'none' && !!memberCancellation; + const isVerificationView = + status?.status === 'none' && + status.canRequestCancellation && + router.query.confirmed === '1' && + !memberCancellation; + + useEffect(() => { + if (loading || !router.isReady || !status || submittedResult) return; + if (status.status === 'pending' || isMemberView || isVerificationView) return; + void router.replace('/account/info'); + }, [isMemberView, isVerificationView, loading, router, status, submittedResult]); + + const onSubmitted = useCallback( + (result: Extract) => { + setSubmittedResult(result); + }, + [] + ); + + const onCancel = async () => { + if (submittedResult) { + setUserInfo(null); + await router.replace('/login?lastRoute=/account/cancel'); + return; + } + + setCanceling(true); + try { + await cancelAccountCancellation(); + toast({ + status: 'success', + title: t('account_info:account_cancellation_cancel_success', '已取消注销') + }); + await router.replace('/account/info'); + } catch { + toast({ + status: 'warning', + title: t('account_info:account_cancellation_cancel_error', '取消失败') + }); + } finally { + setCanceling(false); + } + }; + + const content = (() => { + if (loading || !status) { + return ; + } + if (submittedResult) { + return ( + void onCancel()} + loading={canceling} + /> + ); + } + if (isMemberView && memberCancellation) { + return ( + + ); + } + if (status.status === 'pending') { + return ( + void onCancel()} + loading={canceling} + /> + ); + } + if (isVerificationView) { + return ; + } + return ; + })(); + + return ( + void router.replace('/account/info')} + cardProps={ + loading || !status + ? { minH: '220px', alignItems: 'center', justifyContent: 'center' } + : undefined + } + > + {content} + + ); +}; + +export default CancelAccountPage; diff --git a/projects/app/src/pageComponents/account/cancel/CancelPendingPanel.tsx b/projects/app/src/pageComponents/account/cancel/CancelPendingPanel.tsx new file mode 100644 index 000000000000..7864466d4b7a --- /dev/null +++ b/projects/app/src/pageComponents/account/cancel/CancelPendingPanel.tsx @@ -0,0 +1,89 @@ +import { Button, Text, VStack } from '@chakra-ui/react'; +import { useTranslation } from 'next-i18next'; + +/** 展示本人注销等待期或 finalizing 状态,所有时间均直接使用 API 返回值。 */ +export const CancelPendingPanel = ({ + requestedAt, + scheduledCancelAt, + canCancel, + onCancel, + loading +}: { + requestedAt: string; + scheduledCancelAt?: string; + canCancel: boolean; + onCancel: () => void; + loading: boolean; +}) => { + const { t } = useTranslation(); + const formatDate = (value: string) => new Date(value).toLocaleString(); + + return ( + + + {t('account_info:account_cancellation_in_progress_title', '注销中')} + + + {canCancel ? ( + <> + + {t( + 'account_info:account_cancellation_pending_desc', + '你的账号已提交注销申请,目前处于 15 天注销等待期。' + )} + + + {t('account_info:account_cancellation_requested_at', '申请时间:{{time}}', { + time: formatDate(requestedAt) + })} + + {scheduledCancelAt && ( + + {t('account_info:account_cancellation_scheduled_at', '预计注销时间:{{time}}', { + time: formatDate(scheduledCancelAt) + })} + + )} + + {t( + 'account_info:account_cancellation_pending_service_desc', + '等待期内,该账号将无法正常使用,所有依赖该账号对外提供服务的渠道已停止生效。' + )} + + + {t( + 'account_info:account_cancellation_pending_cancel_desc', + '若这不是你本人操作,或你希望继续使用该账号,请在预计注销时间前取消注销。取消后,账号将恢复正常状态。' + )} + + + ) : ( + <> + + {t( + 'account_info:account_cancellation_finalizing_desc', + '你的账号已进入注销处理阶段,系统正在清理账号及相关数据。' + )} + + + {t('account_info:account_cancellation_requested_at', '申请时间:{{time}}', { + time: formatDate(requestedAt) + })} + + + {t( + 'account_info:account_cancellation_finalizing_no_estimate', + '该阶段无法取消注销,预计完成时间不再展示。' + )} + + + )} + + {canCancel && ( + + )} + + ); +}; diff --git a/projects/app/src/pageComponents/account/cancel/MemberPendingPanel.tsx b/projects/app/src/pageComponents/account/cancel/MemberPendingPanel.tsx new file mode 100644 index 000000000000..6c56e89cb184 --- /dev/null +++ b/projects/app/src/pageComponents/account/cancel/MemberPendingPanel.tsx @@ -0,0 +1,57 @@ +import { Box, Text, VStack } from '@chakra-ui/react'; +import type { TeamAccountCancellationStatus } from '@fastgpt/global/support/user/account/cancellation/type'; +import { useTranslation } from 'next-i18next'; +import TeamSelector from '@/pageComponents/account/TeamSelector'; + +/** 当前团队仍处于 owner 注销生命周期时,向成员提供说明和团队切换入口。 */ +export const MemberPendingPanel = ({ + teamName, + status, + scheduledCancelAt +}: { + teamName: string; + status: TeamAccountCancellationStatus; + scheduledCancelAt?: Date | string; +}) => { + const { t } = useTranslation(); + const isPending = status === 'pending'; + const scheduledTime = + isPending && scheduledCancelAt ? new Date(scheduledCancelAt).toLocaleString() : undefined; + + return ( + + + {t('account_info:account_cancellation_team_title', '团队注销中')} + + + + + {teamName}{' '} + + {isPending + ? t( + 'account_info:account_cancellation_team_pending_desc', + '团队已由团队所有者提交注销申请,目前处于 15 天注销等待期。您可联系团队所有者取消注销。' + ) + : t( + 'account_info:account_cancellation_team_finalizing_desc', + '团队已进入注销清理阶段。您可联系团队所有者了解处理进度。' + )} + + {scheduledTime && ( + + {t('account_info:account_cancellation_team_scheduled_at', '预计清理时间:{{time}}', { + time: scheduledTime + })} + + )} + + + + {t('account_info:account_cancellation_switch_team', '切换团队')} + + + + + ); +}; diff --git a/projects/app/src/pageComponents/account/cancel/VerificationPanel.tsx b/projects/app/src/pageComponents/account/cancel/VerificationPanel.tsx new file mode 100644 index 000000000000..a52eb21f859e --- /dev/null +++ b/projects/app/src/pageComponents/account/cancel/VerificationPanel.tsx @@ -0,0 +1,430 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + Box, + Button, + Center, + Image, + Input, + Spinner, + Text, + VStack, + useDisclosure +} from '@chakra-ui/react'; +import { useTranslation } from 'next-i18next'; +import { useRouter } from 'next/router'; +import type { + AccountVerificationMethod, + OAuthAccountVerificationProvider +} from '@fastgpt/global/support/user/account/verification/type'; +import { resolveAccountCancellationByUsername } from '@fastgpt/global/support/user/account/cancellation'; +import type { FastGPTFeConfigsType } from '@fastgpt/global/common/system/types'; +import type { + CreateAccountCancellationVerificationResponse, + SubmitAccountCancellationResponse +} from '@fastgpt/global/openapi/support/user/account/cancellation/api'; +import { useToast } from '@fastgpt/web/hooks/useToast'; +import SendCodeAuthModal from '@/components/support/user/safe/SendCodeAuthModal'; +import { getClientToken } from '@/web/support/user/hooks/useSendCode'; +import { + createAccountCancellationVerification, + submitAccountCancellation +} from '@/web/support/user/account/cancellation/api'; +import { useSystemStore } from '@/web/common/system/useSystemStore'; +import { useUserStore } from '@/web/support/user/useUserStore'; + +const getCapabilities = (feConfigs: FastGPTFeConfigsType) => ({ + ...(feConfigs.accountVerification?.accountCancellation ?? { + emailCode: feConfigs.login_method?.includes('email') ?? false, + phoneCode: feConfigs.login_method?.includes('phone') ?? false, + accountCancellation: feConfigs.accountCancellation?.enabled === true, + wechat: !!feConfigs.oauth?.wechat, + oauth: { + github: !!feConfigs.oauth?.github, + google: !!feConfigs.oauth?.google, + microsoft: !!feConfigs.oauth?.microsoft, + wecom: !!feConfigs.oauth?.wecom, + sso: !!feConfigs.sso?.url + } + }) +}); + +const isOAuthMethod = ( + method: AccountVerificationMethod +): method is Extract => method.startsWith('oauth/'); + +/** 按统一 resolver 只渲染一种非密码验证方式,并承接各方式的加载与失败状态。 */ +export const VerificationPanel = ({ + onSubmitted +}: { + onSubmitted: (result: Extract) => void; +}) => { + const { t } = useTranslation(); + const router = useRouter(); + const { toast } = useToast(); + const { feConfigs } = useSystemStore(); + const { userInfo } = useUserStore(); + const { isOpen: isCaptchaOpen, onOpen: onOpenCaptcha, onClose: onCloseCaptcha } = useDisclosure(); + const [code, setCode] = useState(''); + const [codeCountDown, setCodeCountDown] = useState(0); + const [hasSentCode, setHasSentCode] = useState(false); + const [codeSending, setCodeSending] = useState(false); + const [codeSubmitting, setCodeSubmitting] = useState(false); + const [wechatQR, setWechatQR] = + useState>(); + const [wechatNow, setWechatNow] = useState(() => Date.now()); + const [wechatCreating, setWechatCreating] = useState(false); + const [wechatLoadFailed, setWechatLoadFailed] = useState(false); + const [oauthSubmitting, setOauthSubmitting] = useState(false); + const wechatCreateRequested = useRef(false); + const wechatPolling = useRef(false); + + const username = userInfo?.username; + const method = useMemo(() => { + if (!username) return; + const result = resolveAccountCancellationByUsername({ + username, + capabilities: getCapabilities(feConfigs) + }); + return result.status === 'supported' ? result.method : undefined; + }, [feConfigs, username]); + + const wechatExpired = + !!wechatQR?.expiredAt && new Date(wechatQR.expiredAt).getTime() <= wechatNow; + + useEffect(() => { + if (codeCountDown <= 0) return; + const timer = window.setTimeout(() => setCodeCountDown(codeCountDown - 1), 1000); + return () => window.clearTimeout(timer); + }, [codeCountDown]); + + useEffect(() => { + if (!wechatQR?.expiredAt) return; + const timer = window.setInterval(() => setWechatNow(Date.now()), 1000); + return () => window.clearInterval(timer); + }, [wechatQR?.expiredAt]); + + const showVerificationFailure = useCallback(() => { + toast({ + status: 'error', + title: t('account_info:account_cancellation_verification_failed', '身份验证失败,请重试') + }); + }, [t, toast]); + + const createWechatVerification = useCallback(async () => { + if (method !== 'wechat') return; + setWechatCreating(true); + setWechatLoadFailed(false); + try { + const result = await createAccountCancellationVerification({ method, payload: {} }); + if (result.method !== 'wechat') return; + setWechatQR(result); + setWechatNow(Date.now()); + } catch { + setWechatLoadFailed(true); + showVerificationFailure(); + } finally { + setWechatCreating(false); + } + }, [method, showVerificationFailure]); + + useEffect(() => { + if (method !== 'wechat' || wechatCreateRequested.current) return; + wechatCreateRequested.current = true; + void createWechatVerification(); + }, [createWechatVerification, method]); + + useEffect(() => { + if (!wechatQR || wechatExpired) return; + let disposed = false; + + const pollVerification = async () => { + if (wechatPolling.current) return; + wechatPolling.current = true; + try { + const result = await submitAccountCancellation({ + method: 'wechat', + payload: { code: wechatQR.code } + }); + if (!disposed && result.status === 'pending') { + toast({ + status: 'success', + title: t('account_info:account_cancellation_verification_success', '身份验证成功') + }); + onSubmitted(result); + } + } catch { + // 未扫码和 Provider 短暂异常都可能落入轮询失败,二维码有效期内继续等待。 + } finally { + wechatPolling.current = false; + } + }; + + void pollVerification(); + const timer = window.setInterval(() => void pollVerification(), 2000); + return () => { + disposed = true; + window.clearInterval(timer); + }; + }, [onSubmitted, t, toast, wechatExpired, wechatQR]); + + const sendCode = async ({ captcha }: { username: string; captcha: string }) => { + if (method !== 'code') return; + setCodeSending(true); + try { + const googleToken = await getClientToken(feConfigs.googleClientVerKey); + const result = await createAccountCancellationVerification({ + method, + payload: { captcha, googleToken } + }); + if (result.method !== 'code') return; + setHasSentCode(true); + setCodeCountDown(60); + toast({ + status: 'success', + title: t('account_info:account_cancellation_code_sent', '验证码已发送') + }); + } catch { + toast({ + status: 'error', + title: t('account_info:account_cancellation_code_send_failed', '验证码发送失败,请重试') + }); + } finally { + setCodeSending(false); + } + }; + + const submitCode = async () => { + if (method !== 'code' || !code.trim()) return; + setCodeSubmitting(true); + try { + const result = await submitAccountCancellation({ method, payload: { code: code.trim() } }); + if (result.status !== 'pending') return; + toast({ + status: 'success', + title: t('account_info:account_cancellation_verification_success', '身份验证成功') + }); + onSubmitted(result); + } catch { + showVerificationFailure(); + } finally { + setCodeSubmitting(false); + } + }; + + const submitOAuth = async () => { + if (!method || !isOAuthMethod(method)) return; + setOauthSubmitting(true); + try { + const callbackUrl = `${window.location.origin}/login/provider`; + const result = await createAccountCancellationVerification({ + method, + payload: { callbackUrl } + }); + if (result.method !== method) return; + const provider = method.slice('oauth/'.length) as OAuthAccountVerificationProvider; + useSystemStore.getState().setLoginStore({ + provider, + lastRoute: '/account/cancel?confirmed=1', + state: result.state, + callbackUrl, + flow: 'accountCancellation' + }); + await router.replace(result.url); + } catch { + setOauthSubmitting(false); + showVerificationFailure(); + } + }; + + if (!method || !username) { + return ( + + + {t('account_info:account_cancellation_title', '注销账号')} + + + {t( + 'account_info:account_cancellation_unavailable_desc', + '当前账号没有可用的非密码验证方式。' + )} + + + ); + } + + const oauthProvider = isOAuthMethod(method) + ? method.slice('oauth/'.length).toLowerCase() + : undefined; + const oauthProviderLabel = (() => { + if (oauthProvider === 'github') return 'GitHub'; + if (oauthProvider === 'google') return 'Google'; + if (oauthProvider === 'microsoft') return 'Microsoft'; + if (oauthProvider === 'wecom') return 'WeCom'; + if (oauthProvider === 'sso') return feConfigs.sso?.title ?? 'SSO'; + return ''; + })(); + + return ( + + + {t('account_info:account_cancellation_title', '注销账号')} + + + {method === 'code' && ( + + + + setCode(event.target.value)} + placeholder={t('user:password.verification_code', '验证码')} + aria-label={t('user:password.verification_code', '验证码')} + onKeyDown={(event) => { + if (event.key === 'Enter') void submitCode(); + }} + /> + + + + {isCaptchaOpen && ( + + )} + + )} + + {method === 'wechat' && ( + + + {t('account_info:account_cancellation_wechat_scan', '微信扫码登录')} + +
+ {wechatCreating ? ( + + ) : wechatQR && !wechatExpired ? ( + {t('account_info:account_cancellation_wechat_qr', + ) : ( + + + {wechatLoadFailed + ? t( + 'account_info:account_cancellation_wechat_load_failed', + '二维码加载失败,请重试。' + ) + : t( + 'account_info:account_cancellation_wechat_expired', + '二维码已过期,请重新获取。' + )} + + + + )} +
+
+ )} + + {isOAuthMethod(method) && ( + + + + + )} +
+ ); +}; diff --git a/projects/app/src/pages/account/cancel/index.tsx b/projects/app/src/pages/account/cancel/index.tsx new file mode 100644 index 000000000000..74580acbb72f --- /dev/null +++ b/projects/app/src/pages/account/cancel/index.tsx @@ -0,0 +1,16 @@ +import dynamic from 'next/dynamic'; +import { serviceSideProps } from '@/web/common/i18n/utils'; + +const CancelAccountPage = dynamic( + () => import('@/pageComponents/account/cancel/CancelAccountPage') +); + +export async function getServerSideProps(context: any) { + return { + props: { + ...(await serviceSideProps(context, ['account', 'account_info', 'user'])) + } + }; +} + +export default CancelAccountPage; diff --git a/projects/app/src/pages/account/info/index.tsx b/projects/app/src/pages/account/info/index.tsx index 248860d2a201..36b0cc8cdc06 100644 --- a/projects/app/src/pages/account/info/index.tsx +++ b/projects/app/src/pages/account/info/index.tsx @@ -48,6 +48,8 @@ import { getUploadAvatarPresignedUrl } from '@/web/common/file/api'; import { TeamErrEnum } from '@fastgpt/global/common/error/code/team'; import { i18nT } from '@fastgpt/global/common/i18n/utils'; import { getIsMemberSyncMode } from '@/web/common/system/utils'; +import { getAccountCancellationStatus } from '@/web/support/user/account/cancellation/api'; +import { AccountCancellationConfirmModal } from '@/pageComponents/account/cancel/AccountCancellationConfirmModal'; const RedeemCouponModal = dynamic(() => import('@/pageComponents/account/info/RedeemCouponModal'), { ssr: false @@ -89,9 +91,9 @@ const Info = () => { return ( - + {isPc ? ( - + @@ -99,7 +101,7 @@ const Info = () => { {!!standardPlan && ( - + )} @@ -783,22 +785,33 @@ const PlanUsage = () => { const ButtonStyles = { bg: 'white', - py: 3, px: 6, - border: 'sm', + h: '40px', borderWidth: '1.5px', + borderColor: 'borderColor.low', borderRadius: 'md', display: 'flex', alignItems: 'center', + gap: 2, cursor: 'pointer', userSelect: 'none' as any, fontSize: 'sm' }; const Other = ({ onOpenContact }: { onOpenContact: () => void }) => { const { feConfigs, setNotSufficientModalType, subPlans } = useSystemStore(); - const { teamPlanStatus } = useUserStore(); + const { teamPlanStatus, userInfo } = useUserStore(); const { t } = useTranslation(); const { isPc } = useSystem(); + const router = useRouter(); + const { + isOpen: isCancellationConfirmOpen, + onOpen: onOpenCancellationConfirm, + onClose: onCloseCancellationConfirm + } = useDisclosure(); + const { data: accountCancellationStatus } = useRequest(getAccountCancellationStatus, { + manual: false, + refreshDeps: [userInfo?._id] + }); const { runAsync: onFeedback } = useRequest( async () => { @@ -826,7 +839,7 @@ const Other = ({ onOpenContact }: { onOpenContact: () => void }) => { return ( - + {feConfigs?.docUrl && ( void }) => { textDecoration={'none !important'} {...ButtonStyles} > - - - {t('account_info:help_document')} - + + {t('account_info:help_document')} )} @@ -846,29 +863,71 @@ const Other = ({ onOpenContact }: { onOpenContact: () => void }) => { ?.filter((item) => item.isActive) .map((item) => ( window.open(item.url, '_blank')}> - - - {item.name} - + + {item.name} ))} {feConfigs?.concatMd && ( - - - {t('account_info:contact_us')} - + + {t('account_info:contact_us')} )} {feConfigs?.show_workorder && ( - - - {t('common:question_feedback')} - + + {t('common:question_feedback')} + + )} + {(accountCancellationStatus?.status === 'pending' || + (accountCancellationStatus?.status === 'none' && + accountCancellationStatus.canRequestCancellation)) && ( + { + if (accountCancellationStatus.status === 'pending') { + void router.push('/account/cancel'); + return; + } + onOpenCancellationConfirm(); + }} + > + + {t('account_info:account_cancellation', '账号注销')} )} + {accountCancellationStatus?.status === 'none' && + accountCancellationStatus.canRequestCancellation && ( + { + onCloseCancellationConfirm(); + void router.push('/account/cancel?confirmed=1'); + }} + /> + )} ); }; diff --git a/projects/app/src/pages/api/support/user/account/tokenLogin.ts b/projects/app/src/pages/api/support/user/account/tokenLogin.ts index 993b6fe118bb..74039db56bf8 100644 --- a/projects/app/src/pages/api/support/user/account/tokenLogin.ts +++ b/projects/app/src/pages/api/support/user/account/tokenLogin.ts @@ -6,7 +6,11 @@ import { pushTrack } from '@fastgpt/service/common/middle/tracks/utils'; import type { UserType } from '@fastgpt/global/support/user/type'; async function handler(req: ApiRequestProps): Promise { - const { tmbId, userId, teamId, isRoot } = await authCert({ req, authToken: true }); + const { tmbId, userId, teamId, isRoot } = await authCert({ + req, + authToken: true, + accountCancellationAccess: 'tokenLogin' + }); const user = await getUserDetail({ tmbId, isRoot }); pushTrack.dailyUserActive({ diff --git a/projects/app/src/pages/login/provider.tsx b/projects/app/src/pages/login/provider.tsx index e6fc38644e0d..80a8d38cd75c 100644 --- a/projects/app/src/pages/login/provider.tsx +++ b/projects/app/src/pages/login/provider.tsx @@ -4,6 +4,7 @@ import { useSystemStore } from '@/web/common/system/useSystemStore'; import { useUserStore } from '@/web/support/user/useUserStore'; import { clearToken } from '@/web/support/user/auth'; import { oauthLogin } from '@/web/support/user/api'; +import { submitAccountCancellation } from '@/web/support/user/account/cancellation/api'; import { useToast } from '@fastgpt/web/hooks/useToast'; import Loading from '@fastgpt/web/components/common/MyLoading'; import { serviceSideProps } from '@/web/common/i18n/utils'; @@ -47,7 +48,12 @@ const provider = () => { ? validateRedirectUrl(loginStore.lastRoute) : '/dashboard/agent'; const lastTmbId = loginStore?.lastTmbId || ''; - const errorRedirectPage = lastRoute.startsWith('/chat') ? lastRoute : '/login'; + const errorRedirectPage = + loginStore?.flow === 'accountCancellation' + ? '/account/cancel?confirmed=1' + : lastRoute.startsWith('/chat') + ? lastRoute + : '/login'; const loginSuccess = useCallback( async (res: LoginSuccessResponseType) => { @@ -97,6 +103,22 @@ const provider = () => { }) => { if (!loginStore) return; try { + if (loginStore.flow === 'accountCancellation') { + await submitAccountCancellation({ + method: `oauth/${callback.provider}` as any, + payload: { + callbackUrl: loginStore.callbackUrl, + code: callback.code, + ...(callback.state !== undefined ? { state: callback.state } : {}), + props + } + }); + setUserInfo(null); + setLoginStore(undefined); + router.replace('/login?lastRoute=/account/cancel'); + return; + } + const res = await oauthLogin({ ...callback, props, @@ -132,7 +154,17 @@ const provider = () => { setLoginStore(undefined); } }, - [errorRedirectPage, i18n.language, loginStore, loginSuccess, router, setLoginStore, t, toast] + [ + errorRedirectPage, + i18n.language, + loginStore, + loginSuccess, + router, + setLoginStore, + setUserInfo, + t, + toast + ] ); useEffect(() => { @@ -171,7 +203,9 @@ const provider = () => { return; } - await retryFn(async () => clearToken()); + if (loginStore?.flow !== 'accountCancellation') { + await retryFn(async () => clearToken()); + } router.prefetch('/dashboard/agent'); await completeOauthLogin({ callback, props: callbackProps }); })(); diff --git a/projects/app/src/service/support/mcp/utils.ts b/projects/app/src/service/support/mcp/utils.ts index 613714fae597..e3f9638f8ab5 100644 --- a/projects/app/src/service/support/mcp/utils.ts +++ b/projects/app/src/service/support/mcp/utils.ts @@ -42,6 +42,22 @@ import { preChatRound } from '@fastgpt/service/core/chat/utils/prepare'; import { UsageSourceEnum } from '@fastgpt/global/support/wallet/usage/constants'; import { removeDatasetCiteText } from '@fastgpt/global/core/ai/llm/utils'; import { getRuntimeNodeResponseSummary } from '@fastgpt/service/core/workflow/dispatch/utils'; +import { assertAccountUsable } from '@fastgpt/service/support/user/account/cancellation/guard'; +import { MongoTeamMember } from '@fastgpt/service/support/user/team/teamMemberSchema'; + +const assertMcpTeamUsable = async (mcp: { teamId?: string; tmbId?: string }) => { + if (!mcp.teamId || !mcp.tmbId) return; + const member = await MongoTeamMember.findOne( + { _id: mcp.tmbId, teamId: mcp.teamId, status: 'active' }, + { userId: 1 } + ).lean(); + if (!member) throw new Error('MCP team member is no longer active'); + await assertAccountUsable({ + userId: String(member.userId), + teamId: String(mcp.teamId), + tmbId: String(mcp.tmbId) + }); +}; const stringifyMcpPluginOutput = (pluginOutput: unknown) => { if (pluginOutput === undefined || pluginOutput === null) { @@ -133,10 +149,11 @@ export const workflow2InputSchema = (chatConfig?: { * 不再因为创建人的应用权限后续变化而隐藏工具,避免已发布集成被普通权限调整意外中断。 */ export const getMcpServerTools = async (key: string): Promise => { - const mcp = await MongoMcpKey.findOne({ key }, { apps: 1 }).lean(); + const mcp = await MongoMcpKey.findOne({ key }, { apps: 1, teamId: 1, tmbId: 1 }).lean(); if (!mcp) { return Promise.reject(CommonErrEnum.invalidResource); } + await assertMcpTeamUsable(mcp); // Get app list const appList = await MongoApp.find( @@ -350,11 +367,12 @@ export const callMcpServerTool = async ({ key, toolName, inputs }: toolCallProps } }; - const mcp = await MongoMcpKey.findOne({ key }, { apps: 1 }).lean(); + const mcp = await MongoMcpKey.findOne({ key }, { apps: 1, teamId: 1, tmbId: 1 }).lean(); if (!mcp) { return Promise.reject(CommonErrEnum.invalidResource); } + await assertMcpTeamUsable(mcp); // Get app list const appList = await MongoApp.find({ diff --git a/projects/app/src/web/common/system/useSystemStore.ts b/projects/app/src/web/common/system/useSystemStore.ts index 033942a26acb..c895859ec72f 100644 --- a/projects/app/src/web/common/system/useSystemStore.ts +++ b/projects/app/src/web/common/system/useSystemStore.ts @@ -28,6 +28,7 @@ type LoginStoreType = { state: string; callbackUrl: string; lastTmbId?: string; + flow?: 'login' | 'accountCancellation'; }; export type NotSufficientModalType = diff --git a/projects/app/src/web/support/user/account/cancellation/api.ts b/projects/app/src/web/support/user/account/cancellation/api.ts new file mode 100644 index 000000000000..554b811a320f --- /dev/null +++ b/projects/app/src/web/support/user/account/cancellation/api.ts @@ -0,0 +1,29 @@ +import { DELETE, GET, POST } from '@/web/common/api/request'; +import type { + AccountCancellationStatusResponse, + CreateAccountCancellationVerificationBody, + CreateAccountCancellationVerificationResponse, + SubmitAccountCancellationBody, + SubmitAccountCancellationResponse +} from '@fastgpt/global/openapi/support/user/account/cancellation/api'; + +export const getAccountCancellationStatus = () => + GET( + '/proApi/support/user/account/cancellation/status', + {}, + { maxQuantity: 1 } + ); + +export const createAccountCancellationVerification = ( + body: CreateAccountCancellationVerificationBody +) => + POST( + '/proApi/support/user/account/cancellation/verification/create', + body + ); + +export const submitAccountCancellation = (body: SubmitAccountCancellationBody) => + POST('/proApi/support/user/account/cancellation/submit', body); + +export const cancelAccountCancellation = () => + DELETE('/proApi/support/user/account/cancellation/cancel'); From f387255c2554839cb9691e56de56938661e9bbd8 Mon Sep 17 00:00:00 2001 From: shortlight5980 Date: Mon, 20 Jul 2026 17:49:58 +0800 Subject: [PATCH 06/10] fix unsubscribe  Conflicts:  packages/service/support/user/audit/schema.ts  packages/service/support/user/auth/schema.ts --- .../account- verification.md | 70 +++++ .../login-register-find-password.md | 99 ------- packages/global/common/error/code/user.ts | 23 +- .../global/common/middle/tracks/constants.ts | 3 + .../openapi/admin/support/user/audit/api.ts | 31 +++ .../openapi/admin/support/user/audit/index.ts | 22 ++ .../openapi/admin/support/user/index.ts | 4 +- .../user/account/cancellation/index.ts | 17 ++ .../support/user/account/login/index.ts | 9 + .../support/user/account/password/index.ts | 17 ++ .../support/user/account/register/index.ts | 17 ++ .../user/account/verification/index.ts | 17 ++ .../global/openapi/support/user/audit/api.ts | 28 ++ .../openapi/support/user/audit/index.ts | 22 ++ packages/global/openapi/support/user/index.ts | 4 +- .../user/account/cancellation/utils.ts | 87 +++++- .../global/support/user/audit/constants.ts | 4 + packages/global/support/user/audit/type.ts | 21 +- packages/global/support/user/auth/type.ts | 10 - .../global/test/common/error/utils.test.ts | 14 + .../admin/support/user/audit/api.test.ts | 31 +++ .../openapi/support/user/audit/api.test.ts | 28 ++ .../user/account/cancellation/utils.test.ts | 77 ++++++ packages/service/common/bullmq/index.ts | 85 +++++- packages/service/common/http/entry.ts | 11 +- .../service/common/middle/tracks/utils.ts | 52 ++++ packages/service/common/response/index.ts | 11 +- packages/service/common/system/cron.ts | 6 +- .../common/system/timerLock/constants.ts | 2 + .../service/core/app/evaluation/delete.ts | 19 ++ .../user/account/cancellation/access.ts | 12 +- .../user/account/cancellation/guard.ts | 10 +- .../user/account/cancellation/service.ts | 59 +--- .../account/verification/password/service.ts | 3 +- .../user/account/verification/schema.ts | 1 + .../user/account/verification/utils.ts | 4 +- packages/service/support/user/audit/schema.ts | 4 +- .../service/support/user/auth/controller.ts | 21 +- packages/service/support/user/auth/schema.ts | 40 --- packages/service/support/user/controller.ts | 14 +- .../service/support/user/team/delete/index.ts | 13 +- .../support/user/team/delete/processor.ts | 32 ++- .../service/support/user/team/fallback.ts | 22 +- .../service/test/common/bullmq/index.test.ts | 256 ++++++++++++++++++ .../service/test/common/http/entry.test.ts | 129 +++++++++ .../test/common/response/index.test.ts | 70 ++++- .../service/test/common/system/cron.test.ts | 30 ++ .../test/core/app/evaluation/delete.test.ts | 66 +++++ .../support/outLink/runtime/utils.test.ts | 4 + .../user/account/cancellation/access.test.ts | 34 +++ .../user/account/cancellation/guard.test.ts | 50 ++++ .../user/account/verification/entity.test.ts | 33 +++ .../verification/password/service.test.ts | 2 +- .../user/account/verification/utils.test.ts | 4 +- .../test/support/user/auth/controller.test.ts | 6 +- .../support/user/team/delete/index.test.ts | 36 +++ .../user/team/delete/processor.test.ts | 155 +++++++++++ packages/web/i18n/en/account_info.json | 1 + packages/web/i18n/en/account_team.json | 8 + packages/web/i18n/en/common.json | 1 + packages/web/i18n/zh-CN/account_info.json | 1 + packages/web/i18n/zh-CN/account_team.json | 8 + packages/web/i18n/zh-CN/common.json | 1 + packages/web/i18n/zh-Hant/account_info.json | 1 + packages/web/i18n/zh-Hant/account_team.json | 8 + packages/web/i18n/zh-Hant/common.json | 1 + packages/web/support/user/audit/constants.ts | 58 ++++ .../AccountCancellationConfirmModal.tsx | 213 ++++++++------- .../account/cancel/CancelAccountPage.tsx | 64 ++--- .../account/cancel/VerificationPanel.tsx | 107 ++++---- .../pageComponents/account/cancel/utils.ts | 27 ++ projects/app/src/pages/account/info/index.tsx | 8 +- projects/app/src/pages/login/provider.tsx | 34 ++- .../src/service/support/user/login/service.ts | 3 +- .../api/support/user/account/preLogin.test.ts | 23 +- .../account/cancel/utils.test.ts | 61 +++++ .../support/user/login/service.test.ts | 45 +++ test/mocks/common/redis.ts | 8 +- 78 files changed, 2124 insertions(+), 508 deletions(-) delete mode 100644 .agents/design/account-verification/login-register-find-password.md create mode 100644 packages/global/openapi/admin/support/user/audit/api.ts create mode 100644 packages/global/openapi/admin/support/user/audit/index.ts create mode 100644 packages/global/openapi/support/user/audit/api.ts create mode 100644 packages/global/openapi/support/user/audit/index.ts delete mode 100644 packages/global/support/user/auth/type.ts create mode 100644 packages/global/test/openapi/admin/support/user/audit/api.test.ts create mode 100644 packages/global/test/openapi/support/user/audit/api.test.ts create mode 100644 packages/service/core/app/evaluation/delete.ts delete mode 100644 packages/service/support/user/auth/schema.ts create mode 100644 packages/service/test/common/bullmq/index.test.ts create mode 100644 packages/service/test/common/http/entry.test.ts create mode 100644 packages/service/test/common/system/cron.test.ts create mode 100644 packages/service/test/core/app/evaluation/delete.test.ts create mode 100644 packages/service/test/support/user/account/cancellation/access.test.ts create mode 100644 packages/service/test/support/user/account/cancellation/guard.test.ts create mode 100644 packages/service/test/support/user/team/delete/index.test.ts create mode 100644 packages/service/test/support/user/team/delete/processor.test.ts create mode 100644 projects/app/src/pageComponents/account/cancel/utils.ts create mode 100644 projects/app/test/pageComponents/account/cancel/utils.test.ts diff --git a/.agents/design/account-verification/account- verification.md b/.agents/design/account-verification/account- verification.md index 0fca6bdd5b32..80ddf9a0027c 100644 --- a/.agents/design/account-verification/account- verification.md +++ b/.agents/design/account-verification/account- verification.md @@ -1625,3 +1625,73 @@ flowchart TD | D-05 | 定向测试、各 workspace 测试、App/Admin typecheck、lint、`pnpm test` 和 `git diff --check` 全部通过 | CI/本地命令输出 | | D-06 | 全部 Mermaid 图由 8.8.3 解析通过,OpenAPI 和运维说明与最终实现一致 | Mermaid 校验输出、文档 diff | | D-07 | 灰度指标无异常,应用回滚演练成功,未产生不可恢复的短期材料或 Session 行为 | 监控截图、演练记录 | + +## 14. 账号注销审查问题修复 + +状态:已确认实施范围
+日期:2026-07-20 + +### 14.1 问题与约束 + +本轮只修复账号验证与注销实现中的审查问题,不改变注销等待期、提醒窗口、验证方式和对外 API: + +1. 团队审计与管理员审计继续共用 `operationLogs` 集合,但前端/API 列表类型和请求校验必须按事件域拆分;团队接口只能接收、查询 `AuditEventEnum`,管理员接口只能接收、查询 `AdminAuditEventEnum`。两个列表接口都必须完整支持公共分页 schema 声明的 pageNum/offset 语义。 +2. `auth_codes` 只能由 `account/verification/schema.ts` 注册一次。旧 `auth/controller` 继续作为兼容业务入口,但必须使用统一 model,不能保留第二份 schema。 +3. API Key 鉴权没有 Session `uid` 时,注销 guard 必须通过 `tmbId` 解析真实用户,不能把团队 owner 当成当前用户。最终注销还必须删除该用户全部成员身份创建的 API Key,并把残留 API Key 纳入完成条件。 +4. BullMQ 稳定 `jobId` 的幂等语义只覆盖未结束任务。发现同 ID 的 `failed` job 时,生产者必须在按 queue/jobId 隔离的 Redis 租约内刷新数据并重试;等待、延迟或执行中的 job 仍直接复用。 +5. `createPendingAccountCancellation`、`claimAccountCancellationForFinalizing`、`getActiveAccountCancellationStatuses` 及 Pro 中两个无引用 alias 没有明确调用方,直接移除,缩小导出面。 +6. 提醒消息已有 `sendInform2OneUser` 的 24 小时投递锁,本轮不增加逐用户状态。只为提醒扫描和最终清理扫描分别增加 Cron 任务级锁,且不得复用套餐提醒的 `TimerIdEnum.notification`。 +7. 注销记录与团队审计日志使用不同 MongoClient。主库状态提交后,日志库故障不能反向改变 submit、cancel 或 finalize 的业务结果;三类成功审计均按 best-effort 记录 warning,submit 的后续通知和 Session 清理继续执行。 + +### 14.2 开发设计 + +#### 审计类型 + +共享类型提供 `TeamAuditEvent`、`AdminAuditEvent`、通用 `AuditSchemaType`,以及分别收窄事件域的 `TeamAuditListItemType`、`AdminAuditListItemType`。Mongo model 使用通用联合事件类型;团队和管理员 API 分别使用 OpenAPI Zod schema 与 `parseApiInput` 拒绝异域事件,并始终通过本域枚举 `$in` 白名单查询,再映射为对应列表类型。两个处理器都通过 `parsePaginationRequest` 消费已校验 body,避免 schema 接受 offset 后仍按第一页查询。 + +#### `auth_codes` 单一 model + +删除旧 `support/user/auth/schema.ts`,把兼容 controller 和测试改为导入 `MongoAccountVerificationMaterial`。兼容写入口显式刷新 `createTime` 和 `expiredTime`,避免依赖旧 schema 默认值。统一 schema 的 type enum 覆盖全部旧值和 `accountCancellation`,并保留 `purpose`、`userIdHash`、`provider` 等绑定字段。 + +#### API Key 注销边界 + +`assertAccountUsable` 在缺少 `userId` 或 `teamId` 任一信息时按 `tmbId` 读取成员记录,从而在 API Key 路径用真实成员 `userId` 查询注销状态。最终清理先收集用户全部成员 ID 并删除对应 `MongoOpenApi` 记录,再清理活跃非 owner 关系;残留检查按用户仍存在的成员 ID 统计 API Key,非零时不得把注销标记为 completed。 + +#### BullMQ 重投 + +共享 BullMQ 模块增加一个小型 helper: + +- 不存在同 ID job:正常 `queue.add`。 +- 同 ID job 为 `failed`:取得按 queue/jobId 隔离的 Redis 租约,重新确认状态后执行 `updateData` 与 `retry`。 +- 其它状态:返回现有 job,不制造并发重复任务。 + +账号 finalizer 和 team-delete 两个生产者统一调用该 helper,并保持现有 attempts/backoff/retention 配置。原 failed job 在刷新和重试期间始终保留,进程中断或 Redis 错误不会形成 remove/add 丢任务窗口。租约竞争显式向调用方报错并由现有重试/下一轮扫描补偿,不能让后来的生产者覆盖已经进入 waiting/active 的任务数据,也不能把仍为 failed 的任务误报为已入队。由于 `getJob` 与 `getState` 不是同一 Redis 操作,`unknown` 状态必须重新读取:确认 retention cleanup 已删除 job 后才重新 `add`;若 job 仍存在但状态无法确认则向上报错,不能返回失效 Job 假装已入队。 + +#### 注销生命周期审计 + +注销生命周期记录先在主库提交,随后向独立日志库写团队审计。submit 审计失败时记录不含验证材料的 warning,并继续团队通知与 Session 清理;cancel 已删除 pending、finalize 已标记 completed 后,审计失败也只记录 warning。三条路径都不能把已经持久化的成功状态伪装成整体失败,也不依赖客户端或队列重试补偿无法再生成的审计。 + +#### Cron 锁 + +增加 `TimerIdEnum.accountCancellationReminder` 和 `TimerIdEnum.accountCancellationFinalize`。Pro Cron 在调用业务扫描前分别申请锁;未取得锁时静默跳过,业务异常继续使用现有结构化错误日志。锁时长覆盖单次日任务执行窗口,但不跨越下一次计划执行。最终清理扫描保持串行投递,但每条记录单独捕获 enqueue 错误并继续后续记录,避免单个 Redis/租约错误把无关账号统一延迟到下一轮 Cron。 + +### 14.3 回归用例 + +1. App typecheck 能证明团队审计事件可安全索引 `auditLogMap`;Admin typecheck 能证明管理员列表使用独立类型;请求 schema 负例证明 Team/Admin 事件不能跨域传入,Admin API 的 offset 用例证明公共分页合约被实际执行。 +2. 先加载兼容 auth controller 后,统一 model 仍能保存和读取注销材料的扩展字段,且 `accountCancellation` 不触发 enum 错误。 +3. API Key 同时提供 `teamId/tmbId` 且 `uid` 为空时,guard 使用成员 `userId` 并阻断 pending/finalizing 用户。 +4. 普通成员最终注销会删除其所有 `tmbId` 对应 API Key;仍有 API Key 时 residual check 返回未完成。 +5. finalizer 和 team-delete 遇到 failed job 会在同一 queue/jobId 的租约内调用 `updateData` 与 `retry`;并发生产者不能覆盖已恢复任务的数据,等待/执行中的同 ID job 不重投;`getState` 返回 unknown 且复查已删除时会重建任务,仍无法确认时不会误报成功。 +6. 两个注销 Cron 分别使用独立 timer ID,未取得锁时不扫描;套餐通知锁不受影响;最终清理首条投递失败时后续到期记录仍继续入队。 +7. 主库已提交后日志库写入失败,submit 仍继续发送团队通知、清理 Session 并返回 pending;cancel 返回成功,finalize 返回 completed 且队列任务不失败。 + +### 14.4 TODO + +- [x] 拆分 Team/Admin 审计事件和列表类型,更新 App/Admin 调用方。 +- [x] 删除重复 `auth_codes` schema,让兼容 controller 使用统一 model 并补回归测试。 +- [x] 修复 API Key 用户解析,清理普通成员 API Key,并扩展 residual check。 +- [x] 增加 BullMQ failed stable-job 重投 helper,接入 finalizer 与 team-delete 并补测试。 +- [x] 增加两个独立 `TimerIdEnum`,为提醒和最终清理 Cron 加任务锁。 +- [x] 删除五个无引用函数/alias,扫描确认没有调用方。 +- [x] 运行定向测试、App/Admin typecheck、全量测试和 `git diff --check`。 +- [x] 使用干净上下文 subagent 执行 `local-pr-review`,忽略微信扫码地址问题并处理其余有效发现。 diff --git a/.agents/design/account-verification/login-register-find-password.md b/.agents/design/account-verification/login-register-find-password.md deleted file mode 100644 index 75dafd50cdc7..000000000000 --- a/.agents/design/account-verification/login-register-find-password.md +++ /dev/null @@ -1,99 +0,0 @@ -# 身份验证组件首轮接入开发文档 - -状态:已完成(本轮范围)
-上游方案:`/Users/sealos/Desktop/docs/账号注销/身份验证组件技术方案.md`
-范围:登录、注册、找回密码 - -## 1. 本轮目标 - -按上游方案把现有认证代码拆成“验证材料 create/consume、可信身份、业务编排”三层,并在不改变公开成功响应的前提下接入: - -- 密码登录; -- 微信扫码登录; -- GitHub、Google、Microsoft、Wecom、SSO 登录; -- 邮箱或手机号验证码注册; -- 邮箱或手机号验证码找回密码。 - -## 2. 明确不做 - -- 修改密码、过期密码重置; -- 用户或团队联系方式绑定; -- 账号注销及其它敏感业务验证分派; -- fastLogin 下线; -- `{ key, type }` 唯一索引上线和生产数据清理; -- `pro/sso` 协议、进程级回调缓存、多实例行为和 PKCE 等专项安全升级。 - -未纳入范围的旧调用方继续使用旧入口。只有全部旧调用方迁移完成后,后续需求才能删除旧 `support/user/auth` 路径;迁移期间旧验证码消费入口仍必须复用统一的 Redis 提交频控,不能保留无限尝试路径。 - -## 3. 兼容约束 - -1. `auth_codes` collection、已有 type 值和公开路由保持不变。 -2. 登录成功响应仍为 `{ user, token }`,Cookie、Session、推广转化和登录埋点保持原成功时序。 -3. 注册和找回密码的请求及成功响应保持不变。 -4. 迁移后的材料读取必须显式检查 `expiredTime > now`,消费使用 `findOneAndDelete`。 -5. 普通验证码采用 upsert,使同一账号和 scene 只有最新验证码有效;本轮不直接创建唯一索引。 -6. 验证组件不创建用户、团队、Session、Cookie,也不写业务埋点。 -7. API 边界统一使用 global Zod schema 和 `parseApiInput`;Provider 响应在内部用普通 schema 解析。 -8. 主仓和 `pro` 子模块配套改动;保留用户已移动的子模块基线,不回退指针。 -9. 微信 callback token 沿用既有源码常量 `WX_AUTH_TOKEN`,后台只配置 AppID 和 AppSecret,不新增 token 配置入口。 -10. Pro 始终向 SSO 传 state;SSO 回调带 state 时完整校验,完全无 state 时按旧协议 code-only;非 SSO Provider 缺 state 时拒绝。 -11. 不增加 OAuth/SSO 版本 capability 或其它兼容开关,不要求 SSO 响应声明能力,也不修改 `pro/sso`。 -12. 短信/邮件验证码提交在读取材料前按 Redis `account + scene` 累加 60 秒固定窗口计数;前 10 次允许,第 11 次起返回“验证过于频繁,请稍后再试”。统一 `CodeAccountVerification.consume` 与迁移期旧 `authCode` 使用同一策略。 - -## 4. 目标调用关系 - -```text -API - -> AccountVerification.create/consume - -> Local/External/Contact identity - -> 登录、注册或找回密码应用编排 - -> Session/Cookie/track -``` - -材料层位于 `packages/service/support/user/account/verification/`,前后端共享契约位于 `packages/global/support/user/account/verification/`。Pro Provider 实现位于 `pro/admin/src/service/support/user/account/verification/`。 - -## 5. 发布策略 - -- 先提交并验证 Pro 代码,再更新主仓共享包、App 和子模块指针。 -- 主应用与 Pro Admin 同步发布统一 OAuth create/consume schema,不增加前端 capability 门控。 -- Admin 调用 SSO 获取授权地址时始终传入服务端生成的 state,但不要求旧 SSO 必须返回。 -- SSO 回调带 state 时执行完整校验;错误、过期或已消费时拒绝。回调完全无 state 时仅 SSO code-only;所有直连 Provider 缺 state 时拒绝。 -- 前端仍要求 loginStore、非空 code 和相同 callback URL;仅 SSO 可缺 state,state 存在时必须与 loginStore.state 相同。 -- 唯一索引必须在生产重复数据 dry-run/清理后单独发布,本轮 schema 只保留非唯一索引。 - -## 6. TODO - -- [x] 阅读上游技术方案、仓库规范并盘点主仓/Pro 现有调用方。 -- [x] 增加共享 method、capabilities、identity scene schema 与 username resolver。 -- [x] 增加 verification material schema/entity/service,覆盖显式过期与原子消费。 -- [x] 实现 PasswordAccountVerification 和 loginLocalAccount,迁移密码登录 API。 -- [x] 实现 CaptchaChallengeService、CodeAccountVerification,迁移发送验证码、注册和找回密码 API。 -- [x] 实现 WechatAccountVerification、OAuth 基类与 Provider adapter。 -- [x] 实现 loginExternalAccount,迁移微信/OAuth 登录 API。 -- [x] 前端 OAuth 改为服务端 create state;直连 Provider callback 必填 state,旧 SSO callback 可完全省略 state。 -- [x] 补 resolver、材料、密码、验证码、Provider 和 API 定向测试。 -- [x] 为统一验证码 consume 和旧 `authCode` 增加 Redis `account + scene` 提交频控,覆盖 1 分钟 10 次边界与场景隔离。 -- [x] 运行 Global/Service/App/Admin 定向测试与 App/Admin typecheck。 -- [x] 最终运行 lint、仓库全量测试和 `git diff --check`。 - -## 7. 验收重点 - -- 验证材料过期后即使 TTL 尚未清理也不能消费;并发消费最多一个成功。 -- 密码错误和用户不存在保持统一错误;Wecom 仍不能通过密码登录创建 Session。 -- 注册与找回密码验证码 scene 不可串用,重发后旧码失效。 -- 验证码提交按账号与 scene 独立计数,前 10 次进入校验,第 11 次起返回频控错误;旧绑定入口不能绕过该限制。 -- 微信同一 scene 最多创建一个登录 Session。 -- OAuth state 短期、一次性,并绑定 Provider 与 callback;第三方 token/secret 不进入日志或响应。 -- 外部登录拒绝 forbidden 用户,同时保持既有用户创建、团队和联系方式行为。 - -## 8. 最终验证 - -- Global、Service、App workspace 全量测试均通过;Admin 全量为 73 个测试文件、400 个测试通过。 -- `pnpm --filter @fastgpt/app typecheck`、`pnpm --filter @fastgpt/admin typecheck`:通过。 -- 旧 SSO 兼容调整覆盖:正确 state 成功、错误 state 拒绝、无 state code-only 成功、直连 Provider 无 state 拒绝;`pro/sso` 无本期改动。 -- 本次 App/Admin 变更文件定向 ESLint:0 error;Admin 保留 2 条既有表达式风格 warning。 -- `git diff --check`、`git -C pro diff --check`:通过。 - -仓库级 `pnpm lint` 仍受既有门禁问题阻断:`@fastgpt/marketplace` 使用当前 Next.js 已不支持的 `next lint` 命令;单独执行 App/Admin 全量 lint 还会分别命中 176/230 个范围外历史错误。本轮没有扩大范围修复这些基线问题,以定向 ESLint 结果作为本次改动的 lint 证据。 - -为兼容现有不支持 state 的 SSO,本期允许 SSO 回调在缺少 state 时按旧协议仅使用一次性 code 完成身份验证。该兼容路径不解决登录 CSRF 和协议降级风险;SSO state 强制校验、PKCE 或等价的流程绑定能力留待后续专项改造。 diff --git a/packages/global/common/error/code/user.ts b/packages/global/common/error/code/user.ts index 82fcc9541586..0695be0ac261 100644 --- a/packages/global/common/error/code/user.ts +++ b/packages/global/common/error/code/user.ts @@ -7,7 +7,10 @@ export enum UserErrEnum { unAuthRole = 'unAuthRole', account_psw_error = 'account_psw_error', unAuthSso = 'unAuthSso', - accountCancellationPending = 'accountCancellationPending' + accountCancellationPending = 'accountCancellationPending', + invalidVerificationCode = 'invalidVerificationCode', + sendVerificationCodeTooFrequently = 'sendVerificationCodeTooFrequently', + verifyCodeTooFrequently = 'verifyCodeTooFrequently' } const errList = [ { @@ -29,6 +32,21 @@ const errList = [ { statusText: UserErrEnum.accountCancellationPending, message: i18nT('common:code_error.account_cancellation_pending') + }, + { + statusText: UserErrEnum.invalidVerificationCode, + message: i18nT('common:error.code_error'), + httpStatus: 400 + }, + { + statusText: UserErrEnum.sendVerificationCodeTooFrequently, + message: i18nT('common:error.send_auth_code_too_frequently'), + httpStatus: 429 + }, + { + statusText: UserErrEnum.verifyCodeTooFrequently, + message: i18nT('common:error.verify_code_too_frequently'), + httpStatus: 429 } ]; export default errList.reduce((acc, cur, index) => { @@ -38,7 +56,8 @@ export default errList.reduce((acc, cur, index) => { code: 503000 + index, statusText: cur.statusText, message: cur.message, - data: null + data: null, + ...(cur.httpStatus !== undefined ? { httpStatus: cur.httpStatus } : {}) } }; }, {} as ErrType<`${UserErrEnum}`>); diff --git a/packages/global/common/middle/tracks/constants.ts b/packages/global/common/middle/tracks/constants.ts index cf2394f2bc82..6717f1486d09 100644 --- a/packages/global/common/middle/tracks/constants.ts +++ b/packages/global/common/middle/tracks/constants.ts @@ -13,6 +13,9 @@ export enum TrackEnum { teamChatQPM = 'teamChatQPM', enterpriseAuthStart = 'enterpriseAuthStart', enterpriseAuthBenefitGrant = 'enterpriseAuthBenefitGrant', + accountCancellationSubmitSuccess = 'account_cancellation_submit_success', + accountCancellationCancelSuccess = 'account_cancellation_cancel_success', + accountCancellationFinalizeSuccess = 'account_cancellation_finalize_success', // Admin cron job tracks subscriptionDeleted = 'subscriptionDeleted', diff --git a/packages/global/openapi/admin/support/user/audit/api.ts b/packages/global/openapi/admin/support/user/audit/api.ts new file mode 100644 index 000000000000..3e4108308089 --- /dev/null +++ b/packages/global/openapi/admin/support/user/audit/api.ts @@ -0,0 +1,31 @@ +import { PaginationResponseSchema, PaginationSchema } from '../../../../api'; +import { AdminAuditEventEnum } from '../../../../../support/user/audit/constants'; +import { SourceMemberSchema } from '../../../../../support/user/type'; +import { z } from 'zod'; + +/* ============================================================================ + * API: 获取管理员审计日志 + * Route: POST /api/support/user/audit/adminList + * Method: POST + * Description: 分页获取当前团队的管理员事件审计日志 + * Tags: ['系统日志'] + * ============================================================================ */ + +export const AdminAuditListBodySchema = PaginationSchema.extend({ + tmbIds: z.array(z.string()).optional().meta({ description: '成员 ID 筛选' }), + events: z + .array(z.enum(AdminAuditEventEnum)) + .optional() + .meta({ description: '管理员审计事件筛选' }) +}).strict(); +export type AdminAuditListBody = z.infer; + +export const AdminAuditListItemSchema = z.object({ + _id: z.string().meta({ description: '审计日志 ID' }), + sourceMember: SourceMemberSchema.meta({ description: '操作成员' }), + event: z.enum(AdminAuditEventEnum).meta({ description: '管理员审计事件' }), + timestamp: z.date().meta({ description: '操作时间' }), + metadata: z.record(z.string(), z.any()).meta({ description: '事件元数据' }) +}); + +export const AdminAuditListResponseSchema = PaginationResponseSchema(AdminAuditListItemSchema); diff --git a/packages/global/openapi/admin/support/user/audit/index.ts b/packages/global/openapi/admin/support/user/audit/index.ts new file mode 100644 index 000000000000..1dc09591a014 --- /dev/null +++ b/packages/global/openapi/admin/support/user/audit/index.ts @@ -0,0 +1,22 @@ +import type { OpenAPIPath } from '../../../../type'; +import { DevApiTagsMap } from '../../../../tag'; +import { AdminAuditListBodySchema, AdminAuditListResponseSchema } from './api'; + +export const AdminAuditPath: OpenAPIPath = { + '/support/user/audit/adminList': { + post: { + summary: '获取管理员审计日志', + description: '分页获取当前团队的管理员事件审计日志', + tags: [DevApiTagsMap.adminLogs], + requestBody: { + content: { 'application/json': { schema: AdminAuditListBodySchema } } + }, + responses: { + 200: { + description: '管理员审计日志列表', + content: { 'application/json': { schema: AdminAuditListResponseSchema } } + } + } + } + } +}; diff --git a/packages/global/openapi/admin/support/user/index.ts b/packages/global/openapi/admin/support/user/index.ts index f5ff6c6223cb..f815cb7bd202 100644 --- a/packages/global/openapi/admin/support/user/index.ts +++ b/packages/global/openapi/admin/support/user/index.ts @@ -2,9 +2,11 @@ import { AdminLoginPath } from './login'; import { AdminInformPath } from './inform'; import { AdminAuthPath } from './auth'; import type { OpenAPIPath } from '../../../type'; +import { AdminAuditPath } from './audit'; export const AdminUserPath: OpenAPIPath = { ...AdminInformPath, ...AdminLoginPath, - ...AdminAuthPath + ...AdminAuthPath, + ...AdminAuditPath }; diff --git a/packages/global/openapi/support/user/account/cancellation/index.ts b/packages/global/openapi/support/user/account/cancellation/index.ts index 1e4cc39dd17f..565dcc1d187e 100644 --- a/packages/global/openapi/support/user/account/cancellation/index.ts +++ b/packages/global/openapi/support/user/account/cancellation/index.ts @@ -1,3 +1,4 @@ +import z from 'zod'; import type { OpenAPIPath } from '../../../../type'; import { DevApiTagsMap } from '../../../../tag'; import { @@ -37,6 +38,14 @@ export const AccountCancellationPath: OpenAPIPath = { content: { 'application/json': { schema: CreateAccountCancellationVerificationResponseSchema } } + }, + 400: { + description: '请求参数或图片验证码错误', + content: { 'application/json': { schema: z.null() } } + }, + 429: { + description: '验证码发送过于频繁', + content: { 'application/json': { schema: z.null() } } } } } @@ -53,6 +62,14 @@ export const AccountCancellationPath: OpenAPIPath = { 200: { description: '验证进行中或已进入注销等待期', content: { 'application/json': { schema: SubmitAccountCancellationResponseSchema } } + }, + 400: { + description: '请求参数或验证码错误', + content: { 'application/json': { schema: z.null() } } + }, + 429: { + description: '验证码校验过于频繁', + content: { 'application/json': { schema: z.null() } } } } } diff --git a/packages/global/openapi/support/user/account/login/index.ts b/packages/global/openapi/support/user/account/login/index.ts index 22be117b9adc..532c3e13b5e7 100644 --- a/packages/global/openapi/support/user/account/login/index.ts +++ b/packages/global/openapi/support/user/account/login/index.ts @@ -1,3 +1,4 @@ +import z from 'zod'; import type { OpenAPIPath } from '../../../../type'; import { DevApiTagsMap } from '../../../../tag'; import { @@ -72,6 +73,14 @@ export const LoginPath: OpenAPIPath = { schema: LoginSuccessResponseSchema } } + }, + 400: { + description: '请求参数或预登录验证码错误', + content: { + 'application/json': { + schema: z.null() + } + } } } } diff --git a/packages/global/openapi/support/user/account/password/index.ts b/packages/global/openapi/support/user/account/password/index.ts index c2bea2d344ba..8699406386ce 100644 --- a/packages/global/openapi/support/user/account/password/index.ts +++ b/packages/global/openapi/support/user/account/password/index.ts @@ -1,3 +1,4 @@ +import z from 'zod'; import type { OpenAPIPath } from '../../../../type'; import { DevApiTagsMap } from '../../../../tag'; import { @@ -95,6 +96,22 @@ export const PasswordPath: OpenAPIPath = { schema: {} } } + }, + 400: { + description: '请求参数或验证码错误', + content: { + 'application/json': { + schema: z.null() + } + } + }, + 429: { + description: '验证码校验过于频繁', + content: { + 'application/json': { + schema: z.null() + } + } } } } diff --git a/packages/global/openapi/support/user/account/register/index.ts b/packages/global/openapi/support/user/account/register/index.ts index ec27f38aa8e4..44d5683bc5ce 100644 --- a/packages/global/openapi/support/user/account/register/index.ts +++ b/packages/global/openapi/support/user/account/register/index.ts @@ -1,3 +1,4 @@ +import z from 'zod'; import type { OpenAPIPath } from '../../../../type'; import { DevApiTagsMap } from '../../../../tag'; import { AccountRegisterBodySchema } from './api'; @@ -23,6 +24,22 @@ export const RegisterPath: OpenAPIPath = { schema: {} } } + }, + 400: { + description: '请求参数或验证码错误', + content: { + 'application/json': { + schema: z.null() + } + } + }, + 429: { + description: '验证码校验过于频繁', + content: { + 'application/json': { + schema: z.null() + } + } } } } diff --git a/packages/global/openapi/support/user/account/verification/index.ts b/packages/global/openapi/support/user/account/verification/index.ts index 47c640bfd828..04b9e359f5ea 100644 --- a/packages/global/openapi/support/user/account/verification/index.ts +++ b/packages/global/openapi/support/user/account/verification/index.ts @@ -1,3 +1,4 @@ +import z from 'zod'; import type { OpenAPIPath } from '../../../../type'; import { DevApiTagsMap } from '../../../../tag'; import { @@ -48,6 +49,22 @@ export const AccountVerificationPath: OpenAPIPath = { schema: SendAccountVerificationCodeResponseSchema } } + }, + 400: { + description: '请求参数或图片验证码错误', + content: { + 'application/json': { + schema: z.null() + } + } + }, + 429: { + description: '验证码发送过于频繁', + content: { + 'application/json': { + schema: z.null() + } + } } } } diff --git a/packages/global/openapi/support/user/audit/api.ts b/packages/global/openapi/support/user/audit/api.ts new file mode 100644 index 000000000000..0d9b1e697f6e --- /dev/null +++ b/packages/global/openapi/support/user/audit/api.ts @@ -0,0 +1,28 @@ +import { PaginationResponseSchema, PaginationSchema } from '../../../api'; +import { AuditEventEnum } from '../../../../support/user/audit/constants'; +import { SourceMemberSchema } from '../../../../support/user/type'; +import { z } from 'zod'; + +/* ============================================================================ + * API: 获取团队审计日志 + * Route: POST /proApi/support/user/audit/list + * Method: POST + * Description: 分页获取当前团队的团队事件审计日志 + * Tags: ['团队管理'] + * ============================================================================ */ + +export const TeamAuditListBodySchema = PaginationSchema.extend({ + tmbIds: z.array(z.string()).optional().meta({ description: '成员 ID 筛选' }), + events: z.array(z.enum(AuditEventEnum)).optional().meta({ description: '团队审计事件筛选' }) +}).strict(); +export type TeamAuditListBody = z.infer; + +export const TeamAuditListItemSchema = z.object({ + _id: z.string().meta({ description: '审计日志 ID' }), + sourceMember: SourceMemberSchema.meta({ description: '操作成员' }), + event: z.enum(AuditEventEnum).meta({ description: '团队审计事件' }), + timestamp: z.date().meta({ description: '操作时间' }), + metadata: z.record(z.string(), z.any()).meta({ description: '事件元数据' }) +}); + +export const TeamAuditListResponseSchema = PaginationResponseSchema(TeamAuditListItemSchema); diff --git a/packages/global/openapi/support/user/audit/index.ts b/packages/global/openapi/support/user/audit/index.ts new file mode 100644 index 000000000000..54d854c8aff4 --- /dev/null +++ b/packages/global/openapi/support/user/audit/index.ts @@ -0,0 +1,22 @@ +import type { OpenAPIPath } from '../../../type'; +import { DevApiTagsMap } from '../../../tag'; +import { TeamAuditListBodySchema, TeamAuditListResponseSchema } from './api'; + +export const UserAuditPath: OpenAPIPath = { + '/proApi/support/user/audit/list': { + post: { + summary: '获取团队审计日志', + description: '分页获取当前团队的团队事件审计日志', + tags: [DevApiTagsMap.teamManage], + requestBody: { + content: { 'application/json': { schema: TeamAuditListBodySchema } } + }, + responses: { + 200: { + description: '团队审计日志列表', + content: { 'application/json': { schema: TeamAuditListResponseSchema } } + } + } + } + } +}; diff --git a/packages/global/openapi/support/user/index.ts b/packages/global/openapi/support/user/index.ts index 8d13f89286e8..a3ca5a8e06c0 100644 --- a/packages/global/openapi/support/user/index.ts +++ b/packages/global/openapi/support/user/index.ts @@ -2,9 +2,11 @@ import { UserInformPath } from './inform'; import type { OpenAPIPath } from '../../type'; import { UserAccountPath } from './account'; import { TeamPath } from './team'; +import { UserAuditPath } from './audit'; export const UserPath: OpenAPIPath = { ...UserInformPath, ...UserAccountPath, - ...TeamPath + ...TeamPath, + ...UserAuditPath }; diff --git a/packages/global/support/user/account/cancellation/utils.ts b/packages/global/support/user/account/cancellation/utils.ts index aa55942522c3..8ad548f40fbc 100644 --- a/packages/global/support/user/account/cancellation/utils.ts +++ b/packages/global/support/user/account/cancellation/utils.ts @@ -6,6 +6,8 @@ import { import type { AccountCancellationSchedule } from './type'; const dayInMilliseconds = 24 * 60 * 60 * 1000; +const accountCancellationAnonymizedUsernameReg = /-[a-z][a-zA-Z0-9]{7}-delete$/; +const legacyAccountCancellationUsernameRegs = [/-deleted$/, /^deleted-[a-f0-9]{32}$/]; type LocalDateParts = { year: number; @@ -94,7 +96,10 @@ const localDateTimeToUtc = ( return new Date(candidate); }; -const addLocalDays = ({ year, month, day }: LocalDateParts, days: number) => { +const addLocalDays = ( + { year, month, day }: Pick, + days: number +) => { const date = new Date(Date.UTC(year, month - 1, day + days)); return { year: date.getUTCFullYear(), @@ -109,6 +114,28 @@ const formatLocalDate = ({ year, month, day }: LocalDateParts) => const atLocalTime = (date: ReturnType, hour: number, timeZone: string) => localDateTimeToUtc({ ...date, hour, minute: 0, second: 0 }, timeZone); +/** 返回目标时区指定相对日期的 UTC 半开区间。 */ +const getLocalDayWindow = ({ + now, + daysFromToday, + timeZone +}: { + now: Date; + daysFromToday: number; + timeZone: string; +}) => { + if (!(now instanceof Date) || Number.isNaN(now.getTime())) { + throw new Error('Invalid account cancellation current time'); + } + assertValidTimeZone(timeZone); + + const targetDate = addLocalDays(parseDateParts(now, timeZone), daysFromToday); + return { + start: atLocalTime(targetDate, 0, timeZone), + end: atLocalTime(addLocalDays(targetDate, 1), 0, timeZone) + }; +}; + /** * 从唯一持久化时间推导注销等待期的全部时间点。 * waitEndsAt 使用完整的 UTC 24 小时周期,提醒和最终清理则使用显式配置时区的自然日。 @@ -161,8 +188,66 @@ export const getAccountCancellationReminderAt = ({ return schedule.finalNoticeAt; }; +/** + * 反推出指定自然日应发送某类提醒的 requestedAt 半开区间,供数据库范围查询使用。 + * 区间按配置时区的自然日计算,避免受服务进程时区影响。 + */ +export const getAccountCancellationReminderRequestedAtWindow = ({ + now, + reminder, + timeZone = accountCancellationTimezone +}: { + now: Date; + reminder: AccountCancellationReminderEnum; + timeZone?: string; +}) => { + const reminderDaysBeforeCleanup = (() => { + if (reminder === AccountCancellationReminderEnum.sevenDays) return 7; + if (reminder === AccountCancellationReminderEnum.oneDay) return 1; + return 0; + })(); + const cleanupDayWindow = getLocalDayWindow({ + now, + daysFromToday: reminderDaysBeforeCleanup, + timeZone + }); + const waitPeriodMs = accountCancellationWaitDays * dayInMilliseconds; + + return { + start: new Date(cleanupDayWindow.start.getTime() - waitPeriodMs), + end: new Date(cleanupDayWindow.end.getTime() - waitPeriodMs) + }; +}; + +/** + * 返回到期 pending 的 requestedAt 排他上界。 + * 当前自然日开始前已进入计划清理时间的记录满足 requestedAt < cutoff。 + */ +export const getAccountCancellationPendingDueCutoff = ({ + now, + timeZone = accountCancellationTimezone +}: { + now: Date; + timeZone?: string; +}) => { + const todayStart = getLocalDayWindow({ + now, + daysFromToday: 0, + timeZone + }).start; + + return new Date(todayStart.getTime() - accountCancellationWaitDays * dayInMilliseconds); +}; + export const isAccountCancellationCancelable = (requestedAt: Date, now = new Date()) => now.getTime() < deriveAccountCancellationSchedule(requestedAt).scheduledCancelAt.getTime(); export const isAccountCancellationMethod = (method: string) => method === 'code' || method === 'wechat' || method.startsWith('oauth/'); + +/** + * 判断用户名是否由账号注销流程生成,同时兼容已落库的历史匿名用户名格式。 + */ +export const isAccountCancellationAnonymizedUsername = (username: string) => + accountCancellationAnonymizedUsernameReg.test(username) || + legacyAccountCancellationUsernameRegs.some((reg) => reg.test(username)); diff --git a/packages/global/support/user/audit/constants.ts b/packages/global/support/user/audit/constants.ts index f48ac0cd6776..e5e7d8cfe7fa 100644 --- a/packages/global/support/user/audit/constants.ts +++ b/packages/global/support/user/audit/constants.ts @@ -5,6 +5,7 @@ export enum AdminAuditEventEnum { ADMIN_ADD_USER = 'ADMIN_ADD_USER', ADMIN_UPDATE_USER = 'ADMIN_UPDATE_USER', + ADMIN_DELETE_USER = 'ADMIN_DELETE_USER', ADMIN_UPDATE_TEAM = 'ADMIN_UPDATE_TEAM', ADMIN_ADD_PLAN = 'ADMIN_ADD_PLAN', ADMIN_UPDATE_PLAN = 'ADMIN_UPDATE_PLAN', @@ -87,6 +88,9 @@ export enum AuditEventEnum { CHANGE_PASSWORD = 'CHANGE_PASSWORD', CHANGE_NOTIFICATION_SETTINGS = 'CHANGE_NOTIFICATION_SETTINGS', CHANGE_MEMBER_NAME_ACCOUNT = 'CHANGE_MEMBER_NAME_ACCOUNT', + ACCOUNT_CANCELLATION_SUBMIT = 'ACCOUNT_CANCELLATION_SUBMIT', + ACCOUNT_CANCELLATION_CANCEL = 'ACCOUNT_CANCELLATION_CANCEL', + ACCOUNT_CANCELLATION_FINALIZE = 'ACCOUNT_CANCELLATION_FINALIZE', PURCHASE_PLAN = 'PURCHASE_PLAN', EXPORT_BILL_RECORDS = 'EXPORT_BILL_RECORDS', CREATE_INVOICE = 'CREATE_INVOICE', diff --git a/packages/global/support/user/audit/type.ts b/packages/global/support/user/audit/type.ts index 052fcd123111..cc4f4124e75c 100644 --- a/packages/global/support/user/audit/type.ts +++ b/packages/global/support/user/audit/type.ts @@ -1,19 +1,26 @@ import type { SourceMemberType } from '../type'; -import type { AuditEventEnum } from './constants'; +import type { AdminAuditEventEnum, AuditEventEnum } from './constants'; -export type TeamAuditSchemaType = { +export type TeamAuditEvent = `${AuditEventEnum}`; +export type AdminAuditEvent = `${AdminAuditEventEnum}`; +export type AuditEvent = TeamAuditEvent | AdminAuditEvent; + +export type AuditSchemaType = { _id: string; tmbId: string; teamId: string; timestamp: Date; - event: `${AuditEventEnum}`; - metadata?: Record; + event: TEvent; + metadata?: Record; }; -export type TeamAuditListItemType = { +export type AuditListItemType = { _id: string; sourceMember: SourceMemberType; - event: `${AuditEventEnum}`; + event: TEvent; timestamp: Date; - metadata: Record; + metadata: Record; }; + +export type TeamAuditListItemType = AuditListItemType; +export type AdminAuditListItemType = AuditListItemType; diff --git a/packages/global/support/user/auth/type.ts b/packages/global/support/user/auth/type.ts deleted file mode 100644 index 829acf106294..000000000000 --- a/packages/global/support/user/auth/type.ts +++ /dev/null @@ -1,10 +0,0 @@ -import type { UserAuthTypeEnum } from './constants'; - -export type UserAuthSchemaType = { - key: string; - type: `${UserAuthTypeEnum}`; - code?: string; - openid?: string; - createTime: Date; - expiredTime: Date; -}; diff --git a/packages/global/test/common/error/utils.test.ts b/packages/global/test/common/error/utils.test.ts index 9eec71792f37..c3f936058128 100644 --- a/packages/global/test/common/error/utils.test.ts +++ b/packages/global/test/common/error/utils.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { getErrText, ToastHandledError, UserError } from '@fastgpt/global/common/error/utils'; import { ERROR_ENUM, ERROR_RESPONSE } from '@fastgpt/global/common/error/errorCode'; +import { UserErrEnum } from '@fastgpt/global/common/error/code/user'; describe('getErrText', () => { it('should return mapped message for error enum', () => { @@ -100,6 +101,19 @@ describe('UserError', () => { }); }); +describe('verification error responses', () => { + it.each([ + [UserErrEnum.invalidVerificationCode, 400], + [UserErrEnum.sendVerificationCodeTooFrequently, 429], + [UserErrEnum.verifyCodeTooFrequently, 429] + ] as const)('maps %s to HTTP %s', (error, httpStatus) => { + expect(ERROR_RESPONSE[error]).toMatchObject({ + statusText: error, + httpStatus + }); + }); +}); + describe('ToastHandledError', () => { it('should set name to ToastHandledError', () => { const err = new ToastHandledError('handled'); diff --git a/packages/global/test/openapi/admin/support/user/audit/api.test.ts b/packages/global/test/openapi/admin/support/user/audit/api.test.ts new file mode 100644 index 000000000000..48a9ee39024d --- /dev/null +++ b/packages/global/test/openapi/admin/support/user/audit/api.test.ts @@ -0,0 +1,31 @@ +import { + AuditEventEnum, + AdminAuditEventEnum +} from '../../../../../../support/user/audit/constants'; +import { AdminAuditListBodySchema } from '../../../../../../openapi/admin/support/user/audit/api'; +import { describe, expect, it } from 'vitest'; + +describe('AdminAuditListBodySchema', () => { + it('accepts admin events', () => { + expect( + AdminAuditListBodySchema.safeParse({ + pageNum: 1, + pageSize: 20, + events: [AdminAuditEventEnum.ADMIN_LOGIN] + }).success + ).toBe(true); + }); + + it('rejects team events and extra fields', () => { + expect( + AdminAuditListBodySchema.safeParse({ + pageNum: 1, + pageSize: 20, + events: [AuditEventEnum.LOGIN] + }).success + ).toBe(false); + expect( + AdminAuditListBodySchema.safeParse({ pageNum: 1, pageSize: 20, unexpected: true }).success + ).toBe(false); + }); +}); diff --git a/packages/global/test/openapi/support/user/audit/api.test.ts b/packages/global/test/openapi/support/user/audit/api.test.ts new file mode 100644 index 000000000000..5f2ac39a8c87 --- /dev/null +++ b/packages/global/test/openapi/support/user/audit/api.test.ts @@ -0,0 +1,28 @@ +import { AuditEventEnum, AdminAuditEventEnum } from '../../../../../support/user/audit/constants'; +import { TeamAuditListBodySchema } from '../../../../../openapi/support/user/audit/api'; +import { describe, expect, it } from 'vitest'; + +describe('TeamAuditListBodySchema', () => { + it('accepts team events', () => { + expect( + TeamAuditListBodySchema.safeParse({ + pageNum: 1, + pageSize: 20, + events: [AuditEventEnum.LOGIN] + }).success + ).toBe(true); + }); + + it('rejects admin events and extra fields', () => { + expect( + TeamAuditListBodySchema.safeParse({ + pageNum: 1, + pageSize: 20, + events: [AdminAuditEventEnum.ADMIN_LOGIN] + }).success + ).toBe(false); + expect( + TeamAuditListBodySchema.safeParse({ pageNum: 1, pageSize: 20, unexpected: true }).success + ).toBe(false); + }); +}); diff --git a/packages/global/test/support/user/account/cancellation/utils.test.ts b/packages/global/test/support/user/account/cancellation/utils.test.ts index 231a714a0906..c16294928b65 100644 --- a/packages/global/test/support/user/account/cancellation/utils.test.ts +++ b/packages/global/test/support/user/account/cancellation/utils.test.ts @@ -2,7 +2,10 @@ import { describe, expect, it } from 'vitest'; import { AccountCancellationReminderEnum } from '@fastgpt/global/support/user/account/cancellation/constants'; import { deriveAccountCancellationSchedule, + getAccountCancellationPendingDueCutoff, getAccountCancellationReminderAt, + getAccountCancellationReminderRequestedAtWindow, + isAccountCancellationAnonymizedUsername, isAccountCancellationCancelable } from '@fastgpt/global/support/user/account/cancellation/utils'; @@ -39,6 +42,62 @@ describe('deriveAccountCancellationSchedule', () => { ).toEqual(schedule.finalNoticeAt); }); + it('derives requestedAt query windows for reminders from the configured local day', () => { + const now = new Date('2026-07-09T02:00:00.000Z'); + + expect( + getAccountCancellationReminderRequestedAtWindow({ + now, + reminder: AccountCancellationReminderEnum.sevenDays + }) + ).toEqual({ + start: new Date('2026-06-30T16:00:00.000Z'), + end: new Date('2026-07-01T16:00:00.000Z') + }); + expect( + getAccountCancellationReminderRequestedAtWindow({ + now, + reminder: AccountCancellationReminderEnum.oneDay + }) + ).toEqual({ + start: new Date('2026-06-24T16:00:00.000Z'), + end: new Date('2026-06-25T16:00:00.000Z') + }); + expect( + getAccountCancellationReminderRequestedAtWindow({ + now, + reminder: AccountCancellationReminderEnum.today + }) + ).toEqual({ + start: new Date('2026-06-23T16:00:00.000Z'), + end: new Date('2026-06-24T16:00:00.000Z') + }); + }); + + it('derives the exclusive pending due cutoff from the configured local day', () => { + expect( + getAccountCancellationPendingDueCutoff({ + now: new Date('2026-07-16T16:00:00.000Z') + }) + ).toEqual(new Date('2026-07-01T16:00:00.000Z')); + }); + + it('keeps requestedAt query windows aligned with the fixed wait period across DST', () => { + const window = getAccountCancellationReminderRequestedAtWindow({ + now: new Date('2026-03-09T14:00:00.000Z'), + reminder: AccountCancellationReminderEnum.today, + timeZone: 'America/New_York' + }); + + expect(window).toEqual({ + start: new Date('2026-02-22T04:00:00.000Z'), + end: new Date('2026-02-23T04:00:00.000Z') + }); + expect( + deriveAccountCancellationSchedule(window.start, 'America/New_York').finalNoticeAt + ).toEqual(new Date('2026-03-09T14:00:00.000Z')); + }); + it('allows cancellation only before the derived local midnight', () => { const requestedAt = new Date('2026-07-01T10:20:00.000Z'); const schedule = deriveAccountCancellationSchedule(requestedAt); @@ -53,3 +112,21 @@ describe('deriveAccountCancellationSchedule', () => { expect(() => deriveAccountCancellationSchedule(new Date(), 'invalid/zone')).toThrow(); }); }); + +describe('isAccountCancellationAnonymizedUsername', () => { + it('matches the current username-random-delete format', () => { + expect(isAccountCancellationAnonymizedUsername('user@example.com-a1B2c3D4-delete')).toBe(true); + }); + + it('keeps historical anonymized usernames recognizable', () => { + expect(isAccountCancellationAnonymizedUsername('user@example.com-deleted')).toBe(true); + expect(isAccountCancellationAnonymizedUsername(`deleted-${'a'.repeat(32)}`)).toBe(true); + }); + + it('does not treat ordinary delete-like usernames as anonymized', () => { + expect(isAccountCancellationAnonymizedUsername('user-delete')).toBe(false); + expect(isAccountCancellationAnonymizedUsername('user-12345678-delete')).toBe(false); + expect(isAccountCancellationAnonymizedUsername('user-a1B2c3D4-delete-suffix')).toBe(false); + expect(isAccountCancellationAnonymizedUsername(`deleted-${'g'.repeat(32)}`)).toBe(false); + }); +}); diff --git a/packages/service/common/bullmq/index.ts b/packages/service/common/bullmq/index.ts index 57418ed6919a..5faca25095f8 100644 --- a/packages/service/common/bullmq/index.ts +++ b/packages/service/common/bullmq/index.ts @@ -1,5 +1,6 @@ import { type ConnectionOptions, + DelayedError, type Processor, Queue, type QueueOptions, @@ -9,9 +10,11 @@ import { } from 'bullmq'; import { getLogger, LogCategories } from '../logger'; import { newQueueRedisConnection, newWorkerRedisConnection } from '../redis'; +import { withRedisLease } from '../redis/lock'; import { delay } from '@fastgpt/global/common/system/utils'; const logger = getLogger(LogCategories.INFRA.QUEUE); +const FAILED_JOB_RECOVERY_LEASE_TTL_MS = 30 * 1000; const defaultWorkerOpts: Omit = { removeOnComplete: { @@ -82,6 +85,86 @@ export function getQueue( return newQueue; } +/** + * 添加稳定 ID 任务;若同 ID 历史任务已失败,则在分布式租约内刷新数据并手动重试。 + * 其它状态继续复用现有任务,避免并发生产者制造重复工作;租约竞争会向上抛错, + * 由调用方或下一轮扫描重试,不能把仍处于 failed 的任务误报为已入队。 + */ +export async function addOrRequeueFailedJob({ + queue, + name, + data, + opts +}: { + queue: Queue; + name: Parameters['add']>[0]; + data: Parameters['add']>[1]; + opts: NonNullable['add']>[2]> & { jobId: string }; +}) { + /** + * getJob 与 getState 之间任务可能被 retention cleanup 删除。unknown 不能视为可复用状态; + * 重新读取后确认任务已不存在才允许使用相同稳定 ID 新建,仍存在的异常状态向上报错。 + */ + const getJobWithConfirmedState = async () => { + const job = await queue.getJob(opts.jobId); + if (!job) return; + + const state = await job.getState(); + if (state !== 'unknown') return { job, state }; + + const latestJob = await queue.getJob(opts.jobId); + if (!latestJob) return; + + const latestState = await latestJob.getState(); + if (latestState === 'unknown') { + throw new Error(`BullMQ job is in an unknown state: ${queue.name}/${opts.jobId}`); + } + return { job: latestJob, state: latestState }; + }; + + const existing = await getJobWithConfirmedState(); + if (existing) { + if (existing.state !== 'failed') return existing.job; + + return withRedisLease({ + key: `bullmq:failed-job-recovery:${queue.name}:${opts.jobId}`, + label: 'bullmq-failed-job-recovery', + ttlMs: FAILED_JOB_RECOVERY_LEASE_TTL_MS, + fn: async () => { + // 取得租约后重新读取,避免等待期间使用过期的 failed 状态或 Job 实例。 + const current = await getJobWithConfirmedState(); + if (!current) return queue.add(name, data, opts); + if (current.state !== 'failed') return current.job; + const currentJob = current.job; + + // 保留 failed job,避免删除与重建之间进程退出造成任务永久丢失。 + try { + // Queue.add 会提取 Job 联合定义的数据类型,Job.updateData 的公开泛型没有同步提取。 + await currentJob.updateData(data as DataType); + } catch (error) { + const latest = await getJobWithConfirmedState(); + if (!latest) return queue.add(name, data, opts); + if (latest.state !== 'failed') return latest.job; + throw error; + } + + try { + await currentJob.retry('failed'); + return currentJob; + } catch (error) { + // 保留真实失败;若任务已由外部操作恢复,则复用其最新状态。 + const latest = await getJobWithConfirmedState(); + if (!latest) return queue.add(name, data, opts); + if (latest.state !== 'failed') return latest.job; + throw error; + } + } + }); + } + + return queue.add(name, data, opts); +} + export function getWorker( name: QueueNames, processor: Processor, @@ -152,5 +235,5 @@ export function getWorker( return newWorker; } -export { Queue, UnrecoverableError, Worker, delay }; +export { DelayedError, Queue, UnrecoverableError, Worker, delay }; export type { ConnectionOptions, Job, Processor, QueueOptions, WorkerOptions } from 'bullmq'; diff --git a/packages/service/common/http/entry.ts b/packages/service/common/http/entry.ts index 3d4fc769e3b4..4c0ebc68b84a 100644 --- a/packages/service/common/http/entry.ts +++ b/packages/service/common/http/entry.ts @@ -167,14 +167,17 @@ export const createApiEntry = < }); } - span.setAttribute('http.response.status_code', 500); - setSpanError(span, error); - - return jsonRes(res, { + const response = jsonRes(res, { code: 500, error, url: req.url }); + span.setAttribute('http.response.status_code', res.statusCode); + if (res.statusCode >= 500) { + setSpanError(span, error); + } + + return response; } } ) diff --git a/packages/service/common/middle/tracks/utils.ts b/packages/service/common/middle/tracks/utils.ts index 507e2e3c0270..014b776f8662 100644 --- a/packages/service/common/middle/tracks/utils.ts +++ b/packages/service/common/middle/tracks/utils.ts @@ -17,6 +17,24 @@ import type { StandardSubLevelEnum } from '@fastgpt/global/support/wallet/sub/co const logger = getLogger(LogCategories.EVENT.TRACK); +type AccountCancellationTrackData = { + uid?: string; + teamId?: string; + tmbId?: string; + userId: string; + operatorUserId: string; + operatorType: 'self' | 'system' | 'admin'; + requestSource: 'self' | 'admin'; + requestedAt: Date; + scheduledCancelAt: Date; + finalizedAt?: Date; + verificationMethod?: string; + verificationProvider?: string; + affectedTeamIds?: string[]; + requestId?: string; + cronExecutionId?: string; +}; + const createTrack = ({ event, data }: { event: TrackEnum; data: Record }) => { if (!global.feConfigs?.isPlus) return; logger.debug('Enqueue track event', { @@ -195,6 +213,40 @@ export const pushTrack = { data }); }, + accountCancellationSubmitSuccess: ( + data: AccountCancellationTrackData & { + operatorType: 'self'; + requestSource: 'self'; + verificationMethod: string; + affectedTeamIds: string[]; + } + ) => { + return createTrack({ + event: TrackEnum.accountCancellationSubmitSuccess, + data + }); + }, + accountCancellationCancelSuccess: ( + data: AccountCancellationTrackData & { + operatorType: 'self'; + requestSource: 'self'; + } + ) => { + return createTrack({ + event: TrackEnum.accountCancellationCancelSuccess, + data + }); + }, + accountCancellationFinalizeSuccess: ( + data: AccountCancellationTrackData & { + finalizedAt: Date; + } + ) => { + return createTrack({ + event: TrackEnum.accountCancellationFinalizeSuccess, + data + }); + }, // Admin cron job tracks subscriptionDeleted: (data: { diff --git a/packages/service/common/response/index.ts b/packages/service/common/response/index.ts index e20bc6a32401..5f3232bbacb9 100644 --- a/packages/service/common/response/index.ts +++ b/packages/service/common/response/index.ts @@ -43,12 +43,21 @@ function resolveHttpStatusForApiError( return bc; } + const raw = typeof error === 'string' ? error : error?.message; + const configuredHttpStatus = ERROR_RESPONSE[raw]?.httpStatus; + if ( + typeof configuredHttpStatus === 'number' && + configuredHttpStatus >= 400 && + configuredHttpStatus <= 599 + ) { + return configuredHttpStatus; + } + // packages/global/common/error/code/s3.ts:510000 段为上传校验类客户端错误 if (typeof bc === 'number' && bc >= 510000 && bc < 511000) { return 400; } - const raw = typeof error === 'string' ? error : error?.message; if (raw === 'EntityTooLarge') { return 413; } diff --git a/packages/service/common/system/cron.ts b/packages/service/common/system/cron.ts index 7b5e32513e90..ec0e477e3e43 100644 --- a/packages/service/common/system/cron.ts +++ b/packages/service/common/system/cron.ts @@ -1,6 +1,6 @@ -import nodeCron from 'node-cron'; +import nodeCron, { type ScheduleOptions } from 'node-cron'; -export const setCron = (time: string, cb: () => void) => { +export const setCron = (time: string, cb: () => void, options?: ScheduleOptions) => { // second minute hour day month week - return nodeCron.schedule(time, cb); + return nodeCron.schedule(time, cb, options); }; diff --git a/packages/service/common/system/timerLock/constants.ts b/packages/service/common/system/timerLock/constants.ts index c07287e4f905..9d41ddc8a7fe 100644 --- a/packages/service/common/system/timerLock/constants.ts +++ b/packages/service/common/system/timerLock/constants.ts @@ -17,6 +17,8 @@ export enum TimerIdEnum { archiveInactiveSandboxes = 'archiveInactiveSandboxes', clearStaleArchivingSandboxes = 'clearStaleArchivingSandboxes', enterpriseAuthTaskCleanup = 'enterpriseAuthTaskCleanup', + accountCancellationReminder = 'accountCancellationReminder', + accountCancellationFinalize = 'accountCancellationFinalize', /** 纠正长时间卡在 generating 的会话状态 */ cleanStaleGeneratingChat = 'cleanStaleGeneratingChat' } diff --git a/packages/service/core/app/evaluation/delete.ts b/packages/service/core/app/evaluation/delete.ts new file mode 100644 index 000000000000..7804df48840b --- /dev/null +++ b/packages/service/core/app/evaluation/delete.ts @@ -0,0 +1,19 @@ +import { MongoEvaluation } from './evalSchema'; +import { MongoEvalItem } from './evalItemSchema'; + +/** + * 删除团队下的评估及其评估项。 + * 评估项只保存 evalId,因此必须先保留父记录 ID 并删除子项,再删除父记录,确保失败重试时仍可定位子项。 + */ +export const deleteEvaluationsByTeamId = async (teamId: string) => { + const evaluations = await MongoEvaluation.find({ teamId }, '_id').lean(); + const evalIds = evaluations.map((evaluation) => evaluation._id); + + if (evalIds.length > 0) { + await MongoEvalItem.deleteMany({ + evalId: { $in: evalIds } + }); + } + + await MongoEvaluation.deleteMany({ teamId }); +}; diff --git a/packages/service/support/user/account/cancellation/access.ts b/packages/service/support/user/account/cancellation/access.ts index 733f391ae84b..906d8d99362b 100644 --- a/packages/service/support/user/account/cancellation/access.ts +++ b/packages/service/support/user/account/cancellation/access.ts @@ -97,17 +97,21 @@ export const resolveAccountCancellationAccess = ({ accountCancellationAccess?: AccountCancellationAccessPreset; }) => { const preset = accountCancellationAccessPresets[accountCancellationAccess]; + const keys = requestKeys(req ?? {}); if (accountCancellationAccess !== 'normal') { - const allowed = requestKeys(req ?? {}).some((key) => preset.apis.includes(key)); + const allowed = keys.some((key) => preset.apis.includes(key)); if (!allowed) throw new Error(ERROR_ENUM.unAuthorization); } if ( accountCancellationAccess === 'selfCancellation' && - !requestKeys(req ?? {}).some((key) => - key.endsWith(' /proApi/support/user/account/cancellation/status') + keys.some((key) => + [ + 'POST /proApi/support/user/account/cancellation/verification/create', + 'POST /proApi/support/user/account/cancellation/submit' + ].includes(key) ) ) { - // 成员等待页需要读取本人 status,但不能借任意 pending 团队绕过停服提交注销。 + // 状态查询和取消注销必须可恢复;仅阻止成员借 pending 团队发起新的注销申请。 return { ...preset.options, allowCurrentSessionTeamAccountCancellationPending: false diff --git a/packages/service/support/user/account/cancellation/guard.ts b/packages/service/support/user/account/cancellation/guard.ts index 36c7f2102bd4..1aa99e187fa7 100644 --- a/packages/service/support/user/account/cancellation/guard.ts +++ b/packages/service/support/user/account/cancellation/guard.ts @@ -26,9 +26,13 @@ export const assertAccountUsable = async ({ allowCurrentUserOwnedTeamAccountCancellationPending = false, allowCurrentSessionTeamAccountCancellationPending = false }: AssertAccountUsableProps) => { - const tmb = tmbId && !teamId ? await MongoTeamMember.findById(tmbId).lean() : null; - const currentUserId = userId || (tmb?.userId ? String(tmb.userId) : undefined); - const currentTeamId = teamId || (tmb?.teamId ? String(tmb.teamId) : undefined); + // API Key 没有 Session userId;即使已有 teamId,也必须从 tmbId 还原实际成员。 + const tmb = + tmbId && (!userId || !teamId) + ? await MongoTeamMember.findById(tmbId, { userId: 1, teamId: 1 }).lean() + : null; + const currentUserId = userId ?? (tmb?.userId ? String(tmb.userId) : undefined); + const currentTeamId = teamId ?? (tmb?.teamId ? String(tmb.teamId) : undefined); const [userCancellation, teamCancellation] = await Promise.all([ allowUserAccountCancellationPending ? null diff --git a/packages/service/support/user/account/cancellation/service.ts b/packages/service/support/user/account/cancellation/service.ts index 94088f58868f..b924c8dfc808 100644 --- a/packages/service/support/user/account/cancellation/service.ts +++ b/packages/service/support/user/account/cancellation/service.ts @@ -1,13 +1,7 @@ -import { UserErrEnum } from '@fastgpt/global/common/error/code/user'; -import { - AccountCancellationStatusEnum, - accountCancellationActiveStatuses -} from '@fastgpt/global/support/user/account/cancellation/constants'; +import { AccountCancellationStatusEnum } from '@fastgpt/global/support/user/account/cancellation/constants'; import { isAccountCancellationMethod } from '@fastgpt/global/support/user/account/cancellation/utils'; import { deriveAccountCancellationSchedule } from '@fastgpt/global/support/user/account/cancellation/utils'; -import type { ClientSession } from '../../../../common/mongo'; import { checkTimerLock, deleteTimerLock } from '../../../../common/system/timerLock/utils'; -import { MongoUser } from '../../schema'; import { getAccountCancellationAuthKey } from './formatter'; import { getActiveAccountCancellationByUserId } from './read'; import { MongoAccountCancellation } from './schema'; @@ -58,44 +52,6 @@ export const assertAccountCancellationMethod = (method: string) => { } }; -/** - * 以唯一 userId 索引幂等创建 pending。记录只写 userId、status、requestedAt 三个业务字段。 - */ -export const createPendingAccountCancellation = async ({ - userId, - requestedAt = new Date(), - session -}: { - userId: string; - requestedAt?: Date; - session?: ClientSession; -}) => - withAccountCancellationUserLock(userId, async () => { - const user = await MongoUser.findById(userId, { username: 1, status: 1 }).lean(); - if (!user) throw new Error(UserErrEnum.notUser); - if (user.username === 'root') throw new Error('Root account can not be cancelled'); - if (user.status !== 'active') throw new Error('Account is not active'); - - const existing = await getActiveAccountCancellationByUserId(userId); - if (existing) return { record: existing, created: false }; - - await MongoAccountCancellation.updateOne( - { userId }, - { - $setOnInsert: { - userId, - status: AccountCancellationStatusEnum.pending, - requestedAt - } - }, - { upsert: true, session } - ); - - const record = await getActiveAccountCancellationByUserId(userId); - if (!record) throw new Error('Account cancellation record was not created'); - return { record, created: String(record.requestedAt) === String(requestedAt) }; - }); - /** 条件删除 pending;finalizing/completed 永远不会被取消。 */ export const cancelPendingAccountCancellation = async ({ userId, @@ -127,16 +83,3 @@ export const cancelPendingAccountCancellation = async ({ record } as const; }); - -/** finalizer 的原子认领入口;调用方必须先复核派生 scheduledCancelAt。 */ -export const claimAccountCancellationForFinalizing = async (userId: string) => - withAccountCancellationUserLock(userId, async () => { - const result = await MongoAccountCancellation.findOneAndUpdate( - { userId, status: AccountCancellationStatusEnum.pending }, - { $set: { status: AccountCancellationStatusEnum.finalizing } }, - { new: true } - ).lean(); - return result; - }); - -export const getActiveAccountCancellationStatuses = () => [...accountCancellationActiveStatuses]; diff --git a/packages/service/support/user/account/verification/password/service.ts b/packages/service/support/user/account/verification/password/service.ts index d881c06f4d3a..a09a92ca7e2e 100644 --- a/packages/service/support/user/account/verification/password/service.ts +++ b/packages/service/support/user/account/verification/password/service.ts @@ -2,7 +2,6 @@ import { addSeconds } from 'date-fns'; import { getNanoid } from '@fastgpt/global/common/string/tools'; import { UserErrEnum } from '@fastgpt/global/common/error/code/user'; import { UserError } from '@fastgpt/global/common/error/utils'; -import { i18nT } from '@fastgpt/global/common/i18n/utils'; import { UserStatusEnum } from '@fastgpt/global/support/user/constant'; import { AccountVerificationMaterialTypeEnum } from '@fastgpt/global/support/user/account/verification/constants'; import { MongoUser } from '../../../schema'; @@ -67,7 +66,7 @@ export class PasswordAccountVerification extends AccountVerification< now: this.dependencies.now() }); if (!material) { - throw new UserError(i18nT('common:error.code_error')); + throw new UserError(UserErrEnum.invalidVerificationCode); } const user = await MongoUser.findOne({ username, password }); diff --git a/packages/service/support/user/account/verification/schema.ts b/packages/service/support/user/account/verification/schema.ts index 0c9195896a50..e74e3641fd77 100644 --- a/packages/service/support/user/account/verification/schema.ts +++ b/packages/service/support/user/account/verification/schema.ts @@ -51,6 +51,7 @@ const AccountVerificationMaterialSchema = new Schema( +export const MongoTeamAudit = getMongoLogModel( TeamAuditCollectionName, TeamAuditSchema ); diff --git a/packages/service/support/user/auth/controller.ts b/packages/service/support/user/auth/controller.ts index b37eadb6cbf5..4ac5b08542ad 100644 --- a/packages/service/support/user/auth/controller.ts +++ b/packages/service/support/user/auth/controller.ts @@ -1,10 +1,11 @@ import { UserAuthTypeEnum } from '@fastgpt/global/support/user/auth/constants'; -import { MongoUserAuth } from './schema'; -import { i18nT } from '@fastgpt/global/common/i18n/utils'; +import { UserErrEnum } from '@fastgpt/global/common/error/code/user'; +import { MongoAccountVerificationMaterial } from '../account/verification/schema'; import { mongoSessionRun } from '../../../common/mongo/sessionRun'; import { UserError } from '@fastgpt/global/common/error/utils'; import { z } from 'zod'; import { assertCodeVerificationConsumeFrequency } from '../account/verification/utils'; +import { addMinutes } from 'date-fns'; export const addAuthCode = async ({ key, @@ -19,15 +20,19 @@ export const addAuthCode = async ({ type: `${UserAuthTypeEnum}`; expiredTime?: Date; }) => { - return MongoUserAuth.updateOne( + const createTime = new Date(); + return MongoAccountVerificationMaterial.updateOne( { key, type }, { - code, - openid, - expiredTime + $set: { + code, + openid, + createTime, + expiredTime: expiredTime ?? addMinutes(createTime, 5) + } }, { upsert: true @@ -45,7 +50,7 @@ export const authCode = async (props: z.infer) => { await assertCodeVerificationConsumeFrequency({ account: key, scene: type }); return mongoSessionRun(async (session) => { - const result = await MongoUserAuth.findOne( + const result = await MongoAccountVerificationMaterial.findOne( { key, type, @@ -56,7 +61,7 @@ export const authCode = async (props: z.infer) => { ); if (!result) { - return Promise.reject(new UserError(i18nT('common:error.code_error'))); + return Promise.reject(new UserError(UserErrEnum.invalidVerificationCode)); } await result.deleteOne(); diff --git a/packages/service/support/user/auth/schema.ts b/packages/service/support/user/auth/schema.ts deleted file mode 100644 index abe78acdfdfa..000000000000 --- a/packages/service/support/user/auth/schema.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { defineIndex, connectionMongo, getMongoModel } from '../../../common/mongo'; -const { Schema } = connectionMongo; -import type { UserAuthSchemaType } from '@fastgpt/global/support/user/auth/type'; -import { userAuthTypeMap } from '@fastgpt/global/support/user/auth/constants'; -import { addMinutes } from 'date-fns'; - -const UserAuthSchema = new Schema({ - key: { - type: String, - required: true - }, - code: { - // auth code - type: String, - length: 6 - }, - // wx openid - openid: String, - type: { - type: String, - enum: Object.keys(userAuthTypeMap), - required: true - }, - createTime: { - type: Date, - default: () => new Date() - }, - expiredTime: { - type: Date, - default: () => addMinutes(new Date(), 5) - } -}); - -defineIndex(UserAuthSchema, { key: { key: 1, type: 1 } }); -defineIndex(UserAuthSchema, { - key: { expiredTime: 1 }, - options: { expireAfterSeconds: 0 } -}); - -export const MongoUserAuth = getMongoModel('auth_codes', UserAuthSchema); diff --git a/packages/service/support/user/controller.ts b/packages/service/support/user/controller.ts index 61dafeab8e19..e00d9df445f3 100644 --- a/packages/service/support/user/controller.ts +++ b/packages/service/support/user/controller.ts @@ -15,24 +15,32 @@ export async function authUserExist({ userId, username }: { userId?: string; use return null; } +/** + * 加载用户及团队详情。登录恢复可显式允许注销中的团队作为 fallback,便于用户进入等待页取消注销。 + */ export async function getUserDetail({ tmbId, userId, - isRoot = false + isRoot = false, + allowAccountCancellationTeamFallback = false }: { tmbId?: string; userId?: string; isRoot?: boolean; + allowAccountCancellationTeamFallback?: boolean; }): Promise { const tmb = await (async () => { if (tmbId) { try { const result = await getTmbInfoByTmbId({ tmbId }); return result; - } catch (error) {} + } catch {} } if (userId) { - const fallback = await getUserFallbackTeam({ userId }); + const fallback = await getUserFallbackTeam({ + userId, + allowAccountCancellationTeam: allowAccountCancellationTeamFallback + }); if (fallback) return getTmbInfoByTmbId({ tmbId: fallback.tmbId }); } return Promise.reject(ERROR_ENUM.unAuthorization); diff --git a/packages/service/support/user/team/delete/index.ts b/packages/service/support/user/team/delete/index.ts index a74d85e84827..fdcaedf6c4de 100644 --- a/packages/service/support/user/team/delete/index.ts +++ b/packages/service/support/user/team/delete/index.ts @@ -1,4 +1,4 @@ -import { getQueue, getWorker, QueueNames } from '../../../../common/bullmq'; +import { addOrRequeueFailedJob, getQueue, getWorker, QueueNames } from '../../../../common/bullmq'; import { teamDeleteProcessor } from './processor'; export type TeamDeleteJobData = { @@ -34,8 +34,13 @@ export const addTeamDeleteJob = (data: TeamDeleteJobData) => { const jobId = `${String(data.teamId)}`; // Use jobId to automatically prevent duplicate deletion tasks (BullMQ feature) - return teamDeleteQueue.add('delete_team', data, { - jobId, - delay: 1000 // Delay 1 second to ensure API response completes + return addOrRequeueFailedJob({ + queue: teamDeleteQueue, + name: 'delete_team', + data, + opts: { + jobId, + delay: 1000 // Delay 1 second to ensure API response completes + } }); }; diff --git a/packages/service/support/user/team/delete/processor.ts b/packages/service/support/user/team/delete/processor.ts index 3f4aa92ceba8..b7dbaff426fa 100644 --- a/packages/service/support/user/team/delete/processor.ts +++ b/packages/service/support/user/team/delete/processor.ts @@ -19,8 +19,7 @@ import { MongoDiscountCoupon } from '../../../wallet/discountCoupon/schema'; import { MongoTeamAudit } from '../../audit/schema'; import { deleteTeamAllDatasets } from '../../../../core/dataset/delete/processor'; import { onDelAllApp } from './utils'; -import { MongoEvaluation } from '../../../../core/app/evaluation/evalSchema'; -import { MongoEvalItem } from '../../../../core/app/evaluation/evalItemSchema'; +import { deleteEvaluationsByTeamId } from '../../../../core/app/evaluation/delete'; import { MongoTeamSub } from '../../../../support/wallet/sub/schema'; import { getLogger, LogCategories } from '../../../../common/logger'; import { getUserFallbackTeam } from '../fallback'; @@ -35,6 +34,9 @@ export const teamDeleteProcessor: Processor = async (job) => const { teamId } = job.data; const startTime = Date.now(); + // App/Dataset 使用独立队列删除,这类残留在 team-delete 重试耗尽前不应升级为 ERR。 + class TeamResourcesStillDeletingError extends Error {} + logger.info('Team delete started', { teamId }); try { @@ -48,14 +50,7 @@ export const teamDeleteProcessor: Processor = async (job) => // 2. 先删除知识库和应用(它们内部有自己的队列) await deleteTeamAllDatasets(teamId); await onDelAllApp(teamId); - // 删除评估 - await MongoEvaluation.deleteMany({ - teamId - }); - // 删除评估项 - await MongoEvalItem.deleteMany({ - teamId - }); + await deleteEvaluationsByTeamId(teamId); // 删除图片(旧的了) await MongoImage.deleteMany({ @@ -106,7 +101,7 @@ export const teamDeleteProcessor: Processor = async (job) => ]); if (remainingApps > 0 || remainingDatasets > 0) { // App/Dataset worker 必须先完成,否则删除团队后 finalizer 无法再按 teamId 观察残留。 - throw new Error( + throw new TeamResourcesStillDeletingError( `Team resources are still being deleted: apps=${remainingApps}, datasets=${remainingDatasets}` ); } @@ -186,8 +181,19 @@ export const teamDeleteProcessor: Processor = async (job) => teamId, durationMs: Date.now() - startTime }); - } catch (error: any) { - logger.error('Team delete failed', { teamId, error }); + } catch (error) { + const maxAttempts = job.opts.attempts ?? 1; + const isFinalAttempt = job.attemptsMade + 1 >= maxAttempts; + if (error instanceof TeamResourcesStillDeletingError && !isFinalAttempt) { + logger.warn('Team delete waiting for resource deletion', { + teamId, + attempt: job.attemptsMade + 1, + maxAttempts, + error + }); + } else { + logger.error('Team delete failed', { teamId, error }); + } throw error; } }); diff --git a/packages/service/support/user/team/fallback.ts b/packages/service/support/user/team/fallback.ts index 314388084e52..fe50cd651077 100644 --- a/packages/service/support/user/team/fallback.ts +++ b/packages/service/support/user/team/fallback.ts @@ -4,14 +4,17 @@ import { MongoTeamMember } from './teamMemberSchema'; import { MongoTeam } from './teamSchema'; /** - * 找到用户可继续使用的团队。已删除团队、无效成员关系和注销中的 owner 团队都不能作为 fallback。 + * 找到用户可继续使用的团队。默认排除已删除团队、无效成员关系和注销中的 owner 团队; + * 登录恢复场景可显式允许注销中的团队作为受限 Session 上下文,后续访问仍由注销 guard 控制。 */ export const getUserFallbackTeam = async ({ userId, - excludedTeamId + excludedTeamId, + allowAccountCancellationTeam = false }: { userId: string; excludedTeamId?: string; + allowAccountCancellationTeam?: boolean; }) => { const members = await MongoTeamMember.find( { @@ -40,15 +43,14 @@ export const getUserFallbackTeam = async ({ ); const blockedTeamIds = new Set(cancellationTeams.map(({ teamId }) => teamId)); const validTeams = new Map(teams.map((team) => [String(team._id), team])); + const candidates = members.flatMap((member) => { + const teamId = String(member.teamId); + return validTeams.has(teamId) ? [{ teamId, tmbId: String(member._id) }] : []; + }); return ( - members - .map((member) => { - const teamId = String(member.teamId); - return blockedTeamIds.has(teamId) || !validTeams.has(teamId) - ? null - : { teamId, tmbId: String(member._id) }; - }) - .find(Boolean) ?? null + candidates.find(({ teamId }) => !blockedTeamIds.has(teamId)) ?? + (allowAccountCancellationTeam ? candidates[0] : undefined) ?? + null ); }; diff --git a/packages/service/test/common/bullmq/index.test.ts b/packages/service/test/common/bullmq/index.test.ts new file mode 100644 index 000000000000..b1a1e09638f7 --- /dev/null +++ b/packages/service/test/common/bullmq/index.test.ts @@ -0,0 +1,256 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { addOrRequeueFailedJob, type Job, type Queue } from '@fastgpt/service/common/bullmq'; +import { RedisLeaseUnavailableError } from '@fastgpt/service/common/redis/lock'; + +type TestJobData = { id: string }; + +describe('addOrRequeueFailedJob', () => { + const getJob = vi.fn(); + const add = vi.fn(); + const queue = { name: 'test-queue', getJob, add } as unknown as Queue; + + beforeEach(() => { + getJob.mockReset(); + add.mockReset(); + }); + + it('adds a new job when the stable job ID does not exist', async () => { + const addedJob = { id: 'job-1' } as Job; + getJob.mockResolvedValue(null); + add.mockResolvedValue(addedJob); + + await expect( + addOrRequeueFailedJob({ + queue, + name: 'test', + data: { id: 'data-1' }, + opts: { jobId: 'job-1' } + }) + ).resolves.toBe(addedJob); + + expect(add).toHaveBeenCalledWith('test', { id: 'data-1' }, { jobId: 'job-1' }); + }); + + it('recreates a job removed between getJob and getState', async () => { + const staleJob = { + getState: vi.fn().mockResolvedValue('unknown') + } as unknown as Job; + const replacementJob = { id: 'job-1' } as Job; + getJob.mockResolvedValueOnce(staleJob).mockResolvedValueOnce(null); + add.mockResolvedValue(replacementJob); + + await expect( + addOrRequeueFailedJob({ + queue, + name: 'test', + data: { id: 'fresh-data' }, + opts: { jobId: 'job-1' } + }) + ).resolves.toBe(replacementJob); + + expect(add).toHaveBeenCalledWith('test', { id: 'fresh-data' }, { jobId: 'job-1' }); + }); + + it('does not report a retained job with an unconfirmed unknown state as queued', async () => { + const unknownJob = { + getState: vi.fn().mockResolvedValue('unknown') + } as unknown as Job; + getJob.mockResolvedValue(unknownJob); + + await expect( + addOrRequeueFailedJob({ + queue, + name: 'test', + data: { id: 'fresh-data' }, + opts: { jobId: 'job-1' } + }) + ).rejects.toThrow('BullMQ job is in an unknown state: test-queue/job-1'); + + expect(add).not.toHaveBeenCalled(); + }); + + it('updates and retries a retained failed job without deleting its recovery point', async () => { + const updateData = vi.fn().mockResolvedValue(undefined); + const retry = vi.fn().mockResolvedValue(undefined); + const failedJob = { + getState: vi.fn().mockResolvedValue('failed'), + updateData, + retry + } as unknown as Job; + getJob.mockResolvedValue(failedJob); + + await expect( + addOrRequeueFailedJob({ + queue, + name: 'test', + data: { id: 'fresh-data' }, + opts: { jobId: 'job-1', delay: 1000 } + }) + ).resolves.toBe(failedJob); + + expect(updateData).toHaveBeenCalledWith({ id: 'fresh-data' }); + expect(retry).toHaveBeenCalledWith('failed'); + expect(add).not.toHaveBeenCalled(); + }); + + it('keeps an unfinished job without adding a duplicate', async () => { + const waitingJob = { + getState: vi.fn().mockResolvedValue('waiting'), + updateData: vi.fn(), + retry: vi.fn() + } as unknown as Job; + getJob.mockResolvedValue(waitingJob); + + await expect( + addOrRequeueFailedJob({ + queue, + name: 'test', + data: { id: 'data-1' }, + opts: { jobId: 'job-1' } + }) + ).resolves.toBe(waitingJob); + + expect(waitingJob.updateData).not.toHaveBeenCalled(); + expect(waitingJob.retry).not.toHaveBeenCalled(); + expect(add).not.toHaveBeenCalled(); + }); + + it('does not hide an update error while the retained job is still failed', async () => { + const error = new Error('redis unavailable'); + const failedJob = { + getState: vi.fn().mockResolvedValue('failed'), + updateData: vi.fn().mockRejectedValue(error), + retry: vi.fn() + } as unknown as Job; + getJob.mockResolvedValue(failedJob); + + await expect( + addOrRequeueFailedJob({ + queue, + name: 'test', + data: { id: 'data-1' }, + opts: { jobId: 'job-1' } + }) + ).rejects.toThrow(error); + + expect(failedJob.retry).not.toHaveBeenCalled(); + expect(add).not.toHaveBeenCalled(); + }); + + it('returns the job retried by another producer when retry races', async () => { + const retryError = new Error('job is not in the failed state'); + const failedJob = { + getState: vi.fn().mockResolvedValue('failed'), + updateData: vi.fn().mockResolvedValue(undefined), + retry: vi.fn().mockRejectedValue(retryError) + } as unknown as Job; + const waitingJob = { + getState: vi.fn().mockResolvedValue('waiting') + } as unknown as Job; + getJob.mockResolvedValueOnce(failedJob).mockResolvedValueOnce(waitingJob); + + await expect( + addOrRequeueFailedJob({ + queue, + name: 'test', + data: { id: 'data-1' }, + opts: { jobId: 'job-1' } + }) + ).resolves.toBe(waitingJob); + + expect(add).not.toHaveBeenCalled(); + }); + + it('keeps and surfaces a failed job when retry itself fails', async () => { + const retryError = new Error('redis unavailable'); + const failedJob = { + getState: vi.fn().mockResolvedValue('failed'), + updateData: vi.fn().mockResolvedValue(undefined), + retry: vi.fn().mockRejectedValue(retryError) + } as unknown as Job; + getJob.mockResolvedValue(failedJob); + + await expect( + addOrRequeueFailedJob({ + queue, + name: 'test', + data: { id: 'data-1' }, + opts: { jobId: 'job-1' } + }) + ).rejects.toThrow(retryError); + + expect(add).not.toHaveBeenCalled(); + }); + + it('recreates the job if retention cleanup removes it during recovery', async () => { + const updateError = new Error('job no longer exists'); + const failedJob = { + getState: vi.fn().mockResolvedValue('failed'), + updateData: vi.fn().mockRejectedValue(updateError), + retry: vi.fn() + } as unknown as Job; + const replacementJob = { id: 'job-1' } as Job; + getJob.mockResolvedValueOnce(failedJob).mockResolvedValueOnce(null); + add.mockResolvedValue(replacementJob); + + await expect( + addOrRequeueFailedJob({ + queue, + name: 'test', + data: { id: 'fresh-data' }, + opts: { jobId: 'job-1' } + }) + ).resolves.toBe(replacementJob); + + expect(add).toHaveBeenCalledWith('test', { id: 'fresh-data' }, { jobId: 'job-1' }); + expect(failedJob.retry).not.toHaveBeenCalled(); + }); + + it('does not let a concurrent producer overwrite data after another recovery starts', async () => { + let state = 'failed'; + let releaseUpdate!: () => void; + const updateStarted = new Promise((resolve) => { + releaseUpdate = resolve; + }); + let continueUpdate!: () => void; + const updateBlocked = new Promise((resolve) => { + continueUpdate = resolve; + }); + const updateData = vi.fn(async () => { + releaseUpdate(); + await updateBlocked; + }); + const retry = vi.fn(async () => { + state = 'waiting'; + }); + const failedJob = { + getState: vi.fn(async () => state), + updateData, + retry + } as unknown as Job; + getJob.mockResolvedValue(failedJob); + + const firstRecovery = addOrRequeueFailedJob({ + queue, + name: 'test', + data: { id: 'first-data' }, + opts: { jobId: 'job-1' } + }); + await updateStarted; + + await expect( + addOrRequeueFailedJob({ + queue, + name: 'test', + data: { id: 'second-data' }, + opts: { jobId: 'job-1' } + }) + ).rejects.toBeInstanceOf(RedisLeaseUnavailableError); + + expect(updateData).toHaveBeenCalledTimes(1); + expect(updateData).toHaveBeenCalledWith({ id: 'first-data' }); + continueUpdate(); + await expect(firstRecovery).resolves.toBe(failedJob); + expect(retry).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/service/test/common/http/entry.test.ts b/packages/service/test/common/http/entry.test.ts new file mode 100644 index 000000000000..0820a686fb94 --- /dev/null +++ b/packages/service/test/common/http/entry.test.ts @@ -0,0 +1,129 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { UserErrEnum } from '@fastgpt/global/common/error/code/user'; +import { ERROR_RESPONSE } from '@fastgpt/global/common/error/errorCode'; +import { UserError } from '@fastgpt/global/common/error/utils'; + +const mocks = vi.hoisted(() => ({ + logger: { + info: vi.fn(), + error: vi.fn() + }, + span: { + setAttribute: vi.fn() + }, + setSpanError: vi.fn() +})); + +vi.mock('@fastgpt/service/common/logger', () => ({ + getLogger: () => mocks.logger, + LogCategories: { + HTTP: { + REQUEST: 'http.request', + RESPONSE: 'http.response', + ERROR: 'http.error' + } + }, + withContext: (_context: unknown, callback: () => unknown) => callback() +})); + +vi.mock('@fastgpt/service/common/tracing', () => ({ + setSpanError: mocks.setSpanError, + withActiveSpan: (_options: unknown, callback: (span: typeof mocks.span) => unknown) => + callback(mocks.span) +})); + +vi.mock('@fastgpt/service/common/security/clientIp', () => ({ + getClientIpFromRequest: () => '127.0.0.1' +})); + +vi.mock('@fastgpt/service/support/permission/auth/common', () => ({ + clearCookie: vi.fn() +})); + +vi.unmock('@fastgpt/service/common/http/entry'); +vi.unmock('@fastgpt/service/common/response'); + +const { createApiEntry } = await import('../../../common/http/entry'); + +const createResponse = () => { + const headers = new Map(); + const listeners = new Map void>(); + const response: any = { + statusCode: 200, + writableFinished: false, + body: undefined, + setHeader: vi.fn((key: string, value: unknown) => { + headers.set(key.toLowerCase(), value); + return response; + }), + getHeader: vi.fn((key: string) => headers.get(key.toLowerCase())), + once: vi.fn((event: string, listener: () => void) => { + listeners.set(event, listener); + return response; + }), + status: vi.fn((statusCode: number) => { + response.statusCode = statusCode; + return response; + }), + json: vi.fn((body: unknown) => { + response.body = body; + response.writableFinished = true; + listeners.get('finish')?.(); + return response; + }) + }; + + return response; +}; + +const request = { + method: 'POST', + url: '/api/support/user/verification', + headers: {}, + body: {}, + query: {}, + socket: { remoteAddress: '127.0.0.1' } +}; + +describe('createApiEntry error status', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it.each([ + [UserErrEnum.invalidVerificationCode, 400], + [UserErrEnum.sendVerificationCodeTooFrequently, 429], + [UserErrEnum.verifyCodeTooFrequently, 429] + ] as const)('returns and traces the configured status for %s', async (errorKey, httpStatus) => { + const response = createResponse(); + const handler = createApiEntry({})(async () => { + throw new UserError(errorKey); + }); + + await handler(request as any, response); + + expect(response.statusCode).toBe(httpStatus); + expect(response.body).toMatchObject({ + code: ERROR_RESPONSE[errorKey].code, + statusText: errorKey, + message: ERROR_RESPONSE[errorKey].message, + errorType: 'UserError' + }); + expect(mocks.span.setAttribute).toHaveBeenCalledWith('http.response.status_code', httpStatus); + expect(mocks.setSpanError).not.toHaveBeenCalled(); + }); + + it('keeps unexpected failures as traced 500 responses', async () => { + const response = createResponse(); + const error = new Error('unexpected'); + const handler = createApiEntry({})(async () => { + throw error; + }); + + await handler(request as any, response); + + expect(response.statusCode).toBe(500); + expect(mocks.span.setAttribute).toHaveBeenCalledWith('http.response.status_code', 500); + expect(mocks.setSpanError).toHaveBeenCalledWith(mocks.span, error); + }); +}); diff --git a/packages/service/test/common/response/index.test.ts b/packages/service/test/common/response/index.test.ts index 184cbb29f350..773372e5ff31 100644 --- a/packages/service/test/common/response/index.test.ts +++ b/packages/service/test/common/response/index.test.ts @@ -1,8 +1,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { z } from 'zod'; -import { ApiRequestInputParseError } from '../../../common/zod/requestParseError'; -import { UserError } from '@fastgpt/global/common/error/utils'; +import { UserErrEnum } from '@fastgpt/global/common/error/code/user'; +import { SandboxErrEnum } from '@fastgpt/global/common/error/code/sandbox'; import { ERROR_ENUM, ERROR_RESPONSE } from '@fastgpt/global/common/error/errorCode'; +import { UserError } from '@fastgpt/global/common/error/utils'; +import { ApiRequestInputParseError } from '../../../common/zod/requestParseError'; vi.unmock('@fastgpt/service/common/response'); @@ -20,7 +22,7 @@ vi.mock('@fastgpt/service/common/logger', () => ({ } })); -const { getSseErrorResponse, processError } = await import('../../../common/response'); +const { getSseErrorResponse, jsonRes, processError } = await import('../../../common/response'); function buildZodError() { try { @@ -85,6 +87,68 @@ describe('processError zod logging', () => { }); }); +describe('jsonRes business HTTP status', () => { + const createResponse = () => { + const response: any = { + statusCode: 200, + status: vi.fn((statusCode: number) => { + response.statusCode = statusCode; + return response; + }), + json: vi.fn() + }; + return response; + }; + + it.each([ + [UserErrEnum.invalidVerificationCode, 400], + [UserErrEnum.sendVerificationCodeTooFrequently, 429], + [UserErrEnum.verifyCodeTooFrequently, 429] + ] as const)('uses the configured HTTP status for %s', (errorKey, httpStatus) => { + const response = createResponse(); + + jsonRes(response, { + code: 500, + error: new UserError(errorKey), + url: '/api/support/user/verification' + }); + + expect(response.status).toHaveBeenCalledWith(httpStatus); + expect(response.json).toHaveBeenCalledWith( + expect.objectContaining({ + code: ERROR_RESPONSE[errorKey].code, + statusText: errorKey, + message: ERROR_RESPONSE[errorKey].message, + errorType: 'UserError' + }) + ); + }); + + it('keeps an unclassified UserError as an internal server error', () => { + const response = createResponse(); + + jsonRes(response, { + code: 500, + error: new UserError('unclassified'), + url: '/api/test' + }); + + expect(response.status).toHaveBeenCalledWith(500); + }); + + it('prefers an explicit HTTP status over a legacy business-code range fallback', () => { + const response = createResponse(); + + jsonRes(response, { + code: 500, + error: new UserError(SandboxErrEnum.agentSandboxInitializing), + url: '/api/core/ai/sandbox' + }); + + expect(response.status).toHaveBeenCalledWith(409); + }); +}); + describe('getSseErrorResponse logging', () => { beforeEach(() => { logger.info.mockClear(); diff --git a/packages/service/test/common/system/cron.test.ts b/packages/service/test/common/system/cron.test.ts new file mode 100644 index 000000000000..e24b22349219 --- /dev/null +++ b/packages/service/test/common/system/cron.test.ts @@ -0,0 +1,30 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + schedule: vi.fn() +})); + +vi.mock('node-cron', () => ({ + default: { + schedule: mocks.schedule + } +})); + +import { setCron } from '@fastgpt/service/common/system/cron'; + +describe('setCron', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('forwards scheduler options to node-cron', () => { + const callback = vi.fn(); + const task = { stop: vi.fn() }; + mocks.schedule.mockReturnValue(task); + + expect(setCron('0 10 * * *', callback, { timezone: 'Asia/Shanghai' })).toBe(task); + expect(mocks.schedule).toHaveBeenCalledWith('0 10 * * *', callback, { + timezone: 'Asia/Shanghai' + }); + }); +}); diff --git a/packages/service/test/core/app/evaluation/delete.test.ts b/packages/service/test/core/app/evaluation/delete.test.ts new file mode 100644 index 000000000000..18a2d4e6ce83 --- /dev/null +++ b/packages/service/test/core/app/evaluation/delete.test.ts @@ -0,0 +1,66 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + findEvaluations: vi.fn(), + deleteEvaluations: vi.fn(), + deleteEvalItems: vi.fn() +})); + +vi.mock('@fastgpt/service/core/app/evaluation/evalSchema', () => ({ + MongoEvaluation: { + find: mocks.findEvaluations, + deleteMany: mocks.deleteEvaluations + } +})); + +vi.mock('@fastgpt/service/core/app/evaluation/evalItemSchema', () => ({ + MongoEvalItem: { + deleteMany: mocks.deleteEvalItems + } +})); + +import { deleteEvaluationsByTeamId } from '@fastgpt/service/core/app/evaluation/delete'; + +describe('deleteEvaluationsByTeamId', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.deleteEvaluations.mockResolvedValue(undefined); + mocks.deleteEvalItems.mockResolvedValue(undefined); + }); + + it('deletes evaluation items by evalId before deleting their parent evaluations', async () => { + const lean = vi.fn().mockResolvedValue([{ _id: 'eval-1' }, { _id: 'eval-2' }]); + mocks.findEvaluations.mockReturnValue({ lean }); + + await deleteEvaluationsByTeamId('team-1'); + + expect(mocks.findEvaluations).toHaveBeenCalledWith({ teamId: 'team-1' }, '_id'); + expect(mocks.deleteEvalItems).toHaveBeenCalledWith({ + evalId: { $in: ['eval-1', 'eval-2'] } + }); + expect(mocks.deleteEvaluations).toHaveBeenCalledWith({ teamId: 'team-1' }); + expect(mocks.deleteEvalItems.mock.invocationCallOrder[0]).toBeLessThan( + mocks.deleteEvaluations.mock.invocationCallOrder[0] + ); + }); + + it('skips the child deletion when the team has no evaluations', async () => { + mocks.findEvaluations.mockReturnValue({ lean: vi.fn().mockResolvedValue([]) }); + + await deleteEvaluationsByTeamId('team-1'); + + expect(mocks.deleteEvalItems).not.toHaveBeenCalled(); + expect(mocks.deleteEvaluations).toHaveBeenCalledWith({ teamId: 'team-1' }); + }); + + it('keeps parent evaluations available when deleting their items fails', async () => { + mocks.findEvaluations.mockReturnValue({ + lean: vi.fn().mockResolvedValue([{ _id: 'eval-1' }]) + }); + mocks.deleteEvalItems.mockRejectedValue(new Error('delete items failed')); + + await expect(deleteEvaluationsByTeamId('team-1')).rejects.toThrow('delete items failed'); + + expect(mocks.deleteEvaluations).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/service/test/support/outLink/runtime/utils.test.ts b/packages/service/test/support/outLink/runtime/utils.test.ts index 621cb5f83df9..10210927f7b8 100644 --- a/packages/service/test/support/outLink/runtime/utils.test.ts +++ b/packages/service/test/support/outLink/runtime/utils.test.ts @@ -74,6 +74,10 @@ vi.mock('@fastgpt/service/support/outLink/tools', () => ({ addOutLinkUsage: vi.fn() })); +vi.mock('@fastgpt/service/support/user/account/cancellation/guard', () => ({ + assertAccountUsable: vi.fn() +})); + vi.mock('@fastgpt/global/core/workflow/runtime/utils', async (importOriginal) => { const actual = await importOriginal(); diff --git a/packages/service/test/support/user/account/cancellation/access.test.ts b/packages/service/test/support/user/account/cancellation/access.test.ts new file mode 100644 index 000000000000..7caa94f116a1 --- /dev/null +++ b/packages/service/test/support/user/account/cancellation/access.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest'; +import { resolveAccountCancellationAccess } from '@fastgpt/service/support/user/account/cancellation/access'; + +describe('resolveAccountCancellationAccess', () => { + it.each([ + { + method: 'GET', + url: '/api/proApi/support/user/account/cancellation/status', + expected: true + }, + { + method: 'DELETE', + url: '/api/proApi/support/user/account/cancellation/cancel', + expected: true + }, + { + method: 'POST', + url: '/api/proApi/support/user/account/cancellation/verification/create', + expected: false + }, + { + method: 'POST', + url: '/api/proApi/support/user/account/cancellation/submit', + expected: false + } + ])('resolves current-session team access for $method $url', ({ method, url, expected }) => { + const result = resolveAccountCancellationAccess({ + req: { method, url }, + accountCancellationAccess: 'selfCancellation' + }); + + expect(result.allowCurrentSessionTeamAccountCancellationPending).toBe(expected); + }); +}); diff --git a/packages/service/test/support/user/account/cancellation/guard.test.ts b/packages/service/test/support/user/account/cancellation/guard.test.ts new file mode 100644 index 000000000000..d10b3074557a --- /dev/null +++ b/packages/service/test/support/user/account/cancellation/guard.test.ts @@ -0,0 +1,50 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { UserErrEnum } from '@fastgpt/global/common/error/code/user'; +import { AccountCancellationStatusEnum } from '@fastgpt/global/support/user/account/cancellation/constants'; +import { Types } from '@fastgpt/service/common/mongo'; +import { assertAccountUsable } from '@fastgpt/service/support/user/account/cancellation/guard'; +import { MongoAccountCancellation } from '@fastgpt/service/support/user/account/cancellation/schema'; +import { MongoTeamMember } from '@fastgpt/service/support/user/team/teamMemberSchema'; +import { MongoTeam } from '@fastgpt/service/support/user/team/teamSchema'; + +describe('assertAccountUsable', () => { + beforeEach(async () => { + await Promise.all([ + MongoAccountCancellation.deleteMany({}), + MongoTeamMember.deleteMany({}), + MongoTeam.deleteMany({}) + ]); + }); + + it('resolves the actual API Key member from tmbId when teamId is already present', async () => { + const userId = new Types.ObjectId(); + const [team] = await MongoTeam.create([ + { + name: 'API Key cancellation team', + ownerId: new Types.ObjectId() + } + ]); + const [member] = await MongoTeamMember.create([ + { + teamId: team._id, + userId, + name: 'Member', + status: 'active' + } + ]); + await MongoAccountCancellation.create([ + { + userId, + status: AccountCancellationStatusEnum.pending, + requestedAt: new Date() + } + ]); + + await expect( + assertAccountUsable({ + teamId: String(team._id), + tmbId: String(member._id) + }) + ).rejects.toThrow(UserErrEnum.accountCancellationPending); + }); +}); diff --git a/packages/service/test/support/user/account/verification/entity.test.ts b/packages/service/test/support/user/account/verification/entity.test.ts index 8210939dc4d8..5781937d47b9 100644 --- a/packages/service/test/support/user/account/verification/entity.test.ts +++ b/packages/service/test/support/user/account/verification/entity.test.ts @@ -10,6 +10,8 @@ import { upsertVerificationMaterial } from '@fastgpt/service/support/user/account/verification/entity'; import { MongoAccountVerificationMaterial } from '@fastgpt/service/support/user/account/verification/schema'; +import { addAuthCode } from '@fastgpt/service/support/user/auth/controller'; +import { UserAuthTypeEnum } from '@fastgpt/global/support/user/auth/constants'; describe('verification material entity', () => { beforeEach(async () => { @@ -42,6 +44,37 @@ describe('verification material entity', () => { expect(records[0].expiredTime).toEqual(addMinutes(secondCreatedAt, 5)); }); + it('shares one model with the legacy auth controller without dropping binding fields', async () => { + await addAuthCode({ + key: 'legacy-account', + type: UserAuthTypeEnum.login, + code: '123456' + }); + + await createVerificationMaterial({ + key: 'accountCancellation:user-hash', + type: AccountVerificationMaterialTypeEnum.accountCancellation, + code: '654321', + userIdHash: 'user-hash', + purpose: 'accountCancellation', + provider: 'github', + callbackHash: 'callback-hash', + expiredTime: addMinutes(new Date(), 5) + }); + + await expect( + MongoAccountVerificationMaterial.findOne({ + key: 'accountCancellation:user-hash', + type: AccountVerificationMaterialTypeEnum.accountCancellation + }).lean() + ).resolves.toMatchObject({ + userIdHash: 'user-hash', + purpose: 'accountCancellation', + provider: 'github', + callbackHash: 'callback-hash' + }); + }); + it('rejects material at and after the expiration boundary', async () => { const expiredTime = new Date('2026-07-14T00:05:00.000Z'); await upsertVerificationMaterial({ diff --git a/packages/service/test/support/user/account/verification/password/service.test.ts b/packages/service/test/support/user/account/verification/password/service.test.ts index aa38025fdd47..9f0d602c01e0 100644 --- a/packages/service/test/support/user/account/verification/password/service.test.ts +++ b/packages/service/test/support/user/account/verification/password/service.test.ts @@ -45,7 +45,7 @@ describe('PasswordAccountVerification', () => { }); await expect( verification.consume({ username: 'git-user', password: 'password', code: 'ABC123' }) - ).rejects.toThrow(); + ).rejects.toThrow(UserErrEnum.invalidVerificationCode); }); it('uses one account error for an unknown user or wrong password', async () => { diff --git a/packages/service/test/support/user/account/verification/utils.test.ts b/packages/service/test/support/user/account/verification/utils.test.ts index 7da4464d583e..ceb42047dd99 100644 --- a/packages/service/test/support/user/account/verification/utils.test.ts +++ b/packages/service/test/support/user/account/verification/utils.test.ts @@ -4,8 +4,8 @@ import { buildVerificationCodeFilter, escapeVerificationCodeForRegExp } from '@fastgpt/service/support/user/account/verification/utils'; +import { UserErrEnum } from '@fastgpt/global/common/error/code/user'; import { getGlobalRedisConnection } from '@fastgpt/service/common/redis'; -import { i18nT } from '@fastgpt/global/common/i18n/utils'; describe('escapeVerificationCodeForRegExp', () => { it('escapes every regular expression metacharacter', () => { @@ -46,7 +46,7 @@ describe('assertCodeVerificationConsumeFrequency', () => { } await expect(assertCodeVerificationConsumeFrequency(params)).rejects.toThrow( - i18nT('common:error.verify_code_too_frequently') + UserErrEnum.verifyCodeTooFrequently ); }); diff --git a/packages/service/test/support/user/auth/controller.test.ts b/packages/service/test/support/user/auth/controller.test.ts index 329703a31c74..de588861f197 100644 --- a/packages/service/test/support/user/auth/controller.test.ts +++ b/packages/service/test/support/user/auth/controller.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { i18nT } from '@fastgpt/global/common/i18n/utils'; +import { UserErrEnum } from '@fastgpt/global/common/error/code/user'; import { UserAuthTypeEnum } from '@fastgpt/global/support/user/auth/constants'; import { getGlobalRedisConnection } from '@fastgpt/service/common/redis'; import { authCode } from '@fastgpt/service/support/user/auth/controller'; @@ -26,8 +26,6 @@ describe('authCode', () => { await authCode(params).catch(() => undefined); } - await expect(authCode(params)).rejects.toThrow( - i18nT('common:error.verify_code_too_frequently') - ); + await expect(authCode(params)).rejects.toThrow(UserErrEnum.verifyCodeTooFrequently); }); }); diff --git a/packages/service/test/support/user/team/delete/index.test.ts b/packages/service/test/support/user/team/delete/index.test.ts new file mode 100644 index 000000000000..5da8b5a8f7e1 --- /dev/null +++ b/packages/service/test/support/user/team/delete/index.test.ts @@ -0,0 +1,36 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + queue: { name: 'teamDelete' }, + addOrRequeueFailedJob: vi.fn() +})); + +vi.mock('@fastgpt/service/common/bullmq', () => ({ + QueueNames: { teamDelete: 'teamDelete' }, + getQueue: vi.fn(() => mocks.queue), + getWorker: vi.fn(), + addOrRequeueFailedJob: mocks.addOrRequeueFailedJob +})); + +vi.mock('@fastgpt/service/support/user/team/delete/processor', () => ({ + teamDeleteProcessor: vi.fn() +})); + +import { addTeamDeleteJob } from '@fastgpt/service/support/user/team/delete'; + +describe('addTeamDeleteJob', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('uses the team ID as a retryable stable job ID', async () => { + await addTeamDeleteJob({ teamId: 'team-1' }); + + expect(mocks.addOrRequeueFailedJob).toHaveBeenCalledWith({ + queue: mocks.queue, + name: 'delete_team', + data: { teamId: 'team-1' }, + opts: { jobId: 'team-1', delay: 1000 } + }); + }); +}); diff --git a/packages/service/test/support/user/team/delete/processor.test.ts b/packages/service/test/support/user/team/delete/processor.test.ts new file mode 100644 index 000000000000..8235c0ce26cf --- /dev/null +++ b/packages/service/test/support/user/team/delete/processor.test.ts @@ -0,0 +1,155 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + withTeamLock: vi.fn(), + findTeamById: vi.fn(), + deleteDatasets: vi.fn(), + deleteApps: vi.fn(), + deleteEvaluations: vi.fn(), + deleteMany: vi.fn(), + countApps: vi.fn(), + countDatasets: vi.fn(), + loggerInfo: vi.fn(), + loggerWarn: vi.fn(), + loggerError: vi.fn() +})); + +vi.mock('@fastgpt/service/support/user/account/cancellation', () => ({ + withAccountCancellationTeamLock: mocks.withTeamLock +})); + +vi.mock('@fastgpt/service/support/user/team/teamSchema', () => ({ + MongoTeam: { findById: mocks.findTeamById } +})); + +vi.mock('@fastgpt/service/core/dataset/delete/processor', () => ({ + deleteTeamAllDatasets: mocks.deleteDatasets +})); + +vi.mock('@fastgpt/service/support/user/team/delete/utils', () => ({ + onDelAllApp: mocks.deleteApps +})); + +vi.mock('@fastgpt/service/core/app/evaluation/delete', () => ({ + deleteEvaluationsByTeamId: mocks.deleteEvaluations +})); + +vi.mock('@fastgpt/service/common/file/image/schema', () => ({ + MongoImage: { deleteMany: mocks.deleteMany } +})); + +vi.mock('@fastgpt/service/core/app/schema', () => ({ + MongoApp: { countDocuments: mocks.countApps } +})); + +vi.mock('@fastgpt/service/core/dataset/schema', () => ({ + MongoDataset: { countDocuments: mocks.countDatasets } +})); + +vi.mock('@fastgpt/service/support/openapi/schema', () => ({ + MongoOpenApi: { deleteMany: mocks.deleteMany } +})); + +vi.mock('@fastgpt/service/core/chat/setting/schema', () => ({ + MongoChatSetting: { deleteMany: mocks.deleteMany } +})); + +vi.mock('@fastgpt/service/core/chat/favouriteApp/schema', () => ({ + MongoChatFavouriteApp: { deleteMany: mocks.deleteMany } +})); + +vi.mock('@fastgpt/service/support/wallet/discountCoupon/schema', () => ({ + MongoDiscountCoupon: { deleteMany: mocks.deleteMany } +})); + +vi.mock('@fastgpt/service/support/user/audit/schema', () => ({ + MongoTeamAudit: { deleteMany: mocks.deleteMany } +})); + +vi.mock('@fastgpt/service/support/wallet/sub/schema', () => ({ + MongoTeamSub: { deleteMany: mocks.deleteMany } +})); + +vi.mock('@fastgpt/service/support/mcp/schema', () => ({ + MongoMcpKey: { deleteMany: mocks.deleteMany } +})); + +vi.mock('@fastgpt/service/support/outLink/schema', () => ({ + MongoOutLink: { deleteMany: mocks.deleteMany } +})); + +vi.mock('@fastgpt/service/common/logger', () => ({ + getLogger: () => ({ + info: mocks.loggerInfo, + warn: mocks.loggerWarn, + error: mocks.loggerError + }), + LogCategories: { MODULE: { USER: { TEAM: 'team' } } } +})); + +import { teamDeleteProcessor } from '@fastgpt/service/support/user/team/delete/processor'; + +const createJob = (attemptsMade: number) => + ({ + data: { teamId: 'team-1' }, + attemptsMade, + opts: { attempts: 10 } + }) as Parameters[0]; + +describe('teamDeleteProcessor failure logging', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.withTeamLock.mockImplementation(async (_teamId, callback) => callback()); + mocks.findTeamById.mockResolvedValue({}); + mocks.deleteDatasets.mockResolvedValue(undefined); + mocks.deleteApps.mockResolvedValue(undefined); + mocks.deleteEvaluations.mockResolvedValue(undefined); + mocks.deleteMany.mockResolvedValue(undefined); + mocks.countApps.mockResolvedValue(1); + mocks.countDatasets.mockResolvedValue(0); + }); + + it('logs expected resource deletion lag as a warning before the final attempt', async () => { + const job = createJob(0); + + await expect(teamDeleteProcessor(job)).rejects.toThrow( + 'Team resources are still being deleted: apps=1, datasets=0' + ); + + expect(mocks.loggerWarn).toHaveBeenCalledWith('Team delete waiting for resource deletion', { + teamId: 'team-1', + attempt: 1, + maxAttempts: 10, + error: expect.any(Error) + }); + expect(mocks.loggerError).not.toHaveBeenCalled(); + }); + + it('logs expected resource deletion lag as an error on the final attempt', async () => { + const job = createJob(9); + + await expect(teamDeleteProcessor(job)).rejects.toThrow( + 'Team resources are still being deleted: apps=1, datasets=0' + ); + + expect(mocks.loggerWarn).not.toHaveBeenCalled(); + expect(mocks.loggerError).toHaveBeenCalledWith('Team delete failed', { + teamId: 'team-1', + error: expect.any(Error) + }); + }); + + it('logs infrastructure failures as errors without waiting for retries to exhaust', async () => { + const error = new Error('mongo unavailable'); + mocks.findTeamById.mockRejectedValueOnce(error); + const job = createJob(0); + + await expect(teamDeleteProcessor(job)).rejects.toThrow('mongo unavailable'); + + expect(mocks.loggerWarn).not.toHaveBeenCalled(); + expect(mocks.loggerError).toHaveBeenCalledWith('Team delete failed', { + teamId: 'team-1', + error + }); + }); +}); diff --git a/packages/web/i18n/en/account_info.json b/packages/web/i18n/en/account_info.json index 04f28dac39d4..6513bbf2a60b 100644 --- a/packages/web/i18n/en/account_info.json +++ b/packages/web/i18n/en/account_info.json @@ -41,6 +41,7 @@ "account_cancellation_requested_at": "Requested: {{time}}", "account_cancellation_scheduled_at": "Scheduled deletion: {{time}}", "account_cancellation_send_code": "Get code", + "account_cancellation_submit_success": "Account deletion request submitted", "account_cancellation_switch_team": "Switch team", "account_cancellation_team_finalizing_desc": "This team is being deleted. Contact the team owner for an update.", "account_cancellation_team_pending_desc": "The team owner requested deletion and the team is in its 15-day waiting period. Contact the owner to cancel deletion.", diff --git a/packages/web/i18n/en/account_team.json b/packages/web/i18n/en/account_team.json index 13b3d9477575..c63ac903e887 100644 --- a/packages/web/i18n/en/account_team.json +++ b/packages/web/i18n/en/account_team.json @@ -27,6 +27,10 @@ "admin_update_system_modal": "System announcement configuration", "admin_update_team": "Edit team information", "admin_update_user": "Edit User", + "admin_delete_user": "Delete User", + "account_cancellation_submit": "Submit Account Deletion", + "account_cancellation_cancel": "Cancel Account Deletion", + "account_cancellation_finalize": "Complete Account Deletion Cleanup", "assign_permission": "Permission change", "audit_log": "audit", "change_department_name": "Department Editor", @@ -123,6 +127,7 @@ "link_forbidden": "Forbidden", "log_admin_add_plan": "【{{name}}】A package will be added to a team with a team id [{{teamId}}]", "log_admin_add_user": "【{{name}}】Create a user named [{{userName}}]", + "log_admin_delete_user": "【{{name}}】Deleted user [{{userName}}]", "log_admin_create_app_template": "【{{name}}】Added a template named [{{templateName}}]", "log_admin_create_plugin": "【{{name}}】Added plugin named [{{pluginName}}]", "log_admin_create_plugin_group": "【{{name}}】Create a plug-in group called [{{groupName}}]", @@ -148,6 +153,9 @@ "log_change_department": "【{{name}}】Updated department【{{departmentName}}】", "log_change_member_name": "【{{name}}】Rename member [{{memberName}}] to 【{{newName}}】", "log_change_member_name_self": "【{{name}}】Change your member name to 【{{newName}}】", + "log_account_cancellation_submit": "【{{name}}】Submitted an account deletion request", + "log_account_cancellation_cancel": "【{{name}}】Canceled an account deletion request", + "log_account_cancellation_finalize": "【{{name}}】Completed account deletion cleanup", "log_change_notification_settings": "【{{name}}】A change notification receiving method operation was carried out", "log_change_password": "【{{name}}】The password change operation was performed", "log_copy_api_key": "【{{name}}】Copied the API key named [{{keyName}}]", diff --git a/packages/web/i18n/en/common.json b/packages/web/i18n/en/common.json index 9e945cc0a93c..71a6eadc66eb 100644 --- a/packages/web/i18n/en/common.json +++ b/packages/web/i18n/en/common.json @@ -794,6 +794,7 @@ "error.invalid_params": "Invalid parameter", "error.llm_track_expired": "Request details expired", "error.missingParams": "Insufficient parameters", + "error.operation_too_frequently": "Too many operations. Please try again later.", "error.s3_upload_invalid_file_type": "Unsupported file content or file extension does not match", "error.send_auth_code_too_frequently": "Please do not obtain verification code frequently", "error.verify_code_too_frequently": "Too many verification attempts. Please try again later.", diff --git a/packages/web/i18n/zh-CN/account_info.json b/packages/web/i18n/zh-CN/account_info.json index cead3f31f318..3d7ef984d3f2 100644 --- a/packages/web/i18n/zh-CN/account_info.json +++ b/packages/web/i18n/zh-CN/account_info.json @@ -41,6 +41,7 @@ "account_cancellation_requested_at": "申请时间:{{time}}", "account_cancellation_scheduled_at": "预计注销时间:{{time}}", "account_cancellation_send_code": "获取验证码", + "account_cancellation_submit_success": "注销提交成功", "account_cancellation_switch_team": "切换团队", "account_cancellation_team_finalizing_desc": "团队已进入注销清理阶段。您可联系团队所有者了解处理进度。", "account_cancellation_team_pending_desc": "团队已由团队所有者提交注销申请,目前处于 15 天注销等待期。您可联系团队所有者取消注销。", diff --git a/packages/web/i18n/zh-CN/account_team.json b/packages/web/i18n/zh-CN/account_team.json index 7040ad153bd8..580619847d81 100644 --- a/packages/web/i18n/zh-CN/account_team.json +++ b/packages/web/i18n/zh-CN/account_team.json @@ -27,6 +27,10 @@ "admin_update_system_modal": "系统公告配置", "admin_update_team": "编辑团队信息", "admin_update_user": "编辑用户信息", + "admin_delete_user": "删除用户", + "account_cancellation_submit": "提交账号注销", + "account_cancellation_cancel": "取消账号注销", + "account_cancellation_finalize": "完成账号注销清理", "assign_permission": "权限变更", "audit_log": "审计", "change_department_name": "部门编辑", @@ -121,6 +125,7 @@ "link_forbidden": "禁用", "log_admin_add_plan": "【{{name}}】将给团队id为【{{teamId}}】的团队添加了套餐", "log_admin_add_user": "【{{name}}】创建了一个名为【{{userName}}】的用户", + "log_admin_delete_user": "【{{name}}】删除了用户【{{userName}}】", "log_admin_create_app_template": "【{{name}}】添加了名为【{{templateName}}】的模板", "log_admin_create_plugin": "【{{name}}】添加了名为【{{pluginName}}】的插件", "log_admin_create_plugin_group": "【{{name}}】创建了名为【{{groupName}}】的插件分组", @@ -146,6 +151,9 @@ "log_change_department": "【{{name}}】更新了部门【{{departmentName}}】", "log_change_member_name": "【{{name}}】将成员【{{memberName}}】重命名为【{{newName}}】", "log_change_member_name_self": "【{{name}}】把自己的成员名从【{{oldName}}】变更为【{{newName}}】", + "log_account_cancellation_submit": "【{{name}}】提交了账号注销申请", + "log_account_cancellation_cancel": "【{{name}}】取消了账号注销申请", + "log_account_cancellation_finalize": "【{{name}}】完成了账号注销清理", "log_change_notification_settings": "【{{name}}】进行了变更通知接收途径操作", "log_change_password": "【{{name}}】进行了变更密码操作", "log_copy_api_key": "【{{name}}】复制了名为【{{keyName}}】的api密钥", diff --git a/packages/web/i18n/zh-CN/common.json b/packages/web/i18n/zh-CN/common.json index 88d42a26e335..2ae62fde7a5c 100644 --- a/packages/web/i18n/zh-CN/common.json +++ b/packages/web/i18n/zh-CN/common.json @@ -794,6 +794,7 @@ "error.invalid_params": "参数无效", "error.llm_track_expired": "请求详情已过期", "error.missingParams": "参数缺失", + "error.operation_too_frequently": "操作过于频繁,请稍后再试", "error.s3_upload_invalid_file_type": "文件内容不受支持,或文件后缀与内容不匹配", "error.send_auth_code_too_frequently": "请勿频繁获取验证码", "error.verify_code_too_frequently": "验证过于频繁,请稍后再试", diff --git a/packages/web/i18n/zh-Hant/account_info.json b/packages/web/i18n/zh-Hant/account_info.json index 710a68ea2954..f67a593e4a4a 100644 --- a/packages/web/i18n/zh-Hant/account_info.json +++ b/packages/web/i18n/zh-Hant/account_info.json @@ -41,6 +41,7 @@ "account_cancellation_requested_at": "申請時間:{{time}}", "account_cancellation_scheduled_at": "預計註銷時間:{{time}}", "account_cancellation_send_code": "取得驗證碼", + "account_cancellation_submit_success": "註銷提交成功", "account_cancellation_switch_team": "切換團隊", "account_cancellation_team_finalizing_desc": "團隊已進入註銷清理階段。你可聯絡團隊擁有者了解處理進度。", "account_cancellation_team_pending_desc": "團隊已由團隊擁有者提交註銷申請,目前處於 15 天註銷等待期。你可聯絡團隊擁有者取消註銷。", diff --git a/packages/web/i18n/zh-Hant/account_team.json b/packages/web/i18n/zh-Hant/account_team.json index cd9317d5e21c..0f0f57f4d82a 100644 --- a/packages/web/i18n/zh-Hant/account_team.json +++ b/packages/web/i18n/zh-Hant/account_team.json @@ -27,6 +27,10 @@ "admin_update_system_modal": "系統公告配置", "admin_update_team": "編輯團隊信息", "admin_update_user": "編輯用戶信息", + "admin_delete_user": "刪除用戶", + "account_cancellation_submit": "提交帳號註銷", + "account_cancellation_cancel": "取消帳號註銷", + "account_cancellation_finalize": "完成帳號註銷清理", "assign_permission": "權限變更", "audit_log": "審計", "change_department_name": "部門編輯", @@ -121,6 +125,7 @@ "link_forbidden": "禁用", "log_admin_add_plan": "【{{name}}】將給團隊id為【{{teamId}}】的團隊添加了套餐", "log_admin_add_user": "【{{name}}】創建了一個名為【{{userName}}】的用戶", + "log_admin_delete_user": "【{{name}}】刪除了用戶【{{userName}}】", "log_admin_create_app_template": "【{{name}}】添加了名為【{{templateName}}】的模板", "log_admin_create_plugin": "【{{name}}】添加了名為【{{pluginName}}】的插件", "log_admin_create_plugin_group": "【{{name}}】創建了名為【{{groupName}}】的插件分組", @@ -146,6 +151,9 @@ "log_change_department": "【{{name}}】更新了部門【{{departmentName}}】", "log_change_member_name": "【{{name}}】將成員【{{memberName}}】重命名為【{{newName}}】", "log_change_member_name_self": "【{{name}}】變更自己的成員名為【{{newName}}】", + "log_account_cancellation_submit": "【{{name}}】提交了帳號註銷申請", + "log_account_cancellation_cancel": "【{{name}}】取消了帳號註銷申請", + "log_account_cancellation_finalize": "【{{name}}】完成了帳號註銷清理", "log_change_notification_settings": "【{{name}}】進行了變更通知接收途徑操作", "log_change_password": "【{{name}}】進行了變更密碼操作", "log_copy_api_key": "【{{name}}】複製了名為【{{keyName}}】的api密鑰", diff --git a/packages/web/i18n/zh-Hant/common.json b/packages/web/i18n/zh-Hant/common.json index 78e4f0ce32ca..e30c010b5855 100644 --- a/packages/web/i18n/zh-Hant/common.json +++ b/packages/web/i18n/zh-Hant/common.json @@ -788,6 +788,7 @@ "error.invalid_params": "參數無效", "error.llm_track_expired": "請求詳情已過期", "error.missingParams": "參數不足", + "error.operation_too_frequently": "操作過於頻繁,請稍後再試", "error.s3_upload_invalid_file_type": "文件內容不受支援,或副檔名與內容不匹配", "error.send_auth_code_too_frequently": "請勿頻繁取得驗證碼", "error.verify_code_too_frequently": "驗證過於頻繁,請稍後再試", diff --git a/packages/web/support/user/audit/constants.ts b/packages/web/support/user/audit/constants.ts index ce6a2aaafaff..be7962648a55 100644 --- a/packages/web/support/user/audit/constants.ts +++ b/packages/web/support/user/audit/constants.ts @@ -19,6 +19,20 @@ export const adminAuditLogMap = { userName?: string; } }, + [AdminAuditEventEnum.ADMIN_DELETE_USER]: { + content: i18nT('account_team:log_admin_delete_user'), + typeLabel: i18nT('account_team:admin_delete_user'), + params: {} as { + userId?: string; + userName?: string; + operatorUserId?: string; + operatorType?: 'admin'; + requestSource?: 'admin'; + requestedAt?: Date; + scheduledCancelAt?: Date; + requestId?: string; + } + }, [AdminAuditEventEnum.ADMIN_UPDATE_TEAM]: { content: i18nT('account_team:log_admin_update_team'), typeLabel: i18nT('account_team:admin_update_team'), @@ -476,6 +490,50 @@ export const auditLogMap = { typeLabel: i18nT('account_team:change_member_name_self'), params: {} as { name?: string; oldName: string; newName: string } }, + [AuditEventEnum.ACCOUNT_CANCELLATION_SUBMIT]: { + content: i18nT('account_team:log_account_cancellation_submit'), + typeLabel: i18nT('account_team:account_cancellation_submit'), + params: {} as { + userId: string; + operatorUserId: string; + operatorType: 'self'; + requestSource: 'self'; + verificationMethod: string; + verificationProvider?: string; + affectedTeamIds: string[]; + requestedAt: Date; + scheduledCancelAt: Date; + requestId?: string; + } + }, + [AuditEventEnum.ACCOUNT_CANCELLATION_CANCEL]: { + content: i18nT('account_team:log_account_cancellation_cancel'), + typeLabel: i18nT('account_team:account_cancellation_cancel'), + params: {} as { + userId: string; + operatorUserId: string; + operatorType: 'self'; + requestSource: 'self'; + requestedAt: Date; + scheduledCancelAt: Date; + requestId?: string; + } + }, + [AuditEventEnum.ACCOUNT_CANCELLATION_FINALIZE]: { + content: i18nT('account_team:log_account_cancellation_finalize'), + typeLabel: i18nT('account_team:account_cancellation_finalize'), + params: {} as { + userId: string; + operatorUserId: string; + operatorType: 'system' | 'admin'; + requestSource: 'self' | 'admin'; + requestedAt: Date; + scheduledCancelAt: Date; + finalizedAt: Date; + requestId?: string; + cronExecutionId?: string; + } + }, [AuditEventEnum.PURCHASE_PLAN]: { content: i18nT('account_team:log_purchase_plan'), typeLabel: i18nT('account_team:purchase_plan'), diff --git a/projects/app/src/pageComponents/account/cancel/AccountCancellationConfirmModal.tsx b/projects/app/src/pageComponents/account/cancel/AccountCancellationConfirmModal.tsx index 6e724fc9313c..f11cbb9e03d4 100644 --- a/projects/app/src/pageComponents/account/cancel/AccountCancellationConfirmModal.tsx +++ b/projects/app/src/pageComponents/account/cancel/AccountCancellationConfirmModal.tsx @@ -1,4 +1,4 @@ -import { Box, Button, Stack, Text } from '@chakra-ui/react'; +import { Box, Button, Text } from '@chakra-ui/react'; import { useTranslation } from 'next-i18next'; import MyModal from '@fastgpt/web/components/v2/common/MyModal'; @@ -12,6 +12,15 @@ export const AccountCancellationConfirmModal = ({ onConfirm: () => void; }) => { const { t } = useTranslation(); + const footerButtonStyles = { + h: 8, + minH: 8, + px: 3.5, + py: 2, + fontSize: 'mini', + lineHeight: '16px', + letterSpacing: 0.5 + }; return ( - - @@ -47,123 +56,133 @@ export const AccountCancellationConfirmModal = ({ px={8} pt={6} pb={6} + color="myGray.900" + fontSize="sm" + lineHeight="20px" + letterSpacing={0.25} > - - - {t('account_info:account_cancellation_confirm_intro', '注销账号前,请确认以下事项:')} + + {t('account_info:account_cancellation_confirm_intro', '注销账号前,请确认以下事项:')} + + +
+ + + {t( + 'account_info:account_cancellation_confirm_waiting_prefix', + '提交注销申请后,账号将进入 15 天等待期。等待期内,该账号将无法正常使用,所有' + )} + + {t( + 'account_info:account_cancellation_confirm_service_impact', + '依赖该账号对外提供服务的渠道将停止生效' + )} + {t( + 'account_info:account_cancellation_confirm_waiting_suffix', + ',包括但不限于 API Key、分享链接和对外调用接口。系统通知信息仍可正常接收。' + )} + + +
+ {t( - 'account_info:account_cancellation_confirm_waiting_prefix', - '提交注销申请后,账号将进入 15 天等待期。等待期内,该账号将无法正常使用,所有' + 'account_info:account_cancellation_confirm_completion_intro', + '等待期结束后,账号注销将正式完成。届时:' )} - + + + + {t('account_info:account_cancellation_confirm_owned_team_prefix', '该账号下')} + + {t( + 'account_info:account_cancellation_confirm_owned_team_impact', + '创建的团队将被删除' + )} + + + {t( - 'account_info:account_cancellation_confirm_service_impact', - '依赖该账号对外提供服务的渠道将停止生效' + 'account_info:account_cancellation_confirm_team_data_impact', + '团队内的应用、数据、成员、配置等信息将被删除,团队成员无法进入团队' )} - - {t( - 'account_info:account_cancellation_confirm_waiting_suffix', - ',包括但不限于 API Key、分享链接和对外调用接口。系统通知信息仍可正常接收。' - )} -
- - - + + {t( - 'account_info:account_cancellation_confirm_completion_intro', - '等待期结束后,账号注销将正式完成。届时:' + 'account_info:account_cancellation_confirm_personal_data_impact', + '该账号的个人信息将被删除或匿名化处理' )} - - - - {t('account_info:account_cancellation_confirm_owned_team_prefix', '该账号下')} - - {t( - 'account_info:account_cancellation_confirm_owned_team_impact', - '创建的团队将被删除' - )} - - - - {t( - 'account_info:account_cancellation_confirm_team_data_impact', - '团队内的应用、数据、成员、配置等信息将被删除,团队成员无法进入团队' - )} - - - {t( - 'account_info:account_cancellation_confirm_personal_data_impact', - '该账号的个人信息将被删除或匿名化处理' - )} - - - {t( - 'account_info:account_cancellation_confirm_leave_team_impact', - '该账号加入的其他团队将自动退出' - )} - - - - - + {t( - 'account_info:account_cancellation_confirm_before_continue', - '在继续前,请确认你已处理好以下事项:' + 'account_info:account_cancellation_confirm_leave_team_impact', + '该账号加入的其他团队将自动退出' )} - - - - {t( - 'account_info:account_cancellation_confirm_team_transfer', - '已完成团队归属转移或团队数据处理' - )} - - - {t( - 'account_info:account_cancellation_confirm_order_refund', - '已处理未完成订单、退款等事项' - )} - - - {t( - 'account_info:account_cancellation_confirm_backup', - '已备份重要数据、配置和业务资料' - )} - - - {t( - 'account_info:account_cancellation_confirm_service_stop', - '已确认相关服务停用不会影响线上业务' - )} - +
+ +
- - + + + {t( + 'account_info:account_cancellation_confirm_before_continue', + '在继续前,请确认你已处理好以下事项:' + )} + + + {t( - 'account_info:account_cancellation_confirm_verification_effect', - '完成身份验证后,注销申请将正式生效。' + 'account_info:account_cancellation_confirm_team_transfer', + '已完成团队归属转移或团队数据处理' )} - - + + {t( - 'account_info:account_cancellation_confirm_cancel_during_wait', - '在 15 天等待期内,你可以重新登录账号并取消注销。' + 'account_info:account_cancellation_confirm_order_refund', + '已处理未完成订单、退款等事项' )} - - + + {t( - 'account_info:account_cancellation_confirm_reregister', - '账号注销完成后,如果你再次使用该账号注册,将会创建一个全新的账号,原账号数据无法恢复。' + 'account_info:account_cancellation_confirm_backup', + '已备份重要数据、配置和业务资料' )} - + + + {t( + 'account_info:account_cancellation_confirm_service_stop', + '已确认相关服务停用不会影响线上业务' + )} +
- +
+ +
+ + + + {t( + 'account_info:account_cancellation_confirm_verification_effect', + '完成身份验证后,注销申请将正式生效。' + )} + + + {t( + 'account_info:account_cancellation_confirm_cancel_during_wait', + '在 15 天等待期内,你可以重新登录账号并取消注销。' + )} + + + {t( + 'account_info:account_cancellation_confirm_reregister', + '账号注销完成后,如果你再次使用该账号注册,将会创建一个全新的账号,原账号数据无法恢复。' + )} + +
); diff --git a/projects/app/src/pageComponents/account/cancel/CancelAccountPage.tsx b/projects/app/src/pageComponents/account/cancel/CancelAccountPage.tsx index 034f06e7d5d7..6844ce10288d 100644 --- a/projects/app/src/pageComponents/account/cancel/CancelAccountPage.tsx +++ b/projects/app/src/pageComponents/account/cancel/CancelAccountPage.tsx @@ -2,10 +2,7 @@ import { Spinner } from '@chakra-ui/react'; import { useCallback, useEffect, useState } from 'react'; import { useRouter } from 'next/router'; import { useTranslation } from 'next-i18next'; -import type { - AccountCancellationStatusResponse, - SubmitAccountCancellationResponse -} from '@fastgpt/global/openapi/support/user/account/cancellation/api'; +import type { AccountCancellationStatusResponse } from '@fastgpt/global/openapi/support/user/account/cancellation/api'; import { useToast } from '@fastgpt/web/hooks/useToast'; import { useUserStore } from '@/web/support/user/useUserStore'; import { @@ -21,27 +18,17 @@ const CancelAccountPage = () => { const { t } = useTranslation(); const router = useRouter(); const { toast } = useToast(); - const { userInfo, initUserInfo, setUserInfo } = useUserStore(); + const { userInfo, setUserInfo } = useUserStore(); const [status, setStatus] = useState(); - const [submittedResult, setSubmittedResult] = - useState>(); const [loading, setLoading] = useState(true); const [canceling, setCanceling] = useState(false); - const loadStatus = useCallback(async () => { - setLoading(true); - try { - setStatus(await getAccountCancellationStatus()); - } catch { - await router.replace('/account/info'); - } finally { - setLoading(false); - } - }, [router]); - useEffect(() => { - void initUserInfo().then(loadStatus); - }, [initUserInfo, loadStatus]); + void getAccountCancellationStatus() + .then(setStatus) + .catch(() => router.replace('/account/info')) + .finally(() => setLoading(false)); + }, [router]); const memberCancellation = userInfo?.team?.accountCancellation; const isMemberView = status?.status === 'none' && !!memberCancellation; @@ -52,25 +39,21 @@ const CancelAccountPage = () => { !memberCancellation; useEffect(() => { - if (loading || !router.isReady || !status || submittedResult) return; + if (loading || !router.isReady || !status) return; if (status.status === 'pending' || isMemberView || isVerificationView) return; void router.replace('/account/info'); - }, [isMemberView, isVerificationView, loading, router, status, submittedResult]); + }, [isMemberView, isVerificationView, loading, router, status]); - const onSubmitted = useCallback( - (result: Extract) => { - setSubmittedResult(result); - }, - [] - ); + const onSubmitted = useCallback(() => { + toast({ + status: 'success', + title: t('account_info:account_cancellation_submit_success', '注销提交成功') + }); + setUserInfo(null); + void router.replace('/login?lastRoute=/account/cancel'); + }, [router, setUserInfo, t, toast]); const onCancel = async () => { - if (submittedResult) { - setUserInfo(null); - await router.replace('/login?lastRoute=/account/cancel'); - return; - } - setCanceling(true); try { await cancelAccountCancellation(); @@ -93,17 +76,6 @@ const CancelAccountPage = () => { if (loading || !status) { return ; } - if (submittedResult) { - return ( - void onCancel()} - loading={canceling} - /> - ); - } if (isMemberView && memberCancellation) { return ( { return ( void router.replace('/account/info')} cardProps={ loading || !status diff --git a/projects/app/src/pageComponents/account/cancel/VerificationPanel.tsx b/projects/app/src/pageComponents/account/cancel/VerificationPanel.tsx index a52eb21f859e..ae3e6226aa7c 100644 --- a/projects/app/src/pageComponents/account/cancel/VerificationPanel.tsx +++ b/projects/app/src/pageComponents/account/cancel/VerificationPanel.tsx @@ -5,6 +5,8 @@ import { Center, Image, Input, + InputGroup, + InputRightElement, Spinner, Text, VStack, @@ -31,6 +33,7 @@ import { } from '@/web/support/user/account/cancellation/api'; import { useSystemStore } from '@/web/common/system/useSystemStore'; import { useUserStore } from '@/web/support/user/useUserStore'; +import { isAccountCancellationCodeError, isAccountCancellationRateLimitError } from './utils'; const getCapabilities = (feConfigs: FastGPTFeConfigsType) => ({ ...(feConfigs.accountVerification?.accountCancellation ?? { @@ -103,12 +106,19 @@ export const VerificationPanel = ({ return () => window.clearInterval(timer); }, [wechatQR?.expiredAt]); - const showVerificationFailure = useCallback(() => { - toast({ - status: 'error', - title: t('account_info:account_cancellation_verification_failed', '身份验证失败,请重试') - }); - }, [t, toast]); + const showVerificationFailure = useCallback( + (error?: unknown) => { + toast({ + status: 'error', + title: isAccountCancellationCodeError(error) + ? t('common:error.code_error') + : isAccountCancellationRateLimitError(error) + ? t('common:error.operation_too_frequently') + : t('account_info:account_cancellation_verification_failed', '身份验证失败,请重试') + }); + }, + [t, toast] + ); const createWechatVerification = useCallback(async () => { if (method !== 'wechat') return; @@ -119,9 +129,9 @@ export const VerificationPanel = ({ if (result.method !== 'wechat') return; setWechatQR(result); setWechatNow(Date.now()); - } catch { + } catch (error) { setWechatLoadFailed(true); - showVerificationFailure(); + showVerificationFailure(error); } finally { setWechatCreating(false); } @@ -146,10 +156,6 @@ export const VerificationPanel = ({ payload: { code: wechatQR.code } }); if (!disposed && result.status === 'pending') { - toast({ - status: 'success', - title: t('account_info:account_cancellation_verification_success', '身份验证成功') - }); onSubmitted(result); } } catch { @@ -165,7 +171,7 @@ export const VerificationPanel = ({ disposed = true; window.clearInterval(timer); }; - }, [onSubmitted, t, toast, wechatExpired, wechatQR]); + }, [onSubmitted, wechatExpired, wechatQR]); const sendCode = async ({ captcha }: { username: string; captcha: string }) => { if (method !== 'code') return; @@ -183,10 +189,14 @@ export const VerificationPanel = ({ status: 'success', title: t('account_info:account_cancellation_code_sent', '验证码已发送') }); - } catch { + } catch (error) { toast({ status: 'error', - title: t('account_info:account_cancellation_code_send_failed', '验证码发送失败,请重试') + title: isAccountCancellationCodeError(error) + ? t('common:error.code_error') + : isAccountCancellationRateLimitError(error) + ? t('common:error.operation_too_frequently') + : t('account_info:account_cancellation_code_send_failed', '验证码发送失败,请重试') }); } finally { setCodeSending(false); @@ -199,13 +209,9 @@ export const VerificationPanel = ({ try { const result = await submitAccountCancellation({ method, payload: { code: code.trim() } }); if (result.status !== 'pending') return; - toast({ - status: 'success', - title: t('account_info:account_cancellation_verification_success', '身份验证成功') - }); onSubmitted(result); - } catch { - showVerificationFailure(); + } catch (error) { + showVerificationFailure(error); } finally { setCodeSubmitting(false); } @@ -281,7 +287,7 @@ export const VerificationPanel = ({ _disabled={{ opacity: 1, color: 'myGray.400', cursor: 'default' }} aria-label={t('account_info:account_cancellation_account', '注销账号')} /> - + - - + + + + + + + {isCaptchaOpen && ( + + )} + + ); + } + + if (method === 'oldPassword') { + return ( + + + + {creating ? ( +
+ +
+ ) : createFailed || !preLoginCode ? ( +
+ +
+ ) : ( + <> + setOldPassword(event.target.value)} + placeholder={t('account_info:password_old_placeholder')} + onKeyDown={(event) => { + if (event.key === 'Enter') void submitOldPassword(); + }} + /> + + + )} +
+
+ ); + } + + if (method === 'wechat') { + return ( + + + {t('account_info:password_wechat_scan')} + +
+ {creating ? ( + + ) : wechatQR && !wechatExpired ? ( + {t('account_info:password_wechat_qr')} + ) : ( + + + {t( + createFailed + ? 'account_info:password_wechat_load_failed' + : 'account_info:password_wechat_expired' + )} + + + + )} +
+
+ ); + } + + const provider = method.slice('oauth/'.length).toLowerCase(); + const providerLabel = (() => { + if (provider === 'github') return 'GitHub'; + if (provider === 'google') return 'Google'; + if (provider === 'microsoft') return 'Microsoft'; + if (provider === 'wecom') return 'WeCom'; + return feConfigs.sso?.title ?? 'SSO'; + })(); + + return ( + + + + + ); +}; diff --git a/projects/app/src/components/support/user/safe/PasswordChangeModal.tsx b/projects/app/src/components/support/user/safe/PasswordChangeModal.tsx new file mode 100644 index 000000000000..0ef5d38b836b --- /dev/null +++ b/projects/app/src/components/support/user/safe/PasswordChangeModal.tsx @@ -0,0 +1,338 @@ +import { useCallback, useEffect, useState } from 'react'; +import { + Box, + Button, + Center, + Flex, + FormControl, + FormErrorMessage, + Input, + Spinner, + Text, + VStack +} from '@chakra-ui/react'; +import { useRouter } from 'next/router'; +import { useTranslation } from 'next-i18next'; +import { useForm } from 'react-hook-form'; +import { UserErrEnum } from '@fastgpt/global/common/error/code/user'; +import { getErrResponse } from '@fastgpt/global/common/error/utils'; +import { checkPasswordRule } from '@fastgpt/global/common/string/password'; +import type { + PasswordAuthorizationResponse, + SensitiveAccountVerificationBody +} from '@fastgpt/global/openapi/support/user/account/password/api'; +import type { AccountVerificationMethod } from '@fastgpt/global/support/user/account/verification/type'; +import MyModal from '@fastgpt/web/components/common/MyModal'; +import { useToast } from '@fastgpt/web/hooks/useToast'; +import { useUserStore } from '@/web/support/user/useUserStore'; +import { + authorizePasswordChange, + createPasswordVerification, + updatePassword +} from '@/web/support/user/account/password/api'; +import { usePasswordChangeStore } from '@/web/support/user/account/password/store'; +import { AccountVerificationPanel } from './AccountVerificationPanel'; + +type Authorization = Extract; +type Stage = + | { type: 'prompt' } + | { type: 'authorizing' } + | { type: 'verification'; method: AccountVerificationMethod } + | { type: 'password'; authorization: Authorization } + | { type: 'unavailable' }; + +type FormType = { + newPassword: string; + confirmPassword: string; +}; + +type Props = { + required?: boolean; + showExpiredPrompt?: boolean; + onClose?: () => void; + onSuccess?: () => void | Promise; +}; + +/** 统一承接设置、修改和过期重置密码的短期授权状态机。 */ +const PasswordChangeModal = ({ + required = false, + showExpiredPrompt = false, + onClose, + onSuccess +}: Props) => { + const { t } = useTranslation(); + const router = useRouter(); + const { toast } = useToast(); + const { userInfo, initUserInfo } = useUserStore(); + const storedAuthorization = usePasswordChangeStore((state) => state.authorization); + const setStoredAuthorization = usePasswordChangeStore((state) => state.setAuthorization); + const initialAuthorization = + storedAuthorization?.required === required ? storedAuthorization : undefined; + const [stage, setStage] = useState(() => { + if (initialAuthorization) { + return { + type: 'password', + authorization: { + status: 'authorized', + token: initialAuthorization.token, + expiredAt: initialAuthorization.expiredAt + } + }; + } + return showExpiredPrompt ? { type: 'prompt' } : { type: 'authorizing' }; + }); + const [submitting, setSubmitting] = useState(false); + const { + register, + handleSubmit, + getValues, + reset, + formState: { errors } + } = useForm({ + defaultValues: { newPassword: '', confirmPassword: '' } + }); + + useEffect(() => { + if (initialAuthorization) setStoredAuthorization(undefined); + }, [initialAuthorization, setStoredAuthorization]); + + const requestAuthorization = useCallback(async () => { + try { + const result = await authorizePasswordChange({ source: 'recentLogin' }); + if (result.status === 'authorized') { + setStage({ type: 'password', authorization: result }); + return; + } + if (result.status === 'verificationRequired') { + setStage({ type: 'verification', method: result.method }); + return; + } + setStage({ type: 'unavailable' }); + } catch { + setStage({ type: 'unavailable' }); + toast({ status: 'error', title: t('account_info:password_verification_failed') }); + } + }, [t, toast]); + + useEffect(() => { + if (stage.type !== 'authorizing') return; + + const timer = window.setTimeout(() => void requestAuthorization(), 0); + return () => window.clearTimeout(timer); + }, [requestAuthorization, stage.type]); + + const consumeVerification = useCallback( + (verification: SensitiveAccountVerificationBody) => + authorizePasswordChange({ source: 'accountVerification', verification }), + [] + ); + + const handleAuthorized = useCallback((authorization: Authorization) => { + setStage({ type: 'password', authorization }); + }, []); + + const closeFlow = () => { + if (required) return; + reset(); + setStoredAuthorization(undefined); + onClose?.(); + }; + + const submitNewPassword = async ({ newPassword }: FormType) => { + if (stage.type !== 'password') return; + setSubmitting(true); + try { + await updatePassword({ + newPassword, + passwordChangeToken: stage.authorization.token + }); + reset(); + setStoredAuthorization(undefined); + await initUserInfo(); + toast({ status: 'success', title: t('account_info:password_set_success') }); + await onSuccess?.(); + } catch (error) { + if (getErrResponse(error)?.statusText === UserErrEnum.passwordChangeAuthorizationInvalid) { + reset(); + setStoredAuthorization(undefined); + setStage({ type: 'authorizing' }); + return; + } + toast({ status: 'error', title: t('account_info:password_update_error') }); + } finally { + setSubmitting(false); + } + }; + + const title = (() => { + if (stage.type === 'verification' || stage.type === 'unavailable') { + return t('account_info:password_verification_title'); + } + if (required || !userInfo?.hasPassword) return t('account_info:password_set_title'); + return userInfo?.hasPassword + ? t('account_info:update_password') + : t('account_info:password_set_title'); + })(); + + const isWechatVerification = stage.type === 'verification' && stage.method === 'wechat'; + const modalWidth = isWechatVerification ? '560px' : '400px'; + + return ( + + {stage.type === 'prompt' && ( + + + {title} + + + {t('account_info:password_expired_tip')} + + + + + + )} + + {stage.type === 'authorizing' && ( + + + {title} + +
+ + + + {t('account_info:password_authorizing')} + + +
+
+ )} + + {stage.type === 'unavailable' && ( + + + {title} + + + {t('account_info:password_verification_unavailable')} + + + + )} + + {stage.type === 'verification' && ( + + + + {title} + + + {t('account_info:password_verification_description')} + + + + + + + )} + + {stage.type === 'password' && ( + + + {title} + + + + checkPasswordRule(value) || t('login:password_tip') + })} + /> + {errors.newPassword?.message ? ( + + {errors.newPassword.message} + + ) : ( + + {t('account_info:password_tip')} + + )} + + + + value === getValues('newPassword') || t('user:password.not_match') + })} + /> + {errors.confirmPassword?.message && ( + + {errors.confirmPassword.message} + + )} + + + + + )} +
+ ); +}; + +export default PasswordChangeModal; diff --git a/projects/app/src/components/support/user/safe/ResetExpiredPswModal.tsx b/projects/app/src/components/support/user/safe/ResetExpiredPswModal.tsx index f4e5da9816f6..9f7dc398f857 100644 --- a/projects/app/src/components/support/user/safe/ResetExpiredPswModal.tsx +++ b/projects/app/src/components/support/user/safe/ResetExpiredPswModal.tsx @@ -1,41 +1,15 @@ import React from 'react'; -import { ModalBody, Box, Flex, Input, ModalFooter, Button, HStack } from '@chakra-ui/react'; -import MyModal from '@fastgpt/web/components/common/MyModal'; -import { useTranslation } from 'next-i18next'; -import { useForm } from 'react-hook-form'; import { useRequest } from '@fastgpt/web/hooks/useRequest'; -import { resetPassword, getCheckPswExpired } from '@/web/support/user/api'; -import { checkPasswordRule } from '@fastgpt/global/common/string/password'; -import { useToast } from '@fastgpt/web/hooks/useToast'; +import { getCheckPswExpired } from '@/web/support/user/api'; import { useUserStore } from '@/web/support/user/useUserStore'; -import Icon from '@fastgpt/web/components/common/Icon'; +import PasswordChangeModal from './PasswordChangeModal'; -type FormType = { - newPsw: string; - confirmPsw: string; -}; - -const ResetPswModal = () => { - const { t } = useTranslation(); - const { toast } = useToast(); +/** 仅在确有存储密码且已过期时开启不可关闭的统一改密流程。 */ +const ResetExpiredPswModal = () => { const { userInfo } = useUserStore(); - - const { register, handleSubmit, getValues } = useForm({ - defaultValues: { - newPsw: '', - confirmPsw: '' - } - }); - - const { - data: passwordExpired = false, - runAsync, - loading: isFetching - } = useRequest( + const { data: passwordExpired = false, runAsync: checkPasswordExpired } = useRequest( async () => { - if (!userInfo?._id) { - return false; - } + if (!userInfo?._id) return false; return getCheckPswExpired(); }, { @@ -44,78 +18,15 @@ const ResetPswModal = () => { } ); - const { runAsync: onSubmit, loading: isSubmitting } = useRequest(resetPassword, { - onSuccess() { - runAsync(); - }, - successToast: t('common:user.Update password successful'), - errorToast: t('common:user.Update password failed') - }); - - const onSubmitErr = (err: Record) => { - const val = Object.values(err)[0]; - if (!val) return; - if (val.message) { - toast({ - status: 'warning', - title: val.message, - duration: 3000, - isClosable: true - }); - } - }; - return passwordExpired ? ( - - - - - {t('common:user.reset_password_tip')} - - - - {t('common:user.new_password') + ':'} - - { - if (!checkPasswordRule(val)) { - return t('common:user.password_tip'); - } - return true; - } - })} - > - - - - {t('common:user.confirm_password') + ':'} - - (getValues('newPsw') === val ? true : t('user:password.not_match')) - })} - > - - - - - - + { + await checkPasswordExpired(); + }} + /> ) : null; }; -export default React.memo(ResetPswModal); +export default React.memo(ResetExpiredPswModal); diff --git a/projects/app/src/pageComponents/account/info/UpdatePswModal.tsx b/projects/app/src/pageComponents/account/info/UpdatePswModal.tsx index 7786127aafb3..2e57be748100 100644 --- a/projects/app/src/pageComponents/account/info/UpdatePswModal.tsx +++ b/projects/app/src/pageComponents/account/info/UpdatePswModal.tsx @@ -1,109 +1,7 @@ -import React from 'react'; -import { ModalBody, Box, Flex, Input, ModalFooter, Button } from '@chakra-ui/react'; -import MyModal from '@fastgpt/web/components/common/MyModal'; -import { useTranslation } from 'next-i18next'; -import { useForm } from 'react-hook-form'; -import { useRequest } from '@fastgpt/web/hooks/useRequest'; -import { updatePasswordByOld } from '@/web/support/user/api'; -import { useToast } from '@fastgpt/web/hooks/useToast'; -import { checkPasswordRule } from '@fastgpt/global/common/string/password'; +import PasswordChangeModal from '@/components/support/user/safe/PasswordChangeModal'; -type FormType = { - oldPsw: string; - newPsw: string; - confirmPsw: string; -}; - -const UpdatePswModal = ({ onClose }: { onClose: () => void }) => { - const { t } = useTranslation(); - const { toast } = useToast(); - - const { register, handleSubmit, getValues } = useForm({ - defaultValues: { - oldPsw: '', - newPsw: '', - confirmPsw: '' - } - }); - - const { runAsync: onSubmit, loading: isLoading } = useRequest(updatePasswordByOld, { - onSuccess() { - onClose(); - }, - successToast: t('account_info:password_update_success'), - errorToast: t('account_info:password_update_error') - }); - const onSubmitErr = (err: Record) => { - const val = Object.values(err)[0]; - if (!val) return; - if (val.message) { - toast({ - status: 'warning', - title: val.message, - duration: 3000, - isClosable: true - }); - } - }; - - return ( - - - - - {t('account_info:old_password') + ':'} - - - - - - {t('account_info:new_password') + ':'} - - { - if (!checkPasswordRule(val)) { - return t('login:password_tip'); - } - return true; - } - })} - > - - - - {t('account_info:confirm_password') + ':'} - - (getValues('newPsw') === val ? true : t('user:password.not_match')) - })} - > - - - - - - - - ); -}; +const UpdatePswModal = ({ onClose }: { onClose: () => void }) => ( + +); export default UpdatePswModal; diff --git a/projects/app/src/pages/account/info/index.tsx b/projects/app/src/pages/account/info/index.tsx index c624e19aee0d..2c286058c664 100644 --- a/projects/app/src/pages/account/info/index.tsx +++ b/projects/app/src/pages/account/info/index.tsx @@ -50,6 +50,7 @@ import { i18nT } from '@fastgpt/global/common/i18n/utils'; import { getIsMemberSyncMode } from '@/web/common/system/utils'; import { getAccountCancellationStatus } from '@/web/support/user/account/cancellation/api'; import { AccountCancellationConfirmModal } from '@/pageComponents/account/cancel/AccountCancellationConfirmModal'; +import { usePasswordChangeStore } from '@/web/support/user/account/password/store'; const RedeemCouponModal = dynamic(() => import('@/pageComponents/account/info/RedeemCouponModal'), { ssr: false @@ -153,6 +154,7 @@ const MyInfo = ({ onOpenContact }: { onOpenContact: () => void }) => { onClose: onCloseUpdatePsw, onOpen: onOpenUpdatePsw } = useDisclosure(); + const passwordChangeAuthorization = usePasswordChangeStore((state) => state.authorization); const { isOpen: isOpenUpdateContact, onClose: onCloseUpdateContact, @@ -221,6 +223,10 @@ const MyInfo = ({ onOpenContact }: { onOpenContact: () => void }) => { window.removeEventListener('hashchange', triggerEnterpriseAuthFromHash); }; }, [triggerEnterpriseAuthFromHash]); + + useEffect(() => { + if (passwordChangeAuthorization?.required === false) onOpenUpdatePsw(); + }, [onOpenUpdatePsw, passwordChangeAuthorization]); const { Component: AvatarUploader, handleFileSelectorOpen } = useUploadAvatar( getUploadAvatarPresignedUrl, { @@ -273,9 +279,11 @@ const MyInfo = ({ onOpenContact }: { onOpenContact: () => void }) => { {feConfigs?.isPlus && ( {t('account_info:password')}  - ***** + + {userInfo?.hasPassword ? '*****' : t('account_info:password_not_set')} + )} diff --git a/projects/app/src/pages/api/support/user/account/checkPswExpired.ts b/projects/app/src/pages/api/support/user/account/checkPswExpired.ts index ce423581a083..1b22ba1f3289 100644 --- a/projects/app/src/pages/api/support/user/account/checkPswExpired.ts +++ b/projects/app/src/pages/api/support/user/account/checkPswExpired.ts @@ -4,6 +4,7 @@ import { checkPswExpired } from '@/service/support/user/account/password'; import { authCert } from '@fastgpt/service/support/permission/auth/common'; import { MongoUser } from '@fastgpt/service/support/user/schema'; import type { CheckPswExpiredResponseType } from '@fastgpt/global/openapi/support/user/account/password/api'; +import { hasStoredPassword } from '@fastgpt/global/support/user/utils'; async function handler( req: ApiRequestProps, @@ -11,9 +12,9 @@ async function handler( ): Promise { const { userId } = await authCert({ req, authToken: true }); - const user = await MongoUser.findById(userId, 'passwordUpdateTime'); + const user = await MongoUser.findById(userId).select('+password passwordUpdateTime'); - if (!user) { + if (!user || !hasStoredPassword(user.password)) { return false; } diff --git a/projects/app/src/pages/api/support/user/account/password/update.ts b/projects/app/src/pages/api/support/user/account/password/update.ts new file mode 100644 index 000000000000..953f099718ff --- /dev/null +++ b/projects/app/src/pages/api/support/user/account/password/update.ts @@ -0,0 +1,58 @@ +import type { ApiRequestProps } from '@fastgpt/next/type'; +import { i18nT } from '@fastgpt/global/common/i18n/utils'; +import { + UpdatePasswordBodySchema, + UpdatePasswordResponseSchema, + type UpdatePasswordBody, + type UpdatePasswordResponse +} from '@fastgpt/global/openapi/support/user/account/password/api'; +import { AuditEventEnum } from '@fastgpt/global/support/user/audit/constants'; +import { hasStoredPassword } from '@fastgpt/global/support/user/utils'; +import { parseApiInput } from '@fastgpt/service/common/zod/requestParseError'; +import { authCert } from '@fastgpt/service/support/permission/auth/common'; +import { passwordChangeTokenService } from '@fastgpt/service/support/user/account/password/service'; +import { addAuditLog } from '@fastgpt/service/support/user/audit/util'; +import { MongoUser } from '@fastgpt/service/support/user/schema'; +import { delUserAllSession } from '@fastgpt/service/support/user/session'; +import { NextAPI } from '@/service/middleware/entry'; + +/** 使用当前 Session 和短期改密授权更新密码,并仅保留发起请求的 Session。 */ +async function handler(req: ApiRequestProps): Promise { + const { body } = parseApiInput({ req, bodySchema: UpdatePasswordBodySchema }); + const { userId, sessionId, tmbId, teamId } = await authCert({ req, authToken: true }); + + passwordChangeTokenService.verify({ token: body.passwordChangeToken, userId }); + + const user = await MongoUser.findById(userId).select('+password'); + if (!user) throw new Error('Failed to update password'); + + if (hasStoredPassword(user.password)) { + const isSamePassword = await MongoUser.exists({ _id: userId, password: body.newPsw }); + if (isSamePassword) { + throw new Error(i18nT('common:user.Password has no change')); + } + } + + const updateResult = await MongoUser.updateOne( + { _id: userId }, + { + $set: { + password: body.newPsw, + passwordUpdateTime: new Date() + } + } + ); + if (updateResult.matchedCount !== 1) throw new Error('Failed to update password'); + + await delUserAllSession(userId, [sessionId]); + void addAuditLog({ + tmbId, + teamId, + event: AuditEventEnum.CHANGE_PASSWORD, + params: {} + }); + + return UpdatePasswordResponseSchema.parse(undefined); +} + +export default NextAPI(handler); diff --git a/projects/app/src/pages/api/support/user/account/resetExpiredPsw.ts b/projects/app/src/pages/api/support/user/account/resetExpiredPsw.ts deleted file mode 100644 index 5a0905a7c7b5..000000000000 --- a/projects/app/src/pages/api/support/user/account/resetExpiredPsw.ts +++ /dev/null @@ -1,46 +0,0 @@ -import type { ApiRequestProps } from '@fastgpt/next/type'; -import { authCert } from '@fastgpt/service/support/permission/auth/common'; -import { MongoUser } from '@fastgpt/service/support/user/schema'; -import { NextAPI } from '@/service/middleware/entry'; -import { i18nT } from '@fastgpt/global/common/i18n/utils'; -import { checkPswExpired } from '@/service/support/user/account/password'; -import { delUserAllSession } from '@fastgpt/service/support/user/session'; -import { - ResetExpiredPswBodySchema, - ResetExpiredPswResponseSchema, - type ResetExpiredPswResponseType -} from '@fastgpt/global/openapi/support/user/account/password/api'; - -async function resetExpiredPswHandler(req: ApiRequestProps): Promise { - const { newPsw } = ResetExpiredPswBodySchema.parse(req.body); - const { userId, sessionId } = await authCert({ req, authToken: true }); - const user = await MongoUser.findById(userId, 'passwordUpdateTime').lean(); - - if (!user) { - return Promise.reject('The password has not expired'); - } - - // check if can reset password - const canReset = checkPswExpired({ updateTime: user.passwordUpdateTime }); - - if (!canReset) { - return Promise.reject(i18nT('common:user.No_right_to_reset_password')); - } - - // 更新对应的记录 - await MongoUser.updateOne( - { - _id: userId - }, - { - password: newPsw, - passwordUpdateTime: new Date() - } - ); - - await delUserAllSession(userId, [sessionId]); - - return ResetExpiredPswResponseSchema.parse(undefined); -} - -export default NextAPI(resetExpiredPswHandler); diff --git a/projects/app/src/pages/api/support/user/account/updatePasswordByOld.ts b/projects/app/src/pages/api/support/user/account/updatePasswordByOld.ts deleted file mode 100644 index 22a9cf5eeab8..000000000000 --- a/projects/app/src/pages/api/support/user/account/updatePasswordByOld.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { authCert } from '@fastgpt/service/support/permission/auth/common'; -import { MongoUser } from '@fastgpt/service/support/user/schema'; - -import { MongoTeamMember } from '@fastgpt/service/support/user/team/teamMemberSchema'; -import { i18nT } from '@fastgpt/global/common/i18n/utils'; -import { NextAPI } from '@/service/middleware/entry'; -import { addAuditLog } from '@fastgpt/service/support/user/audit/util'; -import { AuditEventEnum } from '@fastgpt/global/support/user/audit/constants'; -import { delUserAllSession } from '@fastgpt/service/support/user/session'; -import { - UpdatePasswordByOldBodySchema, - type UpdatePasswordByOldBodyType, - type UpdatePasswordByOldResponseType -} from '@fastgpt/global/openapi/support/user/account/password/api'; -import type { ApiRequestProps, ApiResponseType } from '@fastgpt/next/type'; - -async function handler( - req: ApiRequestProps, - _res: ApiResponseType -): Promise { - const { oldPsw, newPsw } = UpdatePasswordByOldBodySchema.parse(req.body); - - const { tmbId, teamId, sessionId } = await authCert({ req, authToken: true }); - const tmb = await MongoTeamMember.findById(tmbId); - if (!tmb) { - return Promise.reject('can not find it'); - } - const userId = tmb.userId; - // auth old password - const user = await MongoUser.findOne({ - _id: userId, - password: oldPsw - }); - - if (!user) { - return Promise.reject(i18nT('common:user.Old password is error')); - } - - if (oldPsw === newPsw) { - return Promise.reject(i18nT('common:user.Password has no change')); - } - - // 更新对应的记录 - await MongoUser.findByIdAndUpdate(userId, { - password: newPsw, - passwordUpdateTime: new Date() - }); - - await delUserAllSession(userId, [sessionId]); - - (async () => { - addAuditLog({ - tmbId, - teamId, - event: AuditEventEnum.CHANGE_PASSWORD, - params: {} - }); - })(); - return user; -} - -export default NextAPI(handler); diff --git a/projects/app/src/pages/login/provider.tsx b/projects/app/src/pages/login/provider.tsx index eb67886c0f4e..258a85766a93 100644 --- a/projects/app/src/pages/login/provider.tsx +++ b/projects/app/src/pages/login/provider.tsx @@ -5,6 +5,8 @@ import { useUserStore } from '@/web/support/user/useUserStore'; import { clearToken } from '@/web/support/user/auth'; import { oauthLogin } from '@/web/support/user/api'; import { submitAccountCancellation } from '@/web/support/user/account/cancellation/api'; +import { authorizePasswordChange } from '@/web/support/user/account/password/api'; +import { usePasswordChangeStore } from '@/web/support/user/account/password/store'; import { useToast } from '@fastgpt/web/hooks/useToast'; import Loading from '@fastgpt/web/components/common/MyLoading'; import { serviceSideProps } from '@/web/common/i18n/utils'; @@ -48,12 +50,19 @@ const provider = () => { ? validateRedirectUrl(loginStore.lastRoute) : '/dashboard/agent'; const lastTmbId = loginStore?.lastTmbId || ''; - const errorRedirectPage = - loginStore?.flow === 'accountCancellation' - ? '/account/cancel?confirmed=1' - : lastRoute.startsWith('/chat') - ? lastRoute - : '/login'; + const verificationFailureTitle = (() => { + if (loginStore?.flow === 'passwordChange') { + return t('account_info:password_verification_failed'); + } + if (loginStore?.flow === 'accountCancellation') { + return t('account_info:account_cancellation_verification_failed', '身份验证失败,请重试'); + } + })(); + const errorRedirectPage = verificationFailureTitle + ? lastRoute + : lastRoute.startsWith('/chat') + ? lastRoute + : '/login'; const loginSuccess = useCallback( async (res: LoginSuccessResponseType) => { @@ -126,6 +135,32 @@ const provider = () => { return; } + if (loginStore.flow === 'passwordChange') { + const result = await authorizePasswordChange({ + source: 'accountVerification', + verification: { + method: `oauth/${callback.provider}`, + payload: { + callbackUrl: loginStore.callbackUrl, + code: callback.code, + ...(callback.state !== undefined ? { state: callback.state } : {}), + props + } + } + }); + if (result.status !== 'authorized') { + throw new Error('Password change verification is still pending'); + } + usePasswordChangeStore.getState().setAuthorization({ + token: result.token, + expiredAt: result.expiredAt, + required: loginStore.passwordChangeRequired === true + }); + setLoginStore(undefined); + await router.replace(lastRoute); + return; + } + const res = await oauthLogin({ ...callback, props, @@ -151,11 +186,8 @@ const provider = () => { await onFastGPTLoginSuccess(loginSuccess, res); } catch (error) { toast({ - status: loginStore.flow === 'accountCancellation' ? 'error' : 'warning', - title: - loginStore.flow === 'accountCancellation' - ? t('account_info:account_cancellation_verification_failed', '身份验证失败,请重试') - : getErrText(error, t('common:support.user.login.error')) + status: verificationFailureTitle ? 'error' : 'warning', + title: verificationFailureTitle ?? getErrText(error, t('common:support.user.login.error')) }); setTimeout(() => { router.replace(errorRedirectPage); @@ -167,24 +199,23 @@ const provider = () => { [ errorRedirectPage, i18n.language, + lastRoute, loginStore, loginSuccess, router, setLoginStore, setUserInfo, t, - toast + toast, + verificationFailureTitle ] ); useEffect(() => { if (error) { toast({ - status: loginStore?.flow === 'accountCancellation' ? 'error' : 'warning', - title: - loginStore?.flow === 'accountCancellation' - ? t('account_info:account_cancellation_verification_failed', '身份验证失败,请重试') - : t('common:support.user.login.Provider error') + status: verificationFailureTitle ? 'error' : 'warning', + title: verificationFailureTitle ?? t('common:support.user.login.Provider error') }); router.replace(errorRedirectPage); return; @@ -206,11 +237,8 @@ const provider = () => { }); if (!callback) { toast({ - status: loginStore?.flow === 'accountCancellation' ? 'error' : 'warning', - title: - loginStore?.flow === 'accountCancellation' - ? t('account_info:account_cancellation_verification_failed', '身份验证失败,请重试') - : t('common:support.user.login.security_failed') + status: verificationFailureTitle ? 'error' : 'warning', + title: verificationFailureTitle ?? t('common:support.user.login.security_failed') }); setTimeout(() => { router.replace(errorRedirectPage); @@ -219,7 +247,7 @@ const provider = () => { return; } - if (loginStore?.flow !== 'accountCancellation') { + if (!loginStore?.flow || loginStore.flow === 'login') { await retryFn(async () => clearToken()); } router.prefetch('/dashboard/agent'); @@ -237,7 +265,8 @@ const provider = () => { setLoginStore, state, t, - toast + toast, + verificationFailureTitle ]); return ; diff --git a/projects/app/src/service/mongo.ts b/projects/app/src/service/mongo.ts index 64d50d4935d6..351cf0a98362 100644 --- a/projects/app/src/service/mongo.ts +++ b/projects/app/src/service/mongo.ts @@ -8,27 +8,36 @@ import { appEnv } from '@/env'; const logger = getLogger(LogCategories.SYSTEM); +/** 初始化 root 用户,并仅在运维配置密码真实变化时刷新密码更新时间。 */ export async function initRootUser(retry = 3): Promise { try { - const rootUser = await MongoUser.findOne({ - username: 'root' - }); + const rootUser = await MongoUser.findOne({ username: 'root' }).select('+password'); const psw = appEnv.DEFAULT_ROOT_PSW; + const password = hashStr(psw); + const storedPassword = rootUser?.toObject({ getters: false }).password; + const passwordChanged = storedPassword !== hashStr(password); let rootId = rootUser?._id || ''; await mongoSessionRun(async (session) => { // init root user if (rootUser) { - await rootUser.updateOne({ - password: hashStr(psw) - }); + if (passwordChanged) { + await rootUser.updateOne( + { + password, + passwordUpdateTime: new Date() + }, + { session } + ); + } } else { const [{ _id }] = await MongoUser.create( [ { username: 'root', - password: hashStr(psw) + password, + passwordUpdateTime: new Date() } ], { session, ordered: true } diff --git a/projects/app/src/web/common/system/useSystemStore.ts b/projects/app/src/web/common/system/useSystemStore.ts index c895859ec72f..debf675f45ec 100644 --- a/projects/app/src/web/common/system/useSystemStore.ts +++ b/projects/app/src/web/common/system/useSystemStore.ts @@ -28,7 +28,8 @@ type LoginStoreType = { state: string; callbackUrl: string; lastTmbId?: string; - flow?: 'login' | 'accountCancellation'; + flow?: 'login' | 'accountCancellation' | 'passwordChange'; + passwordChangeRequired?: boolean; }; export type NotSufficientModalType = @@ -143,7 +144,7 @@ export const useSystemStore = create()( set((state) => { state.gitStar = git.stargazers_count; }); - } catch (error) {} + } catch {} }, notSufficientModalType: undefined, diff --git a/projects/app/src/web/support/user/account/password/api.ts b/projects/app/src/web/support/user/account/password/api.ts new file mode 100644 index 000000000000..166fae4497aa --- /dev/null +++ b/projects/app/src/web/support/user/account/password/api.ts @@ -0,0 +1,31 @@ +import { POST } from '@/web/common/api/request'; +import { hashStr } from '@fastgpt/global/common/string/tools'; +import type { + CreatePasswordVerificationBody, + CreatePasswordVerificationResponse, + PasswordAuthorizationBody, + PasswordAuthorizationResponse, + UpdatePasswordResponse +} from '@fastgpt/global/openapi/support/user/account/password/api'; + +export const createPasswordVerification = (body: CreatePasswordVerificationBody) => + POST( + '/proApi/support/user/account/password/verification/create', + body + ); + +export const authorizePasswordChange = (body: PasswordAuthorizationBody) => + POST('/proApi/support/user/account/password/authorization', body); + +/** 沿用现有登录协议,只向服务端提交新密码的 SHA-256 摘要。 */ +export const updatePassword = ({ + newPassword, + passwordChangeToken +}: { + newPassword: string; + passwordChangeToken: string; +}) => + POST('/support/user/account/password/update', { + newPsw: hashStr(newPassword), + passwordChangeToken + }); diff --git a/projects/app/src/web/support/user/account/password/store.ts b/projects/app/src/web/support/user/account/password/store.ts new file mode 100644 index 000000000000..af72909ddf02 --- /dev/null +++ b/projects/app/src/web/support/user/account/password/store.ts @@ -0,0 +1,26 @@ +import { create, devtools, immer } from '@fastgpt/web/common/zustand'; + +export type PasswordChangeAuthorization = { + token: string; + expiredAt: string; + required: boolean; +}; + +type State = { + authorization?: PasswordChangeAuthorization; + setAuthorization: (authorization?: PasswordChangeAuthorization) => void; +}; + +/** 仅在当前页面进程中承接 OAuth 回跳结果;该 store 不允许接入持久化中间件。 */ +export const usePasswordChangeStore = create()( + devtools( + immer((set) => ({ + authorization: undefined, + setAuthorization(authorization) { + set((state) => { + state.authorization = authorization; + }); + } + })) + ) +); diff --git a/projects/app/src/web/support/user/api.ts b/projects/app/src/web/support/user/api.ts index 22742e5393c8..f2f44a4aa0fe 100644 --- a/projects/app/src/web/support/user/api.ts +++ b/projects/app/src/web/support/user/api.ts @@ -13,10 +13,7 @@ import type { WxLoginBodyType, GetWXLoginQRResponseType } from '@fastgpt/global/openapi/support/user/account/login/api'; -import type { - UpdatePasswordByCodeBodyType, - UpdatePasswordByOldBodyType -} from '@fastgpt/global/openapi/support/user/account/password/api'; +import type { UpdatePasswordByCodeBodyType } from '@fastgpt/global/openapi/support/user/account/password/api'; import type { AccountRegisterBodyType } from '@fastgpt/global/openapi/support/user/account/register/api'; import type { LoginSuccessResponseType } from '@fastgpt/global/openapi/support/user/account/login/api'; import type { @@ -95,15 +92,6 @@ export const postFindPassword = ({ ...props, password: hashStr(password) }); -export const updatePasswordByOld = ({ oldPsw, newPsw }: UpdatePasswordByOldBodyType) => - POST('/support/user/account/updatePasswordByOld', { - oldPsw: hashStr(oldPsw), - newPsw: hashStr(newPsw) - }); -export const resetPassword = (newPsw: string) => - POST('/support/user/account/resetExpiredPsw', { - newPsw: hashStr(newPsw) - }); // Check the whether password has expired export const getCheckPswExpired = () => GET('/support/user/account/checkPswExpired'); diff --git a/projects/app/test/api/support/user/account/checkPswExpired.test.ts b/projects/app/test/api/support/user/account/checkPswExpired.test.ts index a19ef469dab6..81b36c620b67 100644 --- a/projects/app/test/api/support/user/account/checkPswExpired.test.ts +++ b/projects/app/test/api/support/user/account/checkPswExpired.test.ts @@ -167,6 +167,33 @@ describe('checkPswExpired API', () => { expect(res.data).toBe(false); }); + it.each([ + ['missing', undefined], + ['empty', ''], + ['null', null] + ])('should return false when password is %s', async (_label, password) => { + vi.stubEnv('PASSWORD_EXPIRED_MONTH', '1'); + const checkPswExpiredApi = await loadCheckPswExpiredApi(); + const update = + password === undefined + ? { $set: { passwordUpdateTime: new Date(0) }, $unset: { password: 1 } } + : { $set: { password, passwordUpdateTime: new Date(0) } }; + await MongoUser.collection.updateOne({ _id: testUser._id }, update as any); + + const res = await Call(checkPswExpiredApi.default, { + auth: { + userId: String(testUser._id), + teamId: String(testTeam._id), + tmbId: String(testTmb._id), + isRoot: false, + sessionId: 'session123' + } as any + }); + + expect(res.code).toBe(200); + expect(res.data).toBe(false); + }); + it('should reject request without authentication', async () => { const checkPswExpiredApi = await loadCheckPswExpiredApi(); const res = await Call(checkPswExpiredApi.default, {}); diff --git a/projects/app/test/api/support/user/account/password/update.test.ts b/projects/app/test/api/support/user/account/password/update.test.ts new file mode 100644 index 000000000000..e05fd620aea5 --- /dev/null +++ b/projects/app/test/api/support/user/account/password/update.test.ts @@ -0,0 +1,151 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { hashStr } from '@fastgpt/global/common/string/tools'; +import type { UpdatePasswordBody } from '@fastgpt/global/openapi/support/user/account/password/api'; +import { passwordChangeTokenService } from '@fastgpt/service/support/user/account/password/service'; +import { MongoUser } from '@fastgpt/service/support/user/schema'; +import { MongoTeam } from '@fastgpt/service/support/user/team/teamSchema'; +import { MongoTeamMember } from '@fastgpt/service/support/user/team/teamMemberSchema'; +import { initTeamFreePlan } from '@fastgpt/service/support/wallet/sub/utils'; +import updatePasswordApi from '@/pages/api/support/user/account/password/update'; +import { Call } from '@test/utils/request'; + +describe('password/update API', () => { + let testUser: any; + let testTeam: any; + let testTmb: any; + + beforeEach(async () => { + testUser = await MongoUser.create({ + username: 'password-update-user', + password: hashStr('old-password') + }); + testTeam = await MongoTeam.create({ + name: 'Password Update Team', + ownerId: testUser._id + }); + await initTeamFreePlan({ teamId: String(testTeam._id) }); + testTmb = await MongoTeamMember.create({ + teamId: testTeam._id, + userId: testUser._id, + status: 'active', + role: 'owner' + }); + vi.clearAllMocks(); + }); + + const getAuth = (userId = String(testUser._id)) => + ({ + userId, + teamId: String(testTeam._id), + tmbId: String(testTmb._id), + isRoot: false, + sessionId: 'current-session' + }) as any; + + const getBody = ({ + userId = String(testUser._id), + newPsw = hashStr('new-password') + } = {}): UpdatePasswordBody => ({ + newPsw, + passwordChangeToken: passwordChangeTokenService.sign(userId).token + }); + + it('updates an existing password and its update time', async () => { + const body = getBody(); + const response = await Call, undefined>( + updatePasswordApi, + { + body, + auth: getAuth() + } + ); + + expect(response.code).toBe(200); + expect(await MongoUser.exists({ _id: testUser._id, password: body.newPsw })).toBeTruthy(); + const updatedUser = await MongoUser.findById(testUser._id).lean(); + expect(updatedUser?.passwordUpdateTime).toBeInstanceOf(Date); + }); + + it('sets the first password for an account without a stored password', async () => { + const userWithoutPassword = await MongoUser.create({ username: 'password-first-set-user' }); + const body = getBody({ userId: String(userWithoutPassword._id) }); + + const response = await Call, undefined>( + updatePasswordApi, + { + body, + auth: getAuth(String(userWithoutPassword._id)) + } + ); + + expect(response.code).toBe(200); + expect( + await MongoUser.exists({ _id: userWithoutPassword._id, password: body.newPsw }) + ).toBeTruthy(); + const updatedUser = await MongoUser.findById(userWithoutPassword._id).lean(); + expect(updatedUser?.passwordUpdateTime).toBeInstanceOf(Date); + }); + + it('rejects the current password when a stored password exists', async () => { + const response = await Call, undefined>( + updatePasswordApi, + { + body: getBody({ newPsw: hashStr('old-password') }), + auth: getAuth() + } + ); + + expect(response.code).toBe(500); + const unchangedUser = await MongoUser.findById(testUser._id).lean(); + expect(unchangedUser?.passwordUpdateTime).toBeUndefined(); + }); + + it('rejects a token issued for another user', async () => { + const response = await Call, undefined>( + updatePasswordApi, + { + body: getBody({ userId: 'another-user' }), + auth: getAuth() + } + ); + + expect(response.code).toBe(500); + expect( + await MongoUser.exists({ _id: testUser._id, password: hashStr('old-password') }) + ).toBeTruthy(); + }); + + it('rejects a missing token and non-string password input', async () => { + const missingTokenResponse = await Call, undefined>( + updatePasswordApi, + { + body: { newPsw: hashStr('new-password') }, + auth: getAuth() + } + ); + const invalidPasswordResponse = await Call, undefined>( + updatePasswordApi, + { + body: { + newPsw: { $ne: '' }, + passwordChangeToken: passwordChangeTokenService.sign(String(testUser._id)).token + }, + auth: getAuth() + } + ); + + expect(missingTokenResponse.code).toBe(500); + expect(invalidPasswordResponse.code).toBe(500); + }); + + it('rejects requests without a current Session', async () => { + const response = await Call, undefined>( + updatePasswordApi, + { + body: getBody() + } + ); + + expect(response.code).toBe(500); + }); +}); diff --git a/projects/app/test/api/support/user/account/resetExpiredPsw.test.ts b/projects/app/test/api/support/user/account/resetExpiredPsw.test.ts deleted file mode 100644 index 2880954fb6d3..000000000000 --- a/projects/app/test/api/support/user/account/resetExpiredPsw.test.ts +++ /dev/null @@ -1,196 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { MongoUser } from '@fastgpt/service/support/user/schema'; -import { MongoTeam } from '@fastgpt/service/support/user/team/teamSchema'; -import { MongoTeamMember } from '@fastgpt/service/support/user/team/teamMemberSchema'; -import { UserStatusEnum } from '@fastgpt/global/support/user/constant'; -import { initTeamFreePlan } from '@fastgpt/service/support/wallet/sub/utils'; -import type { ResetExpiredPswBodyType } from '@fastgpt/global/openapi/support/user/account/password/api'; -import { Call } from '@test/utils/request'; - -const originalPasswordExpiredMonth = process.env.PASSWORD_EXPIRED_MONTH; -const loadResetExpiredPswApi = async () => { - vi.resetModules(); - return import('@/pages/api/support/user/account/resetExpiredPsw'); -}; - -describe('resetExpiredPsw API', () => { - let testUser: any; - let testTeam: any; - let testTmb: any; - - beforeEach(async () => { - testUser = await MongoUser.create({ - username: 'testuser', - password: 'oldpassword', - status: UserStatusEnum.active - }); - testTeam = await MongoTeam.create({ - name: 'Test Team', - ownerId: testUser._id - }); - await initTeamFreePlan({ teamId: String(testTeam._id) }); - testTmb = await MongoTeamMember.create({ - teamId: testTeam._id, - userId: testUser._id, - status: 'active', - role: 'owner' - }); - vi.clearAllMocks(); - }); - - afterEach(() => { - vi.stubEnv('PASSWORD_EXPIRED_MONTH', originalPasswordExpiredMonth); - }); - - it('should successfully reset password when expired', async () => { - vi.stubEnv('PASSWORD_EXPIRED_MONTH', '1'); - const resetExpiredPswApi = await loadResetExpiredPswApi(); - - // Set password update time to 2 months ago (expired) - const twoMonthsAgo = new Date(); - twoMonthsAgo.setMonth(twoMonthsAgo.getMonth() - 2); - await MongoUser.findByIdAndUpdate(testUser._id, { - passwordUpdateTime: twoMonthsAgo - }); - - const res = await Call(resetExpiredPswApi.default, { - body: { newPsw: 'newhashedpassword' }, - auth: { - userId: String(testUser._id), - teamId: String(testTeam._id), - tmbId: String(testTmb._id), - isRoot: false, - sessionId: 'session123' - } as any - }); - - expect(res.code).toBe(200); - - // Verify password was updated - const updatedUser = await MongoUser.findById(testUser._id).select( - '+password +passwordUpdateTime' - ); - expect(updatedUser?.password).toBeDefined(); - expect(updatedUser?.passwordUpdateTime).toBeDefined(); - const newUpdateTime = new Date(updatedUser!.passwordUpdateTime!).getTime(); - expect(newUpdateTime).toBeGreaterThan(twoMonthsAgo.getTime()); - }); - - it('should reject when password is not expired (PASSWORD_EXPIRED_MONTH not set)', async () => { - vi.stubEnv('PASSWORD_EXPIRED_MONTH', undefined); - const resetExpiredPswApi = await loadResetExpiredPswApi(); - - await MongoUser.findByIdAndUpdate(testUser._id, { - passwordUpdateTime: new Date() - }); - - const res = await Call(resetExpiredPswApi.default, { - body: { newPsw: 'newhashedpassword' }, - auth: { - userId: String(testUser._id), - teamId: String(testTeam._id), - tmbId: String(testTmb._id), - isRoot: false, - sessionId: 'session123' - } as any - }); - - expect(res.code).toBe(500); - expect(res.error).toBeDefined(); - }); - - it('should reject when password is not expired (still within expiry period)', async () => { - vi.stubEnv('PASSWORD_EXPIRED_MONTH', '3'); - const resetExpiredPswApi = await loadResetExpiredPswApi(); - - // Update password just now — not expired - await MongoUser.findByIdAndUpdate(testUser._id, { - passwordUpdateTime: new Date() - }); - - const res = await Call(resetExpiredPswApi.default, { - body: { newPsw: 'newhashedpassword' }, - auth: { - userId: String(testUser._id), - teamId: String(testTeam._id), - tmbId: String(testTmb._id), - isRoot: false, - sessionId: 'session123' - } as any - }); - - expect(res.code).toBe(500); - }); - - it('should reject when newPsw is missing', async () => { - vi.stubEnv('PASSWORD_EXPIRED_MONTH', '1'); - const resetExpiredPswApi = await loadResetExpiredPswApi(); - - const res = await Call(resetExpiredPswApi.default, { - body: {}, - auth: { - userId: String(testUser._id), - teamId: String(testTeam._id), - tmbId: String(testTmb._id), - isRoot: false, - sessionId: 'session123' - } as any - }); - - expect(res.code).toBe(500); - }); - - it('should reject when user is not found', async () => { - vi.stubEnv('PASSWORD_EXPIRED_MONTH', '1'); - const resetExpiredPswApi = await loadResetExpiredPswApi(); - - const nonExistentId = '000000000000000000000001'; - - const res = await Call(resetExpiredPswApi.default, { - body: { newPsw: 'newhashedpassword' }, - auth: { - userId: nonExistentId, - teamId: String(testTeam._id), - tmbId: String(testTmb._id), - isRoot: false, - sessionId: 'session123' - } as any - }); - - expect(res.code).toBe(500); - expect(res.error).toBe('The password has not expired'); - }); - - it('should reject request without authentication', async () => { - const resetExpiredPswApi = await loadResetExpiredPswApi(); - const res = await Call(resetExpiredPswApi.default, { - body: { newPsw: 'newhashedpassword' } - }); - - expect(res.code).toBe(500); - }); - - it('should reject newPsw as non-string (injection guard)', async () => { - vi.stubEnv('PASSWORD_EXPIRED_MONTH', '1'); - const resetExpiredPswApi = await loadResetExpiredPswApi(); - - const twoMonthsAgo = new Date(); - twoMonthsAgo.setMonth(twoMonthsAgo.getMonth() - 2); - await MongoUser.findByIdAndUpdate(testUser._id, { - passwordUpdateTime: twoMonthsAgo - }); - - const res = await Call(resetExpiredPswApi.default, { - body: { newPsw: { $ne: '' } }, - auth: { - userId: String(testUser._id), - teamId: String(testTeam._id), - tmbId: String(testTmb._id), - isRoot: false, - sessionId: 'session123' - } as any - }); - - expect(res.code).toBe(500); - }); -}); diff --git a/projects/app/test/api/support/user/account/tokenLogin.test.ts b/projects/app/test/api/support/user/account/tokenLogin.test.ts index 64982d882997..c030797dc9e3 100644 --- a/projects/app/test/api/support/user/account/tokenLogin.test.ts +++ b/projects/app/test/api/support/user/account/tokenLogin.test.ts @@ -52,6 +52,31 @@ describe('tokenLogin API', () => { expect(res.data.team).toBeDefined(); expect(res.data.team.teamId).toBe(String(testTeam._id)); expect(res.data.team.tmbId).toBe(String(testTmb._id)); + expect(res.data.hasPassword).toBe(true); + expect(res.data).not.toHaveProperty('password'); + expect(res.data).not.toHaveProperty('passwordUpdateTime'); + }); + + it('derives a missing password as hasPassword=false without exposing internal fields', async () => { + await MongoUser.collection.updateOne( + { _id: testUser._id }, + { $unset: { password: 1 }, $set: { passwordUpdateTime: new Date() } } + ); + + const res = await Call(tokenLoginApi.default, { + auth: { + userId: String(testUser._id), + teamId: String(testTeam._id), + tmbId: String(testTmb._id), + isRoot: false, + sessionId: 'session123' + } as any + }); + + expect(res.code).toBe(200); + expect(res.data.hasPassword).toBe(false); + expect(res.data).not.toHaveProperty('password'); + expect(res.data).not.toHaveProperty('passwordUpdateTime'); }); it('should return owner permissions for root session', async () => { diff --git a/projects/app/test/api/support/user/account/updatePasswordByOld.test.ts b/projects/app/test/api/support/user/account/updatePasswordByOld.test.ts deleted file mode 100644 index 07e5f289af53..000000000000 --- a/projects/app/test/api/support/user/account/updatePasswordByOld.test.ts +++ /dev/null @@ -1,139 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import * as updatePasswordApi from '@/pages/api/support/user/account/updatePasswordByOld'; -import { MongoUser } from '@fastgpt/service/support/user/schema'; -import { MongoTeam } from '@fastgpt/service/support/user/team/teamSchema'; -import { MongoTeamMember } from '@fastgpt/service/support/user/team/teamMemberSchema'; -import { UserStatusEnum } from '@fastgpt/global/support/user/constant'; -import { initTeamFreePlan } from '@fastgpt/service/support/wallet/sub/utils'; -import type { UpdatePasswordByOldBodyType } from '@fastgpt/global/openapi/support/user/account/password/api'; -import { Call } from '@test/utils/request'; - -describe('updatePasswordByOld API', () => { - let testUser: any; - let testTeam: any; - let testTmb: any; - - beforeEach(async () => { - testUser = await MongoUser.create({ - username: 'testuser', - password: 'oldhashpassword', - status: UserStatusEnum.active - }); - testTeam = await MongoTeam.create({ - name: 'Test Team', - ownerId: testUser._id - }); - await initTeamFreePlan({ teamId: String(testTeam._id) }); - testTmb = await MongoTeamMember.create({ - teamId: testTeam._id, - userId: testUser._id, - status: 'active', - role: 'owner' - }); - vi.clearAllMocks(); - }); - - const makeAuth = (user: any, team: any, tmb: any) => ({ - userId: String(user._id), - teamId: String(team._id), - tmbId: String(tmb._id), - isRoot: false, - sessionId: 'session123' - }); - - it('should update password successfully with correct old password', async () => { - const res = await Call(updatePasswordApi.default, { - body: { oldPsw: 'oldhashpassword', newPsw: 'newhashpassword' }, - auth: makeAuth(testUser, testTeam, testTmb) as any - }); - - expect(res.code).toBe(200); - - const updatedUser = await MongoUser.findById(testUser._id).select('+password'); - expect(updatedUser?.password).toBeDefined(); - expect(updatedUser?.passwordUpdateTime).toBeDefined(); - }); - - it('should reject when old password is incorrect', async () => { - const res = await Call(updatePasswordApi.default, { - body: { oldPsw: 'wrongpassword', newPsw: 'newhashpassword' }, - auth: makeAuth(testUser, testTeam, testTmb) as any - }); - - expect(res.code).toBe(500); - - // Password should not change - const user = await MongoUser.findById(testUser._id).select('+passwordUpdateTime'); - expect(user?.passwordUpdateTime).toBeUndefined(); // we didn't set it initially - }); - - it('should reject when old and new passwords are the same', async () => { - const res = await Call(updatePasswordApi.default, { - body: { oldPsw: 'oldhashpassword', newPsw: 'oldhashpassword' }, - auth: makeAuth(testUser, testTeam, testTmb) as any - }); - - expect(res.code).toBe(500); - }); - - it('should reject when oldPsw is missing', async () => { - const res = await Call(updatePasswordApi.default, { - body: { newPsw: 'newhashpassword' }, - auth: makeAuth(testUser, testTeam, testTmb) as any - }); - - expect(res.code).toBe(500); - }); - - it('should reject when newPsw is missing', async () => { - const res = await Call(updatePasswordApi.default, { - body: { oldPsw: 'oldhashpassword' }, - auth: makeAuth(testUser, testTeam, testTmb) as any - }); - - expect(res.code).toBe(500); - }); - - it('should reject request without authentication', async () => { - const res = await Call(updatePasswordApi.default, { - body: { oldPsw: 'oldhashpassword', newPsw: 'newhashpassword' } - }); - - expect(res.code).toBe(500); - }); - - // ===== Security: NoSQL injection prevention (GHSA-jxvr-h2vx-p73r Step 3) ===== - - it('should reject oldPsw as MongoDB operator object ($ne injection)', async () => { - // GHSA-jxvr-h2vx-p73r Step 3: oldPsw: {"$ne": ""} bypasses old password check - const res = await Call(updatePasswordApi.default, { - body: { oldPsw: { $ne: '' }, newPsw: 'newhashpassword' }, - auth: makeAuth(testUser, testTeam, testTmb) as any - }); - - // Zod z.string() must reject object-type oldPsw - expect(res.code).toBe(500); - - // Password must NOT be changed - const user = await MongoUser.findById(testUser._id).select('+passwordUpdateTime'); - expect(user?.passwordUpdateTime).toBeUndefined(); - }); - - it('should reject oldPsw with $regex injection', async () => { - const res = await Call(updatePasswordApi.default, { - body: { oldPsw: { $regex: '.*' }, newPsw: 'newhashpassword' }, - auth: makeAuth(testUser, testTeam, testTmb) as any - }); - - expect(res.code).toBe(500); - }); - - it('should reject newPsw as non-string type', async () => { - const res = await Call(updatePasswordApi.default, { - body: { oldPsw: 'oldhashpassword', newPsw: { $ne: '' } }, - auth: makeAuth(testUser, testTeam, testTmb) as any - }); - - expect(res.code).toBe(500); - }); -}); diff --git a/projects/app/test/components/support/user/safe/AccountVerificationPanel.test.ts b/projects/app/test/components/support/user/safe/AccountVerificationPanel.test.ts new file mode 100644 index 000000000000..51c174c093e0 --- /dev/null +++ b/projects/app/test/components/support/user/safe/AccountVerificationPanel.test.ts @@ -0,0 +1,236 @@ +import React, { act } from 'react'; +import type { Root } from 'react-dom/client'; +import { JSDOM } from 'jsdom'; +import { hashStr } from '@fastgpt/global/common/string/tools'; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +type ChakraProviderComponent = (typeof import('@chakra-ui/react'))['ChakraProvider']; +type AccountVerificationPanelComponent = + (typeof import('@/components/support/user/safe/AccountVerificationPanel'))['AccountVerificationPanel']; + +const mocks = vi.hoisted(() => ({ + toast: vi.fn(), + replace: vi.fn(), + setLoginStore: vi.fn() +})); + +vi.mock('next/router', () => ({ + useRouter: () => ({ replace: mocks.replace }) +})); + +vi.mock('next-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key }) +})); + +vi.mock('@fastgpt/web/hooks/useToast', () => ({ + useToast: () => ({ toast: mocks.toast }) +})); + +vi.mock('@/web/common/system/useSystemStore', () => ({ + useSystemStore: Object.assign(() => ({ feConfigs: {} }), { + getState: () => ({ setLoginStore: mocks.setLoginStore }) + }) +})); + +describe('AccountVerificationPanel', () => { + let dom: JSDOM; + let createRoot: typeof import('react-dom/client').createRoot; + let ChakraProvider: ChakraProviderComponent; + let AccountVerificationPanel: AccountVerificationPanelComponent; + let container: HTMLDivElement; + let root: Root; + + const flushEffects = async () => { + await act(async () => { + await Promise.resolve(); + await new Promise((resolve) => window.setTimeout(resolve, 0)); + }); + }; + + const changeInput = (input: HTMLInputElement, value: string) => { + const valueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set; + if (!valueSetter) throw new Error('HTMLInputElement value setter is unavailable'); + + act(() => { + valueSetter.call(input, value); + input.dispatchEvent(new Event('input', { bubbles: true })); + }); + }; + + beforeAll(async () => { + dom = new JSDOM('', { + url: 'https://fastgpt.example.com/account/info' + }); + vi.stubGlobal('window', dom.window); + vi.stubGlobal('document', dom.window.document); + vi.stubGlobal('navigator', dom.window.navigator); + vi.stubGlobal('HTMLElement', dom.window.HTMLElement); + vi.stubGlobal('HTMLInputElement', dom.window.HTMLInputElement); + vi.stubGlobal('Event', dom.window.Event); + vi.stubGlobal('MouseEvent', dom.window.MouseEvent); + vi.stubGlobal('getComputedStyle', dom.window.getComputedStyle.bind(dom.window)); + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => + dom.window.setTimeout(() => callback(Date.now()), 0) + ); + vi.stubGlobal('cancelAnimationFrame', (handle: number) => dom.window.clearTimeout(handle)); + // 生产构建会注入 JSX runtime,Vitest 直接转换该 TSX 时需显式提供 React。 + vi.stubGlobal('React', React); + globalThis.IS_REACT_ACT_ENVIRONMENT = true; + + ({ createRoot } = await import('react-dom/client')); + ({ ChakraProvider } = await import('@chakra-ui/react')); + ({ AccountVerificationPanel } = + await import('@/components/support/user/safe/AccountVerificationPanel')); + }); + + afterAll(() => { + dom.window.close(); + vi.unstubAllGlobals(); + }); + + beforeEach(() => { + vi.clearAllMocks(); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + it('recreates one-time old-password material after a failed password attempt', async () => { + const createVerification = vi + .fn() + .mockResolvedValueOnce({ method: 'oldPassword', preLoginCode: 'pre-login-code-1' }) + .mockResolvedValue({ method: 'oldPassword', preLoginCode: 'pre-login-code-2' }); + const authorization = { + status: 'authorized' as const, + token: 'password-change-token', + expiredAt: '2026-07-22T08:05:00.000Z' + }; + const consumeVerification = vi + .fn() + .mockRejectedValueOnce(new Error('Wrong password')) + .mockResolvedValueOnce(authorization); + const onAuthorized = vi.fn(); + + await act(async () => { + root.render( + React.createElement( + ChakraProvider, + undefined, + React.createElement(AccountVerificationPanel, { + method: 'oldPassword', + username: 'local-user', + required: false, + returnRoute: '/account/info', + createVerification, + consumeVerification, + onAuthorized + }) + ) + ); + }); + await flushEffects(); + + const accountInput = container.querySelector( + 'input[aria-label="account_info:user_account"]' + ); + const firstInput = container.querySelector( + 'input[placeholder="account_info:password_old_placeholder"]' + ); + const firstButton = container.querySelector('button'); + if (!firstInput || !firstButton) throw new Error('Old password controls did not render'); + expect(accountInput?.value).toBe('local-user'); + expect(accountInput?.disabled).toBe(true); + expect(document.activeElement).not.toBe(firstInput); + + changeInput(firstInput, 'Wrong-password-123'); + act(() => firstButton.click()); + await flushEffects(); + + expect(consumeVerification).toHaveBeenNthCalledWith(1, { + method: 'oldPassword', + payload: { + password: hashStr('Wrong-password-123'), + preLoginCode: 'pre-login-code-1' + } + }); + expect(createVerification).toHaveBeenCalledTimes(2); + + const retryInput = container.querySelector( + 'input[placeholder="account_info:password_old_placeholder"]' + ); + const retryButton = container.querySelector('button'); + if (!retryInput || !retryButton) throw new Error('Retry controls did not render'); + expect(retryInput.value).toBe(''); + + changeInput(retryInput, 'Correct-password-123'); + act(() => retryButton.click()); + await flushEffects(); + + expect(consumeVerification).toHaveBeenNthCalledWith(2, { + method: 'oldPassword', + payload: { + password: hashStr('Correct-password-123'), + preLoginCode: 'pre-login-code-2' + } + }); + expect(onAuthorized).toHaveBeenCalledWith(authorization); + }); + + it('automatically consumes a six-digit verification code without a submit button', async () => { + const createVerification = vi.fn(); + const authorization = { + status: 'authorized' as const, + token: 'password-change-token', + expiredAt: '2026-07-22T08:05:00.000Z' + }; + const consumeVerification = vi.fn().mockResolvedValue(authorization); + const onAuthorized = vi.fn(); + + await act(async () => { + root.render( + React.createElement( + ChakraProvider, + undefined, + React.createElement(AccountVerificationPanel, { + method: 'code', + username: '13800138000', + required: false, + returnRoute: '/account/info', + createVerification, + consumeVerification, + onAuthorized + }) + ) + ); + }); + await flushEffects(); + + const codeInput = container.querySelector( + 'input[aria-label="user:password.verification_code"]' + ); + if (!codeInput) throw new Error('Verification code input did not render'); + + changeInput(codeInput, '12345'); + await flushEffects(); + expect(consumeVerification).not.toHaveBeenCalled(); + + changeInput(codeInput, '123456'); + await flushEffects(); + + expect(consumeVerification).toHaveBeenCalledWith({ + method: 'code', + payload: { code: '123456' } + }); + expect(onAuthorized).toHaveBeenCalledWith(authorization); + expect( + [...container.querySelectorAll('button')].some( + (button) => button.textContent === 'account_info:password_verify' + ) + ).toBe(false); + }); +}); diff --git a/projects/app/test/components/support/user/safe/PasswordChangeModal.test.ts b/projects/app/test/components/support/user/safe/PasswordChangeModal.test.ts new file mode 100644 index 000000000000..3ef5d8f1b844 --- /dev/null +++ b/projects/app/test/components/support/user/safe/PasswordChangeModal.test.ts @@ -0,0 +1,271 @@ +import React, { act } from 'react'; +import type { Root } from 'react-dom/client'; +import { JSDOM } from 'jsdom'; +import { UserErrEnum } from '@fastgpt/global/common/error/code/user'; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +type ChakraProviderComponent = (typeof import('@chakra-ui/react'))['ChakraProvider']; +type PasswordChangeModalComponent = + (typeof import('@/components/support/user/safe/PasswordChangeModal'))['default']; +type PasswordChangeStore = + (typeof import('@/web/support/user/account/password/store'))['usePasswordChangeStore']; + +const mocks = vi.hoisted(() => ({ + toast: vi.fn(), + replace: vi.fn(), + authorizePasswordChange: vi.fn(), + createPasswordVerification: vi.fn(), + updatePassword: vi.fn(), + initUserInfo: vi.fn() +})); + +vi.mock('next/router', () => ({ + useRouter: () => ({ asPath: '/account/info', replace: mocks.replace }) +})); + +vi.mock('next-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key }) +})); + +vi.mock('@fastgpt/web/hooks/useToast', () => ({ + useToast: () => ({ toast: mocks.toast }) +})); + +vi.mock('@chakra-ui/react', async (importOriginal) => { + const original = await importOriginal(); + const createContainer = ({ children }: React.PropsWithChildren) => { + const ReactRuntime = (globalThis as any).React as typeof React; + return ReactRuntime.createElement('div', undefined, children); + }; + + return { + ...original, + ModalBody: createContainer, + ModalFooter: createContainer + }; +}); + +vi.mock('@/web/support/user/useUserStore', () => ({ + useUserStore: () => ({ + userInfo: { username: 'local-user', hasPassword: true }, + initUserInfo: mocks.initUserInfo + }) +})); + +vi.mock('@/web/common/system/useSystemStore', () => ({ + useSystemStore: Object.assign(() => ({ feConfigs: {} }), { + getState: () => ({ setLoginStore: vi.fn() }) + }) +})); + +vi.mock('@/web/support/user/account/password/api', () => ({ + authorizePasswordChange: mocks.authorizePasswordChange, + createPasswordVerification: mocks.createPasswordVerification, + updatePassword: mocks.updatePassword +})); + +vi.mock('@fastgpt/web/components/common/MyModal', () => ({ + default: ({ children, onClose, closeOnOverlayClick }: any) => { + const ReactRuntime = (globalThis as any).React as typeof React; + return ReactRuntime.createElement( + 'section', + { + 'data-testid': 'password-modal', + 'data-closable': String(typeof onClose === 'function'), + 'data-overlay-close': String(closeOnOverlayClick) + }, + typeof onClose === 'function' + ? ReactRuntime.createElement( + 'button', + { type: 'button', 'data-testid': 'modal-close', onClick: onClose }, + 'close' + ) + : null, + children + ); + } +})); + +describe('PasswordChangeModal', () => { + let dom: JSDOM; + let createRoot: typeof import('react-dom/client').createRoot; + let ChakraProvider: ChakraProviderComponent; + let PasswordChangeModal: PasswordChangeModalComponent; + let usePasswordChangeStore: PasswordChangeStore; + let container: HTMLDivElement; + let root: Root; + + const authorized = { + status: 'authorized' as const, + token: 'password-change-token', + expiredAt: '2026-07-22T08:05:00.000Z' + }; + + const flushEffects = async () => { + await act(async () => { + await Promise.resolve(); + await new Promise((resolve) => window.setTimeout(resolve, 0)); + }); + }; + + const changeInput = (input: HTMLInputElement, value: string) => { + const valueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set; + if (!valueSetter) throw new Error('HTMLInputElement value setter is unavailable'); + + act(() => { + valueSetter.call(input, value); + input.dispatchEvent(new Event('input', { bubbles: true })); + }); + }; + + const getInput = (label: string) => { + const input = container.querySelector(`input[aria-label="${label}"]`); + if (!input) throw new Error(`Input did not render: ${label}`); + return input; + }; + + const getConfirmButton = () => { + const button = [...container.querySelectorAll('button')].find( + (item) => item.textContent === 'account_info:password_confirm_action' + ); + if (!button) throw new Error('Confirm button did not render'); + return button; + }; + + beforeAll(async () => { + dom = new JSDOM('', { + url: 'https://fastgpt.example.com/account/info' + }); + vi.stubGlobal('window', dom.window); + vi.stubGlobal('document', dom.window.document); + vi.stubGlobal('navigator', dom.window.navigator); + vi.stubGlobal('HTMLElement', dom.window.HTMLElement); + vi.stubGlobal('HTMLInputElement', dom.window.HTMLInputElement); + vi.stubGlobal('File', dom.window.File); + vi.stubGlobal('FileList', dom.window.FileList); + vi.stubGlobal('Event', dom.window.Event); + vi.stubGlobal('MouseEvent', dom.window.MouseEvent); + vi.stubGlobal('getComputedStyle', dom.window.getComputedStyle.bind(dom.window)); + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => + dom.window.setTimeout(() => callback(Date.now()), 0) + ); + vi.stubGlobal('cancelAnimationFrame', (handle: number) => dom.window.clearTimeout(handle)); + vi.stubGlobal('React', React); + globalThis.IS_REACT_ACT_ENVIRONMENT = true; + + ({ createRoot } = await import('react-dom/client')); + ({ ChakraProvider } = await import('@chakra-ui/react')); + ({ default: PasswordChangeModal } = + await import('@/components/support/user/safe/PasswordChangeModal')); + ({ usePasswordChangeStore } = await import('@/web/support/user/account/password/store')); + }); + + afterAll(() => { + dom.window.close(); + vi.unstubAllGlobals(); + }); + + beforeEach(() => { + vi.clearAllMocks(); + mocks.authorizePasswordChange.mockResolvedValue(authorized); + mocks.updatePassword.mockResolvedValue(undefined); + mocks.initUserInfo.mockResolvedValue(undefined); + usePasswordChangeStore.getState().setAuthorization(undefined); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + const renderModal = async (props: Record = {}) => { + await act(async () => { + root.render( + React.createElement( + ChakraProvider, + undefined, + React.createElement(PasswordChangeModal, props) + ) + ); + }); + await flushEffects(); + }; + + it('enters the password form from recent login and shows validation errors', async () => { + const onSuccess = vi.fn(); + await renderModal({ onSuccess, onClose: vi.fn() }); + + const newPasswordInput = getInput('account_info:password_new_placeholder'); + const confirmPasswordInput = getInput('account_info:password_confirm_placeholder'); + expect(document.activeElement).not.toBe(newPasswordInput); + expect(document.activeElement).not.toBe(confirmPasswordInput); + expect(container.querySelector('[data-testid="password-modal"]')).toMatchObject({ + dataset: expect.objectContaining({ closable: 'true', overlayClose: 'true' }) + }); + expect(container.textContent).toContain('account_info:password_tip'); + expect(container.textContent).not.toContain('common:Cancel'); + expect(mocks.authorizePasswordChange).toHaveBeenCalledTimes(1); + + changeInput(newPasswordInput, 'short'); + changeInput(confirmPasswordInput, 'different'); + act(() => getConfirmButton().click()); + await flushEffects(); + + expect(container.textContent).toContain('login:password_tip'); + expect(container.textContent).toContain('user:password.not_match'); + expect(mocks.updatePassword).not.toHaveBeenCalled(); + + changeInput(newPasswordInput, 'Strong-password-123'); + changeInput(confirmPasswordInput, 'Strong-password-123'); + act(() => getConfirmButton().click()); + await flushEffects(); + + expect(mocks.updatePassword).toHaveBeenCalledWith({ + newPassword: 'Strong-password-123', + passwordChangeToken: authorized.token + }); + expect(mocks.initUserInfo).toHaveBeenCalledTimes(1); + expect(onSuccess).toHaveBeenCalledTimes(1); + }); + + it('does not expose any close path for a required password flow', async () => { + await renderModal({ required: true }); + + const modal = container.querySelector('[data-testid="password-modal"]'); + expect(modal?.dataset.closable).toBe('false'); + expect(modal?.dataset.overlayClose).toBe('false'); + expect(container.querySelector('[data-testid="modal-close"]')).toBeNull(); + }); + + it('clears the password form and returns to verification when the JWT is invalid', async () => { + mocks.authorizePasswordChange + .mockResolvedValueOnce(authorized) + .mockResolvedValueOnce({ status: 'verificationRequired', method: 'oldPassword' }); + mocks.createPasswordVerification.mockResolvedValue({ + method: 'oldPassword', + preLoginCode: 'new-pre-login-code' + }); + mocks.updatePassword.mockRejectedValue({ + statusText: UserErrEnum.passwordChangeAuthorizationInvalid + }); + await renderModal({ onClose: vi.fn() }); + + changeInput(getInput('account_info:password_new_placeholder'), 'Strong-password-123'); + changeInput(getInput('account_info:password_confirm_placeholder'), 'Strong-password-123'); + act(() => getConfirmButton().click()); + await flushEffects(); + await flushEffects(); + + expect(mocks.authorizePasswordChange).toHaveBeenCalledTimes(2); + expect( + container.querySelector('input[aria-label="account_info:password_new_placeholder"]') + ).toBeNull(); + expect( + container.querySelector('input[placeholder="account_info:password_old_placeholder"]') + ).not.toBeNull(); + expect(usePasswordChangeStore.getState().authorization).toBeUndefined(); + }); +}); diff --git a/projects/app/test/service/mongo.test.ts b/projects/app/test/service/mongo.test.ts new file mode 100644 index 000000000000..3720ae721c2c --- /dev/null +++ b/projects/app/test/service/mongo.test.ts @@ -0,0 +1,100 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { hashStr } from '@fastgpt/global/common/string/tools'; + +const mocks = vi.hoisted(() => ({ + findRoot: vi.fn(), + selectPassword: vi.fn(), + createUser: vi.fn(), + createDefaultTeam: vi.fn(), + runTransaction: vi.fn() +})); + +vi.mock('@fastgpt/service/support/user/schema', () => ({ + MongoUser: { + findOne: mocks.findRoot, + create: mocks.createUser + } +})); + +vi.mock('@fastgpt/service/support/user/team/controller', () => ({ + createDefaultTeam: mocks.createDefaultTeam +})); + +vi.mock('@fastgpt/service/common/mongo/sessionRun', () => ({ + mongoSessionRun: mocks.runTransaction +})); + +vi.mock('@/env', () => ({ + appEnv: { DEFAULT_ROOT_PSW: 'configured-root-password' } +})); + +import { initRootUser } from '@/service/mongo'; + +describe('initRootUser', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.findRoot.mockReturnValue({ select: mocks.selectPassword }); + mocks.runTransaction.mockImplementation(async (callback) => callback('mongo-session')); + mocks.createDefaultTeam.mockResolvedValue(undefined); + }); + + it('does not rewrite an unchanged configured password', async () => { + const updateOne = vi.fn(); + mocks.selectPassword.mockResolvedValue({ + _id: 'root-id', + toObject: () => ({ + password: hashStr(hashStr('configured-root-password')) + }), + updateOne + }); + + await initRootUser(); + + expect(updateOne).not.toHaveBeenCalled(); + expect(mocks.createDefaultTeam).toHaveBeenCalledWith({ + userId: 'root-id', + session: 'mongo-session' + }); + }); + + it('updates password and update time when the configured password changes', async () => { + const updateOne = vi.fn().mockResolvedValue(undefined); + mocks.selectPassword.mockResolvedValue({ + _id: 'root-id', + toObject: () => ({ password: hashStr(hashStr('old-root-password')) }), + updateOne + }); + + await initRootUser(); + + expect(updateOne).toHaveBeenCalledWith( + { + password: hashStr('configured-root-password'), + passwordUpdateTime: expect.any(Date) + }, + { session: 'mongo-session' } + ); + }); + + it('writes password update time when creating root for the first time', async () => { + mocks.selectPassword.mockResolvedValue(null); + mocks.createUser.mockResolvedValue([{ _id: 'new-root-id' }]); + + await initRootUser(); + + expect(mocks.createUser).toHaveBeenCalledWith( + [ + { + username: 'root', + password: hashStr('configured-root-password'), + passwordUpdateTime: expect.any(Date) + } + ], + { session: 'mongo-session', ordered: true } + ); + expect(mocks.createDefaultTeam).toHaveBeenCalledWith({ + userId: 'new-root-id', + session: 'mongo-session' + }); + }); +}); diff --git a/projects/app/test/web/support/user/account/password/api.test.ts b/projects/app/test/web/support/user/account/password/api.test.ts new file mode 100644 index 000000000000..453dc2d74899 --- /dev/null +++ b/projects/app/test/web/support/user/account/password/api.test.ts @@ -0,0 +1,49 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { hashStr } from '@fastgpt/global/common/string/tools'; +import { POST } from '@/web/common/api/request'; +import { + authorizePasswordChange, + createPasswordVerification, + updatePassword +} from '@/web/support/user/account/password/api'; + +vi.mock('@/web/common/api/request', () => ({ + POST: vi.fn() +})); + +describe('password account API', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('creates server-bound verification material', async () => { + const body = { method: 'oldPassword', payload: {} } as const; + + await createPasswordVerification(body); + + expect(POST).toHaveBeenCalledWith( + '/proApi/support/user/account/password/verification/create', + body + ); + }); + + it('requests authorization from recent login', async () => { + const body = { source: 'recentLogin' } as const; + + await authorizePasswordChange(body); + + expect(POST).toHaveBeenCalledWith('/proApi/support/user/account/password/authorization', body); + }); + + it('hashes the raw password before submitting the short-lived token', async () => { + await updatePassword({ + newPassword: 'Strong-password-123', + passwordChangeToken: 'password-change-token' + }); + + expect(POST).toHaveBeenCalledWith('/support/user/account/password/update', { + newPsw: hashStr('Strong-password-123'), + passwordChangeToken: 'password-change-token' + }); + }); +}); diff --git a/projects/app/test/web/support/user/account/password/store.test.ts b/projects/app/test/web/support/user/account/password/store.test.ts new file mode 100644 index 000000000000..6475878a511d --- /dev/null +++ b/projects/app/test/web/support/user/account/password/store.test.ts @@ -0,0 +1,33 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { usePasswordChangeStore } from '@/web/support/user/account/password/store'; + +describe('password change authorization store', () => { + beforeEach(() => { + usePasswordChangeStore.getState().setAuthorization(undefined); + }); + + it('keeps the OAuth authorization only in the non-persisted process store', () => { + const authorization = { + token: 'password-change-token', + expiredAt: '2026-07-22T08:05:00.000Z', + required: false + }; + + usePasswordChangeStore.getState().setAuthorization(authorization); + + expect(usePasswordChangeStore.getState().authorization).toEqual(authorization); + expect('persist' in usePasswordChangeStore).toBe(false); + }); + + it('removes the token when the flow is closed or invalidated', () => { + usePasswordChangeStore.getState().setAuthorization({ + token: 'password-change-token', + expiredAt: '2026-07-22T08:05:00.000Z', + required: true + }); + + usePasswordChangeStore.getState().setAuthorization(undefined); + + expect(usePasswordChangeStore.getState().authorization).toBeUndefined(); + }); +}); diff --git a/projects/app/test/web/support/user/api.test.ts b/projects/app/test/web/support/user/api.test.ts index 55dc0df35649..b6aaedbcd849 100644 --- a/projects/app/test/web/support/user/api.test.ts +++ b/projects/app/test/web/support/user/api.test.ts @@ -1,7 +1,6 @@ -import { describe, expect, it, vi } from 'vitest'; +import { describe, it, vi } from 'vitest'; import * as api from '@/web/support/user/api'; import { UserAuthTypeEnum } from '@fastgpt/global/support/user/auth/constants'; -import { hashStr } from '@fastgpt/global/common/string/tools'; vi.mock('@/web/common/api/request', () => ({ GET: vi.fn(), @@ -66,18 +65,6 @@ describe('user api', () => { await api.postFindPassword(data); }); - it('should update password by old password', async () => { - const data = { - oldPsw: 'oldpassword', - newPsw: 'newpassword' - }; - await api.updatePasswordByOld(data); - }); - - it('should reset password', async () => { - await api.resetPassword('newpassword'); - }); - it('should check password expired', async () => { await api.getCheckPswExpired(); }); diff --git a/projects/app/test/web/support/user/useUserStore.test.ts b/projects/app/test/web/support/user/useUserStore.test.ts index ba32274dfc34..3d4db4df0cd3 100644 --- a/projects/app/test/web/support/user/useUserStore.test.ts +++ b/projects/app/test/web/support/user/useUserStore.test.ts @@ -56,7 +56,8 @@ const buildUser = (language: UserType['language']): UserType => permission: new TeamPermission({ isOwner: true }) }, permission: new TeamPermission({ isOwner: true }), - tags: [] + tags: [], + hasPassword: true }) as UserType; describe('useUserStore', () => { diff --git a/projects/app/vitest.config.ts b/projects/app/vitest.config.ts index 96772488c520..0c94be22091e 100644 --- a/projects/app/vitest.config.ts +++ b/projects/app/vitest.config.ts @@ -37,7 +37,8 @@ export default defineConfig({ 'bfd697e7e798f75deaf2d31210bc93a2e41ad4eed9e7831071d77821b7b97cff', AES256_SECRET_KEY: process.env.AES256_SECRET_KEY ?? 'fastgpt_test_aes256_secret_key', INVOKE_TOKEN_SECRET: process.env.INVOKE_TOKEN_SECRET ?? 'fastgpt_test_invoke_token_secret_32', - FE_DOMAIN: process.env.FE_DOMAIN ?? 'https://fastgpt.example.com' + FE_DOMAIN: process.env.FE_DOMAIN ?? 'https://fastgpt.example.com', + JWT_SECRET: process.env.JWT_SECRET ?? 'fastgpt_test_jwt_signing_secret_32_chars' }, coverage: { enabled: true, diff --git a/test/mocks/request.ts b/test/mocks/request.ts index 80a57fd5096c..5c3b78f93965 100644 --- a/test/mocks/request.ts +++ b/test/mocks/request.ts @@ -52,6 +52,7 @@ export type parseHeaderCertRet = { apiKeyAuthProxy?: boolean; isRoot: boolean; sessionId: string; + sessionCreatedAt?: number; }; export type MockReqType = { From 5da1193460988cf7d6a3bae5d29f8c97174a55a9 Mon Sep 17 00:00:00 2001 From: shortlight5980 Date: Thu, 23 Jul 2026 17:19:33 +0800 Subject: [PATCH 09/10] fix password change --- .../version/main/docker-compose.template.yml | 4 +- .../version/v4.14/docker-compose.template.yml | 4 +- .../version/v4.15/docker-compose.template.yml | 4 +- document/content/self-host/config/env.en.mdx | 3 +- document/content/self-host/config/env.mdx | 3 +- .../self-host/upgrading/4-15/4154.en.mdx | 16 ++ .../content/self-host/upgrading/4-15/4154.mdx | 16 ++ document/data/doc-last-modified.json | 2 +- .../docker/main/cn/docker-compose.milvus.yml | 2 + .../main/cn/docker-compose.oceanbase.yml | 2 + .../main/cn/docker-compose.opengauss.yml | 2 + .../docker/main/cn/docker-compose.pg.yml | 2 + .../docker/main/cn/docker-compose.seekdb.yml | 2 + .../docker/main/cn/docker-compose.zilliz.yml | 2 + .../main/global/docker-compose.milvus.yml | 2 + .../main/global/docker-compose.oceanbase.yml | 2 + .../main/global/docker-compose.opengauss.yml | 2 + .../docker/main/global/docker-compose.pg.yml | 2 + .../main/global/docker-compose.seekdb.yml | 2 + .../main/global/docker-compose.zilliz.yml | 2 + .../docker/v4.14/cn/docker-compose.milvus.yml | 2 + .../v4.14/cn/docker-compose.oceanbase.yml | 2 + .../v4.14/cn/docker-compose.opengauss.yml | 2 + .../docker/v4.14/cn/docker-compose.pg.yml | 2 + .../docker/v4.14/cn/docker-compose.seekdb.yml | 2 + .../docker/v4.14/cn/docker-compose.zilliz.yml | 2 + .../v4.14/global/docker-compose.milvus.yml | 2 + .../v4.14/global/docker-compose.oceanbase.yml | 2 + .../v4.14/global/docker-compose.opengauss.yml | 2 + .../docker/v4.14/global/docker-compose.pg.yml | 2 + .../v4.14/global/docker-compose.seekdb.yml | 2 + .../v4.14/global/docker-compose.zilliz.yml | 2 + .../docker/v4.15/cn/docker-compose.milvus.yml | 3 + .../v4.15/cn/docker-compose.oceanbase.yml | 3 + .../v4.15/cn/docker-compose.opengauss.yml | 3 + .../docker/v4.15/cn/docker-compose.pg.yml | 3 + .../docker/v4.15/cn/docker-compose.seekdb.yml | 3 + .../docker/v4.15/cn/docker-compose.zilliz.yml | 3 + .../v4.15/global/docker-compose.milvus.yml | 3 + .../v4.15/global/docker-compose.oceanbase.yml | 3 + .../v4.15/global/docker-compose.opengauss.yml | 3 + .../docker/v4.15/global/docker-compose.pg.yml | 3 + .../v4.15/global/docker-compose.seekdb.yml | 3 + .../v4.15/global/docker-compose.zilliz.yml | 3 + document/public/deploy/install.sh | 7 +- packages/global/common/error/code/user.ts | 8 +- .../support/user/account/password/api.ts | 83 ++++--- .../support/user/account/password/index.ts | 8 +- .../global/test/common/error/utils.test.ts | 3 +- .../support/user/account/password/api.test.ts | 35 ++- .../global/test/support/user/utils.test.ts | 29 ++- .../service/support/permission/auth/common.ts | 13 +- .../support/user/account/password/service.ts | 18 ++ .../support/user/account/password/utils.ts | 18 -- .../service/test/common/http/entry.test.ts | 3 +- .../test/common/response/index.test.ts | 3 +- .../user/account/password/utils.test.ts | 31 --- packages/web/i18n/en/account_info.json | 27 --- packages/web/i18n/en/common.json | 30 ++- packages/web/i18n/en/user.json | 2 +- packages/web/i18n/zh-CN/account_info.json | 27 --- packages/web/i18n/zh-CN/common.json | 30 ++- packages/web/i18n/zh-CN/user.json | 2 +- packages/web/i18n/zh-Hant/account_info.json | 27 --- packages/web/i18n/zh-Hant/common.json | 30 ++- packages/web/i18n/zh-Hant/user.json | 2 +- .../user/safe/AccountVerificationPanel.tsx | 131 ++++++----- .../support/user/safe/PasswordChangeModal.tsx | 139 ++++++----- .../account/cancel/VerificationPanel.tsx | 19 +- .../pageComponents/account/info/password.ts | 8 + projects/app/src/pages/account/info/index.tsx | 13 +- .../support/user/account/checkPswExpired.ts | 7 +- .../support/user/account/password/update.ts | 16 +- projects/app/src/pages/login/provider.tsx | 2 +- projects/app/src/service/mongo.ts | 8 +- .../user/account/verification/error.ts} | 23 +- .../user/account/checkPswExpired.test.ts | 22 ++ .../user/account/password/update.test.ts | 5 + .../safe/AccountVerificationPanel.test.ts | 219 ++++++++++++++++-- .../user/safe/PasswordChangeModal.test.ts | 119 ++++++++-- .../account/cancel/VerificationPanel.test.ts | 168 ++++++++++++++ .../account/info/password.test.ts | 18 ++ projects/app/test/service/mongo.test.ts | 10 +- .../support/user/account/password/api.test.ts | 4 +- .../user/account/verification/error.test.ts} | 68 +++--- 85 files changed, 1130 insertions(+), 441 deletions(-) delete mode 100644 packages/service/support/user/account/password/utils.ts delete mode 100644 packages/service/test/support/user/account/password/utils.test.ts create mode 100644 projects/app/src/pageComponents/account/info/password.ts rename projects/app/src/{pageComponents/account/cancel/utils.ts => web/support/user/account/verification/error.ts} (51%) create mode 100644 projects/app/test/pageComponents/account/cancel/VerificationPanel.test.ts create mode 100644 projects/app/test/pageComponents/account/info/password.test.ts rename projects/app/test/{pageComponents/account/cancel/utils.test.ts => web/support/user/account/verification/error.test.ts} (65%) diff --git a/deploy/version/main/docker-compose.template.yml b/deploy/version/main/docker-compose.template.yml index 574e6896074b..b59221f52533 100644 --- a/deploy/version/main/docker-compose.template.yml +++ b/deploy/version/main/docker-compose.template.yml @@ -179,8 +179,8 @@ services: AES256_SECRET_KEY: fastgptsecret # Invoke 反向调用 JWT 密钥,至少 32 位 INVOKE_TOKEN_SECRET: fastgpt_invoke_token_secret_32_chars_min - # 通用 JWT 签名密钥,至少 32 位;生产环境必须替换 - JWT_SECRET: replace_with_a_random_secret_at_least_32_chars + # 通用 JWT 签名密钥,至少 32 位;安装脚本会自动生成,手动部署必须配置 + JWT_SECRET: '' # 强制将图片转成 base64 传递给模型 MULTIPLE_DATA_TO_BASE64: false diff --git a/deploy/version/v4.14/docker-compose.template.yml b/deploy/version/v4.14/docker-compose.template.yml index 4ae11e7ad844..8f0fbcb6a4db 100644 --- a/deploy/version/v4.14/docker-compose.template.yml +++ b/deploy/version/v4.14/docker-compose.template.yml @@ -182,8 +182,8 @@ services: AES256_SECRET_KEY: fastgptsecret # Invoke 反向调用 JWT 密钥,至少 32 位 INVOKE_TOKEN_SECRET: fastgpt_invoke_token_secret_32_chars_min - # 通用 JWT 签名密钥,至少 32 位;生产环境必须替换 - JWT_SECRET: replace_with_a_random_secret_at_least_32_chars + # 通用 JWT 签名密钥,至少 32 位;安装脚本会自动生成,手动部署必须配置 + JWT_SECRET: '' # 强制将图片转成 base64 传递给模型 MULTIPLE_DATA_TO_BASE64: false diff --git a/deploy/version/v4.15/docker-compose.template.yml b/deploy/version/v4.15/docker-compose.template.yml index 782a921e740a..25e6e55ec957 100644 --- a/deploy/version/v4.15/docker-compose.template.yml +++ b/deploy/version/v4.15/docker-compose.template.yml @@ -14,8 +14,8 @@ x-file-token-key: &x-file-token-key 'filetokenkey' x-aes256-secret-key: &x-aes256-secret-key 'fastgptsecret' # Invoke 反向调用 JWT 密钥,至少 32 位 x-invoke-token-secret: &x-invoke-token-secret 'fastgpt_invoke_token_secret_32_chars_min' -# 通用 JWT 签名密钥,至少 32 位;FastGPT 与 Pro 必须保持一致 -x-jwt-secret: &x-jwt-secret 'replace_with_a_random_secret_at_least_32_chars' +# 通用 JWT 签名密钥,至少 32 位;安装脚本会自动生成,FastGPT 与 Pro 必须保持一致 +x-jwt-secret: &x-jwt-secret '' # plugin auth token,v4.15 plugin 服务要求至少 32 位 x-plugin-auth-token: &x-plugin-auth-token 'fastgpt-plugin-token-please-change' # code sandbox token diff --git a/document/content/self-host/config/env.en.mdx b/document/content/self-host/config/env.en.mdx index 407ee5042c3e..2d5bcf92da64 100644 --- a/document/content/self-host/config/env.en.mdx +++ b/document/content/self-host/config/env.en.mdx @@ -12,7 +12,7 @@ This page describes the environment variables commonly used in a self-hosted Fas - `projects/code-sandbox`: the code execution sandbox service. It exposes the `/sandbox` endpoint and is called by App through `CODE_SANDBOX_URL`. - `packages/service/env.ts` exports `serviceEnv`; `projects/app/src/env.ts` exports `appEnv`. - Shared App/Admin boolean variables use `true`, `1`, `yes`, or `y` to enable a feature. Other values are treated as disabled. -- `FILE_TOKEN_KEY`, `AES256_SECRET_KEY`, and `INVOKE_TOKEN_SECRET` are required at runtime. Use strong random secrets and do not use the example values in production. +- `FILE_TOKEN_KEY`, `AES256_SECRET_KEY`, `INVOKE_TOKEN_SECRET`, and `JWT_SECRET` are required at runtime. Use strong random secrets and do not use the example values in production. ## Shared App/Admin Variables @@ -27,6 +27,7 @@ These variables are mainly validated by `packages/service/env.ts` and apply to ` | `FILE_TOKEN_KEY` | None, **required** | Secret for file read and file authorization flows. Must be at least 6 characters. | | `AES256_SECRET_KEY` | None, **required** | Secret used by AES encryption and decryption. Must be at least 6 characters. | | `INVOKE_TOKEN_SECRET` | None, **required** | JWT secret for Invoke reverse calls. Must be at least 32 characters. | +| `JWT_SECRET` | None, **required** | General-purpose JWT signing secret. Must be at least 32 characters and use the same value in FastGPT and Pro. | | `ROOT_KEY` | `fastgpt_root_key` | Admin API key for the current system. It can call `/api/admin/**` APIs and must be at least 6 characters. | | `PRO_TOKEN` | Empty | Token for FastGPT app server calls to pro/admin internal APIs. It must match the pro/admin configuration and is required when App configures `PRO_URL`. | | `PRO_URL` | Empty | Commercial service URL. When set, App can call Pro APIs, and the domain is allowed by file URL validation. | diff --git a/document/content/self-host/config/env.mdx b/document/content/self-host/config/env.mdx index 25a6c3b7b884..ad700073263f 100644 --- a/document/content/self-host/config/env.mdx +++ b/document/content/self-host/config/env.mdx @@ -12,7 +12,7 @@ description: projects/app、projects/code-sandbox 与 pro/admin 环境变量说 - `projects/code-sandbox`:代码沙箱服务,对外暴露 `/sandbox` 执行接口,供 App 通过 `CODE_SANDBOX_URL` 调用。 - 代码中 `packages/service/env.ts` 导出名为 `serviceEnv`,`projects/app/src/env.ts` 导出名为 `appEnv`。 - App/Admin 共享布尔变量使用 `true`、`1`、`yes` 或 `y` 表示开启;其他值视为关闭。 -- `FILE_TOKEN_KEY`、`AES256_SECRET_KEY` 与 `INVOKE_TOKEN_SECRET` 为运行期必填,建议使用随机强密钥,不要使用示例值。 +- `FILE_TOKEN_KEY`、`AES256_SECRET_KEY`、`INVOKE_TOKEN_SECRET` 与 `JWT_SECRET` 为运行期必填,建议使用随机强密钥,不要使用示例值。 ## App/Admin 共享变量 @@ -27,6 +27,7 @@ description: projects/app、projects/code-sandbox 与 pro/admin 环境变量说 | `FILE_TOKEN_KEY` | 无,**必填** | 文件读取、文件鉴权相关密钥,长度至少 6 位。 | | `AES256_SECRET_KEY` | 无,**必填** | AES 加解密密钥,长度至少 6 位。 | | `INVOKE_TOKEN_SECRET` | 无,**必填** | Invoke 反向调用 JWT 密钥,长度至少 32 位。 | +| `JWT_SECRET` | 无,**必填** | 通用 JWT 签名密钥,长度至少 32 位;FastGPT 与 Pro 必须配置为相同值。 | | `ROOT_KEY` | `fastgpt_root_key` | 当前系统管理员 API 密钥,可用于调用 `/api/admin/**` 接口,长度至少 6 位。 | | `PRO_TOKEN` | 空 | FastGPT app 服务端调用 pro/admin 内部接口的凭证,需与 pro/admin 配置一致;App 配置 `PRO_URL` 时必填。 | | `PRO_URL` | 空 | 商业版服务地址,配置后 App 可调用 Pro API,也会作为文件 URL 安全校验允许域名。 | diff --git a/document/content/self-host/upgrading/4-15/4154.en.mdx b/document/content/self-host/upgrading/4-15/4154.en.mdx index 50ebd862ecaf..9548c006f907 100644 --- a/document/content/self-host/upgrading/4-15/4154.en.mdx +++ b/document/content/self-host/upgrading/4-15/4154.en.mdx @@ -41,6 +41,22 @@ V4.15.3 removes every index that is not declared in its schemas, which may inclu Setting `MONGO_DEPRECATE_INDEX=false` skips deprecated-index cleanup that may be introduced in future releases, but does not skip creation of missing indexes. +### Add the JWT Signing Secret + +`JWT_SECRET` is now required in production. Before updating the images for an existing deployment, generate a random secret with at least 32 characters: + +```bash +openssl rand -hex 32 +``` + +Set the generated value on the `fastgpt` service: + +```dotenv +JWT_SECRET= +``` + +For commercial edition deployments, set the same value on the `fastgpt-pro` service. Redeploy or restart the affected services after updating the configuration. Do not use the example value from the deployment template in production. + ## 🚀 New Features ## ⚙️ Improvements diff --git a/document/content/self-host/upgrading/4-15/4154.mdx b/document/content/self-host/upgrading/4-15/4154.mdx index 8c9f4c949d52..50c71ee45371 100644 --- a/document/content/self-host/upgrading/4-15/4154.mdx +++ b/document/content/self-host/upgrading/4-15/4154.mdx @@ -39,6 +39,22 @@ V4.15.3 的索引同步会删除所有未在当时 Schema 中声明的索引, `MONGO_DEPRECATE_INDEX=false` 会跳过未来版本可能声明的废弃索引清理,但不会跳过缺失索引的创建。 +### 新增 JWT 签名密钥 + +`JWT_SECRET` 已改为生产环境必填项。已有部署在更新镜像前,需要生成一个至少 32 位的随机密钥: + +```bash +openssl rand -hex 32 +``` + +将生成的值配置到 `fastgpt` 服务: + +```dotenv +JWT_SECRET=<上一步生成的随机密钥> +``` + +商业版部署还需将同一个值配置到 `fastgpt-pro` 服务。配置完成后,重新部署或重启对应服务。请勿在生产环境中使用部署模板里的示例值。 + ## 🚀 新增内容 ## ⚙️ 优化 diff --git a/document/data/doc-last-modified.json b/document/data/doc-last-modified.json index 597869f19a79..2d2d6e256801 100644 --- a/document/data/doc-last-modified.json +++ b/document/data/doc-last-modified.json @@ -471,4 +471,4 @@ "content/self-host/upgrading/upgrade-intruction.mdx": "2026-04-26T21:08:47+08:00", "content/toc.en.mdx": "2026-07-26T18:37:23+08:00", "content/toc.mdx": "2026-07-26T18:37:23+08:00" -} \ No newline at end of file +} diff --git a/document/public/deploy/docker/main/cn/docker-compose.milvus.yml b/document/public/deploy/docker/main/cn/docker-compose.milvus.yml index 96b72f7d1e96..8ccfbe2f7a97 100644 --- a/document/public/deploy/docker/main/cn/docker-compose.milvus.yml +++ b/document/public/deploy/docker/main/cn/docker-compose.milvus.yml @@ -237,6 +237,8 @@ services: AES256_SECRET_KEY: fastgptsecret # Invoke 反向调用 JWT 密钥,至少 32 位 INVOKE_TOKEN_SECRET: fastgpt_invoke_token_secret_32_chars_min + # 通用 JWT 签名密钥,至少 32 位;安装脚本会自动生成,手动部署必须配置 + JWT_SECRET: '' # 强制将图片转成 base64 传递给模型 MULTIPLE_DATA_TO_BASE64: false diff --git a/document/public/deploy/docker/main/cn/docker-compose.oceanbase.yml b/document/public/deploy/docker/main/cn/docker-compose.oceanbase.yml index 9a294eafdf44..299e124284c7 100644 --- a/document/public/deploy/docker/main/cn/docker-compose.oceanbase.yml +++ b/document/public/deploy/docker/main/cn/docker-compose.oceanbase.yml @@ -215,6 +215,8 @@ services: AES256_SECRET_KEY: fastgptsecret # Invoke 反向调用 JWT 密钥,至少 32 位 INVOKE_TOKEN_SECRET: fastgpt_invoke_token_secret_32_chars_min + # 通用 JWT 签名密钥,至少 32 位;安装脚本会自动生成,手动部署必须配置 + JWT_SECRET: '' # 强制将图片转成 base64 传递给模型 MULTIPLE_DATA_TO_BASE64: false diff --git a/document/public/deploy/docker/main/cn/docker-compose.opengauss.yml b/document/public/deploy/docker/main/cn/docker-compose.opengauss.yml index 1b433adc3dc1..5d32a6243255 100644 --- a/document/public/deploy/docker/main/cn/docker-compose.opengauss.yml +++ b/document/public/deploy/docker/main/cn/docker-compose.opengauss.yml @@ -199,6 +199,8 @@ services: AES256_SECRET_KEY: fastgptsecret # Invoke 反向调用 JWT 密钥,至少 32 位 INVOKE_TOKEN_SECRET: fastgpt_invoke_token_secret_32_chars_min + # 通用 JWT 签名密钥,至少 32 位;安装脚本会自动生成,手动部署必须配置 + JWT_SECRET: '' # 强制将图片转成 base64 传递给模型 MULTIPLE_DATA_TO_BASE64: false diff --git a/document/public/deploy/docker/main/cn/docker-compose.pg.yml b/document/public/deploy/docker/main/cn/docker-compose.pg.yml index d31f0d11790d..5f9d86d707b7 100644 --- a/document/public/deploy/docker/main/cn/docker-compose.pg.yml +++ b/document/public/deploy/docker/main/cn/docker-compose.pg.yml @@ -197,6 +197,8 @@ services: AES256_SECRET_KEY: fastgptsecret # Invoke 反向调用 JWT 密钥,至少 32 位 INVOKE_TOKEN_SECRET: fastgpt_invoke_token_secret_32_chars_min + # 通用 JWT 签名密钥,至少 32 位;安装脚本会自动生成,手动部署必须配置 + JWT_SECRET: '' # 强制将图片转成 base64 传递给模型 MULTIPLE_DATA_TO_BASE64: false diff --git a/document/public/deploy/docker/main/cn/docker-compose.seekdb.yml b/document/public/deploy/docker/main/cn/docker-compose.seekdb.yml index b3dc5bf47c1b..ea94f198fc1d 100644 --- a/document/public/deploy/docker/main/cn/docker-compose.seekdb.yml +++ b/document/public/deploy/docker/main/cn/docker-compose.seekdb.yml @@ -202,6 +202,8 @@ services: AES256_SECRET_KEY: fastgptsecret # Invoke 反向调用 JWT 密钥,至少 32 位 INVOKE_TOKEN_SECRET: fastgpt_invoke_token_secret_32_chars_min + # 通用 JWT 签名密钥,至少 32 位;安装脚本会自动生成,手动部署必须配置 + JWT_SECRET: '' # 强制将图片转成 base64 传递给模型 MULTIPLE_DATA_TO_BASE64: false diff --git a/document/public/deploy/docker/main/cn/docker-compose.zilliz.yml b/document/public/deploy/docker/main/cn/docker-compose.zilliz.yml index 1f2a23a457b1..f0940cd99050 100644 --- a/document/public/deploy/docker/main/cn/docker-compose.zilliz.yml +++ b/document/public/deploy/docker/main/cn/docker-compose.zilliz.yml @@ -178,6 +178,8 @@ services: AES256_SECRET_KEY: fastgptsecret # Invoke 反向调用 JWT 密钥,至少 32 位 INVOKE_TOKEN_SECRET: fastgpt_invoke_token_secret_32_chars_min + # 通用 JWT 签名密钥,至少 32 位;安装脚本会自动生成,手动部署必须配置 + JWT_SECRET: '' # 强制将图片转成 base64 传递给模型 MULTIPLE_DATA_TO_BASE64: false diff --git a/document/public/deploy/docker/main/global/docker-compose.milvus.yml b/document/public/deploy/docker/main/global/docker-compose.milvus.yml index 14210b6a2d46..765da2c3eb89 100644 --- a/document/public/deploy/docker/main/global/docker-compose.milvus.yml +++ b/document/public/deploy/docker/main/global/docker-compose.milvus.yml @@ -237,6 +237,8 @@ services: AES256_SECRET_KEY: fastgptsecret # Invoke 反向调用 JWT 密钥,至少 32 位 INVOKE_TOKEN_SECRET: fastgpt_invoke_token_secret_32_chars_min + # 通用 JWT 签名密钥,至少 32 位;安装脚本会自动生成,手动部署必须配置 + JWT_SECRET: '' # 强制将图片转成 base64 传递给模型 MULTIPLE_DATA_TO_BASE64: false diff --git a/document/public/deploy/docker/main/global/docker-compose.oceanbase.yml b/document/public/deploy/docker/main/global/docker-compose.oceanbase.yml index 9b7e222a7cb1..54851ab5953b 100644 --- a/document/public/deploy/docker/main/global/docker-compose.oceanbase.yml +++ b/document/public/deploy/docker/main/global/docker-compose.oceanbase.yml @@ -215,6 +215,8 @@ services: AES256_SECRET_KEY: fastgptsecret # Invoke 反向调用 JWT 密钥,至少 32 位 INVOKE_TOKEN_SECRET: fastgpt_invoke_token_secret_32_chars_min + # 通用 JWT 签名密钥,至少 32 位;安装脚本会自动生成,手动部署必须配置 + JWT_SECRET: '' # 强制将图片转成 base64 传递给模型 MULTIPLE_DATA_TO_BASE64: false diff --git a/document/public/deploy/docker/main/global/docker-compose.opengauss.yml b/document/public/deploy/docker/main/global/docker-compose.opengauss.yml index a7c1e40c9ab9..5b2d298ebcc1 100644 --- a/document/public/deploy/docker/main/global/docker-compose.opengauss.yml +++ b/document/public/deploy/docker/main/global/docker-compose.opengauss.yml @@ -199,6 +199,8 @@ services: AES256_SECRET_KEY: fastgptsecret # Invoke 反向调用 JWT 密钥,至少 32 位 INVOKE_TOKEN_SECRET: fastgpt_invoke_token_secret_32_chars_min + # 通用 JWT 签名密钥,至少 32 位;安装脚本会自动生成,手动部署必须配置 + JWT_SECRET: '' # 强制将图片转成 base64 传递给模型 MULTIPLE_DATA_TO_BASE64: false diff --git a/document/public/deploy/docker/main/global/docker-compose.pg.yml b/document/public/deploy/docker/main/global/docker-compose.pg.yml index 490ac5162e2f..5cdc46529b89 100644 --- a/document/public/deploy/docker/main/global/docker-compose.pg.yml +++ b/document/public/deploy/docker/main/global/docker-compose.pg.yml @@ -197,6 +197,8 @@ services: AES256_SECRET_KEY: fastgptsecret # Invoke 反向调用 JWT 密钥,至少 32 位 INVOKE_TOKEN_SECRET: fastgpt_invoke_token_secret_32_chars_min + # 通用 JWT 签名密钥,至少 32 位;安装脚本会自动生成,手动部署必须配置 + JWT_SECRET: '' # 强制将图片转成 base64 传递给模型 MULTIPLE_DATA_TO_BASE64: false diff --git a/document/public/deploy/docker/main/global/docker-compose.seekdb.yml b/document/public/deploy/docker/main/global/docker-compose.seekdb.yml index 8e04037b8fc4..ecef5a91484c 100644 --- a/document/public/deploy/docker/main/global/docker-compose.seekdb.yml +++ b/document/public/deploy/docker/main/global/docker-compose.seekdb.yml @@ -202,6 +202,8 @@ services: AES256_SECRET_KEY: fastgptsecret # Invoke 反向调用 JWT 密钥,至少 32 位 INVOKE_TOKEN_SECRET: fastgpt_invoke_token_secret_32_chars_min + # 通用 JWT 签名密钥,至少 32 位;安装脚本会自动生成,手动部署必须配置 + JWT_SECRET: '' # 强制将图片转成 base64 传递给模型 MULTIPLE_DATA_TO_BASE64: false diff --git a/document/public/deploy/docker/main/global/docker-compose.zilliz.yml b/document/public/deploy/docker/main/global/docker-compose.zilliz.yml index 01b3d456177e..afcb49a1040f 100644 --- a/document/public/deploy/docker/main/global/docker-compose.zilliz.yml +++ b/document/public/deploy/docker/main/global/docker-compose.zilliz.yml @@ -178,6 +178,8 @@ services: AES256_SECRET_KEY: fastgptsecret # Invoke 反向调用 JWT 密钥,至少 32 位 INVOKE_TOKEN_SECRET: fastgpt_invoke_token_secret_32_chars_min + # 通用 JWT 签名密钥,至少 32 位;安装脚本会自动生成,手动部署必须配置 + JWT_SECRET: '' # 强制将图片转成 base64 传递给模型 MULTIPLE_DATA_TO_BASE64: false diff --git a/document/public/deploy/docker/v4.14/cn/docker-compose.milvus.yml b/document/public/deploy/docker/v4.14/cn/docker-compose.milvus.yml index 7efe7441a958..8c9fa2e4e930 100644 --- a/document/public/deploy/docker/v4.14/cn/docker-compose.milvus.yml +++ b/document/public/deploy/docker/v4.14/cn/docker-compose.milvus.yml @@ -239,6 +239,8 @@ services: AES256_SECRET_KEY: fastgptsecret # Invoke 反向调用 JWT 密钥,至少 32 位 INVOKE_TOKEN_SECRET: fastgpt_invoke_token_secret_32_chars_min + # 通用 JWT 签名密钥,至少 32 位;安装脚本会自动生成,手动部署必须配置 + JWT_SECRET: '' # 强制将图片转成 base64 传递给模型 MULTIPLE_DATA_TO_BASE64: false diff --git a/document/public/deploy/docker/v4.14/cn/docker-compose.oceanbase.yml b/document/public/deploy/docker/v4.14/cn/docker-compose.oceanbase.yml index 662c2ce5ca8b..032fbcee8112 100644 --- a/document/public/deploy/docker/v4.14/cn/docker-compose.oceanbase.yml +++ b/document/public/deploy/docker/v4.14/cn/docker-compose.oceanbase.yml @@ -217,6 +217,8 @@ services: AES256_SECRET_KEY: fastgptsecret # Invoke 反向调用 JWT 密钥,至少 32 位 INVOKE_TOKEN_SECRET: fastgpt_invoke_token_secret_32_chars_min + # 通用 JWT 签名密钥,至少 32 位;安装脚本会自动生成,手动部署必须配置 + JWT_SECRET: '' # 强制将图片转成 base64 传递给模型 MULTIPLE_DATA_TO_BASE64: false diff --git a/document/public/deploy/docker/v4.14/cn/docker-compose.opengauss.yml b/document/public/deploy/docker/v4.14/cn/docker-compose.opengauss.yml index b4fb8dfee503..394b1d0db74f 100644 --- a/document/public/deploy/docker/v4.14/cn/docker-compose.opengauss.yml +++ b/document/public/deploy/docker/v4.14/cn/docker-compose.opengauss.yml @@ -201,6 +201,8 @@ services: AES256_SECRET_KEY: fastgptsecret # Invoke 反向调用 JWT 密钥,至少 32 位 INVOKE_TOKEN_SECRET: fastgpt_invoke_token_secret_32_chars_min + # 通用 JWT 签名密钥,至少 32 位;安装脚本会自动生成,手动部署必须配置 + JWT_SECRET: '' # 强制将图片转成 base64 传递给模型 MULTIPLE_DATA_TO_BASE64: false diff --git a/document/public/deploy/docker/v4.14/cn/docker-compose.pg.yml b/document/public/deploy/docker/v4.14/cn/docker-compose.pg.yml index 2b4c035c4b35..1c0d1979b3ec 100644 --- a/document/public/deploy/docker/v4.14/cn/docker-compose.pg.yml +++ b/document/public/deploy/docker/v4.14/cn/docker-compose.pg.yml @@ -199,6 +199,8 @@ services: AES256_SECRET_KEY: fastgptsecret # Invoke 反向调用 JWT 密钥,至少 32 位 INVOKE_TOKEN_SECRET: fastgpt_invoke_token_secret_32_chars_min + # 通用 JWT 签名密钥,至少 32 位;安装脚本会自动生成,手动部署必须配置 + JWT_SECRET: '' # 强制将图片转成 base64 传递给模型 MULTIPLE_DATA_TO_BASE64: false diff --git a/document/public/deploy/docker/v4.14/cn/docker-compose.seekdb.yml b/document/public/deploy/docker/v4.14/cn/docker-compose.seekdb.yml index e26c8389edd9..47b4ab2f10e3 100644 --- a/document/public/deploy/docker/v4.14/cn/docker-compose.seekdb.yml +++ b/document/public/deploy/docker/v4.14/cn/docker-compose.seekdb.yml @@ -204,6 +204,8 @@ services: AES256_SECRET_KEY: fastgptsecret # Invoke 反向调用 JWT 密钥,至少 32 位 INVOKE_TOKEN_SECRET: fastgpt_invoke_token_secret_32_chars_min + # 通用 JWT 签名密钥,至少 32 位;安装脚本会自动生成,手动部署必须配置 + JWT_SECRET: '' # 强制将图片转成 base64 传递给模型 MULTIPLE_DATA_TO_BASE64: false diff --git a/document/public/deploy/docker/v4.14/cn/docker-compose.zilliz.yml b/document/public/deploy/docker/v4.14/cn/docker-compose.zilliz.yml index 7676d31e3500..4a96e1e1e11e 100644 --- a/document/public/deploy/docker/v4.14/cn/docker-compose.zilliz.yml +++ b/document/public/deploy/docker/v4.14/cn/docker-compose.zilliz.yml @@ -182,6 +182,8 @@ services: AES256_SECRET_KEY: fastgptsecret # Invoke 反向调用 JWT 密钥,至少 32 位 INVOKE_TOKEN_SECRET: fastgpt_invoke_token_secret_32_chars_min + # 通用 JWT 签名密钥,至少 32 位;安装脚本会自动生成,手动部署必须配置 + JWT_SECRET: '' # 强制将图片转成 base64 传递给模型 MULTIPLE_DATA_TO_BASE64: false diff --git a/document/public/deploy/docker/v4.14/global/docker-compose.milvus.yml b/document/public/deploy/docker/v4.14/global/docker-compose.milvus.yml index 279ddc88c26d..cc940abc75f1 100644 --- a/document/public/deploy/docker/v4.14/global/docker-compose.milvus.yml +++ b/document/public/deploy/docker/v4.14/global/docker-compose.milvus.yml @@ -239,6 +239,8 @@ services: AES256_SECRET_KEY: fastgptsecret # Invoke 反向调用 JWT 密钥,至少 32 位 INVOKE_TOKEN_SECRET: fastgpt_invoke_token_secret_32_chars_min + # 通用 JWT 签名密钥,至少 32 位;安装脚本会自动生成,手动部署必须配置 + JWT_SECRET: '' # 强制将图片转成 base64 传递给模型 MULTIPLE_DATA_TO_BASE64: false diff --git a/document/public/deploy/docker/v4.14/global/docker-compose.oceanbase.yml b/document/public/deploy/docker/v4.14/global/docker-compose.oceanbase.yml index 56ddec286d43..e15339e17d5c 100644 --- a/document/public/deploy/docker/v4.14/global/docker-compose.oceanbase.yml +++ b/document/public/deploy/docker/v4.14/global/docker-compose.oceanbase.yml @@ -217,6 +217,8 @@ services: AES256_SECRET_KEY: fastgptsecret # Invoke 反向调用 JWT 密钥,至少 32 位 INVOKE_TOKEN_SECRET: fastgpt_invoke_token_secret_32_chars_min + # 通用 JWT 签名密钥,至少 32 位;安装脚本会自动生成,手动部署必须配置 + JWT_SECRET: '' # 强制将图片转成 base64 传递给模型 MULTIPLE_DATA_TO_BASE64: false diff --git a/document/public/deploy/docker/v4.14/global/docker-compose.opengauss.yml b/document/public/deploy/docker/v4.14/global/docker-compose.opengauss.yml index e96c2f0207c9..354d5231a0c0 100644 --- a/document/public/deploy/docker/v4.14/global/docker-compose.opengauss.yml +++ b/document/public/deploy/docker/v4.14/global/docker-compose.opengauss.yml @@ -201,6 +201,8 @@ services: AES256_SECRET_KEY: fastgptsecret # Invoke 反向调用 JWT 密钥,至少 32 位 INVOKE_TOKEN_SECRET: fastgpt_invoke_token_secret_32_chars_min + # 通用 JWT 签名密钥,至少 32 位;安装脚本会自动生成,手动部署必须配置 + JWT_SECRET: '' # 强制将图片转成 base64 传递给模型 MULTIPLE_DATA_TO_BASE64: false diff --git a/document/public/deploy/docker/v4.14/global/docker-compose.pg.yml b/document/public/deploy/docker/v4.14/global/docker-compose.pg.yml index d0ddb4c1b019..a6600509a5af 100644 --- a/document/public/deploy/docker/v4.14/global/docker-compose.pg.yml +++ b/document/public/deploy/docker/v4.14/global/docker-compose.pg.yml @@ -199,6 +199,8 @@ services: AES256_SECRET_KEY: fastgptsecret # Invoke 反向调用 JWT 密钥,至少 32 位 INVOKE_TOKEN_SECRET: fastgpt_invoke_token_secret_32_chars_min + # 通用 JWT 签名密钥,至少 32 位;安装脚本会自动生成,手动部署必须配置 + JWT_SECRET: '' # 强制将图片转成 base64 传递给模型 MULTIPLE_DATA_TO_BASE64: false diff --git a/document/public/deploy/docker/v4.14/global/docker-compose.seekdb.yml b/document/public/deploy/docker/v4.14/global/docker-compose.seekdb.yml index 68a86bc4570b..d4aa2e32b68d 100644 --- a/document/public/deploy/docker/v4.14/global/docker-compose.seekdb.yml +++ b/document/public/deploy/docker/v4.14/global/docker-compose.seekdb.yml @@ -204,6 +204,8 @@ services: AES256_SECRET_KEY: fastgptsecret # Invoke 反向调用 JWT 密钥,至少 32 位 INVOKE_TOKEN_SECRET: fastgpt_invoke_token_secret_32_chars_min + # 通用 JWT 签名密钥,至少 32 位;安装脚本会自动生成,手动部署必须配置 + JWT_SECRET: '' # 强制将图片转成 base64 传递给模型 MULTIPLE_DATA_TO_BASE64: false diff --git a/document/public/deploy/docker/v4.14/global/docker-compose.zilliz.yml b/document/public/deploy/docker/v4.14/global/docker-compose.zilliz.yml index 38d59363a6d6..bf2964105bbf 100644 --- a/document/public/deploy/docker/v4.14/global/docker-compose.zilliz.yml +++ b/document/public/deploy/docker/v4.14/global/docker-compose.zilliz.yml @@ -182,6 +182,8 @@ services: AES256_SECRET_KEY: fastgptsecret # Invoke 反向调用 JWT 密钥,至少 32 位 INVOKE_TOKEN_SECRET: fastgpt_invoke_token_secret_32_chars_min + # 通用 JWT 签名密钥,至少 32 位;安装脚本会自动生成,手动部署必须配置 + JWT_SECRET: '' # 强制将图片转成 base64 传递给模型 MULTIPLE_DATA_TO_BASE64: false diff --git a/document/public/deploy/docker/v4.15/cn/docker-compose.milvus.yml b/document/public/deploy/docker/v4.15/cn/docker-compose.milvus.yml index fdb540811f91..4fd3a31ee1bc 100644 --- a/document/public/deploy/docker/v4.15/cn/docker-compose.milvus.yml +++ b/document/public/deploy/docker/v4.15/cn/docker-compose.milvus.yml @@ -14,6 +14,8 @@ x-file-token-key: &x-file-token-key 'filetokenkey' x-aes256-secret-key: &x-aes256-secret-key 'fastgptsecret' # Invoke 反向调用 JWT 密钥,至少 32 位 x-invoke-token-secret: &x-invoke-token-secret 'fastgpt_invoke_token_secret_32_chars_min' +# 通用 JWT 签名密钥,至少 32 位;安装脚本会自动生成,FastGPT 与 Pro 必须保持一致 +x-jwt-secret: &x-jwt-secret '' # plugin auth token,v4.15 plugin 服务要求至少 32 位 x-plugin-auth-token: &x-plugin-auth-token 'fastgpt-plugin-token-please-change' # code sandbox token @@ -120,6 +122,7 @@ x-service-env-config: &x-service-env-config FILE_TOKEN_KEY: *x-file-token-key AES256_SECRET_KEY: *x-aes256-secret-key INVOKE_TOKEN_SECRET: *x-invoke-token-secret + JWT_SECRET: *x-jwt-secret MULTIPLE_DATA_TO_BASE64: false USE_IP_LIMIT: false CHECK_INTERNAL_IP: false diff --git a/document/public/deploy/docker/v4.15/cn/docker-compose.oceanbase.yml b/document/public/deploy/docker/v4.15/cn/docker-compose.oceanbase.yml index 4e70f2d0ad8d..976ea8b83085 100644 --- a/document/public/deploy/docker/v4.15/cn/docker-compose.oceanbase.yml +++ b/document/public/deploy/docker/v4.15/cn/docker-compose.oceanbase.yml @@ -14,6 +14,8 @@ x-file-token-key: &x-file-token-key 'filetokenkey' x-aes256-secret-key: &x-aes256-secret-key 'fastgptsecret' # Invoke 反向调用 JWT 密钥,至少 32 位 x-invoke-token-secret: &x-invoke-token-secret 'fastgpt_invoke_token_secret_32_chars_min' +# 通用 JWT 签名密钥,至少 32 位;安装脚本会自动生成,FastGPT 与 Pro 必须保持一致 +x-jwt-secret: &x-jwt-secret '' # plugin auth token,v4.15 plugin 服务要求至少 32 位 x-plugin-auth-token: &x-plugin-auth-token 'fastgpt-plugin-token-please-change' # code sandbox token @@ -120,6 +122,7 @@ x-service-env-config: &x-service-env-config FILE_TOKEN_KEY: *x-file-token-key AES256_SECRET_KEY: *x-aes256-secret-key INVOKE_TOKEN_SECRET: *x-invoke-token-secret + JWT_SECRET: *x-jwt-secret MULTIPLE_DATA_TO_BASE64: false USE_IP_LIMIT: false CHECK_INTERNAL_IP: false diff --git a/document/public/deploy/docker/v4.15/cn/docker-compose.opengauss.yml b/document/public/deploy/docker/v4.15/cn/docker-compose.opengauss.yml index 9c126dea6af6..a3fce00a3973 100644 --- a/document/public/deploy/docker/v4.15/cn/docker-compose.opengauss.yml +++ b/document/public/deploy/docker/v4.15/cn/docker-compose.opengauss.yml @@ -14,6 +14,8 @@ x-file-token-key: &x-file-token-key 'filetokenkey' x-aes256-secret-key: &x-aes256-secret-key 'fastgptsecret' # Invoke 反向调用 JWT 密钥,至少 32 位 x-invoke-token-secret: &x-invoke-token-secret 'fastgpt_invoke_token_secret_32_chars_min' +# 通用 JWT 签名密钥,至少 32 位;安装脚本会自动生成,FastGPT 与 Pro 必须保持一致 +x-jwt-secret: &x-jwt-secret '' # plugin auth token,v4.15 plugin 服务要求至少 32 位 x-plugin-auth-token: &x-plugin-auth-token 'fastgpt-plugin-token-please-change' # code sandbox token @@ -120,6 +122,7 @@ x-service-env-config: &x-service-env-config FILE_TOKEN_KEY: *x-file-token-key AES256_SECRET_KEY: *x-aes256-secret-key INVOKE_TOKEN_SECRET: *x-invoke-token-secret + JWT_SECRET: *x-jwt-secret MULTIPLE_DATA_TO_BASE64: false USE_IP_LIMIT: false CHECK_INTERNAL_IP: false diff --git a/document/public/deploy/docker/v4.15/cn/docker-compose.pg.yml b/document/public/deploy/docker/v4.15/cn/docker-compose.pg.yml index 0eec8752d687..c0a5205fe57b 100644 --- a/document/public/deploy/docker/v4.15/cn/docker-compose.pg.yml +++ b/document/public/deploy/docker/v4.15/cn/docker-compose.pg.yml @@ -14,6 +14,8 @@ x-file-token-key: &x-file-token-key 'filetokenkey' x-aes256-secret-key: &x-aes256-secret-key 'fastgptsecret' # Invoke 反向调用 JWT 密钥,至少 32 位 x-invoke-token-secret: &x-invoke-token-secret 'fastgpt_invoke_token_secret_32_chars_min' +# 通用 JWT 签名密钥,至少 32 位;安装脚本会自动生成,FastGPT 与 Pro 必须保持一致 +x-jwt-secret: &x-jwt-secret '' # plugin auth token,v4.15 plugin 服务要求至少 32 位 x-plugin-auth-token: &x-plugin-auth-token 'fastgpt-plugin-token-please-change' # code sandbox token @@ -120,6 +122,7 @@ x-service-env-config: &x-service-env-config FILE_TOKEN_KEY: *x-file-token-key AES256_SECRET_KEY: *x-aes256-secret-key INVOKE_TOKEN_SECRET: *x-invoke-token-secret + JWT_SECRET: *x-jwt-secret MULTIPLE_DATA_TO_BASE64: false USE_IP_LIMIT: false CHECK_INTERNAL_IP: false diff --git a/document/public/deploy/docker/v4.15/cn/docker-compose.seekdb.yml b/document/public/deploy/docker/v4.15/cn/docker-compose.seekdb.yml index c9ec84fb7a7a..b76656004f7b 100644 --- a/document/public/deploy/docker/v4.15/cn/docker-compose.seekdb.yml +++ b/document/public/deploy/docker/v4.15/cn/docker-compose.seekdb.yml @@ -14,6 +14,8 @@ x-file-token-key: &x-file-token-key 'filetokenkey' x-aes256-secret-key: &x-aes256-secret-key 'fastgptsecret' # Invoke 反向调用 JWT 密钥,至少 32 位 x-invoke-token-secret: &x-invoke-token-secret 'fastgpt_invoke_token_secret_32_chars_min' +# 通用 JWT 签名密钥,至少 32 位;安装脚本会自动生成,FastGPT 与 Pro 必须保持一致 +x-jwt-secret: &x-jwt-secret '' # plugin auth token,v4.15 plugin 服务要求至少 32 位 x-plugin-auth-token: &x-plugin-auth-token 'fastgpt-plugin-token-please-change' # code sandbox token @@ -120,6 +122,7 @@ x-service-env-config: &x-service-env-config FILE_TOKEN_KEY: *x-file-token-key AES256_SECRET_KEY: *x-aes256-secret-key INVOKE_TOKEN_SECRET: *x-invoke-token-secret + JWT_SECRET: *x-jwt-secret MULTIPLE_DATA_TO_BASE64: false USE_IP_LIMIT: false CHECK_INTERNAL_IP: false diff --git a/document/public/deploy/docker/v4.15/cn/docker-compose.zilliz.yml b/document/public/deploy/docker/v4.15/cn/docker-compose.zilliz.yml index 2fd50bb8d573..9a87e4648cf0 100644 --- a/document/public/deploy/docker/v4.15/cn/docker-compose.zilliz.yml +++ b/document/public/deploy/docker/v4.15/cn/docker-compose.zilliz.yml @@ -14,6 +14,8 @@ x-file-token-key: &x-file-token-key 'filetokenkey' x-aes256-secret-key: &x-aes256-secret-key 'fastgptsecret' # Invoke 反向调用 JWT 密钥,至少 32 位 x-invoke-token-secret: &x-invoke-token-secret 'fastgpt_invoke_token_secret_32_chars_min' +# 通用 JWT 签名密钥,至少 32 位;安装脚本会自动生成,FastGPT 与 Pro 必须保持一致 +x-jwt-secret: &x-jwt-secret '' # plugin auth token,v4.15 plugin 服务要求至少 32 位 x-plugin-auth-token: &x-plugin-auth-token 'fastgpt-plugin-token-please-change' # code sandbox token @@ -120,6 +122,7 @@ x-service-env-config: &x-service-env-config FILE_TOKEN_KEY: *x-file-token-key AES256_SECRET_KEY: *x-aes256-secret-key INVOKE_TOKEN_SECRET: *x-invoke-token-secret + JWT_SECRET: *x-jwt-secret MULTIPLE_DATA_TO_BASE64: false USE_IP_LIMIT: false CHECK_INTERNAL_IP: false diff --git a/document/public/deploy/docker/v4.15/global/docker-compose.milvus.yml b/document/public/deploy/docker/v4.15/global/docker-compose.milvus.yml index 185b07c51501..aa2d50782f4c 100644 --- a/document/public/deploy/docker/v4.15/global/docker-compose.milvus.yml +++ b/document/public/deploy/docker/v4.15/global/docker-compose.milvus.yml @@ -14,6 +14,8 @@ x-file-token-key: &x-file-token-key 'filetokenkey' x-aes256-secret-key: &x-aes256-secret-key 'fastgptsecret' # Invoke 反向调用 JWT 密钥,至少 32 位 x-invoke-token-secret: &x-invoke-token-secret 'fastgpt_invoke_token_secret_32_chars_min' +# 通用 JWT 签名密钥,至少 32 位;安装脚本会自动生成,FastGPT 与 Pro 必须保持一致 +x-jwt-secret: &x-jwt-secret '' # plugin auth token,v4.15 plugin 服务要求至少 32 位 x-plugin-auth-token: &x-plugin-auth-token 'fastgpt-plugin-token-please-change' # code sandbox token @@ -120,6 +122,7 @@ x-service-env-config: &x-service-env-config FILE_TOKEN_KEY: *x-file-token-key AES256_SECRET_KEY: *x-aes256-secret-key INVOKE_TOKEN_SECRET: *x-invoke-token-secret + JWT_SECRET: *x-jwt-secret MULTIPLE_DATA_TO_BASE64: false USE_IP_LIMIT: false CHECK_INTERNAL_IP: false diff --git a/document/public/deploy/docker/v4.15/global/docker-compose.oceanbase.yml b/document/public/deploy/docker/v4.15/global/docker-compose.oceanbase.yml index d3d946204308..1b8578ed33bd 100644 --- a/document/public/deploy/docker/v4.15/global/docker-compose.oceanbase.yml +++ b/document/public/deploy/docker/v4.15/global/docker-compose.oceanbase.yml @@ -14,6 +14,8 @@ x-file-token-key: &x-file-token-key 'filetokenkey' x-aes256-secret-key: &x-aes256-secret-key 'fastgptsecret' # Invoke 反向调用 JWT 密钥,至少 32 位 x-invoke-token-secret: &x-invoke-token-secret 'fastgpt_invoke_token_secret_32_chars_min' +# 通用 JWT 签名密钥,至少 32 位;安装脚本会自动生成,FastGPT 与 Pro 必须保持一致 +x-jwt-secret: &x-jwt-secret '' # plugin auth token,v4.15 plugin 服务要求至少 32 位 x-plugin-auth-token: &x-plugin-auth-token 'fastgpt-plugin-token-please-change' # code sandbox token @@ -120,6 +122,7 @@ x-service-env-config: &x-service-env-config FILE_TOKEN_KEY: *x-file-token-key AES256_SECRET_KEY: *x-aes256-secret-key INVOKE_TOKEN_SECRET: *x-invoke-token-secret + JWT_SECRET: *x-jwt-secret MULTIPLE_DATA_TO_BASE64: false USE_IP_LIMIT: false CHECK_INTERNAL_IP: false diff --git a/document/public/deploy/docker/v4.15/global/docker-compose.opengauss.yml b/document/public/deploy/docker/v4.15/global/docker-compose.opengauss.yml index 87bee894944c..ff390be3041b 100644 --- a/document/public/deploy/docker/v4.15/global/docker-compose.opengauss.yml +++ b/document/public/deploy/docker/v4.15/global/docker-compose.opengauss.yml @@ -14,6 +14,8 @@ x-file-token-key: &x-file-token-key 'filetokenkey' x-aes256-secret-key: &x-aes256-secret-key 'fastgptsecret' # Invoke 反向调用 JWT 密钥,至少 32 位 x-invoke-token-secret: &x-invoke-token-secret 'fastgpt_invoke_token_secret_32_chars_min' +# 通用 JWT 签名密钥,至少 32 位;安装脚本会自动生成,FastGPT 与 Pro 必须保持一致 +x-jwt-secret: &x-jwt-secret '' # plugin auth token,v4.15 plugin 服务要求至少 32 位 x-plugin-auth-token: &x-plugin-auth-token 'fastgpt-plugin-token-please-change' # code sandbox token @@ -120,6 +122,7 @@ x-service-env-config: &x-service-env-config FILE_TOKEN_KEY: *x-file-token-key AES256_SECRET_KEY: *x-aes256-secret-key INVOKE_TOKEN_SECRET: *x-invoke-token-secret + JWT_SECRET: *x-jwt-secret MULTIPLE_DATA_TO_BASE64: false USE_IP_LIMIT: false CHECK_INTERNAL_IP: false diff --git a/document/public/deploy/docker/v4.15/global/docker-compose.pg.yml b/document/public/deploy/docker/v4.15/global/docker-compose.pg.yml index 4ce2ac230021..4841331fa3c5 100644 --- a/document/public/deploy/docker/v4.15/global/docker-compose.pg.yml +++ b/document/public/deploy/docker/v4.15/global/docker-compose.pg.yml @@ -14,6 +14,8 @@ x-file-token-key: &x-file-token-key 'filetokenkey' x-aes256-secret-key: &x-aes256-secret-key 'fastgptsecret' # Invoke 反向调用 JWT 密钥,至少 32 位 x-invoke-token-secret: &x-invoke-token-secret 'fastgpt_invoke_token_secret_32_chars_min' +# 通用 JWT 签名密钥,至少 32 位;安装脚本会自动生成,FastGPT 与 Pro 必须保持一致 +x-jwt-secret: &x-jwt-secret '' # plugin auth token,v4.15 plugin 服务要求至少 32 位 x-plugin-auth-token: &x-plugin-auth-token 'fastgpt-plugin-token-please-change' # code sandbox token @@ -120,6 +122,7 @@ x-service-env-config: &x-service-env-config FILE_TOKEN_KEY: *x-file-token-key AES256_SECRET_KEY: *x-aes256-secret-key INVOKE_TOKEN_SECRET: *x-invoke-token-secret + JWT_SECRET: *x-jwt-secret MULTIPLE_DATA_TO_BASE64: false USE_IP_LIMIT: false CHECK_INTERNAL_IP: false diff --git a/document/public/deploy/docker/v4.15/global/docker-compose.seekdb.yml b/document/public/deploy/docker/v4.15/global/docker-compose.seekdb.yml index 6482b76a08f8..26dacb1552be 100644 --- a/document/public/deploy/docker/v4.15/global/docker-compose.seekdb.yml +++ b/document/public/deploy/docker/v4.15/global/docker-compose.seekdb.yml @@ -14,6 +14,8 @@ x-file-token-key: &x-file-token-key 'filetokenkey' x-aes256-secret-key: &x-aes256-secret-key 'fastgptsecret' # Invoke 反向调用 JWT 密钥,至少 32 位 x-invoke-token-secret: &x-invoke-token-secret 'fastgpt_invoke_token_secret_32_chars_min' +# 通用 JWT 签名密钥,至少 32 位;安装脚本会自动生成,FastGPT 与 Pro 必须保持一致 +x-jwt-secret: &x-jwt-secret '' # plugin auth token,v4.15 plugin 服务要求至少 32 位 x-plugin-auth-token: &x-plugin-auth-token 'fastgpt-plugin-token-please-change' # code sandbox token @@ -120,6 +122,7 @@ x-service-env-config: &x-service-env-config FILE_TOKEN_KEY: *x-file-token-key AES256_SECRET_KEY: *x-aes256-secret-key INVOKE_TOKEN_SECRET: *x-invoke-token-secret + JWT_SECRET: *x-jwt-secret MULTIPLE_DATA_TO_BASE64: false USE_IP_LIMIT: false CHECK_INTERNAL_IP: false diff --git a/document/public/deploy/docker/v4.15/global/docker-compose.zilliz.yml b/document/public/deploy/docker/v4.15/global/docker-compose.zilliz.yml index 842415db115c..cdaf84f85717 100644 --- a/document/public/deploy/docker/v4.15/global/docker-compose.zilliz.yml +++ b/document/public/deploy/docker/v4.15/global/docker-compose.zilliz.yml @@ -14,6 +14,8 @@ x-file-token-key: &x-file-token-key 'filetokenkey' x-aes256-secret-key: &x-aes256-secret-key 'fastgptsecret' # Invoke 反向调用 JWT 密钥,至少 32 位 x-invoke-token-secret: &x-invoke-token-secret 'fastgpt_invoke_token_secret_32_chars_min' +# 通用 JWT 签名密钥,至少 32 位;安装脚本会自动生成,FastGPT 与 Pro 必须保持一致 +x-jwt-secret: &x-jwt-secret '' # plugin auth token,v4.15 plugin 服务要求至少 32 位 x-plugin-auth-token: &x-plugin-auth-token 'fastgpt-plugin-token-please-change' # code sandbox token @@ -120,6 +122,7 @@ x-service-env-config: &x-service-env-config FILE_TOKEN_KEY: *x-file-token-key AES256_SECRET_KEY: *x-aes256-secret-key INVOKE_TOKEN_SECRET: *x-invoke-token-secret + JWT_SECRET: *x-jwt-secret MULTIPLE_DATA_TO_BASE64: false USE_IP_LIMIT: false CHECK_INTERNAL_IP: false diff --git a/document/public/deploy/install.sh b/document/public/deploy/install.sh index f354eb552798..b743c1cdbede 100644 --- a/document/public/deploy/install.sh +++ b/document/public/deploy/install.sh @@ -220,7 +220,7 @@ normalize_bool_env() { ROOT_LOGIN_PASSWORD="1234" randomize_compose_credentials() { - local system_key file_token_key aes256_secret_key invoke_token_secret + local system_key file_token_key aes256_secret_key invoke_token_secret jwt_secret local plugin_token code_sandbox_token volume_manager_token agent_proxy_secret aiproxy_token local root_password mongo_password redis_password minio_password local pg_password aiproxy_pg_password oceanbase_sys_password oceanbase_tenant_password seekdb_password opengauss_password @@ -229,6 +229,7 @@ randomize_compose_credentials() { file_token_key="$(random_hex 32)" aes256_secret_key="$(random_hex 32)" invoke_token_secret="$(random_hex 32)" + jwt_secret="$(random_hex 32)" plugin_token="$(random_hex 32)" code_sandbox_token="$(random_hex 32)" volume_manager_token="$(random_hex 32)" @@ -263,6 +264,8 @@ randomize_compose_credentials() { replace_text 'x-aes256-secret-key: &x-aes256-secret-key "fastgptsecret"' "x-aes256-secret-key: &x-aes256-secret-key \"$aes256_secret_key\"" replace_text "x-invoke-token-secret: &x-invoke-token-secret 'fastgpt_invoke_token_secret_32_chars_min'" "x-invoke-token-secret: &x-invoke-token-secret '$invoke_token_secret'" replace_text 'x-invoke-token-secret: &x-invoke-token-secret "fastgpt_invoke_token_secret_32_chars_min"' "x-invoke-token-secret: &x-invoke-token-secret \"$invoke_token_secret\"" + replace_text "x-jwt-secret: &x-jwt-secret ''" "x-jwt-secret: &x-jwt-secret '$jwt_secret'" + replace_text 'x-jwt-secret: &x-jwt-secret ""' "x-jwt-secret: &x-jwt-secret \"$jwt_secret\"" replace_text "x-plugin-auth-token: &x-plugin-auth-token 'token'" "x-plugin-auth-token: &x-plugin-auth-token '$plugin_token'" replace_text 'x-plugin-auth-token: &x-plugin-auth-token "token"' "x-plugin-auth-token: &x-plugin-auth-token \"$plugin_token\"" replace_text "x-plugin-auth-token: &x-plugin-auth-token 'fastgpt-plugin-token-please-change'" "x-plugin-auth-token: &x-plugin-auth-token '$plugin_token'" @@ -280,6 +283,8 @@ randomize_compose_credentials() { replace_text "FILE_TOKEN_KEY: filetokenkey" "FILE_TOKEN_KEY: $file_token_key" replace_text "AES256_SECRET_KEY: fastgptsecret" "AES256_SECRET_KEY: $aes256_secret_key" replace_text "INVOKE_TOKEN_SECRET: fastgpt_invoke_token_secret_32_chars_min" "INVOKE_TOKEN_SECRET: $invoke_token_secret" + replace_text "JWT_SECRET: ''" "JWT_SECRET: $jwt_secret" + replace_text 'JWT_SECRET: ""' "JWT_SECRET: $jwt_secret" # MongoDB 主库与 plugin 独立库使用同一个 Mongo root 密码。 replace_text "mongodb://myusername:mypassword@fastgpt-mongo:27017/fastgpt?authSource=admin" "mongodb://myusername:$mongo_password@fastgpt-mongo:27017/fastgpt?authSource=admin" diff --git a/packages/global/common/error/code/user.ts b/packages/global/common/error/code/user.ts index 5b693e98f671..4047e69f3080 100644 --- a/packages/global/common/error/code/user.ts +++ b/packages/global/common/error/code/user.ts @@ -11,7 +11,8 @@ export enum UserErrEnum { invalidVerificationCode = 'invalidVerificationCode', sendVerificationCodeTooFrequently = 'sendVerificationCodeTooFrequently', verifyCodeTooFrequently = 'verifyCodeTooFrequently', - passwordChangeAuthorizationInvalid = 'passwordChangeAuthorizationInvalid' + passwordChangeAuthorizationInvalid = 'passwordChangeAuthorizationInvalid', + newPasswordSameAsOld = 'newPasswordSameAsOld' } const errList = [ { @@ -53,6 +54,11 @@ const errList = [ statusText: UserErrEnum.passwordChangeAuthorizationInvalid, message: 'Password change authorization is invalid', httpStatus: 403 + }, + { + statusText: UserErrEnum.newPasswordSameAsOld, + message: i18nT('common:user.Password has no change'), + httpStatus: 400 } ]; export default errList.reduce((acc, cur, index) => { diff --git a/packages/global/openapi/support/user/account/password/api.ts b/packages/global/openapi/support/user/account/password/api.ts index 8c20dce55baf..c49a44d1ea3f 100644 --- a/packages/global/openapi/support/user/account/password/api.ts +++ b/packages/global/openapi/support/user/account/password/api.ts @@ -13,6 +13,19 @@ const OAuthVerificationMethods = [ 'oauth/wecom', 'oauth/sso' ] as const; +type OAuthVerificationMethod = (typeof OAuthVerificationMethods)[number]; + +/** 将固定的 OAuth 验证方式展开为静态 tuple,避免动态数组擦除 union 的 schema 类型。 */ +const createOAuthVerificationSchemaTuple = ( + createSchema: (method: OAuthVerificationMethod) => Schema +) => + [ + createSchema(OAuthVerificationMethods[0]), + createSchema(OAuthVerificationMethods[1]), + createSchema(OAuthVerificationMethods[2]), + createSchema(OAuthVerificationMethods[3]), + createSchema(OAuthVerificationMethods[4]) + ] as const; const OAuthCreatePayloadSchema = z .object({ @@ -68,26 +81,33 @@ const WechatVerificationCreateSchema = z }) .strict(); +const OAuthVerificationCreateSchemas = createOAuthVerificationSchemaTuple((method) => + z + .object({ + method: z.literal(method), + payload: OAuthCreatePayloadSchema + }) + .strict() +); + export const CreatePasswordVerificationBodySchema = z.discriminatedUnion('method', [ CodeVerificationCreateSchema, OldPasswordVerificationCreateSchema, WechatVerificationCreateSchema, - ...OAuthVerificationMethods.map((method) => - z - .object({ - method: z.literal(method), - payload: OAuthCreatePayloadSchema - }) - .strict() - ) -] as [ - typeof CodeVerificationCreateSchema, - typeof OldPasswordVerificationCreateSchema, - typeof WechatVerificationCreateSchema, - ...any[] + ...OAuthVerificationCreateSchemas ]); export type CreatePasswordVerificationBody = z.infer; +const OAuthVerificationResponseSchemas = createOAuthVerificationSchemaTuple((method) => + z + .object({ + method: z.literal(method), + state: z.string().min(16), + url: z.url() + }) + .strict() +); + export const CreatePasswordVerificationResponseSchema = z.discriminatedUnion('method', [ z.object({ method: z.literal('code'), sent: z.literal(true), maskedTarget: z.string() }).strict(), z.object({ method: z.literal('oldPassword'), preLoginCode: z.string().min(1) }).strict(), @@ -99,16 +119,8 @@ export const CreatePasswordVerificationResponseSchema = z.discriminatedUnion('me expiredAt: DateTimeSchema.optional() }) .strict(), - ...OAuthVerificationMethods.map((method) => - z - .object({ - method: z.literal(method), - state: z.string().min(16), - url: z.url() - }) - .strict() - ) -] as [any, any, any, ...any[]]); + ...OAuthVerificationResponseSchemas +]); export type CreatePasswordVerificationResponse = z.infer< typeof CreatePasswordVerificationResponseSchema >; @@ -139,30 +151,27 @@ const WechatVerificationConsumeSchema = z }) .strict(); +const OAuthVerificationConsumeSchemas = createOAuthVerificationSchemaTuple((method) => + z + .object({ + method: z.literal(method), + payload: OAuthConsumePayloadSchema + }) + .strict() +); + export const SensitiveAccountVerificationBodySchema = z.discriminatedUnion('method', [ CodeVerificationConsumeSchema, OldPasswordVerificationConsumeSchema, WechatVerificationConsumeSchema, - ...OAuthVerificationMethods.map((method) => - z - .object({ - method: z.literal(method), - payload: OAuthConsumePayloadSchema - }) - .strict() - ) -] as [ - typeof CodeVerificationConsumeSchema, - typeof OldPasswordVerificationConsumeSchema, - typeof WechatVerificationConsumeSchema, - ...any[] + ...OAuthVerificationConsumeSchemas ]); export type SensitiveAccountVerificationBody = z.infer< typeof SensitiveAccountVerificationBodySchema >; export const PasswordAuthorizationBodySchema = z.discriminatedUnion('source', [ - z.object({ source: z.literal('recentLogin') }).strict(), + z.object({ source: z.literal('verificationMethod') }).strict(), z .object({ source: z.literal('accountVerification'), diff --git a/packages/global/openapi/support/user/account/password/index.ts b/packages/global/openapi/support/user/account/password/index.ts index c4be53c5a914..b6683b67ed9f 100644 --- a/packages/global/openapi/support/user/account/password/index.ts +++ b/packages/global/openapi/support/user/account/password/index.ts @@ -16,7 +16,7 @@ export const PasswordPath: OpenAPIPath = { '/proApi/support/user/account/password/authorization': { post: { summary: '获取修改密码授权', - description: '通过近期登录或当前账号的唯一身份验证方式签发短期改密授权', + description: '通过当前账号的唯一身份验证方式签发短期改密授权', tags: [DevApiTagsMap.userLogin, 'Account Verification'], requestBody: { content: { 'application/json': { schema: PasswordAuthorizationBodySchema } } @@ -61,6 +61,10 @@ export const PasswordPath: OpenAPIPath = { 200: { description: '密码设置成功', content: { 'application/json': { schema: UpdatePasswordResponseSchema } } + }, + 400: { + description: '新密码与当前密码相同', + content: { 'application/json': { schema: z.null() } } } } } @@ -92,7 +96,7 @@ export const PasswordPath: OpenAPIPath = { content: { 'application/json': { schema: {} } } }, 400: { - description: '请求参数或验证码错误', + description: '请求参数、验证码错误或新密码与当前密码相同', content: { 'application/json': { schema: z.null() } } }, 429: { diff --git a/packages/global/test/common/error/utils.test.ts b/packages/global/test/common/error/utils.test.ts index c3f936058128..d531ff3a600d 100644 --- a/packages/global/test/common/error/utils.test.ts +++ b/packages/global/test/common/error/utils.test.ts @@ -105,7 +105,8 @@ describe('verification error responses', () => { it.each([ [UserErrEnum.invalidVerificationCode, 400], [UserErrEnum.sendVerificationCodeTooFrequently, 429], - [UserErrEnum.verifyCodeTooFrequently, 429] + [UserErrEnum.verifyCodeTooFrequently, 429], + [UserErrEnum.newPasswordSameAsOld, 400] ] as const)('maps %s to HTTP %s', (error, httpStatus) => { expect(ERROR_RESPONSE[error]).toMatchObject({ statusText: error, diff --git a/packages/global/test/openapi/support/user/account/password/api.test.ts b/packages/global/test/openapi/support/user/account/password/api.test.ts index d88790a6ad49..6a705e5f2cf6 100644 --- a/packages/global/test/openapi/support/user/account/password/api.test.ts +++ b/packages/global/test/openapi/support/user/account/password/api.test.ts @@ -1,12 +1,33 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, expectTypeOf, it } from 'vitest'; import { CreatePasswordVerificationBodySchema, PasswordAuthorizationBodySchema, SensitiveAccountVerificationBodySchema, UpdatePasswordBodySchema } from '@fastgpt/global/openapi/support/user/account/password/api'; +import type { + CreatePasswordVerificationBody, + CreatePasswordVerificationResponse, + PasswordAuthorizationBody, + SensitiveAccountVerificationBody +} from '@fastgpt/global/openapi/support/user/account/password/api'; describe('password API contracts', () => { + it('preserves the inferred verification contracts', () => { + expectTypeOf().not.toBeAny(); + expectTypeOf().not.toBeAny(); + expectTypeOf().not.toBeAny(); + + type AccountVerificationAuthorization = Extract< + PasswordAuthorizationBody, + { source: 'accountVerification' } + >; + expectTypeOf().toEqualTypeOf<{ + source: 'accountVerification'; + verification: SensitiveAccountVerificationBody; + }>(); + }); + it('accepts an empty old-password create payload without client identity fields', () => { expect( CreatePasswordVerificationBodySchema.parse({ method: 'oldPassword', payload: {} }) @@ -36,13 +57,17 @@ describe('password API contracts', () => { ).toThrow(); }); - it('keeps recent login authorization strict', () => { - expect(PasswordAuthorizationBodySchema.parse({ source: 'recentLogin' })).toEqual({ - source: 'recentLogin' + it('keeps the verification-flow initializer strict', () => { + expect(PasswordAuthorizationBodySchema.parse({ source: 'verificationMethod' })).toEqual({ + source: 'verificationMethod' }); expect(() => - PasswordAuthorizationBodySchema.parse({ source: 'recentLogin', userId: 'other-user' }) + PasswordAuthorizationBodySchema.parse({ + source: 'verificationMethod', + userId: 'other-user' + }) ).toThrow(); + expect(() => PasswordAuthorizationBodySchema.parse({ source: 'recentLogin' })).toThrow(); }); it('requires a SHA-256 digest and a bounded authorization token for updates', () => { diff --git a/packages/global/test/support/user/utils.test.ts b/packages/global/test/support/user/utils.test.ts index ae2a4c33c2a7..b734c70b2067 100644 --- a/packages/global/test/support/user/utils.test.ts +++ b/packages/global/test/support/user/utils.test.ts @@ -1,5 +1,32 @@ import { describe, expect, it } from 'vitest'; -import { hasStoredPassword } from '@fastgpt/global/support/user/utils'; +import { getRandomUserAvatar, hasStoredPassword } from '@fastgpt/global/support/user/utils'; + +describe('getRandomUserAvatar', () => { + const defaultAvatars = [ + '/imgs/avatar/RoyalBlueAvatar.svg', + '/imgs/avatar/PurpleAvatar.svg', + '/imgs/avatar/AdoraAvatar.svg', + '/imgs/avatar/OrangeAvatar.svg', + '/imgs/avatar/RedAvatar.svg', + '/imgs/avatar/GrayModernAvatar.svg', + '/imgs/avatar/TealAvatar.svg', + '/imgs/avatar/GreenAvatar.svg', + '/imgs/avatar/BrightBlueAvatar.svg', + '/imgs/avatar/BlueAvatar.svg' + ]; + + it('returns one of the default avatars', () => { + expect(defaultAvatars).toContain(getRandomUserAvatar()); + }); + + it('returns a string', () => { + expect(typeof getRandomUserAvatar()).toBe('string'); + }); + + it('returns a valid avatar path', () => { + expect(getRandomUserAvatar()).toMatch(/^\/imgs\/avatar\/\w+Avatar\.svg$/); + }); +}); describe('hasStoredPassword', () => { it.each([undefined, null, '', 0, false])('treats %j as no stored password', (password) => { diff --git a/packages/service/support/permission/auth/common.ts b/packages/service/support/permission/auth/common.ts index 94ea5a61ae0e..5655980e6c46 100644 --- a/packages/service/support/permission/auth/common.ts +++ b/packages/service/support/permission/auth/common.ts @@ -103,7 +103,6 @@ export async function parseHeaderCert({ isRoot, sourceName, sessionId, - sessionCreatedAt, legacyAppId, parsedAppId, apiKeyAuthProxy @@ -121,8 +120,7 @@ export async function parseHeaderCert({ openApiKey: authResponse.apikey, authType: AuthUserTypeEnum.apikey, apiKeyAuthProxy: authResponse.apiKeyAuthProxy, - sourceName: authResponse.sourceName, - sessionCreatedAt: undefined + sourceName: authResponse.sourceName }; } if (authToken && (token || cookie)) { @@ -137,8 +135,7 @@ export async function parseHeaderCert({ openApiKey: '', authType: AuthUserTypeEnum.token, isRoot: res.isRoot, - sessionId: res.sessionId, - sessionCreatedAt: res.createdAt + sessionId: res.sessionId }; } if (authRoot && rootkey) { @@ -151,8 +148,7 @@ export async function parseHeaderCert({ appId: '', openApiKey: '', authType: AuthUserTypeEnum.root, - isRoot: true, - sessionCreatedAt: undefined + isRoot: true }; } @@ -199,8 +195,7 @@ export async function parseHeaderCert({ apiKeyAuthProxy, apikey: openApiKey, isRoot: !!isRoot, - sessionId, - sessionCreatedAt + sessionId }; } diff --git a/packages/service/support/user/account/password/service.ts b/packages/service/support/user/account/password/service.ts index cbb5569435f6..cc00b536e588 100644 --- a/packages/service/support/user/account/password/service.ts +++ b/packages/service/support/user/account/password/service.ts @@ -3,6 +3,7 @@ import { z } from 'zod'; import { UserErrEnum } from '@fastgpt/global/common/error/code/user'; import { UserError } from '@fastgpt/global/common/error/utils'; import { serviceEnv } from '../../../../env'; +import { MongoUser } from '../../schema'; export const PASSWORD_CHANGE_TOKEN_TTL_SECONDS = 5 * 60; @@ -74,3 +75,20 @@ export class PasswordChangeTokenService { } export const passwordChangeTokenService = new PasswordChangeTokenService(); + +/** + * 阻止用户侧改密流程复用当前密码。密码查询交由 Mongoose schema setter 处理, + * 以兼容客户端摘要和数据库持久化摘要的现有双层哈希协议。 + */ +export const assertNewPasswordDiffersFromCurrent = async ({ + userId, + newPassword +}: { + userId: string; + newPassword: string; +}) => { + const isSamePassword = await MongoUser.exists({ _id: userId, password: newPassword }); + if (isSamePassword) { + throw new UserError(UserErrEnum.newPasswordSameAsOld); + } +}; diff --git a/packages/service/support/user/account/password/utils.ts b/packages/service/support/user/account/password/utils.ts deleted file mode 100644 index 2d9c22a1d6d2..000000000000 --- a/packages/service/support/user/account/password/utils.ts +++ /dev/null @@ -1,18 +0,0 @@ -export const RECENT_LOGIN_WINDOW_MS = 5 * 60 * 1000; - -/** - * 仅以当前服务端 Session 的创建时间判断近期登录。 - * 缺失、非有限值或未来时间都按需要重新验证处理。 - */ -export const isRecentLoginSession = ({ - sessionCreatedAt, - now = Date.now() -}: { - sessionCreatedAt?: number; - now?: number; -}) => { - if (!Number.isFinite(sessionCreatedAt) || !Number.isFinite(now)) return false; - - const age = now - (sessionCreatedAt as number); - return age >= 0 && age <= RECENT_LOGIN_WINDOW_MS; -}; diff --git a/packages/service/test/common/http/entry.test.ts b/packages/service/test/common/http/entry.test.ts index 0820a686fb94..8fd70bf8d689 100644 --- a/packages/service/test/common/http/entry.test.ts +++ b/packages/service/test/common/http/entry.test.ts @@ -93,7 +93,8 @@ describe('createApiEntry error status', () => { it.each([ [UserErrEnum.invalidVerificationCode, 400], [UserErrEnum.sendVerificationCodeTooFrequently, 429], - [UserErrEnum.verifyCodeTooFrequently, 429] + [UserErrEnum.verifyCodeTooFrequently, 429], + [UserErrEnum.newPasswordSameAsOld, 400] ] as const)('returns and traces the configured status for %s', async (errorKey, httpStatus) => { const response = createResponse(); const handler = createApiEntry({})(async () => { diff --git a/packages/service/test/common/response/index.test.ts b/packages/service/test/common/response/index.test.ts index 773372e5ff31..7c5dc5a4eaed 100644 --- a/packages/service/test/common/response/index.test.ts +++ b/packages/service/test/common/response/index.test.ts @@ -103,7 +103,8 @@ describe('jsonRes business HTTP status', () => { it.each([ [UserErrEnum.invalidVerificationCode, 400], [UserErrEnum.sendVerificationCodeTooFrequently, 429], - [UserErrEnum.verifyCodeTooFrequently, 429] + [UserErrEnum.verifyCodeTooFrequently, 429], + [UserErrEnum.newPasswordSameAsOld, 400] ] as const)('uses the configured HTTP status for %s', (errorKey, httpStatus) => { const response = createResponse(); diff --git a/packages/service/test/support/user/account/password/utils.test.ts b/packages/service/test/support/user/account/password/utils.test.ts deleted file mode 100644 index 719e172d7521..000000000000 --- a/packages/service/test/support/user/account/password/utils.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { - RECENT_LOGIN_WINDOW_MS, - isRecentLoginSession -} from '@fastgpt/service/support/user/account/password/utils'; - -describe('isRecentLoginSession', () => { - const now = 1_800_000_000_000; - - it.each([0, RECENT_LOGIN_WINDOW_MS])('accepts a session age of %d ms', (age) => { - expect(isRecentLoginSession({ sessionCreatedAt: now - age, now })).toBe(true); - }); - - it('rejects a session just outside the recent-login window', () => { - expect(isRecentLoginSession({ sessionCreatedAt: now - RECENT_LOGIN_WINDOW_MS - 1, now })).toBe( - false - ); - }); - - it.each([undefined, Number.NaN, Number.POSITIVE_INFINITY])( - 'rejects an invalid session creation time: %s', - (sessionCreatedAt) => { - expect(isRecentLoginSession({ sessionCreatedAt, now })).toBe(false); - } - ); - - it('rejects a future session and an invalid server clock', () => { - expect(isRecentLoginSession({ sessionCreatedAt: now + 1, now })).toBe(false); - expect(isRecentLoginSession({ sessionCreatedAt: now, now: Number.NaN })).toBe(false); - }); -}); diff --git a/packages/web/i18n/en/account_info.json b/packages/web/i18n/en/account_info.json index 8c52ffa370b7..cb121ee915ff 100644 --- a/packages/web/i18n/en/account_info.json +++ b/packages/web/i18n/en/account_info.json @@ -103,34 +103,8 @@ "package_expiry_time": "Expired", "package_usage_rules": "Package usage rules: The system will give priority to using more advanced packages, and the original unused packages will take effect later.", "password": "Password", - "password_authorizing": "Checking password change authorization", - "password_code_countdown": "Resend ({{seconds}})", - "password_code_sent": "Verification code sent", - "password_code_sending": "Sending", - "password_confirm_action": "Confirm", - "password_confirm_placeholder": "Confirm password", - "password_expired_action": "Set password", - "password_expired_tip": "It has been a long time since you changed your password. To keep your account secure, set a new password.", - "password_new_placeholder": "Enter password", "password_not_set": "No password set", - "password_oauth_start": "Verify with {{provider}}", - "password_old_placeholder": "Enter current password", - "password_send_code": "Get verification code", - "password_set_title": "Set password", - "password_set_success": "Password set successfully", - "password_tip": "Use at least 8 characters and include any two types: uppercase letters, lowercase letters, numbers, or special characters", - "password_update_error": "Exception when changing password", "password_update_success": "Password changed successfully", - "password_verification_description": "To protect your account, complete identity verification first.", - "password_verification_failed": "Identity verification failed. Try again.", - "password_verification_retry": "Retry", - "password_verification_title": "Identity verification", - "password_verification_unavailable": "No identity verification method is available for this account", - "password_verify": "Verify", - "password_wechat_expired": "The QR code expired. Get a new one.", - "password_wechat_load_failed": "The QR code could not be loaded. Try again.", - "password_wechat_qr": "WeChat verification QR code", - "password_wechat_scan": "Sign in with WeChat QR code", "pending_usage": "To be used", "please_bind_contact": "Please bind the contact information", "purchase_extra_package": "Upgrade", @@ -146,7 +120,6 @@ "tokens": "integral", "type": "type", "unlimited": "Unlimited", - "update_password": "Change password", "update_success_tip": "Update data successfully", "upgrade_package": "Upgrade", "usage_balance": "Use balance: Use balance", diff --git a/packages/web/i18n/en/common.json b/packages/web/i18n/en/common.json index 71a6eadc66eb..212ed185a6cb 100644 --- a/packages/web/i18n/en/common.json +++ b/packages/web/i18n/en/common.json @@ -943,6 +943,33 @@ "pay_money": "Amount payable", "pay_success": "Payment successfully", "pay_year_tip": "Pay {{count}} months, enjoy 1 year!", + "password_authorizing": "Checking password change authorization", + "password_code_countdown": "Resend ({{seconds}})", + "password_code_sent": "Verification code sent", + "password_code_sending": "Sending", + "password_confirm_action": "Confirm", + "password_confirm_placeholder": "Confirm password", + "password_expired_action": "Set password", + "password_expired_tip": "It has been a long time since you changed your password. To keep your account secure, set a new password.", + "password_new_placeholder": "Enter password", + "password_not_match": "The Passwords Entered Do Not Match", + "password_oauth_start": "Verify with {{provider}}", + "password_old_placeholder": "Enter current password", + "password_send_code": "Get verification code", + "password_set_success": "Password set successfully", + "password_set_title": "Set password", + "password_tip": "Use at least 8 characters and include any two types: uppercase letters, lowercase letters, numbers, or special characters", + "password_update_error": "Exception when changing password", + "password_verification_description": "To protect your account, complete identity verification first.", + "password_verification_failed": "Identity verification failed. Try again.", + "password_verification_retry": "Retry", + "password_verification_title": "Identity verification", + "password_verification_unavailable": "No identity verification method is available for this account", + "password_verify": "Verify", + "password_wechat_expired": "The QR code expired. Get a new one.", + "password_wechat_load_failed": "The QR code could not be loaded. Try again.", + "password_wechat_qr": "WeChat verification QR code", + "password_wechat_scan": "Sign in with WeChat QR code", "permission.Collaborator": "Collaborator", "permission.Manage": "Manage", "permission.No InheritPermission": "Permission Inheritance Restricted", @@ -1179,6 +1206,7 @@ "unknow_source": "Unknown Source", "unusable_variable": "No Usable Variables", "update_failed": "Update Failed", + "update_password": "Change password", "update_success": "Updated Successfully", "upgrade": "upgrade", "upload_file": "Upload File", @@ -1187,7 +1215,7 @@ "user.Account": "Account", "user.No_right_to_reset_password": "You do not have the right to reset the password", "user.Old password is error": "Old Password is Incorrect", - "user.Password has no change": "New password is the same as the old password", + "user.Password has no change": "New password cannot be the same as the old password", "user.Pay": "Recharge", "user.Time": "Time", "user.Update password failed": "Failed to Update Password", diff --git a/packages/web/i18n/en/user.json b/packages/web/i18n/en/user.json index 56ee4fa9f756..4c7fcb9894bf 100644 --- a/packages/web/i18n/en/user.json +++ b/packages/web/i18n/en/user.json @@ -10,7 +10,7 @@ "password.confirm": "Confirm Password", "password.email_phone_error": "Invalid Email/Phone Number Format", "password.email_phone_void": "Email/Phone Number Cannot Be Empty", - "password.not_match": "Passwords Do Not Match", + "password.not_match": "The Passwords Entered Do Not Match", "password.retrieve": "Retrieve Password", "password.retrieved": "Password Retrieved", "password.retrieved_account": "Retrieve {{account}} Account", diff --git a/packages/web/i18n/zh-CN/account_info.json b/packages/web/i18n/zh-CN/account_info.json index 23a76cbe298c..a1c9e130ba5e 100644 --- a/packages/web/i18n/zh-CN/account_info.json +++ b/packages/web/i18n/zh-CN/account_info.json @@ -103,34 +103,8 @@ "package_expiry_time": "套餐到期时间", "package_usage_rules": "套餐使用规则:系统优先使用更高级的套餐,原未用完的套餐将延后生效", "password": "密码", - "password_authorizing": "正在确认修改密码权限", - "password_code_countdown": "重新获取({{seconds}})", - "password_code_sent": "验证码已发送", - "password_code_sending": "发送中", - "password_confirm_action": "确定", - "password_confirm_placeholder": "确认密码", - "password_expired_action": "去设置", - "password_expired_tip": "您已较长时间未修改密码。为了您的账号安全,请重新设置密码。", - "password_new_placeholder": "请输入密码", "password_not_set": "未设置密码", - "password_oauth_start": "前往 {{provider}} 验证", - "password_old_placeholder": "填写旧密码", - "password_send_code": "获取验证码", - "password_set_title": "设置密码", - "password_set_success": "密码设置成功", - "password_tip": "至少 8 位,需包含任意两类:大写字母、小写字母、数字、特殊字符", - "password_update_error": "修改密码异常", "password_update_success": "修改密码成功", - "password_verification_description": "为保护账号安全,请先完成身份验证。", - "password_verification_failed": "身份验证失败,请重试", - "password_verification_retry": "重试", - "password_verification_title": "身份验证", - "password_verification_unavailable": "当前账号没有可用的身份验证方式", - "password_verify": "验证", - "password_wechat_expired": "二维码已过期,请重新获取", - "password_wechat_load_failed": "二维码加载失败,请重试", - "password_wechat_qr": "微信验证二维码", - "password_wechat_scan": "微信扫码登录", "pending_usage": "待使用", "please_bind_contact": "请绑定联系方式", "purchase_extra_package": "购买额外套餐", @@ -146,7 +120,6 @@ "tokens": "积分", "type": "类型", "unlimited": "无限制", - "update_password": "修改密码", "update_success_tip": "更新数据成功", "upgrade_package": "升级套餐", "usage_balance": "使用余额: 使用余额", diff --git a/packages/web/i18n/zh-CN/common.json b/packages/web/i18n/zh-CN/common.json index 2ae62fde7a5c..7e7eb47a9fd5 100644 --- a/packages/web/i18n/zh-CN/common.json +++ b/packages/web/i18n/zh-CN/common.json @@ -943,6 +943,33 @@ "pay_money": "应付金额", "pay_success": "支付成功", "pay_year_tip": "支付 {{count}} 个月,畅享一年!", + "password_authorizing": "正在确认修改密码权限", + "password_code_countdown": "重新获取({{seconds}})", + "password_code_sent": "验证码已发送", + "password_code_sending": "发送中", + "password_confirm_action": "确定", + "password_confirm_placeholder": "确认密码", + "password_expired_action": "去设置", + "password_expired_tip": "您已较长时间未修改密码。为了您的账号安全,请重新设置密码。", + "password_new_placeholder": "请输入密码", + "password_not_match": "两次密码输入不一致", + "password_oauth_start": "前往 {{provider}} 验证", + "password_old_placeholder": "填写旧密码", + "password_send_code": "获取验证码", + "password_set_success": "密码设置成功", + "password_set_title": "设置密码", + "password_tip": "至少 8 位,需包含任意两类:大写字母、小写字母、数字、特殊字符", + "password_update_error": "修改密码异常", + "password_verification_description": "为保护账号安全,请先完成身份验证。", + "password_verification_failed": "身份验证失败,请重试", + "password_verification_retry": "重试", + "password_verification_title": "身份验证", + "password_verification_unavailable": "当前账号没有可用的身份验证方式", + "password_verify": "验证", + "password_wechat_expired": "二维码已过期,请重新获取", + "password_wechat_load_failed": "二维码加载失败,请重试", + "password_wechat_qr": "微信验证二维码", + "password_wechat_scan": "微信扫码登录", "permission.Collaborator": "协作者", "permission.Manage": "管理", "permission.No InheritPermission": "已限制权限,不再继承父级文件夹的权限,", @@ -1179,6 +1206,7 @@ "unknow_source": "未知来源", "unusable_variable": "无可用变量", "update_failed": "更新异常", + "update_password": "修改密码", "update_success": "更新成功", "upgrade": "升级", "upload_file": "上传文件", @@ -1187,7 +1215,7 @@ "user.Account": "账号", "user.No_right_to_reset_password": "没有重置密码的权限", "user.Old password is error": "旧密码错误", - "user.Password has no change": "新密码和旧密码重复", + "user.Password has no change": "新密码不能和旧密码相同", "user.Pay": "充值", "user.Time": "时间", "user.Update password failed": "修改密码异常", diff --git a/packages/web/i18n/zh-CN/user.json b/packages/web/i18n/zh-CN/user.json index 1ee53910b6d1..68d967fa091c 100644 --- a/packages/web/i18n/zh-CN/user.json +++ b/packages/web/i18n/zh-CN/user.json @@ -10,7 +10,7 @@ "password.confirm": "确认密码", "password.email_phone_error": "邮箱/手机号格式错误", "password.email_phone_void": "邮箱/手机号不能为空", - "password.not_match": "两次密码不一致", + "password.not_match": "两次密码输入不一致", "password.retrieve": "找回密码", "password.retrieved": "密码已找回", "password.retrieved_account": "找回 {{account}} 账号", diff --git a/packages/web/i18n/zh-Hant/account_info.json b/packages/web/i18n/zh-Hant/account_info.json index da1778fa7cd9..e08070f97fbb 100644 --- a/packages/web/i18n/zh-Hant/account_info.json +++ b/packages/web/i18n/zh-Hant/account_info.json @@ -103,34 +103,8 @@ "package_expiry_time": "套餐到期時間", "package_usage_rules": "套餐使用規則:系統優先使用更進階的套餐,原未用完的套餐將延遲生效", "password": "密碼", - "password_authorizing": "正在確認修改密碼權限", - "password_code_countdown": "重新取得({{seconds}})", - "password_code_sent": "驗證碼已傳送", - "password_code_sending": "傳送中", - "password_confirm_action": "確定", - "password_confirm_placeholder": "確認密碼", - "password_expired_action": "前往設定", - "password_expired_tip": "您已較長時間未修改密碼。為了您的帳號安全,請重新設定密碼。", - "password_new_placeholder": "請輸入密碼", "password_not_set": "尚未設定密碼", - "password_oauth_start": "前往 {{provider}} 驗證", - "password_old_placeholder": "填寫舊密碼", - "password_send_code": "取得驗證碼", - "password_set_title": "設定密碼", - "password_set_success": "密碼設定成功", - "password_tip": "至少 8 位,需包含任意兩類:大寫字母、小寫字母、數字、特殊字元", - "password_update_error": "修改密碼異常", "password_update_success": "修改密碼成功", - "password_verification_description": "為保護帳號安全,請先完成身分驗證。", - "password_verification_failed": "身分驗證失敗,請重試", - "password_verification_retry": "重試", - "password_verification_title": "身分驗證", - "password_verification_unavailable": "目前帳號沒有可用的身分驗證方式", - "password_verify": "驗證", - "password_wechat_expired": "QR Code 已過期,請重新取得", - "password_wechat_load_failed": "QR Code 載入失敗,請重試", - "password_wechat_qr": "微信驗證 QR Code", - "password_wechat_scan": "微信掃碼登入", "pending_usage": "待使用", "please_bind_contact": "請綁定聯繫方式", "purchase_extra_package": "購買額外套餐", @@ -146,7 +120,6 @@ "tokens": "積分", "type": "類型", "unlimited": "無限制", - "update_password": "修改密碼", "update_success_tip": "更新資料成功", "upgrade_package": "升級套餐", "usage_balance": "使用餘額:使用餘額", diff --git a/packages/web/i18n/zh-Hant/common.json b/packages/web/i18n/zh-Hant/common.json index e30c010b5855..877ceaf5f2b7 100644 --- a/packages/web/i18n/zh-Hant/common.json +++ b/packages/web/i18n/zh-Hant/common.json @@ -933,6 +933,33 @@ "pay_money": "應付金額", "pay_success": "支付成功", "pay_year_tip": "支付 {{count}} 個月,暢享一年!", + "password_authorizing": "正在確認修改密碼權限", + "password_code_countdown": "重新取得({{seconds}})", + "password_code_sent": "驗證碼已傳送", + "password_code_sending": "傳送中", + "password_confirm_action": "確定", + "password_confirm_placeholder": "確認密碼", + "password_expired_action": "前往設定", + "password_expired_tip": "您已較長時間未修改密碼。為了您的帳號安全,請重新設定密碼。", + "password_new_placeholder": "請輸入密碼", + "password_not_match": "兩次密碼輸入不一致", + "password_oauth_start": "前往 {{provider}} 驗證", + "password_old_placeholder": "填寫舊密碼", + "password_send_code": "取得驗證碼", + "password_set_success": "密碼設定成功", + "password_set_title": "設定密碼", + "password_tip": "至少 8 位,需包含任意兩類:大寫字母、小寫字母、數字、特殊字元", + "password_update_error": "修改密碼異常", + "password_verification_description": "為保護帳號安全,請先完成身分驗證。", + "password_verification_failed": "身分驗證失敗,請重試", + "password_verification_retry": "重試", + "password_verification_title": "身分驗證", + "password_verification_unavailable": "目前帳號沒有可用的身分驗證方式", + "password_verify": "驗證", + "password_wechat_expired": "QR Code 已過期,請重新取得", + "password_wechat_load_failed": "QR Code 載入失敗,請重試", + "password_wechat_qr": "微信驗證 QR Code", + "password_wechat_scan": "微信掃碼登入", "permission.Collaborator": "協作者", "permission.Manage": "管理", "permission.No InheritPermission": "已限制權限,不再繼承上層資料夾的權限", @@ -1168,6 +1195,7 @@ "unknow_source": "未知來源", "unusable_variable": "無可用變數", "update_failed": "更新失敗", + "update_password": "修改密碼", "update_success": "更新成功", "upgrade": "升級", "upload_file": "上傳檔案", @@ -1175,7 +1203,7 @@ "user.Account": "帳戶", "user.No_right_to_reset_password": "沒有重置密碼的權限", "user.Old password is error": "舊密碼錯誤", - "user.Password has no change": "新密碼和舊密碼重複", + "user.Password has no change": "新密碼不能與舊密碼相同", "user.Pay": "儲值", "user.Time": "時間", "user.Update password failed": "更新密碼失敗", diff --git a/packages/web/i18n/zh-Hant/user.json b/packages/web/i18n/zh-Hant/user.json index c5752b3cca15..4457e43f9d74 100644 --- a/packages/web/i18n/zh-Hant/user.json +++ b/packages/web/i18n/zh-Hant/user.json @@ -10,7 +10,7 @@ "password.confirm": "確認密碼", "password.email_phone_error": "電子郵件/手機號碼格式錯誤", "password.email_phone_void": "電子郵件/手機號碼不能空白", - "password.not_match": "兩次輸入的密碼不相符", + "password.not_match": "兩次密碼輸入不一致", "password.retrieve": "找回密碼", "password.retrieved": "密碼已找回", "password.retrieved_account": "找回 {{account}} 帳號", diff --git a/projects/app/src/components/support/user/safe/AccountVerificationPanel.tsx b/projects/app/src/components/support/user/safe/AccountVerificationPanel.tsx index 082218e57c8d..73de69b30f56 100644 --- a/projects/app/src/components/support/user/safe/AccountVerificationPanel.tsx +++ b/projects/app/src/components/support/user/safe/AccountVerificationPanel.tsx @@ -17,6 +17,7 @@ import { useTranslation } from 'next-i18next'; import { hashStr } from '@fastgpt/global/common/string/tools'; import type { AccountVerificationMethod } from '@fastgpt/global/support/user/account/verification/type'; import { OAuthAccountVerificationProviderSchema } from '@fastgpt/global/support/user/account/verification/type'; +import { checkIsWecomTerminal } from '@fastgpt/global/support/user/login/constants'; import type { CreatePasswordVerificationBody, CreatePasswordVerificationResponse, @@ -27,6 +28,10 @@ import { useToast } from '@fastgpt/web/hooks/useToast'; import SendCodeAuthModal from './SendCodeAuthModal'; import { getClientToken } from '@/web/support/user/hooks/useSendCode'; import { useSystemStore } from '@/web/common/system/useSystemStore'; +import { + isAccountVerificationCodeError, + isAccountVerificationRateLimitError +} from '@/web/support/user/account/verification/error'; type AuthorizedPasswordChange = Extract; @@ -77,12 +82,19 @@ export const AccountVerificationPanel = ({ const createRequested = useRef(false); const wechatPolling = useRef(false); - const showVerificationFailure = useCallback(() => { - toast({ - status: 'error', - title: t('account_info:password_verification_failed') - }); - }, [t, toast]); + const showVerificationFailure = useCallback( + (error?: unknown) => { + toast({ + status: 'error', + title: isAccountVerificationCodeError(error) + ? t('common:error.code_error') + : isAccountVerificationRateLimitError(error) + ? t('common:error.operation_too_frequently') + : t('common:password_verification_failed') + }); + }, + [t, toast] + ); const submitVerification = useCallback( async (verification: SensitiveAccountVerificationBody) => { @@ -108,9 +120,9 @@ export const AccountVerificationPanel = ({ setWechatQR(result); setWechatNow(Date.now()); } - } catch { + } catch (error) { setCreateFailed(true); - showVerificationFailure(); + showVerificationFailure(error); } finally { setCreating(false); } @@ -150,11 +162,11 @@ export const AccountVerificationPanel = ({ payload: { code: wechatQR.code } }); if (authorized) disposed = true; - } catch { + } catch (error) { disposed = true; setWechatQR(undefined); setCreateFailed(true); - showVerificationFailure(); + showVerificationFailure(error); } finally { wechatPolling.current = false; } @@ -181,9 +193,9 @@ export const AccountVerificationPanel = ({ }); if (result.method !== 'code') throw new Error('Verification method mismatch'); setCodeCountDown(60); - toast({ status: 'success', title: t('account_info:password_code_sent') }); - } catch { - showVerificationFailure(); + toast({ status: 'success', title: t('common:password_code_sent') }); + } catch (error) { + showVerificationFailure(error); throw new Error('Failed to send verification code'); } finally { setCodeSending(false); @@ -196,9 +208,8 @@ export const AccountVerificationPanel = ({ setSubmitting(true); try { await submitVerification({ method, payload: { code: verificationCode } }); - } catch { - setCode(''); - showVerificationFailure(); + } catch (error) { + showVerificationFailure(error); } finally { setSubmitting(false); } @@ -206,15 +217,6 @@ export const AccountVerificationPanel = ({ [method, showVerificationFailure, submitVerification, submitting] ); - // 设计稿不提供独立提交按钮,六位验证码输入完成后直接消费验证材料。 - useEffect(() => { - const verificationCode = code.trim(); - if (method !== 'code' || verificationCode.length !== 6) return; - - const timer = window.setTimeout(() => void submitCode(verificationCode), 0); - return () => window.clearTimeout(timer); - }, [code, method, submitCode]); - const submitOldPassword = async () => { if (method !== 'oldPassword' || !oldPassword || !preLoginCode) return; setSubmitting(true); @@ -223,11 +225,11 @@ export const AccountVerificationPanel = ({ method, payload: { password: hashStr(oldPassword), preLoginCode } }); - } catch { + } catch (error) { // 预登录材料在密码校验前即被一次性消费,失败后必须重新创建才能再次尝试。 setOldPassword(''); setPreLoginCode(undefined); - showVerificationFailure(); + showVerificationFailure(error); void createBoundVerification(); } finally { setSubmitting(false); @@ -239,7 +241,13 @@ export const AccountVerificationPanel = ({ setSubmitting(true); try { const callbackUrl = `${window.location.origin}/login/provider`; - const result = await createVerification({ method, payload: { callbackUrl } }); + const result = await createVerification({ + method, + payload: { + callbackUrl, + isWecomWorkTerminal: checkIsWecomTerminal() + } + }); if (result.method !== method) throw new Error('Verification method mismatch'); const provider = OAuthAccountVerificationProviderSchema.parse(method.slice('oauth/'.length)); useSystemStore.getState().setLoginStore({ @@ -251,9 +259,9 @@ export const AccountVerificationPanel = ({ passwordChangeRequired: required }); await router.replace(result.url); - } catch { + } catch (error) { setSubmitting(false); - showVerificationFailure(); + showVerificationFailure(error); } }; @@ -266,17 +274,17 @@ export const AccountVerificationPanel = ({ return ( setCode(event.target.value.replace(/\D/g, '').slice(0, 6))} - aria-label={t('user:password.verification_code')} + aria-label={t('common:support.user.info.verification_code')} onKeyDown={(event) => { if (event.key === 'Enter') void submitCode(code.trim()); }} /> - + + {isCaptchaOpen && ( {creating ? ( @@ -341,31 +358,31 @@ export const AccountVerificationPanel = ({ ) : createFailed || !preLoginCode ? (
) : ( <> setOldPassword(event.target.value)} - placeholder={t('account_info:password_old_placeholder')} + placeholder={t('common:password_old_placeholder')} onKeyDown={(event) => { if (event.key === 'Enter') void submitOldPassword(); }} /> )} @@ -377,8 +394,8 @@ export const AccountVerificationPanel = ({ if (method === 'wechat') { return ( - - {t('account_info:password_wechat_scan')} + + {t('common:password_wechat_scan')}
{creating ? ( ) : wechatQR && !wechatExpired ? ( {t('account_info:password_wechat_qr')} {t( createFailed - ? 'account_info:password_wechat_load_failed' - : 'account_info:password_wechat_expired' + ? 'common:password_wechat_load_failed' + : 'common:password_wechat_expired' )} )} @@ -431,16 +448,16 @@ export const AccountVerificationPanel = ({ return ( - ); diff --git a/projects/app/src/components/support/user/safe/PasswordChangeModal.tsx b/projects/app/src/components/support/user/safe/PasswordChangeModal.tsx index 0ef5d38b836b..796592c87c2a 100644 --- a/projects/app/src/components/support/user/safe/PasswordChangeModal.tsx +++ b/projects/app/src/components/support/user/safe/PasswordChangeModal.tsx @@ -15,7 +15,7 @@ import { useRouter } from 'next/router'; import { useTranslation } from 'next-i18next'; import { useForm } from 'react-hook-form'; import { UserErrEnum } from '@fastgpt/global/common/error/code/user'; -import { getErrResponse } from '@fastgpt/global/common/error/utils'; +import { getErrResponse, getErrText } from '@fastgpt/global/common/error/utils'; import { checkPasswordRule } from '@fastgpt/global/common/string/password'; import type { PasswordAuthorizationResponse, @@ -53,6 +53,18 @@ type Props = { onSuccess?: () => void | Promise; }; +const invalidInputStyles = { + borderColor: 'red.500', + _focus: { + borderColor: 'red.500', + boxShadow: '0 0 0 1px var(--chakra-colors-red-500)' + }, + _focusVisible: { + borderColor: 'red.500', + boxShadow: '0 0 0 1px var(--chakra-colors-red-500)' + } +}; + /** 统一承接设置、修改和过期重置密码的短期授权状态机。 */ const PasswordChangeModal = ({ required = false, @@ -98,7 +110,7 @@ const PasswordChangeModal = ({ const requestAuthorization = useCallback(async () => { try { - const result = await authorizePasswordChange({ source: 'recentLogin' }); + const result = await authorizePasswordChange({ source: 'verificationMethod' }); if (result.status === 'authorized') { setStage({ type: 'password', authorization: result }); return; @@ -110,7 +122,7 @@ const PasswordChangeModal = ({ setStage({ type: 'unavailable' }); } catch { setStage({ type: 'unavailable' }); - toast({ status: 'error', title: t('account_info:password_verification_failed') }); + toast({ status: 'error', title: t('common:password_verification_failed') }); } }, [t, toast]); @@ -149,16 +161,21 @@ const PasswordChangeModal = ({ reset(); setStoredAuthorization(undefined); await initUserInfo(); - toast({ status: 'success', title: t('account_info:password_set_success') }); + toast({ status: 'success', title: t('common:password_set_success') }); await onSuccess?.(); } catch (error) { - if (getErrResponse(error)?.statusText === UserErrEnum.passwordChangeAuthorizationInvalid) { + const errorResponse = getErrResponse(error); + if (errorResponse?.statusText === UserErrEnum.passwordChangeAuthorizationInvalid) { reset(); setStoredAuthorization(undefined); setStage({ type: 'authorizing' }); return; } - toast({ status: 'error', title: t('account_info:password_update_error') }); + const errorTitle = + errorResponse?.statusText === UserErrEnum.newPasswordSameAsOld + ? t(getErrText(error, t('common:user.Password has no change')) as any) + : t('common:password_update_error'); + toast({ status: 'error', title: errorTitle }); } finally { setSubmitting(false); } @@ -166,12 +183,10 @@ const PasswordChangeModal = ({ const title = (() => { if (stage.type === 'verification' || stage.type === 'unavailable') { - return t('account_info:password_verification_title'); + return t('common:password_verification_title'); } - if (required || !userInfo?.hasPassword) return t('account_info:password_set_title'); - return userInfo?.hasPassword - ? t('account_info:update_password') - : t('account_info:password_set_title'); + if (required || !userInfo?.hasPassword) return t('common:password_set_title'); + return userInfo?.hasPassword ? t('common:update_password') : t('common:password_set_title'); })(); const isWechatVerification = stage.type === 'verification' && stage.method === 'wechat'; @@ -181,34 +196,34 @@ const PasswordChangeModal = ({ {stage.type === 'prompt' && ( - + {title} - - {t('account_info:password_expired_tip')} + + {t('common:password_expired_tip')} @@ -216,14 +231,14 @@ const PasswordChangeModal = ({ {stage.type === 'authorizing' && ( - + {title}
- {t('account_info:password_authorizing')} + {t('common:password_authorizing')}
@@ -232,14 +247,14 @@ const PasswordChangeModal = ({ {stage.type === 'unavailable' && ( - + {title} - - {t('account_info:password_verification_unavailable')} + + {t('common:password_verification_unavailable')} - )} @@ -247,11 +262,11 @@ const PasswordChangeModal = ({ {stage.type === 'verification' && ( - + {title} - - {t('account_info:password_verification_description')} + + {t('common:password_verification_description')} @@ -270,63 +285,61 @@ const PasswordChangeModal = ({ {stage.type === 'password' && ( - + {title} checkPasswordRule(value) || t('login:password_tip') + required: t('common:password_new_placeholder'), + validate: (value) => checkPasswordRule(value) || t('common:password_tip') })} /> - {errors.newPassword?.message ? ( - - {errors.newPassword.message} - - ) : ( - - {t('account_info:password_tip')} - - )} + + {t('common:password_tip')} + - value === getValues('newPassword') || t('user:password.not_match') + value === getValues('newPassword') || t('common:password_not_match') })} /> {errors.confirmPassword?.message && ( - + {errors.confirmPassword.message} )} diff --git a/projects/app/src/pageComponents/account/cancel/VerificationPanel.tsx b/projects/app/src/pageComponents/account/cancel/VerificationPanel.tsx index ae3e6226aa7c..0559dc194c43 100644 --- a/projects/app/src/pageComponents/account/cancel/VerificationPanel.tsx +++ b/projects/app/src/pageComponents/account/cancel/VerificationPanel.tsx @@ -18,6 +18,7 @@ import type { AccountVerificationMethod, OAuthAccountVerificationProvider } from '@fastgpt/global/support/user/account/verification/type'; +import { checkIsWecomTerminal } from '@fastgpt/global/support/user/login/constants'; import { resolveAccountCancellationByUsername } from '@fastgpt/global/support/user/account/cancellation'; import type { FastGPTFeConfigsType } from '@fastgpt/global/common/system/types'; import type { @@ -33,7 +34,10 @@ import { } from '@/web/support/user/account/cancellation/api'; import { useSystemStore } from '@/web/common/system/useSystemStore'; import { useUserStore } from '@/web/support/user/useUserStore'; -import { isAccountCancellationCodeError, isAccountCancellationRateLimitError } from './utils'; +import { + isAccountVerificationCodeError, + isAccountVerificationRateLimitError +} from '@/web/support/user/account/verification/error'; const getCapabilities = (feConfigs: FastGPTFeConfigsType) => ({ ...(feConfigs.accountVerification?.accountCancellation ?? { @@ -110,9 +114,9 @@ export const VerificationPanel = ({ (error?: unknown) => { toast({ status: 'error', - title: isAccountCancellationCodeError(error) + title: isAccountVerificationCodeError(error) ? t('common:error.code_error') - : isAccountCancellationRateLimitError(error) + : isAccountVerificationRateLimitError(error) ? t('common:error.operation_too_frequently') : t('account_info:account_cancellation_verification_failed', '身份验证失败,请重试') }); @@ -192,9 +196,9 @@ export const VerificationPanel = ({ } catch (error) { toast({ status: 'error', - title: isAccountCancellationCodeError(error) + title: isAccountVerificationCodeError(error) ? t('common:error.code_error') - : isAccountCancellationRateLimitError(error) + : isAccountVerificationRateLimitError(error) ? t('common:error.operation_too_frequently') : t('account_info:account_cancellation_code_send_failed', '验证码发送失败,请重试') }); @@ -224,7 +228,10 @@ export const VerificationPanel = ({ const callbackUrl = `${window.location.origin}/login/provider`; const result = await createAccountCancellationVerification({ method, - payload: { callbackUrl } + payload: { + callbackUrl, + isWecomWorkTerminal: checkIsWecomTerminal() + } }); if (result.method !== method) return; const provider = method.slice('oauth/'.length) as OAuthAccountVerificationProvider; diff --git a/projects/app/src/pageComponents/account/info/password.ts b/projects/app/src/pageComponents/account/info/password.ts new file mode 100644 index 000000000000..3a2ad140afa7 --- /dev/null +++ b/projects/app/src/pageComponents/account/info/password.ts @@ -0,0 +1,8 @@ +/** 判断当前账号是否允许从用户信息页进入密码管理。root 和企业微信账号不使用本地密码。 */ +export const canManagePasswordFromAccountInfo = ({ + isPlus, + username +}: { + isPlus?: boolean; + username?: string; +}) => isPlus === true && !!username && username !== 'root' && !username.startsWith('wecom-'); diff --git a/projects/app/src/pages/account/info/index.tsx b/projects/app/src/pages/account/info/index.tsx index 2c286058c664..6d9d75d3e86b 100644 --- a/projects/app/src/pages/account/info/index.tsx +++ b/projects/app/src/pages/account/info/index.tsx @@ -51,6 +51,7 @@ import { getIsMemberSyncMode } from '@/web/common/system/utils'; import { getAccountCancellationStatus } from '@/web/support/user/account/cancellation/api'; import { AccountCancellationConfirmModal } from '@/pageComponents/account/cancel/AccountCancellationConfirmModal'; import { usePasswordChangeStore } from '@/web/support/user/account/password/store'; +import { canManagePasswordFromAccountInfo } from '@/pageComponents/account/info/password'; const RedeemCouponModal = dynamic(() => import('@/pageComponents/account/info/RedeemCouponModal'), { ssr: false @@ -141,6 +142,10 @@ const MyInfo = ({ onOpenContact }: { onOpenContact: () => void }) => { const standardPlan = teamPlanStatus?.standard; const { isPc } = useSystem(); const { toast } = useToast(); + const canManagePassword = canManagePasswordFromAccountInfo({ + isPlus: feConfigs?.isPlus, + username: userInfo?.username + }); const [autoOpenEnterpriseAuth, setAutoOpenEnterpriseAuth] = useState(false); const showEnterpriseAuth = feConfigs?.show_enterprise_auth; @@ -225,8 +230,8 @@ const MyInfo = ({ onOpenContact }: { onOpenContact: () => void }) => { }, [triggerEnterpriseAuthFromHash]); useEffect(() => { - if (passwordChangeAuthorization?.required === false) onOpenUpdatePsw(); - }, [onOpenUpdatePsw, passwordChangeAuthorization]); + if (canManagePassword && passwordChangeAuthorization?.required === false) onOpenUpdatePsw(); + }, [canManagePassword, onOpenUpdatePsw, passwordChangeAuthorization]); const { Component: AvatarUploader, handleFileSelectorOpen } = useUploadAvatar( getUploadAvatarPresignedUrl, { @@ -276,7 +281,7 @@ const MyInfo = ({ onOpenContact }: { onOpenContact: () => void }) => { {t('account_info:user_account')}  {userInfo?.username} - {feConfigs?.isPlus && ( + {canManagePassword && ( {t('account_info:password')}  @@ -433,7 +438,7 @@ const MyInfo = ({ onOpenContact }: { onOpenContact: () => void }) => { {isOpenConversionModal && ( )} - {isOpenUpdatePsw && } + {canManagePassword && isOpenUpdatePsw && } {isOpenUpdateContact && } ); diff --git a/projects/app/src/pages/api/support/user/account/checkPswExpired.ts b/projects/app/src/pages/api/support/user/account/checkPswExpired.ts index 1b22ba1f3289..76a81d154481 100644 --- a/projects/app/src/pages/api/support/user/account/checkPswExpired.ts +++ b/projects/app/src/pages/api/support/user/account/checkPswExpired.ts @@ -10,7 +10,12 @@ async function handler( req: ApiRequestProps, _res: ApiResponseType ): Promise { - const { userId } = await authCert({ req, authToken: true }); + const { userId, isRoot } = await authCert({ req, authToken: true }); + + // root 密码由环境变量管理并在服务重启时同步,不参与用户密码过期策略。 + if (isRoot) { + return false; + } const user = await MongoUser.findById(userId).select('+password passwordUpdateTime'); diff --git a/projects/app/src/pages/api/support/user/account/password/update.ts b/projects/app/src/pages/api/support/user/account/password/update.ts index 953f099718ff..3ed20b4e6ec0 100644 --- a/projects/app/src/pages/api/support/user/account/password/update.ts +++ b/projects/app/src/pages/api/support/user/account/password/update.ts @@ -1,5 +1,4 @@ import type { ApiRequestProps } from '@fastgpt/next/type'; -import { i18nT } from '@fastgpt/global/common/i18n/utils'; import { UpdatePasswordBodySchema, UpdatePasswordResponseSchema, @@ -7,10 +6,12 @@ import { type UpdatePasswordResponse } from '@fastgpt/global/openapi/support/user/account/password/api'; import { AuditEventEnum } from '@fastgpt/global/support/user/audit/constants'; -import { hasStoredPassword } from '@fastgpt/global/support/user/utils'; import { parseApiInput } from '@fastgpt/service/common/zod/requestParseError'; import { authCert } from '@fastgpt/service/support/permission/auth/common'; -import { passwordChangeTokenService } from '@fastgpt/service/support/user/account/password/service'; +import { + assertNewPasswordDiffersFromCurrent, + passwordChangeTokenService +} from '@fastgpt/service/support/user/account/password/service'; import { addAuditLog } from '@fastgpt/service/support/user/audit/util'; import { MongoUser } from '@fastgpt/service/support/user/schema'; import { delUserAllSession } from '@fastgpt/service/support/user/session'; @@ -23,15 +24,10 @@ async function handler(req: ApiRequestProps): Promise { const lastTmbId = loginStore?.lastTmbId || ''; const verificationFailureTitle = (() => { if (loginStore?.flow === 'passwordChange') { - return t('account_info:password_verification_failed'); + return t('common:password_verification_failed'); } if (loginStore?.flow === 'accountCancellation') { return t('account_info:account_cancellation_verification_failed', '身份验证失败,请重试'); diff --git a/projects/app/src/service/mongo.ts b/projects/app/src/service/mongo.ts index 351cf0a98362..77092a1e6933 100644 --- a/projects/app/src/service/mongo.ts +++ b/projects/app/src/service/mongo.ts @@ -8,7 +8,7 @@ import { appEnv } from '@/env'; const logger = getLogger(LogCategories.SYSTEM); -/** 初始化 root 用户,并仅在运维配置密码真实变化时刷新密码更新时间。 */ +/** 初始化 root 用户,并仅在运维配置密码真实变化时更新密码。 */ export async function initRootUser(retry = 3): Promise { try { const rootUser = await MongoUser.findOne({ username: 'root' }).select('+password'); @@ -25,8 +25,7 @@ export async function initRootUser(retry = 3): Promise { if (passwordChanged) { await rootUser.updateOne( { - password, - passwordUpdateTime: new Date() + password }, { session } ); @@ -36,8 +35,7 @@ export async function initRootUser(retry = 3): Promise { [ { username: 'root', - password, - passwordUpdateTime: new Date() + password } ], { session, ordered: true } diff --git a/projects/app/src/pageComponents/account/cancel/utils.ts b/projects/app/src/web/support/user/account/verification/error.ts similarity index 51% rename from projects/app/src/pageComponents/account/cancel/utils.ts rename to projects/app/src/web/support/user/account/verification/error.ts index 4e7cbc399808..276ced571c6d 100644 --- a/projects/app/src/pageComponents/account/cancel/utils.ts +++ b/projects/app/src/web/support/user/account/verification/error.ts @@ -1,27 +1,26 @@ import { UserErrEnum } from '@fastgpt/global/common/error/code/user'; import { getErrResponse, getErrText } from '@fastgpt/global/common/error/utils'; -const accountCancellationRateLimitStatusTexts = new Set([ +const accountVerificationRateLimitStatusTexts = new Set([ UserErrEnum.sendVerificationCodeTooFrequently, UserErrEnum.verifyCodeTooFrequently ]); -const legacyAccountCancellationRateLimitErrors = new Set([ +const legacyAccountVerificationRateLimitErrors = new Set([ 'common:error.send_auth_code_too_frequently', 'common:error.verify_code_too_frequently' ]); - const verificationCodeError = 'common:error.code_error'; -/** 统一识别注销验证码发送与校验阶段的频控错误。 */ -export const isAccountCancellationRateLimitError = (error: unknown) => { +/** 识别图片验证码或账号验证码错误,避免统一降级为身份验证失败。 */ +export const isAccountVerificationCodeError = (error: unknown) => + getErrResponse(error)?.statusText === UserErrEnum.invalidVerificationCode || + getErrText(error) === verificationCodeError; + +/** 统一识别账号验证码发送与校验阶段的频控错误,并兼容旧版 message 返回。 */ +export const isAccountVerificationRateLimitError = (error: unknown) => { const statusText = getErrResponse(error)?.statusText; return ( - accountCancellationRateLimitStatusTexts.has(statusText) || - legacyAccountCancellationRateLimitErrors.has(getErrText(error)) + accountVerificationRateLimitStatusTexts.has(statusText) || + legacyAccountVerificationRateLimitErrors.has(getErrText(error)) ); }; - -/** 识别图片验证码错误,避免发送阶段统一降级为“验证码发送失败”。 */ -export const isAccountCancellationCodeError = (error: unknown) => - getErrResponse(error)?.statusText === UserErrEnum.invalidVerificationCode || - getErrText(error) === verificationCodeError; diff --git a/projects/app/test/api/support/user/account/checkPswExpired.test.ts b/projects/app/test/api/support/user/account/checkPswExpired.test.ts index 81b36c620b67..c0705608e45b 100644 --- a/projects/app/test/api/support/user/account/checkPswExpired.test.ts +++ b/projects/app/test/api/support/user/account/checkPswExpired.test.ts @@ -149,6 +149,28 @@ describe('checkPswExpired API', () => { expect(res.data).toBe(true); }); + it('should return false for root when password expiry is configured', async () => { + vi.stubEnv('PASSWORD_EXPIRED_MONTH', '1'); + const checkPswExpiredApi = await loadCheckPswExpiredApi(); + + await MongoUser.findByIdAndUpdate(testUser._id, { + $unset: { passwordUpdateTime: '' } + }); + + const res = await Call(checkPswExpiredApi.default, { + auth: { + userId: String(testUser._id), + teamId: String(testTeam._id), + tmbId: String(testTmb._id), + isRoot: true, + sessionId: 'session123' + } as any + }); + + expect(res.code).toBe(200); + expect(res.data).toBe(false); + }); + it('should return false when user is not found', async () => { const nonExistentId = '000000000000000000000001'; const checkPswExpiredApi = await loadCheckPswExpiredApi(); diff --git a/projects/app/test/api/support/user/account/password/update.test.ts b/projects/app/test/api/support/user/account/password/update.test.ts index e05fd620aea5..c400d300b569 100644 --- a/projects/app/test/api/support/user/account/password/update.test.ts +++ b/projects/app/test/api/support/user/account/password/update.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { hashStr } from '@fastgpt/global/common/string/tools'; import type { UpdatePasswordBody } from '@fastgpt/global/openapi/support/user/account/password/api'; import { passwordChangeTokenService } from '@fastgpt/service/support/user/account/password/service'; +import { UserErrEnum } from '@fastgpt/global/common/error/code/user'; import { MongoUser } from '@fastgpt/service/support/user/schema'; import { MongoTeam } from '@fastgpt/service/support/user/team/teamSchema'; import { MongoTeamMember } from '@fastgpt/service/support/user/team/teamMemberSchema'; @@ -96,6 +97,10 @@ describe('password/update API', () => { ); expect(response.code).toBe(500); + expect(response.error).toMatchObject({ + name: 'UserError', + message: UserErrEnum.newPasswordSameAsOld + }); const unchangedUser = await MongoUser.findById(testUser._id).lean(); expect(unchangedUser?.passwordUpdateTime).toBeUndefined(); }); diff --git a/projects/app/test/components/support/user/safe/AccountVerificationPanel.test.ts b/projects/app/test/components/support/user/safe/AccountVerificationPanel.test.ts index 51c174c093e0..46a2aa5800fe 100644 --- a/projects/app/test/components/support/user/safe/AccountVerificationPanel.test.ts +++ b/projects/app/test/components/support/user/safe/AccountVerificationPanel.test.ts @@ -1,6 +1,7 @@ import React, { act } from 'react'; import type { Root } from 'react-dom/client'; import { JSDOM } from 'jsdom'; +import { UserErrEnum } from '@fastgpt/global/common/error/code/user'; import { hashStr } from '@fastgpt/global/common/string/tools'; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -11,7 +12,8 @@ type AccountVerificationPanelComponent = const mocks = vi.hoisted(() => ({ toast: vi.fn(), replace: vi.fn(), - setLoginStore: vi.fn() + setLoginStore: vi.fn(), + checkIsWecomTerminal: vi.fn() })); vi.mock('next/router', () => ({ @@ -19,19 +21,49 @@ vi.mock('next/router', () => ({ })); vi.mock('next-i18next', () => ({ - useTranslation: () => ({ t: (key: string) => key }) + useTranslation: () => ({ + t: (key: string) => { + if (!key.startsWith('common:')) throw new Error(`Unexpected i18n namespace: ${key}`); + return key; + } + }) })); vi.mock('@fastgpt/web/hooks/useToast', () => ({ useToast: () => ({ toast: mocks.toast }) })); +vi.mock('@fastgpt/global/support/user/login/constants', () => ({ + checkIsWecomTerminal: mocks.checkIsWecomTerminal +})); + vi.mock('@/web/common/system/useSystemStore', () => ({ useSystemStore: Object.assign(() => ({ feConfigs: {} }), { getState: () => ({ setLoginStore: mocks.setLoginStore }) }) })); +vi.mock('@/components/support/user/safe/SendCodeAuthModal', () => ({ + default: ({ + onSendCode + }: { + onSendCode: (data: { username: string; captcha: string }) => Promise; + }) => { + const ReactRuntime = (globalThis as any).React as typeof React; + return ReactRuntime.createElement( + 'button', + { + type: 'button', + 'data-testid': 'send-code-modal-submit', + onClick: () => { + void onSendCode({ username: '13800138000', captcha: 'captcha-code' }).catch(() => {}); + } + }, + 'send-code' + ); + } +})); + describe('AccountVerificationPanel', () => { let dom: JSDOM; let createRoot: typeof import('react-dom/client').createRoot; @@ -90,6 +122,7 @@ describe('AccountVerificationPanel', () => { beforeEach(() => { vi.clearAllMocks(); + mocks.checkIsWecomTerminal.mockReturnValue(false); container = document.createElement('div'); document.body.appendChild(container); root = createRoot(container); @@ -136,10 +169,10 @@ describe('AccountVerificationPanel', () => { await flushEffects(); const accountInput = container.querySelector( - 'input[aria-label="account_info:user_account"]' + 'input[aria-label="common:user.Account"]' ); const firstInput = container.querySelector( - 'input[placeholder="account_info:password_old_placeholder"]' + 'input[placeholder="common:password_old_placeholder"]' ); const firstButton = container.querySelector('button'); if (!firstInput || !firstButton) throw new Error('Old password controls did not render'); @@ -161,7 +194,7 @@ describe('AccountVerificationPanel', () => { expect(createVerification).toHaveBeenCalledTimes(2); const retryInput = container.querySelector( - 'input[placeholder="account_info:password_old_placeholder"]' + 'input[placeholder="common:password_old_placeholder"]' ); const retryButton = container.querySelector('button'); if (!retryInput || !retryButton) throw new Error('Retry controls did not render'); @@ -181,7 +214,7 @@ describe('AccountVerificationPanel', () => { expect(onAuthorized).toHaveBeenCalledWith(authorization); }); - it('automatically consumes a six-digit verification code without a submit button', async () => { + it('does not auto-submit a six-digit code and submits from the verify button', async () => { const createVerification = vi.fn(); const authorization = { status: 'authorized' as const, @@ -211,26 +244,186 @@ describe('AccountVerificationPanel', () => { await flushEffects(); const codeInput = container.querySelector( - 'input[aria-label="user:password.verification_code"]' + 'input[aria-label="common:support.user.info.verification_code"]' + ); + const verifyButton = [...container.querySelectorAll('button')].find( + (button) => button.textContent === 'common:password_verify' ); - if (!codeInput) throw new Error('Verification code input did not render'); + if (!codeInput || !verifyButton) throw new Error('Verification code controls did not render'); + + expect(verifyButton.disabled).toBe(true); changeInput(codeInput, '12345'); await flushEffects(); expect(consumeVerification).not.toHaveBeenCalled(); + expect(verifyButton.disabled).toBe(true); changeInput(codeInput, '123456'); await flushEffects(); + expect(consumeVerification).not.toHaveBeenCalled(); + expect(verifyButton.disabled).toBe(false); + + act(() => verifyButton.click()); + await flushEffects(); expect(consumeVerification).toHaveBeenCalledWith({ method: 'code', payload: { code: '123456' } }); expect(onAuthorized).toHaveBeenCalledWith(authorization); - expect( - [...container.querySelectorAll('button')].some( - (button) => button.textContent === 'account_info:password_verify' - ) - ).toBe(false); + }); + + it('keeps an invalid verification code and shows the code error', async () => { + const consumeVerification = vi.fn().mockRejectedValue({ + statusText: UserErrEnum.invalidVerificationCode, + message: 'common:error.code_error' + }); + + await act(async () => { + root.render( + React.createElement( + ChakraProvider, + undefined, + React.createElement(AccountVerificationPanel, { + method: 'code', + username: '13800138000', + required: false, + returnRoute: '/account/info', + createVerification: vi.fn(), + consumeVerification, + onAuthorized: vi.fn() + }) + ) + ); + }); + await flushEffects(); + + const codeInput = container.querySelector( + 'input[aria-label="common:support.user.info.verification_code"]' + ); + const verifyButton = [...container.querySelectorAll('button')].find( + (button) => button.textContent === 'common:password_verify' + ); + if (!codeInput || !verifyButton) throw new Error('Verification code controls did not render'); + + changeInput(codeInput, '123456'); + act(() => verifyButton.click()); + await flushEffects(); + + expect(consumeVerification).toHaveBeenCalledWith({ + method: 'code', + payload: { code: '123456' } + }); + expect(codeInput.value).toBe('123456'); + expect(mocks.toast).toHaveBeenCalledWith({ + status: 'error', + title: 'common:error.code_error' + }); + expect(mocks.toast).not.toHaveBeenCalledWith({ + status: 'error', + title: 'common:password_verification_failed' + }); + }); + + it('shows the rate-limit message when sending a verification code too frequently', async () => { + const createVerification = vi.fn().mockRejectedValue({ + code: 503006, + statusText: UserErrEnum.sendVerificationCodeTooFrequently, + message: 'common:error.send_auth_code_too_frequently' + }); + + await act(async () => { + root.render( + React.createElement( + ChakraProvider, + undefined, + React.createElement(AccountVerificationPanel, { + method: 'code', + username: '13800138000', + required: false, + returnRoute: '/account/info', + createVerification, + consumeVerification: vi.fn(), + onAuthorized: vi.fn() + }) + ) + ); + }); + await flushEffects(); + + const openCaptchaButton = [...container.querySelectorAll('button')].find( + (button) => button.textContent === 'common:password_send_code' + ); + if (!openCaptchaButton) throw new Error('Send-code button did not render'); + + act(() => openCaptchaButton.click()); + await flushEffects(); + + const sendCodeButton = container.querySelector( + '[data-testid="send-code-modal-submit"]' + ); + if (!sendCodeButton) throw new Error('Send-code modal did not render'); + + act(() => sendCodeButton.click()); + await flushEffects(); + + expect(createVerification).toHaveBeenCalledWith({ + method: 'code', + payload: { captcha: 'captcha-code', googleToken: '' } + }); + expect(mocks.toast).toHaveBeenCalledWith({ + status: 'error', + title: 'common:error.operation_too_frequently' + }); + expect(mocks.toast).not.toHaveBeenCalledWith({ + status: 'error', + title: 'common:password_verification_failed' + }); + }); + + it('passes the WeCom terminal flag when creating OAuth verification', async () => { + mocks.checkIsWecomTerminal.mockReturnValue(true); + const createVerification = vi.fn().mockResolvedValue({ + method: 'oauth/sso', + state: 'oauth-state-value', + url: 'https://sso.example.com/authorize' + }); + + await act(async () => { + root.render( + React.createElement( + ChakraProvider, + undefined, + React.createElement(AccountVerificationPanel, { + method: 'oauth/sso', + username: 'wecom-user', + required: true, + returnRoute: '/account/info', + createVerification, + consumeVerification: vi.fn(), + onAuthorized: vi.fn() + }) + ) + ); + }); + await flushEffects(); + + const oauthButton = [...container.querySelectorAll('button')].find( + (button) => button.textContent === 'common:password_oauth_start' + ); + if (!oauthButton) throw new Error('OAuth verification button did not render'); + + act(() => oauthButton.click()); + await flushEffects(); + + expect(mocks.checkIsWecomTerminal).toHaveBeenCalledOnce(); + expect(createVerification).toHaveBeenCalledWith({ + method: 'oauth/sso', + payload: { + callbackUrl: 'https://fastgpt.example.com/login/provider', + isWecomWorkTerminal: true + } + }); + expect(mocks.replace).toHaveBeenCalledWith('https://sso.example.com/authorize'); }); }); diff --git a/projects/app/test/components/support/user/safe/PasswordChangeModal.test.ts b/projects/app/test/components/support/user/safe/PasswordChangeModal.test.ts index 3ef5d8f1b844..d22a80aa52de 100644 --- a/projects/app/test/components/support/user/safe/PasswordChangeModal.test.ts +++ b/projects/app/test/components/support/user/safe/PasswordChangeModal.test.ts @@ -24,7 +24,12 @@ vi.mock('next/router', () => ({ })); vi.mock('next-i18next', () => ({ - useTranslation: () => ({ t: (key: string) => key }) + useTranslation: () => ({ + t: (key: string) => { + if (!key.startsWith('common:')) throw new Error(`Unexpected i18n namespace: ${key}`); + return key; + } + }) })); vi.mock('@fastgpt/web/hooks/useToast', () => ({ @@ -126,12 +131,29 @@ describe('PasswordChangeModal', () => { const getConfirmButton = () => { const button = [...container.querySelectorAll('button')].find( - (item) => item.textContent === 'account_info:password_confirm_action' + (item) => item.textContent === 'common:password_confirm_action' ); if (!button) throw new Error('Confirm button did not render'); return button; }; + const completeOldPasswordVerification = async () => { + await flushEffects(); + const oldPasswordInput = container.querySelector( + 'input[placeholder="common:password_old_placeholder"]' + ); + if (!oldPasswordInput) throw new Error('Old password input did not render'); + + changeInput(oldPasswordInput, 'Existing-password-123'); + const verifyButton = [...container.querySelectorAll('button')].find( + (item) => item.textContent === 'common:password_verify' + ); + if (!verifyButton) throw new Error('Password verification button did not render'); + + act(() => verifyButton.click()); + await flushEffects(); + }; + beforeAll(async () => { dom = new JSDOM('', { url: 'https://fastgpt.example.com/account/info' @@ -167,7 +189,17 @@ describe('PasswordChangeModal', () => { beforeEach(() => { vi.clearAllMocks(); - mocks.authorizePasswordChange.mockResolvedValue(authorized); + mocks.authorizePasswordChange.mockImplementation(({ source }: { source: string }) => + Promise.resolve( + source === 'verificationMethod' + ? { status: 'verificationRequired', method: 'oldPassword' } + : authorized + ) + ); + mocks.createPasswordVerification.mockResolvedValue({ + method: 'oldPassword', + preLoginCode: 'pre-login-code' + }); mocks.updatePassword.mockResolvedValue(undefined); mocks.initUserInfo.mockResolvedValue(undefined); usePasswordChangeStore.getState().setAuthorization(undefined); @@ -194,28 +226,39 @@ describe('PasswordChangeModal', () => { await flushEffects(); }; - it('enters the password form from recent login and shows validation errors', async () => { + it('requires verification before showing the password form and disables overlay close', async () => { const onSuccess = vi.fn(); await renderModal({ onSuccess, onClose: vi.fn() }); - const newPasswordInput = getInput('account_info:password_new_placeholder'); - const confirmPasswordInput = getInput('account_info:password_confirm_placeholder'); + expect( + container.querySelector('input[aria-label="common:password_new_placeholder"]') + ).toBeNull(); + expect(mocks.authorizePasswordChange).toHaveBeenCalledWith({ source: 'verificationMethod' }); + expect(container.querySelector('[data-testid="password-modal"]')).toMatchObject({ + dataset: expect.objectContaining({ closable: 'true', overlayClose: 'true' }) + }); + + await completeOldPasswordVerification(); + + const newPasswordInput = getInput('common:password_new_placeholder'); + const confirmPasswordInput = getInput('common:password_confirm_placeholder'); expect(document.activeElement).not.toBe(newPasswordInput); expect(document.activeElement).not.toBe(confirmPasswordInput); expect(container.querySelector('[data-testid="password-modal"]')).toMatchObject({ - dataset: expect.objectContaining({ closable: 'true', overlayClose: 'true' }) + dataset: expect.objectContaining({ closable: 'true', overlayClose: 'false' }) }); - expect(container.textContent).toContain('account_info:password_tip'); + expect(container.textContent).toContain('common:password_tip'); expect(container.textContent).not.toContain('common:Cancel'); - expect(mocks.authorizePasswordChange).toHaveBeenCalledTimes(1); + expect(mocks.authorizePasswordChange).toHaveBeenCalledTimes(2); changeInput(newPasswordInput, 'short'); changeInput(confirmPasswordInput, 'different'); act(() => getConfirmButton().click()); await flushEffects(); - expect(container.textContent).toContain('login:password_tip'); - expect(container.textContent).toContain('user:password.not_match'); + expect(container.textContent?.match(/common:password_tip/g)).toHaveLength(1); + expect(newPasswordInput.getAttribute('aria-invalid')).toBe('true'); + expect(container.textContent).toContain('common:password_not_match'); expect(mocks.updatePassword).not.toHaveBeenCalled(); changeInput(newPasswordInput, 'Strong-password-123'); @@ -241,31 +284,61 @@ describe('PasswordChangeModal', () => { }); it('clears the password form and returns to verification when the JWT is invalid', async () => { - mocks.authorizePasswordChange - .mockResolvedValueOnce(authorized) - .mockResolvedValueOnce({ status: 'verificationRequired', method: 'oldPassword' }); - mocks.createPasswordVerification.mockResolvedValue({ - method: 'oldPassword', - preLoginCode: 'new-pre-login-code' - }); mocks.updatePassword.mockRejectedValue({ statusText: UserErrEnum.passwordChangeAuthorizationInvalid }); await renderModal({ onClose: vi.fn() }); + await completeOldPasswordVerification(); - changeInput(getInput('account_info:password_new_placeholder'), 'Strong-password-123'); - changeInput(getInput('account_info:password_confirm_placeholder'), 'Strong-password-123'); + changeInput(getInput('common:password_new_placeholder'), 'Strong-password-123'); + changeInput(getInput('common:password_confirm_placeholder'), 'Strong-password-123'); act(() => getConfirmButton().click()); await flushEffects(); await flushEffects(); - expect(mocks.authorizePasswordChange).toHaveBeenCalledTimes(2); + expect(mocks.authorizePasswordChange).toHaveBeenCalledTimes(3); expect( - container.querySelector('input[aria-label="account_info:password_new_placeholder"]') + container.querySelector('input[aria-label="common:password_new_placeholder"]') ).toBeNull(); expect( - container.querySelector('input[placeholder="account_info:password_old_placeholder"]') + container.querySelector('input[placeholder="common:password_old_placeholder"]') ).not.toBeNull(); expect(usePasswordChangeStore.getState().authorization).toBeUndefined(); }); + + it.each([ + ['voluntary password change', false], + ['expired required password change', true] + ])('shows the same-password business error for %s', async (_caseName, isExpired) => { + mocks.updatePassword.mockRejectedValue({ + statusText: UserErrEnum.newPasswordSameAsOld, + message: 'common:user.Password has no change' + }); + await renderModal( + isExpired ? { required: true, showExpiredPrompt: true } : { onClose: vi.fn() } + ); + + if (isExpired) { + const continueButton = [...container.querySelectorAll('button')].find( + (item) => item.textContent === 'common:password_expired_action' + ); + if (!continueButton) throw new Error('Expired password action did not render'); + + act(() => continueButton.click()); + await flushEffects(); + await flushEffects(); + } + + await completeOldPasswordVerification(); + + changeInput(getInput('common:password_new_placeholder'), 'Strong-password-123'); + changeInput(getInput('common:password_confirm_placeholder'), 'Strong-password-123'); + act(() => getConfirmButton().click()); + await flushEffects(); + + expect(mocks.toast).toHaveBeenCalledWith({ + status: 'error', + title: 'common:user.Password has no change' + }); + }); }); diff --git a/projects/app/test/pageComponents/account/cancel/VerificationPanel.test.ts b/projects/app/test/pageComponents/account/cancel/VerificationPanel.test.ts new file mode 100644 index 000000000000..3165922f2ca8 --- /dev/null +++ b/projects/app/test/pageComponents/account/cancel/VerificationPanel.test.ts @@ -0,0 +1,168 @@ +import React, { act } from 'react'; +import type { Root } from 'react-dom/client'; +import { JSDOM } from 'jsdom'; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +type ChakraProviderComponent = (typeof import('@chakra-ui/react'))['ChakraProvider']; +type VerificationPanelComponent = + (typeof import('@/pageComponents/account/cancel/VerificationPanel'))['VerificationPanel']; + +const mocks = vi.hoisted(() => ({ + toast: vi.fn(), + replace: vi.fn(), + setLoginStore: vi.fn(), + checkIsWecomTerminal: vi.fn(), + createAccountCancellationVerification: vi.fn(), + submitAccountCancellation: vi.fn() +})); + +vi.mock('next/router', () => ({ + useRouter: () => ({ replace: mocks.replace }) +})); + +vi.mock('next-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key }) +})); + +vi.mock('@fastgpt/web/hooks/useToast', () => ({ + useToast: () => ({ toast: mocks.toast }) +})); + +vi.mock('@fastgpt/global/support/user/login/constants', () => ({ + checkIsWecomTerminal: mocks.checkIsWecomTerminal +})); + +vi.mock('@/web/support/user/account/cancellation/api', () => ({ + createAccountCancellationVerification: mocks.createAccountCancellationVerification, + submitAccountCancellation: mocks.submitAccountCancellation +})); + +vi.mock('@/web/common/system/useSystemStore', () => ({ + useSystemStore: Object.assign( + () => ({ + feConfigs: { + sso: { title: 'Corporate SSO' }, + accountVerification: { + accountCancellation: { + emailCode: false, + phoneCode: false, + accountCancellation: true, + wechat: false, + oauth: { + github: false, + google: false, + microsoft: false, + wecom: false, + sso: true + } + } + } + } + }), + { + getState: () => ({ setLoginStore: mocks.setLoginStore }) + } + ) +})); + +vi.mock('@/web/support/user/useUserStore', () => ({ + useUserStore: () => ({ userInfo: { username: 'wecom-user' } }) +})); + +vi.mock('@/components/support/user/safe/SendCodeAuthModal', () => ({ + default: () => null +})); + +describe('Account cancellation VerificationPanel', () => { + let dom: JSDOM; + let createRoot: typeof import('react-dom/client').createRoot; + let ChakraProvider: ChakraProviderComponent; + let VerificationPanel: VerificationPanelComponent; + let container: HTMLDivElement; + let root: Root; + + const flushEffects = async () => { + await act(async () => { + await Promise.resolve(); + await new Promise((resolve) => window.setTimeout(resolve, 0)); + }); + }; + + beforeAll(async () => { + dom = new JSDOM('', { + url: 'https://fastgpt.example.com/account/cancel' + }); + vi.stubGlobal('window', dom.window); + vi.stubGlobal('document', dom.window.document); + vi.stubGlobal('navigator', dom.window.navigator); + vi.stubGlobal('HTMLElement', dom.window.HTMLElement); + vi.stubGlobal('HTMLInputElement', dom.window.HTMLInputElement); + vi.stubGlobal('Event', dom.window.Event); + vi.stubGlobal('MouseEvent', dom.window.MouseEvent); + vi.stubGlobal('getComputedStyle', dom.window.getComputedStyle.bind(dom.window)); + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => + dom.window.setTimeout(() => callback(Date.now()), 0) + ); + vi.stubGlobal('cancelAnimationFrame', (handle: number) => dom.window.clearTimeout(handle)); + vi.stubGlobal('React', React); + globalThis.IS_REACT_ACT_ENVIRONMENT = true; + + ({ createRoot } = await import('react-dom/client')); + ({ ChakraProvider } = await import('@chakra-ui/react')); + ({ VerificationPanel } = await import('@/pageComponents/account/cancel/VerificationPanel')); + }); + + afterAll(() => { + dom.window.close(); + vi.unstubAllGlobals(); + }); + + beforeEach(() => { + vi.clearAllMocks(); + mocks.checkIsWecomTerminal.mockReturnValue(true); + mocks.createAccountCancellationVerification.mockResolvedValue({ + method: 'oauth/sso', + state: 'oauth-state-value', + url: 'https://sso.example.com/authorize' + }); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + it('passes the WeCom terminal flag when creating OAuth verification', async () => { + await act(async () => { + root.render( + React.createElement( + ChakraProvider, + undefined, + React.createElement(VerificationPanel, { onSubmitted: vi.fn() }) + ) + ); + }); + await flushEffects(); + + const oauthButton = [...container.querySelectorAll('button')].find( + (button) => button.textContent === 'account_info:account_cancellation_oauth_start' + ); + if (!oauthButton) throw new Error('OAuth verification button did not render'); + + act(() => oauthButton.click()); + await flushEffects(); + + expect(mocks.checkIsWecomTerminal).toHaveBeenCalledOnce(); + expect(mocks.createAccountCancellationVerification).toHaveBeenCalledWith({ + method: 'oauth/sso', + payload: { + callbackUrl: 'https://fastgpt.example.com/login/provider', + isWecomWorkTerminal: true + } + }); + expect(mocks.replace).toHaveBeenCalledWith('https://sso.example.com/authorize'); + }); +}); diff --git a/projects/app/test/pageComponents/account/info/password.test.ts b/projects/app/test/pageComponents/account/info/password.test.ts new file mode 100644 index 000000000000..36da445e5d22 --- /dev/null +++ b/projects/app/test/pageComponents/account/info/password.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest'; +import { canManagePasswordFromAccountInfo } from '@/pageComponents/account/info/password'; + +describe('canManagePasswordFromAccountInfo', () => { + it('does not expose password management for root', () => { + expect(canManagePasswordFromAccountInfo({ isPlus: true, username: 'root' })).toBe(false); + }); + + it('does not expose password management for WeCom users', () => { + expect(canManagePasswordFromAccountInfo({ isPlus: true, username: 'wecom-user' })).toBe(false); + }); + + it('exposes password management only for loaded Plus users', () => { + expect(canManagePasswordFromAccountInfo({ isPlus: true, username: 'member' })).toBe(true); + expect(canManagePasswordFromAccountInfo({ isPlus: false, username: 'member' })).toBe(false); + expect(canManagePasswordFromAccountInfo({ isPlus: true })).toBe(false); + }); +}); diff --git a/projects/app/test/service/mongo.test.ts b/projects/app/test/service/mongo.test.ts index 3720ae721c2c..ef7b4d21bd45 100644 --- a/projects/app/test/service/mongo.test.ts +++ b/projects/app/test/service/mongo.test.ts @@ -57,7 +57,7 @@ describe('initRootUser', () => { }); }); - it('updates password and update time when the configured password changes', async () => { + it('updates password when the configured password changes', async () => { const updateOne = vi.fn().mockResolvedValue(undefined); mocks.selectPassword.mockResolvedValue({ _id: 'root-id', @@ -69,14 +69,13 @@ describe('initRootUser', () => { expect(updateOne).toHaveBeenCalledWith( { - password: hashStr('configured-root-password'), - passwordUpdateTime: expect.any(Date) + password: hashStr('configured-root-password') }, { session: 'mongo-session' } ); }); - it('writes password update time when creating root for the first time', async () => { + it('creates root with the configured password', async () => { mocks.selectPassword.mockResolvedValue(null); mocks.createUser.mockResolvedValue([{ _id: 'new-root-id' }]); @@ -86,8 +85,7 @@ describe('initRootUser', () => { [ { username: 'root', - password: hashStr('configured-root-password'), - passwordUpdateTime: expect.any(Date) + password: hashStr('configured-root-password') } ], { session: 'mongo-session', ordered: true } diff --git a/projects/app/test/web/support/user/account/password/api.test.ts b/projects/app/test/web/support/user/account/password/api.test.ts index 453dc2d74899..72e73c29dcd6 100644 --- a/projects/app/test/web/support/user/account/password/api.test.ts +++ b/projects/app/test/web/support/user/account/password/api.test.ts @@ -27,8 +27,8 @@ describe('password account API', () => { ); }); - it('requests authorization from recent login', async () => { - const body = { source: 'recentLogin' } as const; + it('starts the password-change verification flow', async () => { + const body = { source: 'verificationMethod' } as const; await authorizePasswordChange(body); diff --git a/projects/app/test/pageComponents/account/cancel/utils.test.ts b/projects/app/test/web/support/user/account/verification/error.test.ts similarity index 65% rename from projects/app/test/pageComponents/account/cancel/utils.test.ts rename to projects/app/test/web/support/user/account/verification/error.test.ts index ab0406133b4f..4bd9d65c0839 100644 --- a/projects/app/test/pageComponents/account/cancel/utils.test.ts +++ b/projects/app/test/web/support/user/account/verification/error.test.ts @@ -1,61 +1,61 @@ import { describe, expect, it } from 'vitest'; import { UserErrEnum } from '@fastgpt/global/common/error/code/user'; import { - isAccountCancellationCodeError, - isAccountCancellationRateLimitError -} from '@/pageComponents/account/cancel/utils'; + isAccountVerificationCodeError, + isAccountVerificationRateLimitError +} from '@/web/support/user/account/verification/error'; -describe('isAccountCancellationRateLimitError', () => { - it.each([ - 'common:error.send_auth_code_too_frequently', - 'common:error.verify_code_too_frequently' - ])('recognizes %s', (message) => { - expect(isAccountCancellationRateLimitError(new Error(message))).toBe(true); +describe('isAccountVerificationCodeError', () => { + it('recognizes a verification code error', () => { + expect(isAccountVerificationCodeError(new Error('common:error.code_error'))).toBe(true); }); it('recognizes an error returned by the request client', () => { expect( - isAccountCancellationRateLimitError({ - response: { data: { message: 'common:error.send_auth_code_too_frequently' } } + isAccountVerificationCodeError({ + response: { data: { message: 'common:error.code_error' } } }) ).toBe(true); }); - it.each([UserErrEnum.sendVerificationCodeTooFrequently, UserErrEnum.verifyCodeTooFrequently])( - 'recognizes stable statusText %s', - (statusText) => { - expect(isAccountCancellationRateLimitError({ statusText })).toBe(true); - } - ); + it('recognizes the stable invalid verification code statusText', () => { + expect( + isAccountVerificationCodeError({ + statusText: UserErrEnum.invalidVerificationCode, + message: 'localized message may change' + }) + ).toBe(true); + }); - it('does not classify unrelated verification failures as rate limits', () => { - expect(isAccountCancellationRateLimitError(new Error('common:error.code_error'))).toBe(false); + it('does not classify other send failures as verification code errors', () => { + expect(isAccountVerificationCodeError(new Error('common:error.send_failed'))).toBe(false); }); }); -describe('isAccountCancellationCodeError', () => { - it('recognizes a verification code error', () => { - expect(isAccountCancellationCodeError(new Error('common:error.code_error'))).toBe(true); +describe('isAccountVerificationRateLimitError', () => { + it.each([ + 'common:error.send_auth_code_too_frequently', + 'common:error.verify_code_too_frequently' + ])('recognizes legacy message %s', (message) => { + expect(isAccountVerificationRateLimitError(new Error(message))).toBe(true); }); it('recognizes an error returned by the request client', () => { expect( - isAccountCancellationCodeError({ - response: { data: { message: 'common:error.code_error' } } + isAccountVerificationRateLimitError({ + response: { data: { message: 'common:error.send_auth_code_too_frequently' } } }) ).toBe(true); }); - it('recognizes the stable invalid verification code statusText', () => { - expect( - isAccountCancellationCodeError({ - statusText: UserErrEnum.invalidVerificationCode, - message: 'localized message may change' - }) - ).toBe(true); - }); + it.each([UserErrEnum.sendVerificationCodeTooFrequently, UserErrEnum.verifyCodeTooFrequently])( + 'recognizes stable statusText %s', + (statusText) => { + expect(isAccountVerificationRateLimitError({ statusText })).toBe(true); + } + ); - it('does not classify other send failures as verification code errors', () => { - expect(isAccountCancellationCodeError(new Error('common:error.send_failed'))).toBe(false); + it('does not classify unrelated verification failures as rate limits', () => { + expect(isAccountVerificationRateLimitError(new Error('common:error.code_error'))).toBe(false); }); }); From 2337dd2944e370d4f71a81b778d915c4f2b24593 Mon Sep 17 00:00:00 2001 From: shortlight5980 Date: Sat, 25 Jul 2026 14:30:07 +0800 Subject: [PATCH 10/10] feat(auth): Add SSO password availability handling - Add explicit SSO password and verification channel errors - Extend system auth config with SSO password disablement - Tighten admin settings OpenAPI schemas and update response typing - Expose SSO user state and contact fields in admin user schemas --- packages/global/common/error/code/user.ts | 14 +- packages/global/common/system/types/index.ts | 1 + .../openapi/admin/routes/settings/api.ts | 133 ++++++++- .../openapi/admin/routes/settings/index.ts | 4 +- .../global/openapi/admin/routes/users/api.ts | 11 +- .../openapi/support/user/account/login/api.ts | 8 - .../support/user/account/login/index.ts | 25 -- .../user/account/verification/utils.ts | 63 +++-- packages/global/support/user/type.ts | 3 +- .../openapi/admin/routes/settings/api.test.ts | 47 ++++ .../user/account/verification/utils.test.ts | 31 ++- .../support/user/account/password/service.ts | 23 ++ .../account/verification/password/service.ts | 10 +- packages/service/support/user/controller.ts | 4 +- .../user/account/password/service.test.ts | 62 ++++- .../verification/password/service.test.ts | 53 ++++ packages/web/i18n/en/common.json | 3 + packages/web/i18n/zh-CN/common.json | 3 + packages/web/i18n/zh-Hant/common.json | 3 + pro | 2 +- projects/app/src/components/Layout/auth.tsx | 3 +- projects/app/src/components/Layout/index.tsx | 2 - .../user/safe/ResetExpiredPswModal.tsx | 12 +- .../pageComponents/account/info/password.ts | 20 +- .../login/LoginForm/FormLayout.tsx | 257 +++--------------- .../login/LoginForm/LoginBrand.tsx | 38 +++ .../login/LoginForm/LoginGuideLink.tsx | 31 +++ .../login/LoginForm/PolicyTip.tsx | 6 +- .../login/LoginForm/useLoginMethods.ts | 92 +++++++ .../login/LoginMethodSelection.tsx | 60 ++++ .../src/pageComponents/login/LoginModal.tsx | 5 +- .../login/components/LoginFormPanel.tsx | 14 +- .../app/src/pageComponents/login/index.tsx | 88 +++--- projects/app/src/pages/account/info/index.tsx | 3 +- .../support/user/account/checkPswExpired.ts | 5 +- .../support/user/account/password/update.ts | 2 + projects/app/src/pages/login/fastlogin.tsx | 107 -------- projects/app/src/web/support/user/api.ts | 3 - .../src/web/support/user/login/constants.ts | 1 + .../app/src/web/support/user/login/utils.ts | 165 +++++++++++ .../user/account/loginByPassword.test.ts | 34 +++ .../user/account/password/update.test.ts | 39 +++ .../account/info/password.test.ts | 32 ++- .../app/test/web/support/user/api.test.ts | 7 - .../test/web/support/user/login/utils.test.ts | 169 ++++++++++++ 45 files changed, 1236 insertions(+), 462 deletions(-) create mode 100644 packages/global/test/openapi/admin/routes/settings/api.test.ts create mode 100644 projects/app/src/pageComponents/login/LoginForm/LoginBrand.tsx create mode 100644 projects/app/src/pageComponents/login/LoginForm/LoginGuideLink.tsx create mode 100644 projects/app/src/pageComponents/login/LoginForm/useLoginMethods.ts create mode 100644 projects/app/src/pageComponents/login/LoginMethodSelection.tsx delete mode 100644 projects/app/src/pages/login/fastlogin.tsx create mode 100644 projects/app/src/web/support/user/login/utils.ts create mode 100644 projects/app/test/web/support/user/login/utils.test.ts diff --git a/packages/global/common/error/code/user.ts b/packages/global/common/error/code/user.ts index 4047e69f3080..cfe03504c701 100644 --- a/packages/global/common/error/code/user.ts +++ b/packages/global/common/error/code/user.ts @@ -12,7 +12,9 @@ export enum UserErrEnum { sendVerificationCodeTooFrequently = 'sendVerificationCodeTooFrequently', verifyCodeTooFrequently = 'verifyCodeTooFrequently', passwordChangeAuthorizationInvalid = 'passwordChangeAuthorizationInvalid', - newPasswordSameAsOld = 'newPasswordSameAsOld' + newPasswordSameAsOld = 'newPasswordSameAsOld', + ssoPasswordUnavailable = 'ssoPasswordUnavailable', + verificationChannelUnavailable = 'verificationChannelUnavailable' } const errList = [ { @@ -59,6 +61,16 @@ const errList = [ statusText: UserErrEnum.newPasswordSameAsOld, message: i18nT('common:user.Password has no change'), httpStatus: 400 + }, + { + statusText: UserErrEnum.ssoPasswordUnavailable, + message: i18nT('common:error.sso_password_unavailable'), + httpStatus: 403 + }, + { + statusText: UserErrEnum.verificationChannelUnavailable, + message: i18nT('common:error.verification_channel_unavailable'), + httpStatus: 403 } ]; export default errList.reduce((acc, cur, index) => { diff --git a/packages/global/common/system/types/index.ts b/packages/global/common/system/types/index.ts index 93ebfcbdadf5..accc4dd9ca1d 100644 --- a/packages/global/common/system/types/index.ts +++ b/packages/global/common/system/types/index.ts @@ -117,6 +117,7 @@ export type FastGPTFeConfigsType = { title?: string; url?: string; autoLogin?: boolean; + disablePasswordForSsoUsers?: boolean; }; oauth?: { github?: string; diff --git a/packages/global/openapi/admin/routes/settings/api.ts b/packages/global/openapi/admin/routes/settings/api.ts index d8726537ad86..6ddee4c0bdf2 100644 --- a/packages/global/openapi/admin/routes/settings/api.ts +++ b/packages/global/openapi/admin/routes/settings/api.ts @@ -1,5 +1,124 @@ import z from 'zod'; +const AuthProviderSchema = z + .object({ + enabled: z.boolean().optional(), + clientId: z.string(), + secret: z.string() + }) + .passthrough(); + +const UpdateAuthConfigSchema = z + .object({ + googleServiceVerKey: z.string().optional(), + email: z + .object({ + enabled: z.boolean().optional(), + register: z.boolean(), + notification: z.boolean().optional(), + smtp: z.string(), + user: z.string(), + pass: z.string(), + port: z.number().int().positive().max(65535).optional(), + secure: z.boolean().optional() + }) + .passthrough() + .optional(), + phone: z + .object({ + enabled: z.boolean().optional(), + register: z.boolean().optional(), + notification: z.boolean().optional(), + SNED_PHONE_ACCESSKEYID: z.string(), + SNED_PHONE_ACCESSSECRET: z.string(), + SNED_PHONE_SIGNNAME: z.string() + }) + .passthrough() + .optional(), + sms: z.record(z.string(), z.string()).optional(), + thirdPartyLogin: z + .object({ + enabled: z.boolean().optional() + }) + .passthrough() + .optional(), + wechat: z + .object({ + enabled: z.boolean().optional(), + appID: z.string(), + appSecret: z.string() + }) + .passthrough() + .optional(), + github: AuthProviderSchema.optional(), + google: AuthProviderSchema.optional(), + microsoft: AuthProviderSchema.extend({ + tenantId: z.string(), + customButton: z.string().optional() + }).optional(), + dingtalk: z + .object({ + clientId: z.string(), + secret: z.string() + }) + .passthrough() + .optional(), + wecom: z + .object({ + suiteId: z.string(), + secret: z.string(), + token: z.string(), + encodingAESKey: z.string(), + cropId: z.string(), + providerSecret: z.string(), + buyerUserId: z.string(), + basicVersionId: z.string(), + advancedVersionId: z.string(), + paySecret: z.string() + }) + .passthrough() + .optional() + }) + .passthrough() + .optional(); + +const UpdateFastGPTConfigSchema = z + .object({ + feConfigs: z + .object({ + sso: z + .object({ + icon: z.string().optional(), + title: z.string().optional(), + url: z.string().optional(), + autoLogin: z.boolean().optional(), + disablePasswordForSsoUsers: z.boolean().optional() + }) + .passthrough() + .optional() + }) + .passthrough(), + systemEnv: z.object({}).passthrough() + }) + .passthrough(); + +const UpdateFastGPTProConfigSchema = z + .object({ + auth: UpdateAuthConfigSchema, + teamMode: z.enum(['multi', 'single', 'sync']).optional(), + accountCancellation: z + .object({ + enabled: z.boolean().optional() + }) + .passthrough() + .optional(), + censor: z.object({}).passthrough().optional(), + pay: z.object({}).passthrough().optional(), + fileUrlWhitelist: z.array(z.string()).optional(), + license: z.never().optional() + }) + .passthrough(); + export const GetConfigResponseSchema = z.object({ fastgpt: z.any().optional().meta({ description: '系统 FastGPT 配置' }), fastgptPro: z @@ -8,7 +127,13 @@ export const GetConfigResponseSchema = z.object({ .meta({ description: '系统 FastGPT Pro 商业版配置(不含 license)' }) }); -export const UpdateConfigBodySchema = z.object({ - fastgpt: z.any().optional().meta({ description: 'FastGPT 系统配置对象' }), - fastgptPro: z.any().optional().meta({ description: 'FastGPT Pro 商业版配置对象' }) -}); +export const UpdateConfigBodySchema = z + .object({ + fastgpt: UpdateFastGPTConfigSchema.meta({ description: 'FastGPT 系统配置对象' }), + fastgptPro: UpdateFastGPTProConfigSchema.meta({ + description: 'FastGPT Pro 商业版配置对象(不允许提交 license)' + }) + }) + .strict(); + +export const UpdateConfigResponseSchema = z.undefined().meta({ description: '更新成功' }); diff --git a/packages/global/openapi/admin/routes/settings/index.ts b/packages/global/openapi/admin/routes/settings/index.ts index fdd29887e20a..7657a13dcc68 100644 --- a/packages/global/openapi/admin/routes/settings/index.ts +++ b/packages/global/openapi/admin/routes/settings/index.ts @@ -1,6 +1,6 @@ import type { OpenAPIPath } from '../../../type'; import { DevApiTagsMap } from '../../../tag'; -import { GetConfigResponseSchema, UpdateConfigBodySchema } from './api'; +import { GetConfigResponseSchema, UpdateConfigBodySchema, UpdateConfigResponseSchema } from './api'; export const AdminSettingsPath: OpenAPIPath = { '/admin/routes/settings/getConfig': { @@ -37,7 +37,7 @@ export const AdminSettingsPath: OpenAPIPath = { description: '更新成功', content: { 'application/json': { - schema: {} + schema: UpdateConfigResponseSchema } } } diff --git a/packages/global/openapi/admin/routes/users/api.ts b/packages/global/openapi/admin/routes/users/api.ts index 0ca2028a99dc..b170a5525758 100644 --- a/packages/global/openapi/admin/routes/users/api.ts +++ b/packages/global/openapi/admin/routes/users/api.ts @@ -5,22 +5,25 @@ import { UserStatusEnum } from '../../../../support/user/constant'; export const UserItemSchema = z.object({ _id: z.string().meta({ description: '用户ID' }), username: z.string().meta({ description: '用户名' }), - avatar: z.string().optional().meta({ description: '用户头像' }), + contact: z.string().optional().meta({ description: '用户联系方式' }), status: z.enum(UserStatusEnum).meta({ description: '用户状态' }), - createTime: z.date().meta({ description: '创建时间' }) + createTime: z.number().meta({ description: '创建时间戳' }), + isSsoUser: z.boolean().meta({ description: '当前运行时是否将该账号识别为 SSO 用户' }) }); +export type UserItemType = z.infer; // getUsers export const GetUsersBodySchema = PaginationSchema.extend({ - username: z.string().meta({ description: '搜索用户名(支持模糊匹配)' }) + username: z.string().optional().default('').meta({ description: '搜索用户名(支持模糊匹配)' }) }); export type GetUsersBodyType = z.infer; export const GetUsersResponseSchema = PaginationResponseSchema(UserItemSchema); +export type GetUsersResponseType = z.infer; // addUser export const AddUserBodySchema = z .object({ - username: z.string().min(1).meta({ description: '用户名' }), + username: z.string().trim().min(1).meta({ description: '用户名' }), password: z.string().min(1).meta({ description: '密码' }) }) .strict(); diff --git a/packages/global/openapi/support/user/account/login/api.ts b/packages/global/openapi/support/user/account/login/api.ts index c7501b3e82f6..a0ff60c3d51f 100644 --- a/packages/global/openapi/support/user/account/login/api.ts +++ b/packages/global/openapi/support/user/account/login/api.ts @@ -177,14 +177,6 @@ export const OauthLoginBodySchema = z.discriminatedUnion('provider', [ ]); export type OauthLoginBodyType = z.infer; -// ===== Fast Login ===== -export const FastLoginBodySchema = TrackRegisterParamsSchema.extend({ - token: z.string().meta({ description: 'Token' }), - code: z.string().meta({ description: 'Code' }), - language: LanguageSchema.optional().meta({ description: '语言' }) -}).strict(); -export type FastLoginBodyType = z.infer; - // ===== WeChat Login Result ===== export const WxLoginBodySchema = TrackRegisterParamsSchema.extend({ code: z.string().min(16).max(128).meta({ description: '微信登录 Code' }), diff --git a/packages/global/openapi/support/user/account/login/index.ts b/packages/global/openapi/support/user/account/login/index.ts index 532c3e13b5e7..eea78a449a49 100644 --- a/packages/global/openapi/support/user/account/login/index.ts +++ b/packages/global/openapi/support/user/account/login/index.ts @@ -8,7 +8,6 @@ import { OauthLoginBodySchema, CreateOauthLoginBodySchema, CreateOauthLoginResponseSchema, - FastLoginBodySchema, WxLoginBodySchema, GetWXLoginQRResponseSchema, LoginSuccessResponseSchema, @@ -133,30 +132,6 @@ export const LoginPath: OpenAPIPath = { } } }, - '/proApi/support/user/account/login/fastLogin': { - post: { - summary: '快捷登录', - description: '使用 Token 和 Code 进行快捷登录', - tags: [DevApiTagsMap.userLogin], - requestBody: { - content: { - 'application/json': { - schema: FastLoginBodySchema - } - } - }, - responses: { - 200: { - description: '登录成功', - content: { - 'application/json': { - schema: LoginSuccessResponseSchema - } - } - } - } - } - }, '/proApi/support/user/account/login/wx/getQR': { get: { summary: '获取微信登录二维码', diff --git a/packages/global/support/user/account/verification/utils.ts b/packages/global/support/user/account/verification/utils.ts index 455e3ca92944..5a1fecb84506 100644 --- a/packages/global/support/user/account/verification/utils.ts +++ b/packages/global/support/user/account/verification/utils.ts @@ -1,13 +1,44 @@ import { AccountEmailUsernameSchema, AccountPhoneUsernameSchema, + type AccountKind, type AccountVerificationCapabilities, type AccountVerificationMethod, type AccountVerificationPasswordPolicy, - type AccountVerificationResolution, - type RecognizedAccountKind + type AccountVerificationResolution } from './type'; +/** + * 根据持久化 username 和 SSO 配置状态识别账号类型。 + * 邮箱、手机号和已知第三方前缀优先于通用 SSO 连字符规则。 + */ +export const resolveAccountKindByUsername = ({ + username, + ssoConfigured +}: { + username: string; + ssoConfigured: boolean; +}): AccountKind => { + const normalizedUsername = username.trim(); + if (!normalizedUsername) return 'invalid'; + + const firstSeparatorIndex = normalizedUsername.indexOf('-'); + const prefix = + firstSeparatorIndex > 0 && firstSeparatorIndex < normalizedUsername.length - 1 + ? normalizedUsername.slice(0, firstSeparatorIndex) + : undefined; + + if (AccountEmailUsernameSchema.safeParse(normalizedUsername).success) return 'email'; + if (AccountPhoneUsernameSchema.safeParse(normalizedUsername).success) return 'phone'; + if (prefix === 'wechat') return 'wechat'; + if (prefix === 'git') return 'github'; + if (prefix === 'google') return 'google'; + if (prefix === 'microsoft') return 'microsoft'; + if (prefix === 'wecom') return 'wecom'; + if (ssoConfigured && prefix) return 'sso'; + return 'local'; +}; + /** * 根据持久化 username 和部署能力推导唯一验证方式。 * 该纯函数只做分类和降级,不读取运行环境,也不改写传入的 username。 @@ -21,8 +52,11 @@ export const resolveAccountVerificationByUsername = ({ username: string; capabilities: AccountVerificationCapabilities; } & AccountVerificationPasswordPolicy): AccountVerificationResolution => { - const normalizedUsername = username.trim(); - if (!normalizedUsername) { + const accountKind = resolveAccountKindByUsername({ + username, + ssoConfigured: capabilities.oauth.sso + }); + if (accountKind === 'invalid') { return { status: 'unsupported', accountKind: 'invalid', @@ -30,27 +64,6 @@ export const resolveAccountVerificationByUsername = ({ }; } - /** Provider 前缀必须完整匹配,且分隔符后至少保留一个字符。 */ - const hasPrefix = (prefix: string) => - normalizedUsername.startsWith(`${prefix}-`) && normalizedUsername.length > prefix.length + 1; - - const firstSeparatorIndex = normalizedUsername.indexOf('-'); - const hasSsoPrefix = - firstSeparatorIndex > 0 && firstSeparatorIndex < normalizedUsername.length - 1; - - // 邮箱和手机号必须先于通用连字符规则,避免合法邮箱被误判为 SSO。 - const accountKind = (() => { - if (AccountEmailUsernameSchema.safeParse(normalizedUsername).success) return 'email'; - if (AccountPhoneUsernameSchema.safeParse(normalizedUsername).success) return 'phone'; - if (hasPrefix('wechat')) return 'wechat'; - if (hasPrefix('git')) return 'github'; - if (hasPrefix('google')) return 'google'; - if (hasPrefix('microsoft')) return 'microsoft'; - if (hasPrefix('wecom')) return 'wecom'; - if (capabilities.oauth.sso && hasSsoPrefix) return 'sso'; - return 'local'; - })() satisfies RecognizedAccountKind; - type ConfiguredAccountVerificationMethod = Exclude; const candidateMethods: readonly ConfiguredAccountVerificationMethod[] = (() => { diff --git a/packages/global/support/user/type.ts b/packages/global/support/user/type.ts index 742871faf3a5..876640fc8d14 100644 --- a/packages/global/support/user/type.ts +++ b/packages/global/support/user/type.ts @@ -44,7 +44,8 @@ export const UserSchema = z.object({ permission: z.instanceof(TeamPermission), contact: z.string().optional(), tags: z.array(UserTagsSchema).optional(), - hasPassword: z.boolean() + hasPassword: z.boolean(), + passwordAvailable: z.boolean().optional() }); export type UserType = z.infer; diff --git a/packages/global/test/openapi/admin/routes/settings/api.test.ts b/packages/global/test/openapi/admin/routes/settings/api.test.ts new file mode 100644 index 000000000000..fbcd68e8117f --- /dev/null +++ b/packages/global/test/openapi/admin/routes/settings/api.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest'; +import { UpdateConfigBodySchema } from '../../../../../openapi/admin/routes/settings/api'; + +const createConfig = () => ({ + fastgpt: { + feConfigs: { + uploadFileMaxAmount: 10, + uploadFileMaxSize: 100, + sso: { + url: 'https://sso.example.com', + disablePasswordForSsoUsers: true + } + }, + systemEnv: {} + }, + fastgptPro: { + teamMode: 'multi', + auth: { + email: { + enabled: true, + register: false, + notification: true, + smtp: 'smtp.example.com', + user: 'mailer', + pass: 'secret' + } + } + } +}); + +describe('UpdateConfigBodySchema', () => { + it('accepts the account configuration contract', () => { + expect(UpdateConfigBodySchema.safeParse(createConfig()).success).toBe(true); + }); + + it('rejects malformed channel switches and attempts to submit a license', () => { + const malformedSwitch = createConfig(); + malformedSwitch.fastgptPro.auth.email.enabled = 'true' as unknown as boolean; + expect(UpdateConfigBodySchema.safeParse(malformedSwitch).success).toBe(false); + + const forgedLicense = createConfig() as ReturnType & { + fastgptPro: ReturnType['fastgptPro'] & { license: string }; + }; + forgedLicense.fastgptPro.license = 'forged-license'; + expect(UpdateConfigBodySchema.safeParse(forgedLicense).success).toBe(false); + }); +}); diff --git a/packages/global/test/support/user/account/verification/utils.test.ts b/packages/global/test/support/user/account/verification/utils.test.ts index 7d487b885c42..957c9b859c08 100644 --- a/packages/global/test/support/user/account/verification/utils.test.ts +++ b/packages/global/test/support/user/account/verification/utils.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from 'vitest'; import type { AccountVerificationCapabilities } from '@fastgpt/global/support/user/account/verification/type'; -import { resolveAccountVerificationByUsername } from '@fastgpt/global/support/user/account/verification/utils'; +import { + resolveAccountKindByUsername, + resolveAccountVerificationByUsername +} from '@fastgpt/global/support/user/account/verification/utils'; const capabilities = { emailCode: true, @@ -34,6 +37,32 @@ const resolve = (username: string, overrides: CapabilityOverrides = {}) => oldPasswordAvailable: true }); +describe('resolveAccountKindByUsername', () => { + it.each([ + ['', true, 'invalid'], + [' ', true, 'invalid'], + ['user@example.com', true, 'email'], + ['user-name@example-domain.com', true, 'email'], + ['13800138000', true, 'phone'], + ['wechat-openid', false, 'wechat'], + ['git-octocat', false, 'github'], + ['google-sub', false, 'google'], + ['microsoft-id', false, 'microsoft'], + ['wecom-id', false, 'wecom'], + ['customer-user', true, 'sso'], + ['customer-user-extra', true, 'sso'], + ['customer-user', false, 'local'], + ['Git-user', true, 'sso'], + ['local', true, 'local'], + ['-leading', true, 'local'], + ['trailing-', true, 'local'], + ['git-', true, 'local'], + [' customer-user ', true, 'sso'] + ])('classifies %j with ssoConfigured=%s as %s', (username, ssoConfigured, accountKind) => { + expect(resolveAccountKindByUsername({ username, ssoConfigured })).toBe(accountKind); + }); +}); + describe('resolveAccountVerificationByUsername', () => { it.each(['', ' '])('rejects an empty username: %j', (username) => { expect(resolve(username)).toEqual({ diff --git a/packages/service/support/user/account/password/service.ts b/packages/service/support/user/account/password/service.ts index cc00b536e588..9f2303e92027 100644 --- a/packages/service/support/user/account/password/service.ts +++ b/packages/service/support/user/account/password/service.ts @@ -4,9 +4,32 @@ import { UserErrEnum } from '@fastgpt/global/common/error/code/user'; import { UserError } from '@fastgpt/global/common/error/utils'; import { serviceEnv } from '../../../../env'; import { MongoUser } from '../../schema'; +import { resolveAccountKindByUsername } from '@fastgpt/global/support/user/account/verification/utils'; export const PASSWORD_CHANGE_TOKEN_TTL_SECONDS = 5 * 60; +/** 当前运行时是否开启 SSO 用户禁用密码策略。 */ +export const isSsoPasswordDisabled = () => + Boolean(global.feConfigs?.sso?.url) && global.feConfigs?.sso?.disablePasswordForSsoUsers === true; + +/** 使用共享账号分类规则判断持久化 username 是否属于当前 SSO 环境。 */ +export const isSsoUserByUsername = (username: string) => + resolveAccountKindByUsername({ + username, + ssoConfigured: Boolean(global.feConfigs?.sso?.url) + }) === 'sso'; + +/** 返回当前运行时中指定账号是否允许使用或维护平台密码。 */ +export const getUserPasswordAvailability = (username: string) => + !(isSsoPasswordDisabled() && isSsoUserByUsername(username)); + +/** 在密码比对或最终写入前拒绝受限 SSO 用户。 */ +export const assertUserPasswordAvailable = (username: string) => { + if (!getUserPasswordAvailability(username)) { + throw new UserError(UserErrEnum.ssoPasswordUnavailable); + } +}; + export const PasswordChangeTokenPayloadSchema = z .object({ userId: z.string().min(1), diff --git a/packages/service/support/user/account/verification/password/service.ts b/packages/service/support/user/account/verification/password/service.ts index a1b662c0bac4..c0738bd1b109 100644 --- a/packages/service/support/user/account/verification/password/service.ts +++ b/packages/service/support/user/account/verification/password/service.ts @@ -8,6 +8,7 @@ import type { AccountVerificationPurpose } from '@fastgpt/global/support/user/ac import { MongoUser } from '../../../schema'; import { consumeVerificationMaterial, upsertVerificationMaterial } from '../entity'; import { AccountVerification, type LocalAccountIdentity } from '../service'; +import { assertUserPasswordAvailable } from '../../password/service'; type PasswordVerificationDependencies = { generateCode: () => string; @@ -108,7 +109,7 @@ export class PasswordAccountVerification extends AccountVerification< throw new UserError(UserErrEnum.invalidVerificationCode); } - const user = await MongoUser.findOne({ username, password }); + const user = await MongoUser.findOne({ username }); if (!user) { return Promise.reject(UserErrEnum.account_psw_error); } @@ -116,6 +117,13 @@ export class PasswordAccountVerification extends AccountVerification< return Promise.reject('Invalid account!'); } + assertUserPasswordAvailable(user.username); + + const passwordMatched = await MongoUser.exists({ _id: user._id, password }); + if (!passwordMatched) { + return Promise.reject(UserErrEnum.account_psw_error); + } + return { kind: 'local', userId: String(user._id), diff --git a/packages/service/support/user/controller.ts b/packages/service/support/user/controller.ts index ba1ae0f45080..7d2484f52a23 100644 --- a/packages/service/support/user/controller.ts +++ b/packages/service/support/user/controller.ts @@ -5,6 +5,7 @@ import { ERROR_ENUM } from '@fastgpt/global/common/error/errorCode'; import { TeamPermission } from '@fastgpt/global/support/permission/user/controller'; import { getUserFallbackTeam } from './team/fallback'; import { hasStoredPassword } from '@fastgpt/global/support/user/utils'; +import { getUserPasswordAvailability } from './account/password/service'; export async function authUserExist({ userId, username }: { userId?: string; username?: string }) { if (userId) { @@ -69,6 +70,7 @@ export async function getUserDetail({ contact: user.contact, language: user.language, tags: user.tags, - hasPassword: hasStoredPassword(user.password) + hasPassword: hasStoredPassword(user.password), + passwordAvailable: getUserPasswordAvailability(user.username) }; } diff --git a/packages/service/test/support/user/account/password/service.test.ts b/packages/service/test/support/user/account/password/service.test.ts index 50d55b812db7..466754e042de 100644 --- a/packages/service/test/support/user/account/password/service.test.ts +++ b/packages/service/test/support/user/account/password/service.test.ts @@ -1,15 +1,73 @@ import jwt from 'jsonwebtoken'; -import { describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { UserErrEnum } from '@fastgpt/global/common/error/code/user'; import { PASSWORD_CHANGE_TOKEN_TTL_SECONDS, - PasswordChangeTokenService + PasswordChangeTokenService, + assertUserPasswordAvailable, + getUserPasswordAvailability, + isSsoPasswordDisabled, + isSsoUserByUsername } from '@fastgpt/service/support/user/account/password/service'; const secret = 'password_change_test_secret_32_chars_min'; const otherSecret = 'password_change_other_secret_32_chars_min'; const issuedAtMs = Date.UTC(2026, 6, 22, 10, 0, 0); const issuedAt = Math.floor(issuedAtMs / 1000); +const originalFeConfigs = global.feConfigs; + +const setSsoPasswordPolicy = (enabled: boolean) => { + global.feConfigs = { + uploadFileMaxAmount: 10, + uploadFileMaxSize: 10, + ...global.feConfigs, + sso: { + url: 'https://sso.example.com', + disablePasswordForSsoUsers: enabled + } + }; +}; + +describe('SSO password policy', () => { + beforeEach(() => setSsoPasswordPolicy(false)); + afterEach(() => { + global.feConfigs = originalFeConfigs; + }); + + it('only disables password for dynamically classified SSO users when the policy is enabled', () => { + setSsoPasswordPolicy(true); + + expect(isSsoPasswordDisabled()).toBe(true); + expect(isSsoUserByUsername('tenant-user')).toBe(true); + expect(getUserPasswordAvailability('tenant-user')).toBe(false); + expect(() => assertUserPasswordAvailable('tenant-user')).toThrow( + UserErrEnum.ssoPasswordUnavailable + ); + }); + + it.each([ + 'local', + 'user-name@example-domain.com', + 'wechat-openid', + 'git-octocat', + 'google-sub', + 'microsoft-id', + 'wecom-id' + ])('keeps password available for non-SSO account %s', (username) => { + setSsoPasswordPolicy(true); + expect(getUserPasswordAvailability(username)).toBe(true); + }); + + it('restores password availability when the switch is disabled or SSO is not configured', () => { + expect(getUserPasswordAvailability('tenant-user')).toBe(true); + + global.feConfigs.sso = { + disablePasswordForSsoUsers: true + }; + expect(isSsoPasswordDisabled()).toBe(false); + expect(getUserPasswordAvailability('tenant-user')).toBe(true); + }); +}); describe('PasswordChangeTokenService', () => { it('signs a five-minute HS256 token and verifies the current user', () => { diff --git a/packages/service/test/support/user/account/verification/password/service.test.ts b/packages/service/test/support/user/account/verification/password/service.test.ts index ba6df434ab28..7e463eca2cde 100644 --- a/packages/service/test/support/user/account/verification/password/service.test.ts +++ b/packages/service/test/support/user/account/verification/password/service.test.ts @@ -7,6 +7,12 @@ import { PasswordAccountVerification } from '@fastgpt/service/support/user/accou describe('PasswordAccountVerification', () => { beforeEach(async () => { + global.feConfigs = { + uploadFileMaxAmount: 10, + uploadFileMaxSize: 10, + ...global.feConfigs, + sso: undefined + }; await MongoAccountVerificationMaterial.deleteMany({}); }); @@ -96,4 +102,51 @@ describe('PasswordAccountVerification', () => { verification.consume({ username: 'user', password: 'password', code: 'ABC123' }) ).rejects.toBe('Invalid account!'); }); + + it.each(['password', 'wrong-password'])( + 'rejects an SSO user before comparing the submitted password: %s', + async (submittedPassword) => { + global.feConfigs.sso = { + url: 'https://sso.example.com', + disablePasswordForSsoUsers: true + }; + await MongoUser.create({ + username: 'tenant-user', + password: 'password', + status: UserStatusEnum.active + }); + const verification = new PasswordAccountVerification({ generateCode: () => 'ABC123' }); + await verification.create({ username: 'tenant-user' }); + + await expect( + verification.consume({ + username: 'tenant-user', + password: submittedPassword, + code: 'ABC123' + }) + ).rejects.toThrow(UserErrEnum.ssoPasswordUnavailable); + } + ); + + it('keeps the forbidden-account status ahead of the SSO password policy', async () => { + global.feConfigs.sso = { + url: 'https://sso.example.com', + disablePasswordForSsoUsers: true + }; + await MongoUser.create({ + username: 'tenant-forbidden', + password: 'password', + status: UserStatusEnum.forbidden + }); + const verification = new PasswordAccountVerification({ generateCode: () => 'ABC123' }); + await verification.create({ username: 'tenant-forbidden' }); + + await expect( + verification.consume({ + username: 'tenant-forbidden', + password: 'password', + code: 'ABC123' + }) + ).rejects.toBe('Invalid account!'); + }); }); diff --git a/packages/web/i18n/en/common.json b/packages/web/i18n/en/common.json index 212ed185a6cb..367c69c3ddd4 100644 --- a/packages/web/i18n/en/common.json +++ b/packages/web/i18n/en/common.json @@ -797,7 +797,9 @@ "error.operation_too_frequently": "Too many operations. Please try again later.", "error.s3_upload_invalid_file_type": "Unsupported file content or file extension does not match", "error.send_auth_code_too_frequently": "Please do not obtain verification code frequently", + "error.sso_password_unavailable": "Password is unavailable. Use an identity provider to sign in.", "error.verify_code_too_frequently": "Too many verification attempts. Please try again later.", + "error.verification_channel_unavailable": "This verification method is currently unavailable.", "error.too_many_request": "Too many request", "error.tool_not_exist": "Tool deleted", "error.unAuthFile": "Unauthorized to read this file", @@ -1092,6 +1094,7 @@ "support.user.login.Provider error": "Login Error, Please Try Again", "support.user.login.Username": "Username", "support.user.login.Wechat": "WeChat Login", + "support.user.login.Wecom": "WeCom", "support.user.login.can_not_login": "Cannot log in?", "support.user.login.error": "Login Error", "support.user.login.security_failed": "Security Verification Failed", diff --git a/packages/web/i18n/zh-CN/common.json b/packages/web/i18n/zh-CN/common.json index 7e7eb47a9fd5..9f414c93b732 100644 --- a/packages/web/i18n/zh-CN/common.json +++ b/packages/web/i18n/zh-CN/common.json @@ -797,7 +797,9 @@ "error.operation_too_frequently": "操作过于频繁,请稍后再试", "error.s3_upload_invalid_file_type": "文件内容不受支持,或文件后缀与内容不匹配", "error.send_auth_code_too_frequently": "请勿频繁获取验证码", + "error.sso_password_unavailable": "密码不可用,请使用身份验证方式登录", "error.verify_code_too_frequently": "验证过于频繁,请稍后再试", + "error.verification_channel_unavailable": "该身份验证方式当前不可用", "error.too_many_request": "请求太频繁了,请稍后重试", "error.tool_not_exist": "工具已删除", "error.unAuthFile": "无权读取该文件", @@ -1092,6 +1094,7 @@ "support.user.login.Provider error": "登录异常,请重试", "support.user.login.Username": "用户名", "support.user.login.Wechat": "微信登录", + "support.user.login.Wecom": "企业微信登录", "support.user.login.can_not_login": "无法登录?", "support.user.login.error": "登录异常", "support.user.login.security_failed": "安全校验失败", diff --git a/packages/web/i18n/zh-Hant/common.json b/packages/web/i18n/zh-Hant/common.json index 877ceaf5f2b7..2f2ae84eed6c 100644 --- a/packages/web/i18n/zh-Hant/common.json +++ b/packages/web/i18n/zh-Hant/common.json @@ -791,7 +791,9 @@ "error.operation_too_frequently": "操作過於頻繁,請稍後再試", "error.s3_upload_invalid_file_type": "文件內容不受支援,或副檔名與內容不匹配", "error.send_auth_code_too_frequently": "請勿頻繁取得驗證碼", + "error.sso_password_unavailable": "密碼不可用,請使用身分驗證方式登入", "error.verify_code_too_frequently": "驗證過於頻繁,請稍後再試", + "error.verification_channel_unavailable": "該身分驗證方式目前不可用", "error.too_many_request": "請求太頻繁了,請稍後重試", "error.tool_not_exist": "工具已刪除", "error.unAuthFile": "無權讀取該文件", @@ -1081,6 +1083,7 @@ "support.user.login.Provider error": "登入錯誤,請重試", "support.user.login.Username": "使用者名稱", "support.user.login.Wechat": "微信登入", + "support.user.login.Wecom": "企業微信登入", "support.user.login.can_not_login": "無法登入?", "support.user.login.error": "登入錯誤", "support.user.login.security_failed": "安全驗證失敗", diff --git a/pro b/pro index 88f6dad16f8c..a42ce85e5048 160000 --- a/pro +++ b/pro @@ -1 +1 @@ -Subproject commit 88f6dad16f8c9911f2ed7d419134aa2cc24de6b1 +Subproject commit a42ce85e5048bb6e5e4ed1eb1199dcef69b63377 diff --git a/projects/app/src/components/Layout/auth.tsx b/projects/app/src/components/Layout/auth.tsx index d8f54e43ef1b..a90dee6bee1c 100644 --- a/projects/app/src/components/Layout/auth.tsx +++ b/projects/app/src/components/Layout/auth.tsx @@ -8,7 +8,6 @@ const unAuthPage: { [key: string]: boolean } = { '/': true, '/login': true, '/login/provider': true, - '/login/fastlogin': true, '/login/sso': true, '/appStore': true, '/chat': true, @@ -34,7 +33,7 @@ const Auth = ({ children }: { children: JSX.Element | React.ReactNode }) => { }, { refetchInterval: 10 * 60 * 1000, - onError(error) { + onError() { toast({ status: 'warning', title: t('common:support.user.Need to login') diff --git a/projects/app/src/components/Layout/index.tsx b/projects/app/src/components/Layout/index.tsx index 1facb21b9b63..29d20c27cda3 100644 --- a/projects/app/src/components/Layout/index.tsx +++ b/projects/app/src/components/Layout/index.tsx @@ -57,7 +57,6 @@ const pcUnShowLayoutRoute: Record = { '/': true, '/login': true, '/login/provider': true, - '/login/fastlogin': true, '/account/cancel': true, '/chat/share': true, '/app/edit': true, @@ -70,7 +69,6 @@ const phoneUnShowLayoutRoute: Record = { '/': true, '/login': true, '/login/provider': true, - '/login/fastlogin': true, '/account/cancel': true, '/chat': true, '/chat/share': true, diff --git a/projects/app/src/components/support/user/safe/ResetExpiredPswModal.tsx b/projects/app/src/components/support/user/safe/ResetExpiredPswModal.tsx index 9f7dc398f857..02e3c1e61f37 100644 --- a/projects/app/src/components/support/user/safe/ResetExpiredPswModal.tsx +++ b/projects/app/src/components/support/user/safe/ResetExpiredPswModal.tsx @@ -3,18 +3,26 @@ import { useRequest } from '@fastgpt/web/hooks/useRequest'; import { getCheckPswExpired } from '@/web/support/user/api'; import { useUserStore } from '@/web/support/user/useUserStore'; import PasswordChangeModal from './PasswordChangeModal'; +import { shouldCheckPasswordExpiration } from '@/pageComponents/account/info/password'; /** 仅在确有存储密码且已过期时开启不可关闭的统一改密流程。 */ const ResetExpiredPswModal = () => { const { userInfo } = useUserStore(); const { data: passwordExpired = false, runAsync: checkPasswordExpired } = useRequest( async () => { - if (!userInfo?._id) return false; + if ( + !shouldCheckPasswordExpiration({ + userId: userInfo?._id, + passwordAvailable: userInfo?.passwordAvailable + }) + ) { + return false; + } return getCheckPswExpired(); }, { manual: false, - refreshDeps: [userInfo?._id] + refreshDeps: [userInfo?._id, userInfo?.passwordAvailable] } ); diff --git a/projects/app/src/pageComponents/account/info/password.ts b/projects/app/src/pageComponents/account/info/password.ts index 3a2ad140afa7..ed0ca7ccadfe 100644 --- a/projects/app/src/pageComponents/account/info/password.ts +++ b/projects/app/src/pageComponents/account/info/password.ts @@ -1,8 +1,24 @@ /** 判断当前账号是否允许从用户信息页进入密码管理。root 和企业微信账号不使用本地密码。 */ export const canManagePasswordFromAccountInfo = ({ isPlus, - username + username, + passwordAvailable }: { isPlus?: boolean; username?: string; -}) => isPlus === true && !!username && username !== 'root' && !username.startsWith('wecom-'); + passwordAvailable?: boolean; +}) => + isPlus === true && + passwordAvailable !== false && + !!username && + username !== 'root' && + !username.startsWith('wecom-'); + +/** 仅在用户详情已加载且当前账号允许使用密码时检查密码是否过期。 */ +export const shouldCheckPasswordExpiration = ({ + userId, + passwordAvailable +}: { + userId?: string; + passwordAvailable?: boolean; +}) => Boolean(userId) && passwordAvailable !== false; diff --git a/projects/app/src/pageComponents/login/LoginForm/FormLayout.tsx b/projects/app/src/pageComponents/login/LoginForm/FormLayout.tsx index e01d81e48192..30222f7561bc 100644 --- a/projects/app/src/pageComponents/login/LoginForm/FormLayout.tsx +++ b/projects/app/src/pageComponents/login/LoginForm/FormLayout.tsx @@ -1,17 +1,11 @@ -import { LoginPageTypeEnum } from '@/web/support/user/login/constants'; -import { useSystemStore } from '@/web/common/system/useSystemStore'; +import type { LoginPageTypeEnum } from '@/web/support/user/login/constants'; import { Box, Flex, IconButton, Button } from '@chakra-ui/react'; -import { LOGO_ICON } from '@fastgpt/global/common/system/constants'; -import { useRouter } from 'next/router'; -import { type Dispatch, useCallback, useEffect, useMemo } from 'react'; -import { useTranslation } from 'next-i18next'; -import MyImage from '@fastgpt/web/components/common/Image/MyImage'; -import { checkIsWecomTerminal } from '@fastgpt/global/support/user/login/constants'; +import { type Dispatch } from 'react'; import Avatar from '@fastgpt/web/components/common/Avatar'; import dynamic from 'next/dynamic'; import MyTooltip from '@fastgpt/web/components/common/MyTooltip'; -import type { OAuthAccountVerificationProvider } from '@fastgpt/global/support/user/account/verification/type'; -import { createOauthLogin } from '@/web/support/user/api'; +import LoginBrand from './LoginBrand'; +import { useLoginMethods } from './useLoginMethods'; type Props = { children: React.ReactNode; @@ -19,234 +13,63 @@ type Props = { pageType: `${LoginPageTypeEnum}`; }; -type OAuthItem = { - label: string; - provider: OAuthAccountVerificationProvider | LoginPageTypeEnum; - icon: any; - pageType?: LoginPageTypeEnum; -}; - const FormLayout = ({ children, setPageType, pageType }: Props) => { - const { t } = useTranslation(); - const router = useRouter(); - const rootLogin = router.query.rootLogin === '1'; - - const { setLoginStore, feConfigs } = useSystemStore(); - - const { lastRoute = '/dashboard/agent', lastTmbId = '' } = router.query as { - lastRoute: string; - lastTmbId?: string; - }; - const computedLastRoute = useMemo(() => { - return router.pathname === '/chat' ? router.asPath : lastRoute; - }, [lastRoute, router.pathname, router.asPath]); - - const redirectUri = `${location.origin}/login/provider`; - - const isWecomWorkTerminal = checkIsWecomTerminal(); - const canWecomTerminalAutoRedirect = - !isWecomWorkTerminal || feConfigs?.wecomLoginAutoRedirect === true; - - const oAuthList = useMemo( - () => [ - ...(feConfigs?.sso?.url - ? [ - { - label: feConfigs.sso.title || 'Unknown', - provider: 'sso' as const, - icon: feConfigs.sso.icon - } - ] - : []), - ...(feConfigs?.oauth?.wechat && pageType !== LoginPageTypeEnum.wechat - ? [ - { - label: t('common:support.user.login.Wechat'), - provider: LoginPageTypeEnum.wechat, - icon: 'common/wechatFill', - pageType: LoginPageTypeEnum.wechat - } - ] - : []), - ...(pageType !== LoginPageTypeEnum.passwordLogin - ? [ - { - label: t('common:support.user.login.Password login'), - provider: LoginPageTypeEnum.passwordLogin, - icon: 'support/permission/privateLight', - pageType: LoginPageTypeEnum.passwordLogin - } - ] - : []), - ...(feConfigs?.oauth?.google - ? [ - { - label: t('common:support.user.login.Google'), - provider: 'google' as const, - icon: 'common/googleFill' - } - ] - : []), - ...(feConfigs?.oauth?.github - ? [ - { - label: t('common:support.user.login.Github'), - provider: 'github' as const, - icon: 'common/gitFill' - } - ] - : []), - ...(feConfigs?.oauth?.microsoft - ? [ - { - label: - feConfigs?.oauth?.microsoft?.customButton || - t('common:support.user.login.Microsoft'), - provider: 'microsoft' as const, - icon: 'common/microsoft' - } - ] - : []) - ], - [feConfigs, pageType, t] - ); - - const show_oauth = oAuthList.length > 0; - - const onClickOauth = useCallback( - async (item: OAuthItem) => { - if (item.pageType) { - setPageType(item.pageType); - return; - } - - const provider = item.provider as OAuthAccountVerificationProvider; - const { state, url } = await createOauthLogin({ - provider, - callbackUrl: redirectUri, - isWecomWorkTerminal - }); - setLoginStore({ - provider, - lastRoute: computedLastRoute, - lastTmbId, - state, - callbackUrl: redirectUri - }); - router.replace(url, '_self'); - }, - [ - computedLastRoute, - isWecomWorkTerminal, - lastTmbId, - redirectUri, - router, - setLoginStore, - setPageType - ] - ); - - // Auto login - useEffect(() => { - if (rootLogin) return; - const sso = oAuthList.find((item) => item.provider === 'sso'); - // sso auto login - if (sso && canWecomTerminalAutoRedirect && (feConfigs?.sso?.autoLogin || isWecomWorkTerminal)) { - void onClickOauth(sso); - return; - } - if (feConfigs.oauth?.wecom && isWecomWorkTerminal && canWecomTerminalAutoRedirect) { - void onClickOauth({ - label: 'Wecom', - provider: 'wecom', - icon: 'common/wecom' - }); - } - }, [ - rootLogin, - canWecomTerminalAutoRedirect, - feConfigs?.sso?.autoLogin, - isWecomWorkTerminal, - onClickOauth, - oAuthList, - feConfigs.oauth?.wecom - ]); + const { methods, startLogin } = useLoginMethods({ + mode: 'alternatives', + pageType, + setPageType + }); return ( - - - - - - - {feConfigs?.systemTitle} - - - - + + {children} - {show_oauth && ( - - - - - - + {methods.length > 0 && ( + + + + or - + - {oAuthList.length > 2 ? ( - - {oAuthList.map((item) => ( - + {methods.length > 2 ? ( + + {methods.map((method) => ( + } - onClick={() => onClickOauth(item)} + size="lgSquare" + borderRadius="50%" + aria-label={method.label} + variant="whitePrimary" + icon={} + onClick={() => void startLogin(method).catch(() => undefined)} /> ))} ) : ( - - {oAuthList.map((item) => ( - + + {methods.map((method) => ( + ))} diff --git a/projects/app/src/pageComponents/login/LoginForm/LoginBrand.tsx b/projects/app/src/pageComponents/login/LoginForm/LoginBrand.tsx new file mode 100644 index 000000000000..c52ed550a24e --- /dev/null +++ b/projects/app/src/pageComponents/login/LoginForm/LoginBrand.tsx @@ -0,0 +1,38 @@ +import { Box, Flex } from '@chakra-ui/react'; +import { LOGO_ICON } from '@fastgpt/global/common/system/constants'; +import MyImage from '@fastgpt/web/components/common/Image/MyImage'; +import { useSystemStore } from '@/web/common/system/useSystemStore'; + +/** 登录表单与登录方式选择页共用的品牌区域。 */ +const LoginBrand = () => { + const { feConfigs } = useSystemStore(); + + return ( + + + + + + + {feConfigs?.systemTitle} + + + + ); +}; + +export default LoginBrand; diff --git a/projects/app/src/pageComponents/login/LoginForm/LoginGuideLink.tsx b/projects/app/src/pageComponents/login/LoginForm/LoginGuideLink.tsx new file mode 100644 index 000000000000..9061fb6e0e59 --- /dev/null +++ b/projects/app/src/pageComponents/login/LoginForm/LoginGuideLink.tsx @@ -0,0 +1,31 @@ +import { Link, type LinkProps } from '@chakra-ui/react'; +import { useTranslation } from 'next-i18next'; +import { useSystemStore } from '@/web/common/system/useSystemStore'; + +/** 仅在配置了登录引导地址时渲染帮助入口,不为缺失配置保留占位。 */ +const LoginGuideLink = ({ mt = 8 }: Pick) => { + const { t } = useTranslation(); + const { feConfigs } = useSystemStore(); + const loginGuideDocUrl = feConfigs?.loginGuideDocUrl?.trim(); + + if (!loginGuideDocUrl) return null; + + return ( + + {t('common:support.user.login.can_not_login')} + + ); +}; + +export default LoginGuideLink; diff --git a/projects/app/src/pageComponents/login/LoginForm/PolicyTip.tsx b/projects/app/src/pageComponents/login/LoginForm/PolicyTip.tsx index 5313c750e852..f6b934941a23 100644 --- a/projects/app/src/pageComponents/login/LoginForm/PolicyTip.tsx +++ b/projects/app/src/pageComponents/login/LoginForm/PolicyTip.tsx @@ -1,11 +1,11 @@ import { getDocPath } from '@/web/common/system/doc'; import { useSystemStore } from '@/web/common/system/useSystemStore'; -import { Box, Link } from '@chakra-ui/react'; +import { Box, Link, type BoxProps } from '@chakra-ui/react'; import React, { useEffect, useRef, useState } from 'react'; import { Trans, useTranslation } from 'next-i18next'; import { i18nT } from '@fastgpt/global/common/i18n/utils'; -const PolicyTip = () => { +const PolicyTip = ({ textAlign }: { textAlign?: BoxProps['textAlign'] }) => { const { feConfigs } = useSystemStore(); const { i18n } = useTranslation(); const tipRef = useRef(null); @@ -43,7 +43,7 @@ const PolicyTip = () => { ref={tipRef} display={'block'} position={'relative'} - textAlign={isMultiline ? 'center' : 'left'} + textAlign={textAlign ?? (isMultiline ? 'center' : 'left')} mt={6} fontSize={'mini'} lineHeight={'16px'} diff --git a/projects/app/src/pageComponents/login/LoginForm/useLoginMethods.ts b/projects/app/src/pageComponents/login/LoginForm/useLoginMethods.ts new file mode 100644 index 000000000000..bc0b63165b2e --- /dev/null +++ b/projects/app/src/pageComponents/login/LoginForm/useLoginMethods.ts @@ -0,0 +1,92 @@ +import { useCallback, useMemo, type Dispatch } from 'react'; +import { useRouter } from 'next/router'; +import { useTranslation } from 'next-i18next'; +import { checkIsWecomTerminal } from '@fastgpt/global/support/user/login/constants'; +import { getErrText } from '@fastgpt/global/common/error/utils'; +import { useToast } from '@fastgpt/web/hooks/useToast'; +import { createOauthLogin } from '@/web/support/user/api'; +import { useSystemStore } from '@/web/common/system/useSystemStore'; +import type { LoginPageTypeEnum } from '@/web/support/user/login/constants'; +import { getLoginMethodItems, type LoginMethodItem } from '@/web/support/user/login/utils'; + +/** + * 统一发起页面切换或 OAuth,并将纯函数生成的渠道列表绑定到当前登录上下文。 + */ +export const useLoginMethods = ({ + mode, + pageType, + setPageType +}: { + mode: 'selection' | 'alternatives'; + pageType?: `${LoginPageTypeEnum}`; + setPageType: Dispatch<`${LoginPageTypeEnum}`>; +}) => { + const { t } = useTranslation(); + const router = useRouter(); + const { toast } = useToast(); + const { feConfigs, setLoginStore } = useSystemStore(); + const { lastRoute = '/dashboard/agent', lastTmbId = '' } = router.query as { + lastRoute: string; + lastTmbId?: string; + }; + + const computedLastRoute = useMemo( + () => (router.pathname === '/chat' ? router.asPath : lastRoute), + [lastRoute, router.asPath, router.pathname] + ); + + const methods = useMemo( + () => + getLoginMethodItems({ + mode, + pageType, + feConfigs, + labels: { + wechat: t('common:support.user.login.Wechat'), + wecom: t('common:support.user.login.Wecom'), + password: t('common:support.user.login.Password login'), + google: t('common:support.user.login.Google'), + github: t('common:support.user.login.Github'), + microsoft: t('common:support.user.login.Microsoft') + } + }), + [feConfigs, mode, pageType, t] + ); + + const startLogin = useCallback( + async (method: LoginMethodItem) => { + if (method.type === 'page') { + setPageType(method.pageType); + return; + } + + try { + const callbackUrl = `${window.location.origin}/login/provider`; + const isWecomWorkTerminal = checkIsWecomTerminal(); + const { state, url } = await createOauthLogin({ + provider: method.provider, + callbackUrl, + isWecomWorkTerminal + }); + + setLoginStore({ + provider: method.provider, + lastRoute: computedLastRoute, + lastTmbId, + state, + callbackUrl + }); + await router.replace(url, '_self'); + } catch (error) { + toast({ + status: 'warning', + title: getErrText(error, t('common:support.user.login.error')) + }); + throw error; + } + }, + [computedLastRoute, lastTmbId, router, setLoginStore, setPageType, t, toast] + ); + + return { methods, startLogin }; +}; diff --git a/projects/app/src/pageComponents/login/LoginMethodSelection.tsx b/projects/app/src/pageComponents/login/LoginMethodSelection.tsx new file mode 100644 index 000000000000..3a781556e779 --- /dev/null +++ b/projects/app/src/pageComponents/login/LoginMethodSelection.tsx @@ -0,0 +1,60 @@ +import { Button, Flex } from '@chakra-ui/react'; +import Avatar from '@fastgpt/web/components/common/Avatar'; +import { useState, type Dispatch } from 'react'; +import { LoginPageTypeEnum } from '@/web/support/user/login/constants'; +import LoginBrand from './LoginForm/LoginBrand'; +import LoginGuideLink from './LoginForm/LoginGuideLink'; +import PolicyTip from './LoginForm/PolicyTip'; +import { useLoginMethods } from './LoginForm/useLoginMethods'; + +type Props = { + setPageType: Dispatch<`${LoginPageTypeEnum}`>; +}; + +/** Figma 登录方式选择页:完整宽度展示所有可用渠道,密码登录固定在末尾。 */ +const LoginMethodSelection = ({ setPageType }: Props) => { + const [pendingMethod, setPendingMethod] = useState(); + const { methods, startLogin } = useLoginMethods({ + mode: 'selection', + pageType: LoginPageTypeEnum.methodSelection, + setPageType + }); + + return ( + + + + {methods.map((method) => ( + + ))} + + + + + ); +}; + +export default LoginMethodSelection; diff --git a/projects/app/src/pageComponents/login/LoginModal.tsx b/projects/app/src/pageComponents/login/LoginModal.tsx index 78b6de2eacb7..04061fb3bf96 100644 --- a/projects/app/src/pageComponents/login/LoginModal.tsx +++ b/projects/app/src/pageComponents/login/LoginModal.tsx @@ -32,7 +32,7 @@ const LoginModal = ({ onSuccess }: LoginModalProps) => { { minH={['100vh', '720px']} bg={['transparent', 'white']} borderRadius={[0, '24px']} - overflow={'hidden'} + overflowX={'hidden'} + overflowY={'auto'} > void | Promise; @@ -13,7 +14,7 @@ const ForgetPasswordForm = dynamic(() => import('@/pageComponents/login/ForgetPa const WechatForm = dynamic(() => import('@/pageComponents/login/LoginForm/WechatForm')); type LoginFormPanelProps = { - pageType: `${LoginPageTypeEnum}`; + pageType?: `${LoginPageTypeEnum}`; setPageType: Dispatch<`${LoginPageTypeEnum}`>; loginSuccess: LoginSuccessHandler; reserveLoginGuideSpace?: boolean; @@ -28,8 +29,15 @@ const LoginFormPanel = ({ const DynamicComponent = useMemo(() => { if (!pageType) return null; - const TypeMap = { + const TypeMap: Record< + LoginPageTypeEnum, + ComponentType<{ + setPageType: Dispatch<`${LoginPageTypeEnum}`>; + loginSuccess: LoginSuccessHandler; + }> + > = { [LoginPageTypeEnum.passwordLogin]: LoginForm, + [LoginPageTypeEnum.methodSelection]: LoginMethodSelection, [LoginPageTypeEnum.register]: RegisterForm, [LoginPageTypeEnum.forgetPassword]: ForgetPasswordForm, [LoginPageTypeEnum.wechat]: WechatForm diff --git a/projects/app/src/pageComponents/login/index.tsx b/projects/app/src/pageComponents/login/index.tsx index f3e93c1f4fd4..315007229a14 100644 --- a/projects/app/src/pageComponents/login/index.tsx +++ b/projects/app/src/pageComponents/login/index.tsx @@ -1,20 +1,27 @@ -import React, { useState, useCallback, useEffect } from 'react'; +import React, { useState, useCallback, useEffect, useRef } from 'react'; import { Box, Flex } from '@chakra-ui/react'; import { LoginPageTypeEnum } from '@/web/support/user/login/constants'; +import { + resolveAutoLoginProvider, + resolveInitialLoginPageType +} from '@/web/support/user/login/utils'; import { useSystemStore } from '@/web/common/system/useSystemStore'; import { useChatStore } from '@/web/core/chat/context/useChatStore'; import Script from 'next/script'; -import { useTranslation } from 'next-i18next'; import ChineseRedirectModal from './components/ChineseRedirectModal'; import CookieConsentModal from './components/CookieConsentModal'; import LoginFormPanel from './components/LoginFormPanel'; import type { LoginSuccessResponseType } from '@fastgpt/global/openapi/support/user/account/login/api'; import I18nLngSelector from '@/components/Select/I18nLngSelector'; import { useSystem } from '@fastgpt/web/hooks/useSystem'; +import { useRouter } from 'next/router'; +import { checkIsWecomTerminal } from '@fastgpt/global/support/user/login/constants'; +import { useLoginMethods } from './LoginForm/useLoginMethods'; +import LoginGuideLink from './LoginForm/LoginGuideLink'; type LoginSuccessHandler = (res: LoginSuccessResponseType) => void | Promise; -// login container component +/** 登录容器先完成自动跳转判断,再暴露可见页面,避免方式选择页短暂闪现。 */ export const LoginContainer = ({ children, onSuccess @@ -22,15 +29,44 @@ export const LoginContainer = ({ children?: React.ReactNode; onSuccess: LoginSuccessHandler; }) => { - const { t } = useTranslation(); - const { feConfigs } = useSystemStore(); + const router = useRouter(); + const { initd, feConfigs } = useSystemStore(); const { resetChatCache } = useChatStore(); const { isPc } = useSystem(); const loginGuideDocUrl = feConfigs?.loginGuideDocUrl?.trim(); + const initializedRef = useRef(false); - const [pageType, setPageType] = useState<`${LoginPageTypeEnum}`>(LoginPageTypeEnum.passwordLogin); + const [selectedPageType, setPageType] = useState<`${LoginPageTypeEnum}`>(); + const [autoLoginFailed, setAutoLoginFailed] = useState(false); + const { methods, startLogin } = useLoginMethods({ + mode: 'selection', + pageType: LoginPageTypeEnum.methodSelection, + setPageType + }); + const rootLogin = router.query.rootLogin === '1'; + const isWecomTerminal = typeof navigator === 'undefined' ? false : checkIsWecomTerminal(); + const initialPageType = resolveInitialLoginPageType({ + rootLogin, + ssoAvailable: Boolean(feConfigs?.sso?.url), + disablePasswordForSsoUsers: feConfigs?.sso?.disablePasswordForSsoUsers === true + }); + const autoLoginProvider = resolveAutoLoginProvider({ + rootLogin, + ssoAvailable: Boolean(feConfigs?.sso?.url), + ssoAutoLogin: feConfigs?.sso?.autoLogin === true, + wecomAvailable: Boolean(feConfigs?.oauth?.wecom), + isWecomTerminal, + canWecomTerminalAutoRedirect: !isWecomTerminal || feConfigs?.wecomLoginAutoRedirect === true + }); + const autoLoginMethod = methods.find( + (method) => method.type === 'oauth' && method.provider === autoLoginProvider + ); + const pageType = + selectedPageType ?? + (initd && router.isReady && (!autoLoginMethod || autoLoginFailed) + ? initialPageType + : undefined); - // login success handler const loginSuccess = useCallback( async (res: LoginSuccessResponseType) => { await onSuccess?.(res); @@ -38,15 +74,21 @@ export const LoginContainer = ({ [onSuccess] ); - // initialization logic useEffect(() => { - // reset chat state resetChatCache(); }, [feConfigs?.oauth?.wechat, resetChatCache]); + useEffect(() => { + if (!initd || !router.isReady || initializedRef.current) return; + initializedRef.current = true; + + if (autoLoginMethod) { + void startLogin(autoLoginMethod).catch(() => setAutoLoginFailed(true)); + } + }, [autoLoginMethod, initd, router.isReady, startLogin]); + return ( <> - {/* Google reCAPTCHA Script */} {feConfigs.googleClientVerKey && (