From b57a2508b1b9b90968c8ca0878b1200a3e02b8c1 Mon Sep 17 00:00:00 2001 From: shortlight5980 Date: Fri, 4 Sep 2026 11:13:54 +0800 Subject: [PATCH 1/7] feat: change password --- document/content/self-host/config/env.en.mdx | 2 +- packages/global/common/error/code/user.ts | 14 +- .../support/user/account/cancellation/api.ts | 28 +- .../support/user/account/password/api.ts | 252 ++++++- .../support/user/account/password/index.ts | 100 ++- packages/global/support/tmpData/constants.ts | 14 +- .../user/account/cancellation/constants.ts | 10 - .../user/account/cancellation/index.ts | 1 - .../user/account/cancellation/resolver.ts | 16 +- .../support/user/account/cancellation/type.ts | 18 +- .../user/account/cancellation/utils.ts | 6 +- .../user/account/verification/constants.ts | 26 +- .../support/user/account/verification/type.ts | 50 +- .../user/account/verification/utils.ts | 32 +- packages/global/support/user/type.ts | 5 +- packages/global/support/user/utils.ts | 4 + .../support/user/account/login/api.test.ts | 24 +- .../support/user/account/password/api.test.ts | 87 +++ .../user/account/cancellation/type.test.ts | 12 +- .../user/account/verification/utils.test.ts | 41 +- .../global/test/support/user/utils.test.ts | 12 +- packages/service/env.ts | 1 - .../service/support/tmpData/controller.ts | 10 +- .../support/user/account/password/service.ts | 102 +++ packages/service/support/user/controller.ts | 6 +- packages/service/support/user/schema.ts | 10 +- packages/service/test/env.test.ts | 10 + .../user/account/cancellation/service.test.ts | 4 +- .../user/account/password/service.test.ts | 37 + packages/web/i18n/en/account_info.json | 4 +- packages/web/i18n/en/common.json | 30 +- packages/web/i18n/zh-CN/account_info.json | 4 +- packages/web/i18n/zh-CN/common.json | 30 +- packages/web/i18n/zh-Hant/account_info.json | 4 +- packages/web/i18n/zh-Hant/common.json | 30 +- pro | 2 +- .../user/safe/AccountVerificationPanel.tsx | 679 ++++++++++++++++++ .../support/user/safe/PasswordChangeModal.tsx | 456 ++++++++++++ .../user/safe/ResetExpiredPswModal.tsx | 120 +--- .../support/user/safe/SendCodeAuthModal.tsx | 8 +- .../account/cancel/CancelAccountPage.tsx | 85 ++- .../account/cancel/VerificationPanel.tsx | 414 ----------- .../account/info/UpdatePswModal.tsx | 110 +-- .../pageComponents/account/info/password.ts | 8 + projects/app/src/pages/account/info/index.tsx | 22 +- .../support/user/account/checkPswExpired.ts | 10 +- .../support/user/account/password/update.ts | 58 ++ .../support/user/account/resetExpiredPsw.ts | 50 -- .../user/account/updatePasswordByOld.ts | 66 -- projects/app/src/pages/login/provider.tsx | 79 +- .../src/web/common/system/useSystemStore.ts | 3 +- .../web/support/user/account/password/api.ts | 31 + .../support/user/account/password/store.ts | 26 + .../user/account/verification/error.ts | 55 ++ projects/app/src/web/support/user/api.ts | 14 +- .../user/account/checkPswExpired.test.ts | 49 ++ .../user/account/password/update.test.ts | 255 +++++++ .../user/account/resetExpiredPsw.test.ts | 214 ------ .../user/account/updatePasswordByOld.test.ts | 155 ---- .../user/safe/SendCodeAuthModal.test.ts | 4 +- .../account/info/password.test.ts | 18 + .../migration/authCodeToTmpData.test.ts | 3 +- .../support/user/account/password/api.test.ts | 49 ++ .../user/account/password/store.test.ts | 33 + .../user/account/verification/error.test.ts | 61 ++ .../app/test/web/support/user/api.test.ts | 12 - 66 files changed, 2802 insertions(+), 1383 deletions(-) create mode 100644 packages/global/test/openapi/support/user/account/password/api.test.ts create mode 100644 packages/service/support/user/account/password/service.ts create mode 100644 packages/service/test/support/user/account/password/service.test.ts create mode 100644 projects/app/src/components/support/user/safe/AccountVerificationPanel.tsx create mode 100644 projects/app/src/components/support/user/safe/PasswordChangeModal.tsx delete mode 100644 projects/app/src/pageComponents/account/cancel/VerificationPanel.tsx create mode 100644 projects/app/src/pageComponents/account/info/password.ts create mode 100644 projects/app/src/pages/api/support/user/account/password/update.ts delete mode 100644 projects/app/src/pages/api/support/user/account/resetExpiredPsw.ts delete mode 100644 projects/app/src/pages/api/support/user/account/updatePasswordByOld.ts create mode 100644 projects/app/src/web/support/user/account/password/api.ts create mode 100644 projects/app/src/web/support/user/account/password/store.ts create mode 100644 projects/app/src/web/support/user/account/verification/error.ts create mode 100644 projects/app/test/api/support/user/account/password/update.test.ts delete mode 100644 projects/app/test/api/support/user/account/resetExpiredPsw.test.ts delete mode 100644 projects/app/test/api/support/user/account/updatePasswordByOld.test.ts create mode 100644 projects/app/test/pageComponents/account/info/password.test.ts create mode 100644 projects/app/test/web/support/user/account/password/api.test.ts create mode 100644 projects/app/test/web/support/user/account/password/store.test.ts create mode 100644 projects/app/test/web/support/user/account/verification/error.test.ts diff --git a/document/content/self-host/config/env.en.mdx b/document/content/self-host/config/env.en.mdx index f80fc5594b67..4585c3827299 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`, and `INVOKE_TOKEN_SECRET` are required at runtime. Use separate strong random secrets and do not use the example values in production. ## Shared App/Admin Variables diff --git a/packages/global/common/error/code/user.ts b/packages/global/common/error/code/user.ts index 2974c01bea7c..38702747b0ff 100644 --- a/packages/global/common/error/code/user.ts +++ b/packages/global/common/error/code/user.ts @@ -12,7 +12,9 @@ export enum UserErrEnum { verifyCodeTooFrequently = 'verifyCodeTooFrequently', invalidAccount = 'invalidAccount', accountCancellationPending = 'accountCancellationPending', - registrationMethodNotSupported = 'registrationMethodNotSupported' + registrationMethodNotSupported = 'registrationMethodNotSupported', + passwordChangeAuthorizationInvalid = 'passwordChangeAuthorizationInvalid', + newPasswordSameAsOld = 'newPasswordSameAsOld' } const errList = [ { @@ -59,6 +61,16 @@ const errList = [ statusText: UserErrEnum.registrationMethodNotSupported, message: i18nT('common:error.registration_method_not_supported'), httpStatus: 403 + }, + { + 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/cancellation/api.ts b/packages/global/openapi/support/user/account/cancellation/api.ts index 4d7e93238e14..0288a2a65e20 100644 --- a/packages/global/openapi/support/user/account/cancellation/api.ts +++ b/packages/global/openapi/support/user/account/cancellation/api.ts @@ -1,8 +1,8 @@ import { z } from 'zod'; -import { - AccountCancellationAllowedMethodSchema, - AccountCancellationUnavailableReasonSchema -} from '../../../../../support/user/account/cancellation/type'; +import { AccountExternalVerificationMethodSchema } from '../../../../../support/user/account/verification/type'; +import { oauthAccountVerificationMethods } from '../../../../../support/user/account/verification/constants'; +import type { OAuthAccountVerificationMethod } from '../../../../../support/user/account/verification/type'; +import { AccountCancellationUnavailableReasonSchema } from '../../../../../support/user/account/cancellation/type'; /* ============================================================================ * API: 账号注销 @@ -22,7 +22,7 @@ export const AccountCancellationStatusResponseSchema = z description: '是否允许发起注销申请', example: true }), - verificationMethod: AccountCancellationAllowedMethodSchema.optional().meta({ + verificationMethod: AccountExternalVerificationMethodSchema.optional().meta({ description: '当前账号可用的注销验证方式', example: 'code' }), @@ -75,18 +75,14 @@ const OAuthCreatePayloadSchema = z.object({ isWecomWorkTerminal: z.boolean().optional().meta({ description: '是否来自企业微信工作台' }) }); -const OAuthCreateMethodSchemas = [ - 'oauth/github', - 'oauth/google', - 'oauth/microsoft', - 'oauth/wecom', - 'oauth/sso' -] as const; +const createOAuthVerificationSchemaTuple = ( + createSchema: (method: OAuthAccountVerificationMethod) => Schema +) => oauthAccountVerificationMethods.map(createSchema) as [Schema, Schema, Schema, Schema, Schema]; export const CreateAccountCancellationVerificationBodySchema = z.discriminatedUnion('method', [ CodeVerificationCreateSchema, WechatVerificationCreateSchema, - ...OAuthCreateMethodSchemas.map((method) => + ...createOAuthVerificationSchemaTuple((method) => z.object({ method: z.literal(method).meta({ description: 'OAuth 验证方式', example: method }), payload: OAuthCreatePayloadSchema @@ -113,7 +109,7 @@ export const CreateAccountCancellationVerificationResponseSchema = z.discriminat .meta({ description: '微信二维码地址', example: 'https://mp.weixin.qq.com/...' }), expiredAt: DateTimeSchema.optional().meta({ description: '二维码过期时间' }) }), - ...OAuthCreateMethodSchemas.map((method) => + ...createOAuthVerificationSchemaTuple((method) => z.object({ method: z.literal(method), state: z.string().min(16).meta({ description: '一次性 OAuth state', example: 'state' }), @@ -172,7 +168,7 @@ const OAuthSubmitPayloadSchema = z.object({ export const SubmitAccountCancellationBodySchema = z.discriminatedUnion('method', [ CodeSubmitSchema, WechatSubmitSchema, - ...OAuthCreateMethodSchemas.map((method) => + ...createOAuthVerificationSchemaTuple((method) => z.object({ method: z.literal(method), payload: OAuthSubmitPayloadSchema }) ) ] as [typeof CodeSubmitSchema, typeof WechatSubmitSchema, ...any[]]); @@ -198,5 +194,3 @@ export const CancelAccountCancellationResponseSchema = z export type CancelAccountCancellationResponse = z.infer< typeof CancelAccountCancellationResponseSchema >; - -export { AccountCancellationAllowedMethodSchema }; diff --git a/packages/global/openapi/support/user/account/password/api.ts b/packages/global/openapi/support/user/account/password/api.ts index 373b133ea5a0..7c016692d3b5 100644 --- a/packages/global/openapi/support/user/account/password/api.ts +++ b/packages/global/openapi/support/user/account/password/api.ts @@ -3,32 +3,20 @@ import { LanguageSchema } from '../../../../../common/i18n/type'; import { AccountContactUsernameSchema, AccountPasswordSchema, - ShortAuthStringSchema + AccountVerificationMethodSchema, + ShortAuthStringSchema, + type OAuthAccountVerificationMethod } from '../../../../../support/user/account/verification/type'; +import { oauthAccountVerificationMethods } from '../../../../../support/user/account/verification/constants'; -// ===== Update password by old password ===== -export const UpdatePasswordByOldBodySchema = z - .object({ - oldPsw: AccountPasswordSchema.meta({ - example: 'hashed_old_password', - description: '旧密码(已加密)' - }), - newPsw: AccountPasswordSchema.meta({ - example: 'hashed_new_password', - description: '新密码(已加密)' - }) - }) - .meta({ - example: { - oldPsw: 'hashed_old_password', - newPsw: 'hashed_new_password' - } - }); -export type UpdatePasswordByOldBodyType = z.infer; -export const UpdatePasswordByOldResponseSchema = z.any().meta({ - description: '用户信息' -}); -export type UpdatePasswordByOldResponseType = z.infer; +/* ============================================================================ + * API: 安全修改密码 + * Routes: POST /proApi/support/user/account/password/verification/create + * POST /proApi/support/user/account/password/authorization + * POST /support/user/account/password/update + * Description: 创建身份验证材料、一次性改密 Session 并更新当前用户密码 + * Tags: ['User Login', 'Account Verification'] + * ============================================================================ */ // ===== Check password expired ===== export const CheckPswExpiredResponseSchema = z.boolean().meta({ @@ -37,25 +25,213 @@ export const CheckPswExpiredResponseSchema = z.boolean().meta({ }); export type CheckPswExpiredResponseType = z.infer; -// ===== Reset expired password ===== -export const ResetExpiredPswBodySchema = z +const DateTimeSchema = z.iso.datetime({ offset: true }); +const createOAuthVerificationSchemaTuple = ( + createSchema: (method: OAuthAccountVerificationMethod) => Schema +) => oauthAccountVerificationMethods.map(createSchema) as [Schema, Schema, Schema, Schema, Schema]; + +const OAuthCreatePayloadSchema = z .object({ - newPsw: AccountPasswordSchema.meta({ - example: 'hashed_new_password', - description: '新密码(已加密)' + callbackUrl: z.url().max(2048).meta({ + description: 'OAuth 回调地址', + example: 'https://fastgpt.example.com/login/provider' + }), + isWecomWorkTerminal: z.boolean().optional().meta({ + description: '是否来自企业微信工作台', + example: false }) }) - .meta({ - example: { - newPsw: 'hashed_new_password' - } + .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' }); -export type ResetExpiredPswBodyType = z.infer; +const OAuthConsumePayloadSchema = z + .object({ + callbackUrl: z.url().max(2048).meta({ + description: 'OAuth 回调地址', + example: 'https://fastgpt.example.com/login/provider' + }), + code: z.string().min(1).max(4096).meta({ + description: 'Provider 返回的一次性授权码', + example: 'provider-code' + }), + state: z.string().min(16).max(256).optional().meta({ + description: '创建验证材料时签发的 OAuth state', + example: 'state-abcdefghijklmnopqrstuvwxyz' + }), + props: OAuthPropsSchema.optional().meta({ description: 'SSO Provider 附加回调参数' }) + }) + .strict(); -export const ResetExpiredPswResponseSchema = z.undefined().meta({ - description: '重置成功' -}); -export type ResetExpiredPswResponseType = z.infer; +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' + }) + }) + .strict() + }) + .strict(); +const OldPasswordVerificationCreateSchema = z + .object({ + method: z.literal('oldPassword'), + payload: z.object({}).strict() + }) + .strict(); +const WechatVerificationCreateSchema = z + .object({ + method: z.literal('wechat'), + payload: z.object({}).strict() + }) + .strict(); +const OAuthVerificationCreateSchemas = createOAuthVerificationSchemaTuple((method) => + z.object({ method: z.literal(method), payload: OAuthCreatePayloadSchema }).strict() +); + +export const CreatePasswordVerificationBodySchema = z.discriminatedUnion('method', [ + CodeVerificationCreateSchema, + OldPasswordVerificationCreateSchema, + WechatVerificationCreateSchema, + ...OAuthVerificationCreateSchemas +]); +export type CreatePasswordVerificationBody = z.infer; + +const OAuthVerificationResponseSchemas = createOAuthVerificationSchemaTuple((method) => + z.object({ + method: z.literal(method), + state: z.string().min(16).meta({ description: 'OAuth state', example: 'state-value' }), + url: z.url().meta({ description: 'Provider 重新认证地址' }) + }) +); +export const CreatePasswordVerificationResponseSchema = z.discriminatedUnion('method', [ + z.object({ + method: z.literal('code'), + sent: z.literal(true), + maskedTarget: z.string().meta({ description: '验证码接收目标脱敏值' }) + }), + z.object({ + method: z.literal('oldPassword'), + preLoginCode: z.string().min(1).meta({ description: '绑定当前密码验证的短期材料' }) + }), + z.object({ + method: z.literal('wechat'), + code: z.string().min(16).meta({ description: '微信二维码场景码' }), + codeUrl: z.url().meta({ description: '微信二维码图片地址' }), + expiredAt: DateTimeSchema.optional().meta({ description: '二维码过期时间' }) + }), + ...OAuthVerificationResponseSchemas +]); +export type CreatePasswordVerificationResponse = z.infer< + typeof CreatePasswordVerificationResponseSchema +>; + +const CodeVerificationConsumeSchema = z + .object({ + method: z.literal('code'), + payload: z.object({ code: z.string().min(1).max(32) }).strict() + }) + .strict(); +const OldPasswordVerificationConsumeSchema = z + .object({ + method: z.literal('oldPassword'), + payload: z + .object({ + password: z.string().length(64), + preLoginCode: z.string().min(1).max(128) + }) + .strict() + }) + .strict(); +const WechatVerificationConsumeSchema = z + .object({ + method: z.literal('wechat'), + payload: z.object({ code: z.string().min(1).max(128) }).strict() + }) + .strict(); +const OAuthVerificationConsumeSchemas = createOAuthVerificationSchemaTuple((method) => + z.object({ method: z.literal(method), payload: OAuthConsumePayloadSchema }).strict() +); +export const SensitiveAccountVerificationBodySchema = z.discriminatedUnion('method', [ + CodeVerificationConsumeSchema, + OldPasswordVerificationConsumeSchema, + WechatVerificationConsumeSchema, + ...OAuthVerificationConsumeSchemas +]); +export type SensitiveAccountVerificationBody = z.infer< + typeof SensitiveAccountVerificationBodySchema +>; + +export const PasswordAuthorizationBodySchema = z.discriminatedUnion('source', [ + z + .object({ + source: z.literal('verificationMethod').meta({ + description: '请求服务端解析唯一验证方式', + example: 'verificationMethod' + }) + }) + .strict(), + z + .object({ + source: z.literal('accountVerification').meta({ + description: '消费账号身份验证材料', + example: 'accountVerification' + }), + verification: SensitiveAccountVerificationBodySchema.meta({ description: '身份验证材料' }) + }) + .strict() +]); +export type PasswordAuthorizationBody = z.infer; + +export const PasswordAuthorizationResponseSchema = z.discriminatedUnion('status', [ + z.object({ + status: z.literal('authorized'), + sessionId: z.string().min(1).max(128).meta({ description: '五分钟有效的一次性改密 Session' }), + expiredAt: DateTimeSchema.meta({ description: '改密授权过期时间' }) + }), + z.object({ + status: z.literal('verificationRequired'), + method: AccountVerificationMethodSchema.meta({ description: '服务端选择的唯一验证方式' }) + }), + z.object({ status: z.literal('verificationPending') }), + z.object({ status: z.literal('verificationExpired') }), + z.object({ + status: z.literal('verificationUnavailable'), + reason: z.literal('no_available_verification_method') + }) +]); +export type PasswordAuthorizationResponse = z.infer; + +export const UpdatePasswordBodySchema = z + .object({ + newPsw: z + .string() + .length(64) + .meta({ + description: '客户端 SHA-256 处理后的新密码摘要', + example: 'a'.repeat(64) + }), + passwordChangeSession: z.string().min(1).max(128).meta({ + description: '身份验证成功后签发的一次性改密 Session', + example: 'password-change-session' + }) + }) + .strict(); +export type UpdatePasswordBody = z.infer; + +export const UpdatePasswordResponseSchema = z.undefined().meta({ description: '密码设置成功' }); +export type UpdatePasswordResponse = z.infer; // ===== Find Password (update by code) ===== export const UpdatePasswordByCodeBodySchema = z.object({ diff --git a/packages/global/openapi/support/user/account/password/index.ts b/packages/global/openapi/support/user/account/password/index.ts index 55ee961735f4..264fc1ca9ee9 100644 --- a/packages/global/openapi/support/user/account/password/index.ts +++ b/packages/global/openapi/support/user/account/password/index.ts @@ -1,77 +1,75 @@ import type { OpenAPIPath } from '../../../../type'; import { DevApiTagsMap } from '../../../../tag'; import { - UpdatePasswordByOldBodySchema, - UpdatePasswordByOldResponseSchema, CheckPswExpiredResponseSchema, - ResetExpiredPswBodySchema, - ResetExpiredPswResponseSchema, - UpdatePasswordByCodeBodySchema + CreatePasswordVerificationBodySchema, + CreatePasswordVerificationResponseSchema, + PasswordAuthorizationBodySchema, + PasswordAuthorizationResponseSchema, + UpdatePasswordBodySchema, + UpdatePasswordByCodeBodySchema, + UpdatePasswordResponseSchema } from './api'; import { LoginSuccessResponseSchema } from '../login/api'; export const PasswordPath: OpenAPIPath = { - '/support/user/account/updatePasswordByOld': { + '/proApi/support/user/account/password/authorization': { post: { - summary: '通过旧密码修改密码', - description: '使用旧密码验证后修改为新密码,修改成功后其他会话将被注销', - tags: [DevApiTagsMap.userLogin], + summary: '获取修改密码授权', + description: '解析当前账号的唯一验证方式,或消费验证材料后创建一次性改密 Session', + tags: [DevApiTagsMap.userLogin, 'Account Verification'], requestBody: { - content: { - 'application/json': { - schema: UpdatePasswordByOldBodySchema - } - } + content: { 'application/json': { schema: PasswordAuthorizationBodySchema } } }, responses: { 200: { - description: '密码修改成功', - content: { - 'application/json': { - schema: UpdatePasswordByOldResponseSchema - } - } + description: '授权结果', + content: { 'application/json': { schema: PasswordAuthorizationResponseSchema } } } } } }, - '/support/user/account/checkPswExpired': { - get: { - summary: '检查密码是否过期', - description: '检查当前用户的密码是否已过期,需要强制修改', - tags: [DevApiTagsMap.userLogin], + '/proApi/support/user/account/password/verification/create': { + post: { + summary: '创建修改密码验证材料', + description: '创建绑定当前用户和 changePassword 场景的验证材料', + tags: [DevApiTagsMap.userLogin, 'Account Verification'], + requestBody: { + content: { 'application/json': { schema: CreatePasswordVerificationBodySchema } } + }, responses: { 200: { - description: '返回密码是否过期', - content: { - 'application/json': { - schema: CheckPswExpiredResponseSchema - } - } + description: '验证材料已创建', + content: { 'application/json': { schema: CreatePasswordVerificationResponseSchema } } } } } }, - '/support/user/account/resetExpiredPsw': { + '/support/user/account/password/update': { post: { - summary: '重置过期密码', - description: '当密码过期时,使用此接口重置密码,重置后其他会话将被注销', + summary: '设置或修改密码', + description: '使用当前 Session 和一次性改密 Session 设置或修改密码,并注销其他 Session', tags: [DevApiTagsMap.userLogin], requestBody: { - content: { - 'application/json': { - schema: ResetExpiredPswBodySchema - } - } + content: { 'application/json': { schema: UpdatePasswordBodySchema } } }, responses: { 200: { - description: '密码重置成功', - content: { - 'application/json': { - schema: ResetExpiredPswResponseSchema - } - } + description: '密码设置成功', + content: { 'application/json': { schema: UpdatePasswordResponseSchema } } + } + } + } + }, + '/support/user/account/checkPswExpired': { + get: { + summary: '检查密码是否过期', + description: '无密码账号和 root 返回 false;其他账号按密码更新时间规则判断', + tags: [DevApiTagsMap.userLogin], + responses: { + 200: { + description: '返回密码是否过期', + content: { 'application/json': { schema: CheckPswExpiredResponseSchema } } } } } @@ -82,20 +80,12 @@ export const PasswordPath: OpenAPIPath = { description: '通过邮箱/手机验证码找回或修改密码', tags: [DevApiTagsMap.userLogin], requestBody: { - content: { - 'application/json': { - schema: UpdatePasswordByCodeBodySchema - } - } + content: { 'application/json': { schema: UpdatePasswordByCodeBodySchema } } }, responses: { 200: { description: '修改成功', - content: { - 'application/json': { - schema: LoginSuccessResponseSchema - } - } + content: { 'application/json': { schema: LoginSuccessResponseSchema } } } } } diff --git a/packages/global/support/tmpData/constants.ts b/packages/global/support/tmpData/constants.ts index 67ba77c5d6a2..8e30d6285de5 100644 --- a/packages/global/support/tmpData/constants.ts +++ b/packages/global/support/tmpData/constants.ts @@ -2,9 +2,11 @@ export enum TmpDataEnum { FeishuAccessToken = 'feishu_access_token', WecomAccessToken = 'wecom_access_token', OffiAccountAccessToken = 'offiaccount_access_token', - MyModels = 'my_models' + MyModels = 'my_models', + PasswordChangeSession = 'password_change_session' } +// PasswordChangeSession 使用专用的哈希 dataId,不通过通用 metadata 拼接原始 Session,避免敏感凭证落库。 type _TmpDataMetadata = { [TmpDataEnum.FeishuAccessToken]: { FeishuAppId: string; @@ -39,14 +41,20 @@ type _TmpDataType = { version: string; catalogRevision?: number; }; + [TmpDataEnum.PasswordChangeSession]: { + userId: string; + loginSessionId: string; + }; }; export const TmpDataExpireTime = { [TmpDataEnum.FeishuAccessToken]: 1000 * 60 * 60 * 1.5, // 1.5 hours [TmpDataEnum.WecomAccessToken]: 1000 * 60 * 60 * 2, // 2 hours [TmpDataEnum.OffiAccountAccessToken]: 1000 * 60 * 60 * 2, // 2 hours - [TmpDataEnum.MyModels]: 1000 * 60 * 60 // 1 hour + [TmpDataEnum.MyModels]: 1000 * 60 * 60, // 1 hour + [TmpDataEnum.PasswordChangeSession]: 1000 * 60 * 5 // 5 minutes }; -export type TmpDataMetadata = _TmpDataMetadata[T]; +export type TmpDataWithMetadataEnum = keyof _TmpDataMetadata; +export type TmpDataMetadata = _TmpDataMetadata[T]; export type TmpDataType = _TmpDataType[T]; diff --git a/packages/global/support/user/account/cancellation/constants.ts b/packages/global/support/user/account/cancellation/constants.ts index 3ee735a55b0f..408affc0f608 100644 --- a/packages/global/support/user/account/cancellation/constants.ts +++ b/packages/global/support/user/account/cancellation/constants.ts @@ -11,16 +11,6 @@ export const accountCancellationActiveStatuses = [ AccountCancellationStatus.finalizing ] as const; -export const accountCancellationAllowedMethods = [ - 'code', - 'wechat', - 'oauth/github', - 'oauth/google', - 'oauth/microsoft', - 'oauth/wecom', - 'oauth/sso' -] as const; - export const AccountCancellationReminder = { sevenDays: '7d', oneDay: '1d', diff --git a/packages/global/support/user/account/cancellation/index.ts b/packages/global/support/user/account/cancellation/index.ts index 1b70c922b439..66dabdb76c55 100644 --- a/packages/global/support/user/account/cancellation/index.ts +++ b/packages/global/support/user/account/cancellation/index.ts @@ -3,7 +3,6 @@ export { AccountCancellationStatus, AccountCancellationUnavailableReason, accountCancellationActiveStatuses, - accountCancellationAllowedMethods, accountCancellationStatusMap, accountCancellationWaitDays } from './constants'; diff --git a/packages/global/support/user/account/cancellation/resolver.ts b/packages/global/support/user/account/cancellation/resolver.ts index 00aab01189d7..4d999c31e0f6 100644 --- a/packages/global/support/user/account/cancellation/resolver.ts +++ b/packages/global/support/user/account/cancellation/resolver.ts @@ -8,10 +8,22 @@ export const resolveAccountCancellationByUsername = ({ }: AccountCancellationResolverInput): AccountCancellationResolveResult => { const result = resolveAccountVerificationByUsername({ username: username ?? '', - capabilities + capabilities, + // 注销不实际使用密码;这里保留 fallback 仅用于将该分类收窄为既有注销错误。 + allowPasswordFallback: true, + oldPasswordAvailable: true }); - if (result.status === 'unsupported') return result; + if (result.status === 'unsupported') { + return { + status: 'unsupported', + accountKind: result.accountKind, + unsupportedReason: + result.unsupportedReason === 'empty_username' + ? 'empty_username' + : 'verification_unavailable' + }; + } if (result.method === 'oldPassword') { return { diff --git a/packages/global/support/user/account/cancellation/type.ts b/packages/global/support/user/account/cancellation/type.ts index 4a3b3027165a..1dbcc93a6428 100644 --- a/packages/global/support/user/account/cancellation/type.ts +++ b/packages/global/support/user/account/cancellation/type.ts @@ -2,9 +2,12 @@ import { z } from 'zod'; import { AccountCancellationStatus as AccountCancellationStatusValues, AccountCancellationReminder as AccountCancellationReminderValues, - AccountCancellationUnavailableReason as AccountCancellationUnavailableReasonValues, - accountCancellationAllowedMethods + AccountCancellationUnavailableReason as AccountCancellationUnavailableReasonValues } from './constants'; +import type { + AccountExternalVerificationMethod, + OAuthAccountVerificationProvider +} from '../verification/type'; export const AccountCancellationStatusSchema = z.enum(AccountCancellationStatusValues); export type AccountCancellationStatus = z.infer; @@ -20,11 +23,6 @@ export const AccountCancellationSummarySchema = z.object({ }); export type AccountCancellationSummary = z.infer; -export const AccountCancellationAllowedMethodSchema = z.enum(accountCancellationAllowedMethods); -export type AccountCancellationAllowedMethod = z.infer< - typeof AccountCancellationAllowedMethodSchema ->; - export const AccountCancellationReminderSchema = z.enum(AccountCancellationReminderValues); export type AccountCancellationReminder = z.infer; @@ -46,14 +44,12 @@ export type TeamAccountCancellationSummary = { scheduledCancelAt?: Date | string; }; -export type AccountCancellationOAuthProvider = 'github' | 'google' | 'microsoft' | 'wecom' | 'sso'; - export type AccountCancellationVerificationCapabilities = { emailCode: boolean; phoneCode: boolean; accountCancellation?: boolean; wechat: boolean; - oauth: Record; + oauth: Record; }; export type AccountCancellationResolverInput = { @@ -64,7 +60,7 @@ export type AccountCancellationResolverInput = { export type AccountCancellationResolveResult = | { status: 'supported'; - method: AccountCancellationAllowedMethod; + method: AccountExternalVerificationMethod; accountKind: string; unsupportedReason?: undefined; } diff --git a/packages/global/support/user/account/cancellation/utils.ts b/packages/global/support/user/account/cancellation/utils.ts index d8ecffb9e93b..31c4a9c81769 100644 --- a/packages/global/support/user/account/cancellation/utils.ts +++ b/packages/global/support/user/account/cancellation/utils.ts @@ -1,8 +1,8 @@ import { accountCancellationWaitDays, - AccountCancellationReminder as AccountCancellationReminderValues, - accountCancellationAllowedMethods + AccountCancellationReminder as AccountCancellationReminderValues } from './constants'; +import { accountExternalVerificationMethods } from '../verification/constants'; import type { AccountCancellationReminder, AccountCancellationSchedule } from './type'; const dayInMilliseconds = 24 * 60 * 60 * 1000; @@ -140,7 +140,7 @@ export const getAccountCancellationPendingDueCutoff = ({ now }: { now: Date }) = }; export const isAccountCancellationMethod = (method: string) => - (accountCancellationAllowedMethods as readonly string[]).includes(method); + (accountExternalVerificationMethods as readonly string[]).includes(method); /** * 判断用户名是否由账号注销流程生成,同时兼容已落库的历史匿名用户名格式。 diff --git a/packages/global/support/user/account/verification/constants.ts b/packages/global/support/user/account/verification/constants.ts index 97f6b3a44f2a..08ab5b58e405 100644 --- a/packages/global/support/user/account/verification/constants.ts +++ b/packages/global/support/user/account/verification/constants.ts @@ -2,14 +2,21 @@ export enum VerificationCodeTypeEnum { register = 'register', findPassword = 'findPassword', + passwordChange = 'passwordChange', unsubscribe = 'unsubscribe', bindNotification = 'bindNotification' } -export const accountVerificationMethods = [ - 'code', - 'oldPassword', - 'wechat', +/** 账号身份验证支持的 OAuth provider,微信扫码在验证 method 中单独处理。 */ +export const oauthAccountVerificationProviders = [ + 'github', + 'google', + 'microsoft', + 'wecom', + 'sso' +] as const; + +export const oauthAccountVerificationMethods = [ 'oauth/github', 'oauth/google', 'oauth/microsoft', @@ -17,6 +24,17 @@ export const accountVerificationMethods = [ 'oauth/sso' ] as const; +export const accountExternalVerificationMethods = [ + 'code', + 'wechat', + ...oauthAccountVerificationMethods +] as const; + +export const accountVerificationMethods = [ + ...accountExternalVerificationMethods, + 'oldPassword' +] as const; + export const recognizedAccountKinds = [ 'email', 'phone', diff --git a/packages/global/support/user/account/verification/type.ts b/packages/global/support/user/account/verification/type.ts index 2d804df2d715..791e051572b6 100644 --- a/packages/global/support/user/account/verification/type.ts +++ b/packages/global/support/user/account/verification/type.ts @@ -1,6 +1,9 @@ import { z } from 'zod'; import { + accountExternalVerificationMethods, accountVerificationMethods, + oauthAccountVerificationMethods, + oauthAccountVerificationProviders, recognizedAccountKinds, VerificationCodeTypeEnum } from './constants'; @@ -33,9 +36,9 @@ export const VERIFICATION_TYPES = ['password', 'code', 'captcha', 'wechat', 'oau export type VerificationType = (typeof VERIFICATION_TYPES)[number]; export const VERIFICATION_SCENES_BY_TYPE = { - password: ['login'], - code: ['register', 'forgetPassword', 'unsubscribe', 'bindNotification'], - captcha: ['register', 'forgetPassword', 'unsubscribe', 'bindNotification'], + password: ['login', 'changePassword'], + code: ['register', 'forgetPassword', 'changePassword', 'unsubscribe', 'bindNotification'], + captcha: ['register', 'forgetPassword', 'changePassword', 'unsubscribe', 'bindNotification'], // The callback adapter discovers the scene from all active QR materials. wechat: ACCOUNT_VERIFICATION_PURPOSES, oauth: ['login'] @@ -83,6 +86,7 @@ export type VerificationMaterialMatch = Partial<{ export const VERIFICATION_CODE_TYPES = [ VerificationCodeTypeEnum.register, VerificationCodeTypeEnum.findPassword, + VerificationCodeTypeEnum.passwordChange, VerificationCodeTypeEnum.unsubscribe, VerificationCodeTypeEnum.bindNotification ] as const; @@ -98,6 +102,7 @@ export type CodeVerificationPurpose = z.infer; @@ -129,6 +134,7 @@ export type PasswordVerificationPurpose = z.infer; @@ -152,9 +158,22 @@ export const AccountLoginUsernameSchema = z.union([ AccountUsernameSchema ]); +export const AccountExternalVerificationMethodSchema = z.enum(accountExternalVerificationMethods); +export type AccountExternalVerificationMethod = z.infer< + typeof AccountExternalVerificationMethodSchema +>; + export const AccountVerificationMethodSchema = z.enum(accountVerificationMethods); export type AccountVerificationMethod = z.infer; +export const OAuthAccountVerificationProviderSchema = z.enum(oauthAccountVerificationProviders); +export type OAuthAccountVerificationProvider = z.infer< + typeof OAuthAccountVerificationProviderSchema +>; + +export const OAuthAccountVerificationMethodSchema = z.enum(oauthAccountVerificationMethods); +export type OAuthAccountVerificationMethod = z.infer; + export const AccountVerificationCapabilitiesSchema = z.object({ emailCode: z.boolean(), phoneCode: z.boolean(), @@ -172,6 +191,17 @@ export type AccountVerificationCapabilities = z.infer; +export const AccountKindSchema = z.union([RecognizedAccountKindSchema, z.literal('invalid')]); +export type AccountKind = z.infer; + +export const AccountVerificationUnsupportedReasonSchema = z.enum([ + 'empty_username', + 'no_available_verification_method' +]); +export type AccountVerificationUnsupportedReason = z.infer< + typeof AccountVerificationUnsupportedReasonSchema +>; + export const AccountVerificationResolutionSchema = z.discriminatedUnion('status', [ z.object({ status: z.literal('supported'), @@ -181,9 +211,19 @@ export const AccountVerificationResolutionSchema = z.discriminatedUnion('status' }), z.object({ status: z.literal('unsupported'), - accountKind: z.literal('invalid'), + accountKind: AccountKindSchema, method: z.undefined().optional(), - unsupportedReason: z.literal('empty_username') + unsupportedReason: AccountVerificationUnsupportedReasonSchema }) ]); export type AccountVerificationResolution = z.infer; + +export type AccountVerificationPasswordPolicy = + | { + allowPasswordFallback: false; + oldPasswordAvailable?: never; + } + | { + allowPasswordFallback: true; + oldPasswordAvailable: boolean; + }; diff --git a/packages/global/support/user/account/verification/utils.ts b/packages/global/support/user/account/verification/utils.ts index 0ed4ee94d48a..110dd669bfe3 100644 --- a/packages/global/support/user/account/verification/utils.ts +++ b/packages/global/support/user/account/verification/utils.ts @@ -3,21 +3,24 @@ import { AccountPhoneUsernameSchema, type AccountVerificationCapabilities, type AccountVerificationMethod, + type AccountVerificationPasswordPolicy, type AccountVerificationResolution, type RecognizedAccountKind } from './type'; /** - * 根据持久化 username 和部署能力推导唯一验证方式。 - * 当账号没有可用的外部验证方式时,统一降级为密码验证。 + * 该纯函数只做账号分类和验证方式选择,不读取运行环境。 + * 只有调用方显式允许且数据库确认存在密码时,才降级为旧密码验证。 */ export const resolveAccountVerificationByUsername = ({ username, - capabilities + capabilities, + allowPasswordFallback, + oldPasswordAvailable }: { username: string; capabilities: AccountVerificationCapabilities; -}): AccountVerificationResolution => { +} & AccountVerificationPasswordPolicy): AccountVerificationResolution => { const normalizedUsername = username.trim(); if (!normalizedUsername) { return { @@ -97,9 +100,26 @@ export const resolveAccountVerificationByUsername = ({ } }; + const method = candidateMethods.find(isMethodAvailable); + if (method) { + return { + status: 'supported', + accountKind, + method + }; + } + + if (allowPasswordFallback && oldPasswordAvailable) { + return { + status: 'supported', + accountKind, + method: 'oldPassword' + }; + } + return { - status: 'supported', + status: 'unsupported', accountKind, - method: candidateMethods.find(isMethodAvailable) ?? 'oldPassword' + unsupportedReason: 'no_available_verification_method' }; }; diff --git a/packages/global/support/user/type.ts b/packages/global/support/user/type.ts index 4434f94f5b68..fbbd2327df41 100644 --- a/packages/global/support/user/type.ts +++ b/packages/global/support/user/type.ts @@ -19,7 +19,7 @@ export type UserMetaType = { export type UserModelSchema = { _id: string; username: string; - password: string; + password?: string; openaiKey: string; createTime: number; timezone: string; @@ -43,7 +43,8 @@ export const UserSchema = z.object({ team: TeamTmbItemSchema, permission: z.instanceof(TeamPermission), contact: z.string().nullish(), - tags: z.array(UserTagsSchema).optional() + tags: z.array(UserTagsSchema).optional(), + hasPassword: z.boolean() }); export type UserType = z.infer; diff --git a/packages/global/support/user/utils.ts b/packages/global/support/user/utils.ts index b0db78a92d95..3cc217064004 100644 --- a/packages/global/support/user/utils.ts +++ b/packages/global/support/user/utils.ts @@ -14,3 +14,7 @@ export const getRandomUserAvatar = () => { return defaultAvatars[Math.floor(Math.random() * defaultAvatars.length)]; }; + +/** 历史缺失、null 和空字符串都表示用户尚未设置本地密码。 */ +export const hasStoredPassword = (password: unknown): password is string => + typeof password === 'string' && password.length > 0; diff --git a/packages/global/test/openapi/support/user/account/login/api.test.ts b/packages/global/test/openapi/support/user/account/login/api.test.ts index 87d400612aa7..4ccab40760a1 100644 --- a/packages/global/test/openapi/support/user/account/login/api.test.ts +++ b/packages/global/test/openapi/support/user/account/login/api.test.ts @@ -14,11 +14,7 @@ import { WxLoginResultResponseSchema } from '../../../../../../openapi/support/user/account/login/api'; import { GetImgCaptchaQuerySchema } from '../../../../../../openapi/support/user/account/captcha/api'; -import { - ResetExpiredPswBodySchema, - UpdatePasswordByCodeBodySchema, - UpdatePasswordByOldBodySchema -} from '../../../../../../openapi/support/user/account/password/api'; +import { UpdatePasswordByCodeBodySchema } from '../../../../../../openapi/support/user/account/password/api'; import { AccountRegisterBodySchema } from '../../../../../../openapi/support/user/account/register/api'; import { AccountEmailUsernameSchema, @@ -152,14 +148,6 @@ describe('user account OpenAPI contracts', () => { ).toThrow(); }); - it.each([ - ['old password', UpdatePasswordByOldBodySchema, { oldPsw: 'a'.repeat(101), newPsw: 'new' }], - ['new password', UpdatePasswordByOldBodySchema, { oldPsw: 'old', newPsw: 'a'.repeat(101) }], - ['expired password', ResetExpiredPswBodySchema, { newPsw: 'a'.repeat(101) }] - ] as const)('rejects an overlong %s', (_name, schema, body) => { - expect(() => schema.parse(body)).toThrow(); - }); - it('strips a client-supplied team member ID from password reset input', () => { expect( UpdatePasswordByCodeBodySchema.parse({ @@ -175,14 +163,6 @@ describe('user account OpenAPI contracts', () => { }); }); - it.each([ - ['old password', UpdatePasswordByOldBodySchema, { oldPsw: ' ', newPsw: 'new' }], - ['new password', UpdatePasswordByOldBodySchema, { oldPsw: 'old', newPsw: ' ' }], - ['expired password', ResetExpiredPswBodySchema, { newPsw: ' ' }] - ] as const)('rejects a blank %s', (_name, schema, body) => { - expect(() => schema.parse(body)).toThrow(); - }); - it('does not apply request limits to authentication response strings', () => { const longToken = 't'.repeat(101); @@ -202,6 +182,7 @@ describe('user account OpenAPI contracts', () => { avatar: '/icon/avatar.svg', timezone: 'Asia/Shanghai', contact: null, + hasPassword: true, team: { userId: objectIdLike, teamId: objectIdLike, @@ -231,6 +212,7 @@ describe('user account OpenAPI contracts', () => { username: 'user@example.com', avatar: '/icon/avatar.svg', timezone: 'Asia/Shanghai', + hasPassword: true, team: { userId: objectIdLike, teamId: objectIdLike, 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 new file mode 100644 index 000000000000..0ad71f05dade --- /dev/null +++ b/packages/global/test/openapi/support/user/account/password/api.test.ts @@ -0,0 +1,87 @@ +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 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: {} }) + ).toEqual({ method: 'oldPassword', payload: {} }); + expect(() => + CreatePasswordVerificationBodySchema.parse({ + method: 'oldPassword', + payload: {}, + username: 'other-user' + }) + ).toThrow(); + }); + + it('requires a bounded password digest and pre-login code', () => { + expect( + SensitiveAccountVerificationBodySchema.parse({ + method: 'oldPassword', + payload: { password: 'a'.repeat(64), preLoginCode: 'pre-login-code' } + }) + ).toMatchObject({ method: 'oldPassword' }); + expect(() => + SensitiveAccountVerificationBodySchema.parse({ + method: 'oldPassword', + payload: { password: 'plain-text', preLoginCode: 'pre-login-code' } + }) + ).toThrow(); + }); + + it('keeps the verification-flow initializer strict', () => { + expect(PasswordAuthorizationBodySchema.parse({ source: 'verificationMethod' })).toEqual({ + source: 'verificationMethod' + }); + expect(() => + PasswordAuthorizationBodySchema.parse({ source: 'verificationMethod', userId: 'other-user' }) + ).toThrow(); + expect(() => PasswordAuthorizationBodySchema.parse({ source: 'recentLogin' })).toThrow(); + }); + + it('requires a SHA-256 digest and bounded authorization token', () => { + expect( + UpdatePasswordBodySchema.parse({ + newPsw: 'b'.repeat(64), + passwordChangeSession: 'session' + }) + ).toBeDefined(); + expect(() => + UpdatePasswordBodySchema.parse({ newPsw: 'short', passwordChangeSession: 'session' }) + ).toThrow(); + expect(() => + UpdatePasswordBodySchema.parse({ + newPsw: 'b'.repeat(64), + passwordChangeSession: 'session', + confirmPsw: 'b'.repeat(64) + }) + ).toThrow(); + }); +}); diff --git a/packages/global/test/support/user/account/cancellation/type.test.ts b/packages/global/test/support/user/account/cancellation/type.test.ts index 1f5d9c8ab276..11a4c869dd56 100644 --- a/packages/global/test/support/user/account/cancellation/type.test.ts +++ b/packages/global/test/support/user/account/cancellation/type.test.ts @@ -1,6 +1,5 @@ import { describe, expect, it } from 'vitest'; import { - AccountCancellationAllowedMethodSchema, AccountCancellationReminderSchema, AccountCancellationStatusSchema, AccountCancellationUnavailableReasonSchema, @@ -9,9 +8,10 @@ import { import { AccountCancellationReminder, AccountCancellationStatus, - AccountCancellationUnavailableReason, - accountCancellationAllowedMethods + AccountCancellationUnavailableReason } from '@fastgpt/global/support/user/account/cancellation/constants'; +import { AccountExternalVerificationMethodSchema } from '@fastgpt/global/support/user/account/verification/type'; +import { accountExternalVerificationMethods } from '@fastgpt/global/support/user/account/verification/constants'; describe('account cancellation schemas', () => { it('accepts and rejects cancellation statuses', () => { @@ -42,10 +42,10 @@ describe('account cancellation schemas', () => { ) ).toBe(true); expect( - accountCancellationAllowedMethods.every( - (value) => AccountCancellationAllowedMethodSchema.safeParse(value).success + accountExternalVerificationMethods.every( + (value) => AccountExternalVerificationMethodSchema.safeParse(value).success ) ).toBe(true); - expect(AccountCancellationAllowedMethodSchema.safeParse('oauth/unknown').success).toBe(false); + expect(AccountExternalVerificationMethodSchema.safeParse('oauth/unknown').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 b80bb68d00e9..efce62ac8042 100644 --- a/packages/global/test/support/user/account/verification/utils.test.ts +++ b/packages/global/test/support/user/account/verification/utils.test.ts @@ -15,6 +15,22 @@ const capabilities: AccountVerificationCapabilities = { } }; +const resolve = ({ + username, + capabilities: currentCapabilities = capabilities, + oldPasswordAvailable = true +}: { + username: string; + capabilities?: AccountVerificationCapabilities; + oldPasswordAvailable?: boolean; +}) => + resolveAccountVerificationByUsername({ + username, + capabilities: currentCapabilities, + allowPasswordFallback: true, + oldPasswordAvailable + }); + describe('resolveAccountVerificationByUsername', () => { it.each([ ['user@example.com', 'email', 'code'], @@ -25,7 +41,7 @@ describe('resolveAccountVerificationByUsername', () => { ['microsoft-user', 'microsoft', 'oauth/microsoft'], ['tenant-user', 'sso', 'oauth/sso'] ] as const)('resolves %s to %s verification', (username, accountKind, method) => { - expect(resolveAccountVerificationByUsername({ username, capabilities })).toEqual({ + expect(resolve({ username })).toEqual({ status: 'supported', accountKind, method @@ -33,14 +49,12 @@ describe('resolveAccountVerificationByUsername', () => { }); it('prefers SSO over the standalone WeCom provider', () => { - expect( - resolveAccountVerificationByUsername({ username: 'wecom-user', capabilities }) - ).toMatchObject({ method: 'oauth/sso' }); + expect(resolve({ username: 'wecom-user' })).toMatchObject({ method: 'oauth/sso' }); }); it('falls back to the standalone WeCom provider when SSO is unavailable', () => { expect( - resolveAccountVerificationByUsername({ + resolve({ username: 'wecom-user', capabilities: { ...capabilities, oauth: { ...capabilities.oauth, sso: false } } }) @@ -52,13 +66,22 @@ describe('resolveAccountVerificationByUsername', () => { ['user@example.com', { ...capabilities, emailCode: false }], ['git-octocat', { ...capabilities, oauth: { ...capabilities.oauth, github: false } }] ] as const)('falls back to password verification for %s', (username, currentCapabilities) => { - expect( - resolveAccountVerificationByUsername({ username, capabilities: currentCapabilities }) - ).toMatchObject({ status: 'supported', method: 'oldPassword' }); + expect(resolve({ username, capabilities: currentCapabilities })).toMatchObject({ + status: 'supported', + method: 'oldPassword' + }); + }); + + it('does not expose old-password verification when no password is stored', () => { + expect(resolve({ username: 'local', oldPasswordAvailable: false })).toEqual({ + status: 'unsupported', + accountKind: 'local', + unsupportedReason: 'no_available_verification_method' + }); }); it('rejects an empty username', () => { - expect(resolveAccountVerificationByUsername({ username: ' ', capabilities })).toEqual({ + expect(resolve({ username: ' ' })).toEqual({ status: 'unsupported', accountKind: 'invalid', unsupportedReason: 'empty_username' diff --git a/packages/global/test/support/user/utils.test.ts b/packages/global/test/support/user/utils.test.ts index 0cd94c277718..a5f4516b149e 100644 --- a/packages/global/test/support/user/utils.test.ts +++ b/packages/global/test/support/user/utils.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { getRandomUserAvatar } from '@fastgpt/global/support/user/utils'; +import { getRandomUserAvatar, hasStoredPassword } from '@fastgpt/global/support/user/utils'; describe('user/utils', () => { describe('getRandomUserAvatar', () => { @@ -43,4 +43,14 @@ describe('user/utils', () => { } }); }); + + describe('hasStoredPassword', () => { + it.each([undefined, null, '', 0, false])('treats %j as no stored password', (password) => { + expect(hasStoredPassword(password)).toBe(false); + }); + + it.each(['digest', ' '])('treats a non-empty string as a stored password', (password) => { + expect(hasStoredPassword(password)).toBe(true); + }); + }); }); diff --git a/packages/service/env.ts b/packages/service/env.ts index 80fe4c61774c..ff2bd6b96c96 100644 --- a/packages/service/env.ts +++ b/packages/service/env.ts @@ -44,7 +44,6 @@ export const serviceEnv = createEnv({ // Invoke 反向调用相关。该密钥用于签发/校验插件反向调用 JWT,必须显式配置,避免未配置时落到公开默认值。 INVOKE_TOKEN_SECRET: z.string().min(32, 'INVOKE_TOKEN_SECRET must be at least 32 characters'), - // ==================== 服务地址与集成 ==================== // 插件 PLUGIN_BASE_URL: UrlSchema.default('http://localhost:3004'), diff --git a/packages/service/support/tmpData/controller.ts b/packages/service/support/tmpData/controller.ts index bf42ad942b0d..79316f5ec871 100644 --- a/packages/service/support/tmpData/controller.ts +++ b/packages/service/support/tmpData/controller.ts @@ -1,18 +1,18 @@ -import type { TmpDataEnum } from '@fastgpt/global/support/tmpData/constants'; import { TmpDataExpireTime, type TmpDataMetadata, - type TmpDataType + type TmpDataType, + type TmpDataWithMetadataEnum } from '@fastgpt/global/support/tmpData/constants'; import { MongoTmpData } from './schema'; import { type TmpDataSchema } from '@fastgpt/global/support/tmpData/type'; import { addMilliseconds } from 'date-fns'; -function getDataId(type: T, metadata: TmpDataMetadata) { +function getDataId(type: T, metadata: TmpDataMetadata) { return `${type}--${Object.values(metadata).join('--')}`; } -export async function getTmpData({ +export async function getTmpData({ type, metadata }: { @@ -26,7 +26,7 @@ export async function getTmpData({ }).lean()) as TmpDataSchema> | null; } -export function setTmpData({ +export function setTmpData({ type, metadata, data diff --git a/packages/service/support/user/account/password/service.ts b/packages/service/support/user/account/password/service.ts new file mode 100644 index 000000000000..3d1f0f454558 --- /dev/null +++ b/packages/service/support/user/account/password/service.ts @@ -0,0 +1,102 @@ +import { randomBytes } from 'node:crypto'; +import { UserErrEnum } from '@fastgpt/global/common/error/code/user'; +import { UserError } from '@fastgpt/global/common/error/utils'; +import { hashStr } from '@fastgpt/global/common/string/tools'; +import { TmpDataEnum, TmpDataExpireTime } from '@fastgpt/global/support/tmpData/constants'; +import { mongoSessionRun } from '../../../../common/mongo/sessionRun'; +import { type ClientSession } from '../../../../common/mongo'; +import { MongoTmpData } from '../../../tmpData/schema'; +import { MongoUser } from '../../schema'; + +export const PASSWORD_CHANGE_SESSION_TTL_SECONDS = 5 * 60; + +type PasswordChangeSessionData = { + userId: string; + loginSessionId: string; +}; + +type PasswordChangeSession = { + sessionId: string; + expiredAt: string; +}; + +const getPasswordChangeSessionDataId = (sessionId: string) => + `${TmpDataEnum.PasswordChangeSession}--${hashStr(sessionId)}`; + +/** 创建绑定当前用户和当前登录 Session 的一次性改密 Session,原始值只返回给前端。 */ +export const createPasswordChangeSession = async ({ + userId, + loginSessionId +}: PasswordChangeSessionData): Promise => { + const sessionId = randomBytes(32).toString('base64url'); + const expiredAt = new Date(Date.now() + TmpDataExpireTime[TmpDataEnum.PasswordChangeSession]); + + await MongoTmpData.create({ + dataId: getPasswordChangeSessionDataId(sessionId), + data: { userId, loginSessionId }, + expireAt: expiredAt + }); + + return { sessionId, expiredAt: expiredAt.toISOString() }; +}; + +/** + * 在同一 Mongo 事务中校验并消费改密 Session,同时执行密码更新。 + * 改密 Session 绑定当前登录端且只在密码更新成功后删除,避免并发重放和失败误消费。 + */ +export const consumePasswordChangeSessionInTransaction = async ({ + sessionId, + userId, + loginSessionId, + newPassword, + handler +}: PasswordChangeSessionData & { + sessionId: string; + newPassword: string; + handler: (session: ClientSession) => Promise; +}) => + mongoSessionRun(async (session) => { + const dataId = getPasswordChangeSessionDataId(sessionId); + const record = await MongoTmpData.findOne({ + dataId, + expireAt: { $gt: new Date() }, + 'data.userId': userId, + 'data.loginSessionId': loginSessionId + }) + .session(session) + .lean(); + + if (!record) throw new UserError(UserErrEnum.passwordChangeAuthorizationInvalid); + + await assertNewPasswordDiffersFromCurrent({ userId, newPassword, session }); + const result = await handler(session); + const deleted = await MongoTmpData.deleteOne( + { + dataId, + expireAt: { $gt: new Date() }, + 'data.userId': userId, + 'data.loginSessionId': loginSessionId + }, + { session } + ); + if (deleted.deletedCount !== 1) { + throw new UserError(UserErrEnum.passwordChangeAuthorizationInvalid); + } + + return result; + }); + +/** 拒绝将当前持久化密码再次设置为新密码。Schema setter 负责沿用现有双层哈希协议。 */ +export const assertNewPasswordDiffersFromCurrent = async ({ + userId, + newPassword, + session +}: { + userId: string; + newPassword: string; + session?: ClientSession; +}) => { + const query = MongoUser.exists({ _id: userId, password: newPassword }); + if (session) query.session(session); + if (await query) throw new UserError(UserErrEnum.newPasswordSameAsOld); +}; diff --git a/packages/service/support/user/controller.ts b/packages/service/support/user/controller.ts index 305b02a0f1ec..9486bf9a5062 100644 --- a/packages/service/support/user/controller.ts +++ b/packages/service/support/user/controller.ts @@ -7,6 +7,7 @@ import type { ClientSession } from '../../common/mongo'; import { getUserFallbackTeam } from './team/fallback'; import { getActiveAccountCancellationByUserId } from './account/cancellation/read'; import { formatTeamAccountCancellationSummary } from './account/cancellation/formatter'; +import { hasStoredPassword } from '@fastgpt/global/support/user/utils'; export async function authUserExist({ userId, username }: { userId?: string; username?: string }) { if (userId) { @@ -61,7 +62,7 @@ export async function getUserDetail({ } return Promise.reject(ERROR_ENUM.unAuthorization); })(); - const query = MongoUser.findById(tmb.userId); + const query = MongoUser.findById(tmb.userId).select('+password'); if (session) query.session(session); const user = await query; @@ -88,6 +89,7 @@ export async function getUserDetail({ tags: user.tags, ...(accountCancellation ? { accountCancellation: formatTeamAccountCancellationSummary(accountCancellation) } - : {}) + : {}), + hasPassword: hasStoredPassword(user.password) }; } diff --git a/packages/service/support/user/schema.ts b/packages/service/support/user/schema.ts index 5b8b2edeff5c..4a1dc2a71c1e 100644 --- a/packages/service/support/user/schema.ts +++ b/packages/service/support/user/schema.ts @@ -8,6 +8,10 @@ import { LangEnum } from '@fastgpt/global/common/i18n/type'; export const userCollectionName = 'users'; +// 历史缺失、null 和空字符串必须保留为“无密码”,不能被哈希成有效摘要。 +const hashPasswordValue = (value: unknown) => + typeof value === 'string' && value.length > 0 ? hashStr(value) : value; + const UserSchema = new Schema({ status: { type: String, @@ -21,9 +25,9 @@ const UserSchema = new Schema({ }, password: { type: String, - required: true, - set: (val: string) => hashStr(val), - get: (val: string) => hashStr(val), + required: false, + set: hashPasswordValue, + get: hashPasswordValue, select: false }, passwordUpdateTime: Date, diff --git a/packages/service/test/env.test.ts b/packages/service/test/env.test.ts index ab664edd40c6..f41548bca97a 100644 --- a/packages/service/test/env.test.ts +++ b/packages/service/test/env.test.ts @@ -302,6 +302,16 @@ describe('serviceEnv', () => { }); }); + it('does not require a password change session secret during service env init', async () => { + vi.stubEnv('FILE_TOKEN_KEY', 'filetokenkey'); + vi.stubEnv('AES256_SECRET_KEY', 'fastgptsecret'); + vi.stubEnv('INVOKE_TOKEN_SECRET', validInvokeTokenSecret); + vi.stubEnv('VITEST', undefined); + vi.stubEnv('NODE_ENV', 'production'); + + await expect(importServiceEnv()).resolves.not.toThrow(); + }); + it('requires FE_DOMAIN during service env init', async () => { vi.stubEnv('FILE_TOKEN_KEY', 'filetokenkey'); vi.stubEnv('AES256_SECRET_KEY', 'fastgptsecret'); diff --git a/packages/service/test/support/user/account/cancellation/service.test.ts b/packages/service/test/support/user/account/cancellation/service.test.ts index e1ea7dfe754b..bece2d376070 100644 --- a/packages/service/test/support/user/account/cancellation/service.test.ts +++ b/packages/service/test/support/user/account/cancellation/service.test.ts @@ -13,11 +13,11 @@ import { withAccountCancellationTeamLock, withAccountCancellationUserLock } from '@fastgpt/service/support/user/account/cancellation/service'; -import { accountCancellationAllowedMethods } from '@fastgpt/global/support/user/account/cancellation/constants'; +import { accountExternalVerificationMethods } from '@fastgpt/global/support/user/account/verification/constants'; import { MongoTeam } from '@fastgpt/service/support/user/team/teamSchema'; describe('assertAccountCancellationMethod', () => { - it.each(accountCancellationAllowedMethods)('accepts %s', (method) => { + it.each(accountExternalVerificationMethods)('accepts %s', (method) => { expect(() => assertAccountCancellationMethod(method)).not.toThrow(); }); diff --git a/packages/service/test/support/user/account/password/service.test.ts b/packages/service/test/support/user/account/password/service.test.ts new file mode 100644 index 000000000000..a788b1bc2921 --- /dev/null +++ b/packages/service/test/support/user/account/password/service.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest'; +import { MongoTmpData } from '@fastgpt/service/support/tmpData/schema'; +import { + PASSWORD_CHANGE_SESSION_TTL_SECONDS, + consumePasswordChangeSessionInTransaction, + createPasswordChangeSession +} from '@fastgpt/service/support/user/account/password/service'; +import { UserErrEnum } from '@fastgpt/global/common/error/code/user'; + +describe('password change session service', () => { + it('creates a short-lived session bound to the user and login session', async () => { + const result = await createPasswordChangeSession({ + userId: 'user-1', + loginSessionId: 'login-1' + }); + const record = await MongoTmpData.findOne({ + data: { userId: 'user-1', loginSessionId: 'login-1' } + }).lean(); + + expect(result.sessionId).toHaveLength(43); + expect(new Date(result.expiredAt).getTime()).toBeGreaterThan(Date.now()); + expect(record).toMatchObject({ data: { userId: 'user-1', loginSessionId: 'login-1' } }); + expect(PASSWORD_CHANGE_SESSION_TTL_SECONDS).toBe(300); + }); + + it('rejects a missing, expired, mismatched, or reused session', async () => { + await expect( + consumePasswordChangeSessionInTransaction({ + sessionId: 'missing', + userId: 'user-1', + loginSessionId: 'login-1', + newPassword: 'new', + handler: async () => undefined + }) + ).rejects.toThrow(UserErrEnum.passwordChangeAuthorizationInvalid); + }); +}); diff --git a/packages/web/i18n/en/account_info.json b/packages/web/i18n/en/account_info.json index 3a56fa8acfbf..4e5b7f1f0014 100644 --- a/packages/web/i18n/en/account_info.json +++ b/packages/web/i18n/en/account_info.json @@ -125,5 +125,7 @@ "user_account": "Account", "user_team_team_name": "Team", "you_can_convert": "You can redeem", - "yuan": "Yuan" + "yuan": "Yuan", + "password_not_set": "No password set", + "set_password": "Set" } diff --git a/packages/web/i18n/en/common.json b/packages/web/i18n/en/common.json index 08802cf5a2d7..6f4a00468a63 100644 --- a/packages/web/i18n/en/common.json +++ b/packages/web/i18n/en/common.json @@ -1256,5 +1256,33 @@ "xx_search_result": "{{key}} Search Results", "yes": "Yes", "yesterday": "yesterday", - "yesterday_detail_time": "Yesterday {{time}}" + "yesterday_detail_time": "Yesterday {{time}}", + "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", + "update_password": "Change password" } diff --git a/packages/web/i18n/zh-CN/account_info.json b/packages/web/i18n/zh-CN/account_info.json index 3e01f722b0bf..e5e5885ae959 100644 --- a/packages/web/i18n/zh-CN/account_info.json +++ b/packages/web/i18n/zh-CN/account_info.json @@ -125,5 +125,7 @@ "user_account": "账号", "user_team_team_name": "团队", "you_can_convert": "您可以兑换", - "yuan": "元" + "yuan": "元", + "password_not_set": "未设置密码", + "set_password": "设置" } diff --git a/packages/web/i18n/zh-CN/common.json b/packages/web/i18n/zh-CN/common.json index c4b6f1caf461..ab7d6d2bf0d6 100644 --- a/packages/web/i18n/zh-CN/common.json +++ b/packages/web/i18n/zh-CN/common.json @@ -1256,5 +1256,33 @@ "xx_search_result": "{{key}} 的搜索结果", "yes": "是", "yesterday": "昨天", - "yesterday_detail_time": "昨天 {{time}}" + "yesterday_detail_time": "昨天 {{time}}", + "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": "微信扫码登录", + "update_password": "修改密码" } diff --git a/packages/web/i18n/zh-Hant/account_info.json b/packages/web/i18n/zh-Hant/account_info.json index 377051fb933f..7c17aaa1233f 100644 --- a/packages/web/i18n/zh-Hant/account_info.json +++ b/packages/web/i18n/zh-Hant/account_info.json @@ -125,5 +125,7 @@ "user_account": "帳號", "user_team_team_name": "團隊", "you_can_convert": "您可以兌換", - "yuan": "元" + "yuan": "元", + "password_not_set": "尚未設定密碼", + "set_password": "設定" } diff --git a/packages/web/i18n/zh-Hant/common.json b/packages/web/i18n/zh-Hant/common.json index 8a17a00fefac..4d5221e1c1b4 100644 --- a/packages/web/i18n/zh-Hant/common.json +++ b/packages/web/i18n/zh-Hant/common.json @@ -1256,5 +1256,33 @@ "xx_search_result": "{{key}} 的搜尋結果", "yes": "是", "yesterday": "昨天", - "yesterday_detail_time": "昨天 {{time}}" + "yesterday_detail_time": "昨天 {{time}}", + "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": "微信掃碼登入", + "update_password": "修改密碼" } diff --git a/pro b/pro index e10680d495e9..15eaddd799dc 160000 --- a/pro +++ b/pro @@ -1 +1 @@ -Subproject commit e10680d495e9938004e22ca4f95d18dd8cab60bc +Subproject commit 15eaddd799dcad022712a7165cf8f28c806a1efa diff --git a/projects/app/src/components/support/user/safe/AccountVerificationPanel.tsx b/projects/app/src/components/support/user/safe/AccountVerificationPanel.tsx new file mode 100644 index 000000000000..4b67e4cb48fb --- /dev/null +++ b/projects/app/src/components/support/user/safe/AccountVerificationPanel.tsx @@ -0,0 +1,679 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { + Box, + Button, + Center, + Image, + Input, + InputGroup, + InputRightElement, + Spinner, + Text, + VStack, + useDisclosure +} from '@chakra-ui/react'; +import { useRouter } from 'next/router'; +import { useTranslation } from 'next-i18next'; +import { hashStr } from '@fastgpt/global/common/string/tools'; +import type { + AccountVerificationMethod, + CaptchaVerificationPurpose +} from '@fastgpt/global/support/user/account/verification/type'; +import { useToast } from '@fastgpt/web/hooks/useToast'; +import SendCodeAuthModal from './SendCodeAuthModal'; +import { useSystemStore } from '@/web/common/system/useSystemStore'; +import { + isAccountVerificationCodeError, + isAccountVerificationRateLimitError, + isRetryableAccountVerificationPollingError +} from '@/web/support/user/account/verification/error'; + +export type WechatVerificationMaterial = { + code: string; + codeUrl: string; + expiredAt?: string; +}; + +export type VerificationSubmitResult = 'verified' | 'pending' | 'expired'; + +type Props = { + method: AccountVerificationMethod; + username: string; + purpose: CaptchaVerificationPurpose; + createCodeVerification: (captcha: string) => Promise; + submitCodeVerification: (code: string) => Promise; + createOldPasswordVerification?: () => Promise; + submitOldPasswordVerification?: (params: { + password: string; + preLoginCode: string; + }) => Promise; + createWechatVerification?: () => Promise; + submitWechatVerification?: (code: string) => Promise; + startOAuthVerification?: () => Promise<{ url: string }>; +}; + +const isOAuthMethod = ( + method: AccountVerificationMethod +): method is Extract => method.startsWith('oauth/'); + +/** + * 渲染账号验证方式并承接验证交互;业务 API 和验证成功后的动作由调用方通过适配器提供。 + * `pending` 仅表示当前轮询仍需继续,`expired` 表示二维码等一次性材料需要重新创建。 + */ +export const AccountVerificationPanel = ({ + method, + username, + purpose, + createCodeVerification, + submitCodeVerification, + createOldPasswordVerification, + submitOldPasswordVerification, + createWechatVerification, + submitWechatVerification, + startOAuthVerification +}: Props) => { + const { t } = useTranslation(); + const router = useRouter(); + const { toast } = useToast(); + const { feConfigs } = useSystemStore(); + 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 [submitting, setSubmitting] = useState(false); + const [oldPassword, setOldPassword] = useState(''); + const [preLoginCode, setPreLoginCode] = useState(); + const [wechatQR, setWechatQR] = useState(); + const [wechatNow, setWechatNow] = useState(() => Date.now()); + const [creating, setCreating] = useState(false); + const [createFailed, setCreateFailed] = useState(false); + const createRequested = useRef(false); + const wechatPolling = useRef(false); + const isAccountCancellation = purpose === 'unsubscribe'; + + const getVerificationErrorTitle = useCallback( + (error: unknown, fallback: string) => + isAccountVerificationCodeError(error) + ? t('common:error.code_error') + : isAccountVerificationRateLimitError(error) + ? t('common:error.operation_too_frequently') + : fallback, + [t] + ); + + const showVerificationFailure = useCallback( + (error?: unknown) => { + toast({ + status: 'error', + title: getVerificationErrorTitle( + error, + isAccountCancellation + ? t('account_info:account_cancellation_verification_failed', '身份验证失败,请重试') + : t('common:password_verification_failed') + ) + }); + }, + [getVerificationErrorTitle, isAccountCancellation, t, toast] + ); + + const handleResult = useCallback((result: VerificationSubmitResult) => result === 'verified', []); + + const createBoundVerification = useCallback(async () => { + if (method !== 'oldPassword' && method !== 'wechat') return; + setCreating(true); + setCreateFailed(false); + try { + if (method === 'oldPassword') { + if (!createOldPasswordVerification) + throw new Error('Old password verification is unavailable'); + setPreLoginCode(await createOldPasswordVerification()); + } else { + if (!createWechatVerification) throw new Error('WeChat verification is unavailable'); + setWechatQR(await createWechatVerification()); + setWechatNow(Date.now()); + } + } catch (error) { + setCreateFailed(true); + showVerificationFailure(error); + } finally { + setCreating(false); + } + }, [createOldPasswordVerification, createWechatVerification, method, showVerificationFailure]); + + useEffect(() => { + if ((method !== 'oldPassword' && method !== 'wechat') || createRequested.current) return; + createRequested.current = true; + void createBoundVerification(); + }, [createBoundVerification, method]); + + useEffect(() => { + if (codeCountDown <= 0) return; + const timer = window.setTimeout(() => setCodeCountDown((value) => value - 1), 1000); + return () => window.clearTimeout(timer); + }, [codeCountDown]); + + const wechatExpired = + !!wechatQR?.expiredAt && new Date(wechatQR.expiredAt).getTime() <= wechatNow; + + useEffect(() => { + if (!wechatQR?.expiredAt) return; + const timer = window.setInterval(() => setWechatNow(Date.now()), 1000); + return () => window.clearInterval(timer); + }, [wechatQR?.expiredAt]); + + useEffect(() => { + if (!wechatQR || wechatExpired || !submitWechatVerification) return; + let disposed = false; + + const pollVerification = async () => { + if (wechatPolling.current) return; + wechatPolling.current = true; + try { + const result = await submitWechatVerification(wechatQR.code); + if (disposed) return; + if (result === 'verified') { + handleResult(result); + disposed = true; + } else if (result === 'expired') { + setWechatQR(undefined); + void createBoundVerification(); + } + } catch (error) { + if (!disposed && !isRetryableAccountVerificationPollingError(error)) { + setWechatQR(undefined); + setCreateFailed(true); + showVerificationFailure(error); + disposed = true; + } + } finally { + wechatPolling.current = false; + } + }; + + void pollVerification(); + const timer = window.setInterval(() => { + if (!disposed) void pollVerification(); + }, 2000); + return () => { + disposed = true; + window.clearInterval(timer); + }; + }, [ + createBoundVerification, + handleResult, + showVerificationFailure, + submitWechatVerification, + wechatExpired, + wechatQR + ]); + + const sendCode = async ({ captcha }: { username: string; captcha: string }) => { + if (method !== 'code') return; + setCodeSending(true); + try { + await createCodeVerification(captcha); + setHasSentCode(true); + setCodeCountDown(60); + toast({ + status: 'success', + title: isAccountCancellation + ? t('account_info:account_cancellation_code_sent', '验证码已发送') + : t('common:password_code_sent') + }); + } catch (error) { + if (isAccountCancellation) { + toast({ + status: 'error', + title: getVerificationErrorTitle( + error, + t('account_info:account_cancellation_code_send_failed', '验证码发送失败,请重试') + ) + }); + } else { + showVerificationFailure(error); + } + throw error; + } finally { + setCodeSending(false); + } + }; + + const submitCode = useCallback( + async (verificationCode: string) => { + if ( + method !== 'code' || + !verificationCode || + (!isAccountCancellation && verificationCode.length !== 6) || + submitting + ) + return; + setSubmitting(true); + try { + handleResult(await submitCodeVerification(verificationCode)); + } catch (error) { + showVerificationFailure(error); + } finally { + setSubmitting(false); + } + }, + [ + handleResult, + isAccountCancellation, + method, + showVerificationFailure, + submitCodeVerification, + submitting + ] + ); + + const submitOldPassword = async () => { + if (method !== 'oldPassword' || !oldPassword || !preLoginCode || !submitOldPasswordVerification) + return; + setSubmitting(true); + try { + handleResult( + await submitOldPasswordVerification({ + password: hashStr(oldPassword), + preLoginCode + }) + ); + } catch (error) { + // 预登录材料在密码校验前即被一次性消费,失败后必须重新创建才能再次尝试。 + setOldPassword(''); + setPreLoginCode(undefined); + showVerificationFailure(error); + void createBoundVerification(); + } finally { + setSubmitting(false); + } + }; + + const submitOAuth = async () => { + if (!isOAuthMethod(method) || !startOAuthVerification) return; + setSubmitting(true); + try { + const result = await startOAuthVerification(); + await router.replace(result.url); + } catch (error) { + setSubmitting(false); + showVerificationFailure(error); + } + }; + + const retryCreate = () => { + createRequested.current = true; + void createBoundVerification(); + }; + + if (isAccountCancellation) { + const title = ( + + {t('account_info:account_cancellation_title', '注销账号')} + + ); + + if (method === 'code') { + return ( + + {title} + + + + 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(code.trim()); + }} + /> + + + + + + {isCaptchaOpen && ( + + )} + + + ); + } + + if (method === 'wechat') { + return ( + + {title} + + + {t('account_info:account_cancellation_wechat_scan', '微信扫码登录')} + +
+ {creating ? ( + + ) : wechatQR && !wechatExpired ? ( + {t('account_info:account_cancellation_wechat_qr', + ) : ( + + + {createFailed + ? t( + 'account_info:account_cancellation_wechat_load_failed', + '二维码加载失败,请重试。' + ) + : t( + 'account_info:account_cancellation_wechat_expired', + '二维码已过期,请重新获取。' + )} + + + + )} +
+
+
+ ); + } + + const cancellationProvider = method.slice('oauth/'.length).toLowerCase(); + const cancellationProviderLabel = (() => { + if (cancellationProvider === 'github') return 'GitHub'; + if (cancellationProvider === 'google') return 'Google'; + if (cancellationProvider === 'microsoft') return 'Microsoft'; + if (cancellationProvider === 'wecom') return 'WeCom'; + return feConfigs.sso?.title ?? 'SSO'; + })(); + + return ( + + {title} + + + + + + ); + } + + if (method === 'code') { + return ( + + + + setCode(event.target.value.replace(/\D/g, '').slice(0, 6))} + aria-label={t('common:support.user.info.verification_code')} + onKeyDown={(event) => { + if (event.key === 'Enter') void submitCode(code.trim()); + }} + /> + + + + + + {isCaptchaOpen && ( + + )} + + ); + } + + if (method === 'oldPassword') { + return ( + + + + {creating ? ( +
+ +
+ ) : createFailed || !preLoginCode ? ( +
+ +
+ ) : ( + <> + setOldPassword(event.target.value)} + placeholder={t('common:password_old_placeholder')} + onKeyDown={(event) => { + if (event.key === 'Enter') void submitOldPassword(); + }} + /> + + + )} +
+
+ ); + } + + if (method === 'wechat') { + return ( + + + {t('common:password_wechat_scan')} + +
+ {creating ? ( + + ) : wechatQR && !wechatExpired ? ( + {t('common:password_wechat_qr')} + ) : ( + + + {t( + createFailed + ? 'common:password_wechat_load_failed' + : 'common: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..ffe0f3e48f57 --- /dev/null +++ b/projects/app/src/components/support/user/safe/PasswordChangeModal.tsx @@ -0,0 +1,456 @@ +import { useCallback, useEffect, useState, type KeyboardEvent } 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 { + OAuthAccountVerificationProviderSchema, + type AccountVerificationMethod, + type OAuthAccountVerificationMethod +} from '@fastgpt/global/support/user/account/verification/type'; +import { checkIsWecomTerminal } from '@fastgpt/global/support/user/login/constants'; +import type { OAuthEnum } from '@fastgpt/global/support/user/constant'; +import MyModal from '@fastgpt/web/components/common/MyModal'; +import { useToast } from '@fastgpt/web/hooks/useToast'; +import { useSystemStore } from '@/web/common/system/useSystemStore'; +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, + type VerificationSubmitResult, + type WechatVerificationMaterial +} 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; +}; + +const isOAuthMethod = ( + method: AccountVerificationMethod +): method is OAuthAccountVerificationMethod => method.startsWith('oauth/'); + +type Props = { + required?: boolean; + showExpiredPrompt?: boolean; + onClose?: () => void; + 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, + showExpiredPrompt = false, + onClose, + onSuccess +}: Props) => { + const { t } = useTranslation(); + const router = useRouter(); + const { toast } = useToast(); + const { userInfo, initUserInfo } = useUserStore(); + const storedSession = usePasswordChangeStore((state) => state.session); + const setStoredSession = usePasswordChangeStore((state) => state.setSession); + const initialAuthorization = storedSession?.required === required ? storedSession : undefined; + const [stage, setStage] = useState(() => { + if (initialAuthorization) { + return { + type: 'password', + authorization: { + status: 'authorized', + sessionId: initialAuthorization.sessionId, + 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) setStoredSession(undefined); + }, [initialAuthorization, setStoredSession]); + + const requestAuthorization = useCallback(async () => { + try { + const result = await authorizePasswordChange({ source: 'verificationMethod' }); + 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('common: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 createCodeVerification = useCallback(async (captcha: string) => { + const result = await createPasswordVerification({ method: 'code', payload: { captcha } }); + if (result.method !== 'code') throw new Error('Verification method mismatch'); + }, []); + + const submitPasswordVerification = useCallback( + async (verification: SensitiveAccountVerificationBody): Promise => { + const result = await consumeVerification(verification); + if (result.status === 'authorized') { + handleAuthorized(result); + return 'verified'; + } + if (result.status === 'verificationPending') return 'pending'; + if (result.status === 'verificationExpired') return 'expired'; + throw new Error('Password verification is unavailable'); + }, + [consumeVerification, handleAuthorized] + ); + + const createOldPasswordVerification = useCallback(async () => { + const result = await createPasswordVerification({ method: 'oldPassword', payload: {} }); + if (result.method !== 'oldPassword') throw new Error('Verification method mismatch'); + return result.preLoginCode; + }, []); + + const createWechatVerification = useCallback(async (): Promise => { + const result = await createPasswordVerification({ method: 'wechat', payload: {} }); + if (result.method !== 'wechat') throw new Error('Verification method mismatch'); + return result; + }, []); + + const startOAuthVerification = useCallback(async () => { + const callbackUrl = `${window.location.origin}/login/provider`; + const method: OAuthAccountVerificationMethod = + stage.type === 'verification' && isOAuthMethod(stage.method) ? stage.method : 'oauth/sso'; + const result = await createPasswordVerification({ + method, + payload: { + callbackUrl, + isWecomWorkTerminal: checkIsWecomTerminal() + } + }); + if (!('url' in result) || !('state' in result)) throw new Error('Verification method mismatch'); + const provider = OAuthAccountVerificationProviderSchema.parse( + result.method.slice('oauth/'.length) + ); + useSystemStore.getState().setLoginStore({ + provider: provider as OAuthEnum, + lastRoute: router.asPath, + state: result.state, + flow: 'passwordChange', + passwordChangeRequired: required + }); + return { url: result.url }; + }, [required, router.asPath, stage]); + + const submitCodeVerification = useCallback( + (code: string) => submitPasswordVerification({ method: 'code', payload: { code } }), + [submitPasswordVerification] + ); + + const submitOldPasswordVerification = useCallback( + ({ password, preLoginCode }: { password: string; preLoginCode: string }) => + submitPasswordVerification({ + method: 'oldPassword', + payload: { password, preLoginCode } + }), + [submitPasswordVerification] + ); + + const submitWechatVerification = useCallback( + (code: string) => submitPasswordVerification({ method: 'wechat', payload: { code } }), + [submitPasswordVerification] + ); + + const closeFlow = () => { + if (required) return; + reset(); + setStoredSession(undefined); + onClose?.(); + }; + + const submitNewPassword = async ({ newPassword }: FormType) => { + if (stage.type !== 'password') return; + setSubmitting(true); + try { + await updatePassword({ + newPassword, + passwordChangeSession: stage.authorization.sessionId + }); + reset(); + setStoredSession(undefined); + await initUserInfo(); + toast({ status: 'success', title: t('common:password_set_success') }); + await onSuccess?.(); + } catch (error) { + const errorResponse = getErrResponse(error); + if (errorResponse?.statusText === UserErrEnum.passwordChangeAuthorizationInvalid) { + reset(); + setStoredSession(undefined); + setStage({ type: 'authorizing' }); + return; + } + const errorTitle = + errorResponse?.statusText === UserErrEnum.newPasswordSameAsOld + ? t('common:user.Password has no change') + : t('common:password_update_error'); + toast({ status: 'error', title: errorTitle }); + } finally { + setSubmitting(false); + } + }; + + const handlePasswordEnterKeyDown = (event: KeyboardEvent) => { + event.stopPropagation(); + if ( + submitting || + event.nativeEvent.isComposing || + event.keyCode === 229 || + event.key.toLowerCase() !== 'enter' + ) { + return; + } + handleSubmit(submitNewPassword)(); + }; + + const title = (() => { + if (stage.type === 'verification' || stage.type === 'unavailable') { + return t('common:password_verification_title'); + } + if (required || !userInfo?.hasPassword) return t('common:password_set_title'); + return t('common:update_password'); + })(); + + const isWechatVerification = stage.type === 'verification' && stage.method === 'wechat'; + const modalWidth = isWechatVerification ? '560px' : '400px'; + + return ( + + {stage.type === 'prompt' && ( + + + {title} + + + {t('common:password_expired_tip')} + + + + + + )} + + {stage.type === 'authorizing' && ( + + + {title} + +
+ + + + {t('common:password_authorizing')} + + +
+
+ )} + + {stage.type === 'unavailable' && ( + + + {title} + + + {t('common:password_verification_unavailable')} + + + + )} + + {stage.type === 'verification' && ( + + + + {title} + + + {t('common:password_verification_description')} + + + + + + + )} + + {stage.type === 'password' && ( + + + {title} + + + + checkPasswordRule(value) || t('common:password_tip') + })} + /> + + {t('common:password_tip')} + + + + + value === getValues('newPassword') || t('common: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 da9d1646f107..a92400212764 100644 --- a/projects/app/src/components/support/user/safe/ResetExpiredPswModal.tsx +++ b/projects/app/src/components/support/user/safe/ResetExpiredPswModal.tsx @@ -1,49 +1,20 @@ 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 { accountCancellationActiveStatuses } from '@fastgpt/global/support/user/account/cancellation/constants'; +import PasswordChangeModal from './PasswordChangeModal'; -type FormType = { - newPsw: string; - confirmPsw: string; -}; - -const ResetPswModal = () => { - const { t } = useTranslation(); - const { toast } = useToast(); +/** 仅在确有存储密码且已过期时开启不可关闭的统一改密流程;注销期间不触发改密。 */ +const ResetExpiredPswModal = () => { const { userInfo } = useUserStore(); const isAccountCancellationPending = accountCancellationActiveStatuses.includes( userInfo?.team?.accountCancellation ?.status as (typeof accountCancellationActiveStatuses)[number] ); - - 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 (isAccountCancellationPending) { - return false; - } + if (!userInfo?._id || isAccountCancellationPending) return false; return getCheckPswExpired(); }, { @@ -52,78 +23,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/components/support/user/safe/SendCodeAuthModal.tsx b/projects/app/src/components/support/user/safe/SendCodeAuthModal.tsx index b7f9d40170b5..42b55505daec 100644 --- a/projects/app/src/components/support/user/safe/SendCodeAuthModal.tsx +++ b/projects/app/src/components/support/user/safe/SendCodeAuthModal.tsx @@ -50,8 +50,12 @@ const SendCodeAuthModal = ({ }, [data?.captchaImage, refreshCaptcha]); const onSubmit = async ({ code }: { code: string }) => { - await onSendCode({ username, captcha: code }); - onClose(); + try { + await onSendCode({ username, captcha: code }); + onClose(); + } catch { + // 发送方负责展示具体错误;保留弹窗和验证码,允许用户直接重试。 + } }; const handleEnterKeyDown = (e: React.KeyboardEvent) => { diff --git a/projects/app/src/pageComponents/account/cancel/CancelAccountPage.tsx b/projects/app/src/pageComponents/account/cancel/CancelAccountPage.tsx index ff83485bb59a..b03142b6864c 100644 --- a/projects/app/src/pageComponents/account/cancel/CancelAccountPage.tsx +++ b/projects/app/src/pageComponents/account/cancel/CancelAccountPage.tsx @@ -3,16 +3,26 @@ import { useCallback, useEffect, useState } from 'react'; import { useRouter } from 'next/router'; import { useTranslation } from 'next-i18next'; import type { AccountCancellationStatusResponse } from '@fastgpt/global/openapi/support/user/account/cancellation/api'; +import type { OAuthAccountVerificationProvider } from '@fastgpt/global/support/user/account/verification/type'; +import { checkIsWecomTerminal } from '@fastgpt/global/support/user/login/constants'; +import type { OAuthEnum } from '@fastgpt/global/support/user/constant'; import { useToast } from '@fastgpt/web/hooks/useToast'; +import { useSystemStore } from '@/web/common/system/useSystemStore'; +import { + AccountVerificationPanel, + type VerificationSubmitResult, + type WechatVerificationMaterial +} from '@/components/support/user/safe/AccountVerificationPanel'; import { useUserStore } from '@/web/support/user/useUserStore'; import { cancelAccountCancellation, - getAccountCancellationStatus + createAccountCancellationVerification, + getAccountCancellationStatus, + submitAccountCancellation } 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(); @@ -54,6 +64,64 @@ const CancelAccountPage = () => { void router.replace('/login?lastRoute=/account/cancel'); }, [router, setUserInfo, t, toast]); + const createCodeVerification = useCallback(async (captcha: string) => { + const result = await createAccountCancellationVerification({ + method: 'code', + payload: { captcha } + }); + if (result.method !== 'code') throw new Error('Verification method mismatch'); + }, []); + + const submitCodeVerification = useCallback( + async (code: string): Promise => { + const result = await submitAccountCancellation({ method: 'code', payload: { code } }); + if (result.status === 'pending') { + onSubmitted(); + return 'verified'; + } + return result.status === 'verificationExpired' ? 'expired' : 'pending'; + }, + [onSubmitted] + ); + + const createWechatVerification = useCallback(async (): Promise => { + const result = await createAccountCancellationVerification({ method: 'wechat', payload: {} }); + if (result.method !== 'wechat') throw new Error('Verification method mismatch'); + return result; + }, []); + + const submitWechatVerification = useCallback( + async (code: string): Promise => { + const result = await submitAccountCancellation({ method: 'wechat', payload: { code } }); + if (result.status === 'pending') { + onSubmitted(); + return 'verified'; + } + return result.status === 'verificationExpired' ? 'expired' : 'pending'; + }, + [onSubmitted] + ); + + const startOAuthVerification = useCallback(async () => { + const method = status?.status === 'none' ? status.verificationMethod : undefined; + if (!method || !method.startsWith('oauth/')) + throw new Error('OAuth verification is unavailable'); + const callbackUrl = `${window.location.origin}/login/provider`; + const result = await createAccountCancellationVerification({ + method, + payload: { callbackUrl, isWecomWorkTerminal: checkIsWecomTerminal() } + }); + if (result.method !== method) throw new Error('Verification method mismatch'); + const provider = method.slice('oauth/'.length) as OAuthAccountVerificationProvider; + useSystemStore.getState().setLoginStore({ + provider: provider as OAuthEnum, + lastRoute: '/account/cancel?confirmed=1', + state: result.state, + flow: 'accountCancellation' + }); + return { url: result.url }; + }, [status]); + const onCancel = async () => { setCanceling(true); try { @@ -109,7 +177,18 @@ const CancelAccountPage = () => { ); } if (isVerificationView) { - return ; + return ( + + ); } return ; })(); diff --git a/projects/app/src/pageComponents/account/cancel/VerificationPanel.tsx b/projects/app/src/pageComponents/account/cancel/VerificationPanel.tsx deleted file mode 100644 index 0225c21bdf43..000000000000 --- a/projects/app/src/pageComponents/account/cancel/VerificationPanel.tsx +++ /dev/null @@ -1,414 +0,0 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; -import { - Box, - Button, - Center, - Image, - Input, - InputGroup, - InputRightElement, - Spinner, - Text, - VStack, - useDisclosure -} from '@chakra-ui/react'; -import { useTranslation } from 'next-i18next'; -import { useRouter } from 'next/router'; -import type { OAuthEnum } from '@fastgpt/global/support/user/constant'; -import type { - AccountCancellationAllowedMethod, - AccountCancellationOAuthProvider -} from '@fastgpt/global/support/user/account/cancellation/type'; -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 { - createAccountCancellationVerification, - submitAccountCancellation -} 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 isOAuthMethod = ( - method: AccountCancellationAllowedMethod -): method is Extract => - method.startsWith('oauth/'); - -/** 按统一 resolver 只渲染一种非密码验证方式,并承接各方式的加载与失败状态。 */ -export const VerificationPanel = ({ - method, - onSubmitted -}: { - method: AccountCancellationAllowedMethod; - 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 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( - (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; - setWechatCreating(true); - setWechatLoadFailed(false); - try { - const result = await createAccountCancellationVerification({ method, payload: {} }); - if (result.method !== 'wechat') return; - setWechatQR(result); - setWechatNow(Date.now()); - } catch (error) { - setWechatLoadFailed(true); - showVerificationFailure(error); - } 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 === 'verificationExpired') { - setWechatQR(undefined); - await createWechatVerification(); - return; - } - if (!disposed && result.status === 'pending') { - onSubmitted(result); - } - } catch { - // 未扫码和 Provider 短暂异常都可能落入轮询失败,二维码有效期内继续等待。 - } finally { - wechatPolling.current = false; - } - }; - - void pollVerification(); - const timer = window.setInterval(() => void pollVerification(), 2000); - return () => { - disposed = true; - window.clearInterval(timer); - }; - }, [createWechatVerification, onSubmitted, wechatExpired, wechatQR]); - - const sendCode = async ({ captcha }: { username: string; captcha: string }) => { - if (method !== 'code') return; - setCodeSending(true); - try { - const result = await createAccountCancellationVerification({ - method, - payload: { captcha } - }); - if (result.method !== 'code') return; - setHasSentCode(true); - setCodeCountDown(60); - toast({ - status: 'success', - title: t('account_info:account_cancellation_code_sent', '验证码已发送') - }); - } catch (error) { - toast({ - status: 'error', - 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); - } - }; - - 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; - onSubmitted(result); - } catch (error) { - showVerificationFailure(error); - } 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 AccountCancellationOAuthProvider; - useSystemStore.getState().setLoginStore({ - provider: provider as OAuthEnum, - lastRoute: '/account/cancel?confirmed=1', - state: result.state, - 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/pageComponents/account/info/UpdatePswModal.tsx b/projects/app/src/pageComponents/account/info/UpdatePswModal.tsx index b881a263c00d..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 { useClientTranslation } from '@fastgpt/web/i18n/useClientTranslation'; -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 } = useClientTranslation(['account_info', 'login', 'user']); - 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/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 3ab1fc1889e8..f6c4e260d193 100644 --- a/projects/app/src/pages/account/info/index.tsx +++ b/projects/app/src/pages/account/info/index.tsx @@ -49,6 +49,8 @@ import { getIsMemberSyncMode } from '@/web/common/system/utils'; import { accountPageRootStyles, accountTitleTextStyles } from '@/pageComponents/account/styles'; 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 @@ -138,6 +140,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; @@ -151,6 +157,7 @@ const MyInfo = ({ onOpenContact }: { onOpenContact: () => void }) => { onClose: onCloseUpdatePsw, onOpen: onOpenUpdatePsw } = useDisclosure(); + const passwordChangeSession = usePasswordChangeStore((state) => state.session); const { isOpen: isOpenUpdateContact, onClose: onCloseUpdateContact, @@ -222,6 +229,11 @@ const MyInfo = ({ onOpenContact }: { onOpenContact: () => void }) => { window.removeEventListener('hashchange', triggerEnterpriseAuthFromHash); }; }, [triggerEnterpriseAuthFromHash]); + + useEffect(() => { + if (canManagePassword && passwordChangeSession?.required === false) onOpenUpdatePsw(); + }, [canManagePassword, onOpenUpdatePsw, passwordChangeSession]); + const { Component: AvatarUploader, handleFileSelectorOpen } = useUploadAvatar( getUploadAvatarPresignedUrl, { @@ -260,12 +272,14 @@ const MyInfo = ({ onOpenContact }: { onOpenContact: () => void }) => { {t('account_info:user_account')}  {userInfo?.username} - {feConfigs?.isPlus && ( + {canManagePassword && ( {t('account_info:password')}  - ***** + + {userInfo?.hasPassword ? '*****' : t('account_info:password_not_set')} + )} @@ -415,7 +429,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 ce423581a083..42e4a02dd0e3 100644 --- a/projects/app/src/pages/api/support/user/account/checkPswExpired.ts +++ b/projects/app/src/pages/api/support/user/account/checkPswExpired.ts @@ -4,16 +4,20 @@ 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, _res: ApiResponseType ): Promise { - const { userId } = await authCert({ req, authToken: true }); + const { userId, isRoot } = await authCert({ req, authToken: true }); - const user = await MongoUser.findById(userId, 'passwordUpdateTime'); + // root 密码由环境变量管理并在服务启动时同步,不参与用户密码过期策略。 + if (isRoot) return false; - if (!user) { + const user = await MongoUser.findById(userId).select('+password passwordUpdateTime'); + + 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..eddbd5e4ef48 --- /dev/null +++ b/projects/app/src/pages/api/support/user/account/password/update.ts @@ -0,0 +1,58 @@ +import { ERROR_ENUM } from '@fastgpt/global/common/error/errorCode'; +import type { ApiRequestProps } from '@fastgpt/next/type'; +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 { parseApiInput } from '@fastgpt/service/common/zod/requestParseError'; +import { authCert } from '@fastgpt/service/support/permission/auth/common'; +import { consumePasswordChangeSessionInTransaction } 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 更新密码,并仅保留发起请求的 Session。 */ +async function handler(req: ApiRequestProps): Promise { + const { body } = parseApiInput({ req, bodySchema: UpdatePasswordBodySchema }); + const { userId, sessionId, tmbId, teamId, isRoot } = await authCert({ + req, + authToken: true + }); + if (!sessionId || isRoot) return Promise.reject(ERROR_ENUM.unAuthorization); + + await consumePasswordChangeSessionInTransaction({ + sessionId: body.passwordChangeSession, + userId, + loginSessionId: sessionId, + newPassword: body.newPsw, + handler: async (session) => { + const updateResult = await MongoUser.updateOne( + { _id: userId }, + { + $set: { + password: body.newPsw, + passwordUpdateTime: new Date() + } + }, + { session } + ); + 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 d13dff5b8107..000000000000 --- a/projects/app/src/pages/api/support/user/account/resetExpiredPsw.ts +++ /dev/null @@ -1,50 +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 { parseApiInput } from '@fastgpt/service/common/zod/requestParseError'; -import { - ResetExpiredPswBodySchema, - ResetExpiredPswResponseSchema, - type ResetExpiredPswResponseType -} from '@fastgpt/global/openapi/support/user/account/password/api'; - -async function resetExpiredPswHandler(req: ApiRequestProps): Promise { - const { newPsw } = parseApiInput({ - req, - bodySchema: ResetExpiredPswBodySchema - }).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 9090c92583eb..000000000000 --- a/projects/app/src/pages/api/support/user/account/updatePasswordByOld.ts +++ /dev/null @@ -1,66 +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'; -import { parseApiInput } from '@fastgpt/service/common/zod/requestParseError'; - -async function handler( - req: ApiRequestProps, - _res: ApiResponseType -): Promise { - const { oldPsw, newPsw } = parseApiInput({ - req, - bodySchema: UpdatePasswordByOldBodySchema - }).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 8198a650e70e..b2e4ca11d233 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'; @@ -42,9 +44,11 @@ const provider = () => { const errorRedirectPage = loginStore?.flow === 'accountCancellation' ? '/account/cancel?confirmed=1' - : lastRoute.startsWith('/chat') + : loginStore?.flow === 'passwordChange' ? lastRoute - : '/login'; + : lastRoute.startsWith('/chat') + ? lastRoute + : '/login'; const loginSuccess = useCallback( async (res: LoginSuccessResponseType) => { @@ -114,6 +118,35 @@ const provider = () => { return; } + if (loginStore?.flow === 'passwordChange') { + if (!props.code) { + throw new Error('OAuth password change callback is incomplete'); + } + const result = await authorizePasswordChange({ + source: 'accountVerification', + verification: { + method: `oauth/${loginStore.provider}` as any, + payload: { + callbackUrl: `${window.location.origin}/login/provider`, + code: props.code, + ...(state ? { state } : {}), + props + } + } + }); + if (result.status !== 'authorized') { + throw new Error('Password change verification is still pending'); + } + usePasswordChangeStore.getState().setSession({ + sessionId: result.sessionId, + expiredAt: result.expiredAt, + required: loginStore.passwordChangeRequired === true + }); + setLoginStore(undefined); + await router.replace(lastRoute); + return; + } + const res = await oauthLogin({ type: loginStore?.provider || OAuthEnum.sso, props, @@ -137,11 +170,16 @@ const provider = () => { await onFastGPTLoginSuccess(loginSuccess, res); } catch (error) { toast({ - status: loginStore?.flow === 'accountCancellation' ? 'error' : 'warning', + status: + loginStore?.flow === 'accountCancellation' || loginStore?.flow === 'passwordChange' + ? 'error' + : 'warning', title: - loginStore?.flow === 'accountCancellation' - ? t('account_info:account_cancellation_verification_failed', '身份验证失败,请重试') - : getErrText(error, t('common:support.user.login.error')) + loginStore?.flow === 'passwordChange' + ? t('common:password_verification_failed') + : loginStore?.flow === 'accountCancellation' + ? t('account_info:account_cancellation_verification_failed', '身份验证失败,请重试') + : getErrText(error, t('common:support.user.login.error')) }); setTimeout(() => { router.replace(errorRedirectPage); @@ -154,6 +192,7 @@ const provider = () => { i18n.language, loginStore, loginSuccess, + lastRoute, router, setLoginStore, setUserInfo, @@ -166,11 +205,16 @@ const provider = () => { useEffect(() => { if (error) { toast({ - status: loginStore?.flow === 'accountCancellation' ? 'error' : 'warning', + status: + loginStore?.flow === 'accountCancellation' || loginStore?.flow === 'passwordChange' + ? 'error' + : 'warning', title: - loginStore?.flow === 'accountCancellation' - ? t('account_info:account_cancellation_verification_failed', '身份验证失败,请重试') - : t('common:support.user.login.Provider error') + loginStore?.flow === 'passwordChange' + ? t('common:password_verification_failed') + : loginStore?.flow === 'accountCancellation' + ? t('account_info:account_cancellation_verification_failed', '身份验证失败,请重试') + : t('common:support.user.login.Provider error') }); router.replace(errorRedirectPage); return; @@ -183,17 +227,22 @@ const provider = () => { handledCallbackRef.current = callbackKey; (async () => { - if (loginStore?.flow !== 'accountCancellation') { + if (!loginStore?.flow || loginStore.flow === 'login') { await retryFn(async () => clearToken()); } router.prefetch('/dashboard/agent'); if (loginStore && loginStore.provider !== 'sso' && state !== loginStore.state) { toast({ - status: loginStore?.flow === 'accountCancellation' ? 'error' : 'warning', + status: + loginStore?.flow === 'accountCancellation' || loginStore?.flow === 'passwordChange' + ? 'error' + : 'warning', title: - loginStore?.flow === 'accountCancellation' - ? t('account_info:account_cancellation_verification_failed', '身份验证失败,请重试') - : t('common:support.user.login.security_failed') + loginStore?.flow === 'passwordChange' + ? t('common:password_verification_failed') + : loginStore?.flow === 'accountCancellation' + ? t('account_info:account_cancellation_verification_failed', '身份验证失败,请重试') + : t('common:support.user.login.security_failed') }); setTimeout(() => { router.replace(errorRedirectPage); diff --git a/projects/app/src/web/common/system/useSystemStore.ts b/projects/app/src/web/common/system/useSystemStore.ts index 68ef162fce87..e90e9b1cfa57 100644 --- a/projects/app/src/web/common/system/useSystemStore.ts +++ b/projects/app/src/web/common/system/useSystemStore.ts @@ -12,7 +12,8 @@ type LoginStoreType = { lastRoute: string; state: string; lastTmbId?: string; - flow?: 'login' | 'accountCancellation'; + flow?: 'login' | 'accountCancellation' | 'passwordChange'; + passwordChangeRequired?: boolean; }; export type NotSufficientModalType = 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..ed859b86945d --- /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, + passwordChangeSession +}: { + newPassword: string; + passwordChangeSession: string; +}) => + POST('/support/user/account/password/update', { + newPsw: hashStr(newPassword), + passwordChangeSession + }); 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..b62e81d76b84 --- /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 PasswordChangeSession = { + sessionId: string; + expiredAt: string; + required: boolean; +}; + +type State = { + session?: PasswordChangeSession; + setSession: (session?: PasswordChangeSession) => void; +}; + +/** 仅在当前页面进程中承接 OAuth 回跳结果;该 store 不允许接入持久化中间件。 */ +export const usePasswordChangeStore = create()( + devtools( + immer((set) => ({ + session: undefined, + setSession(session) { + set((state) => { + state.session = session; + }); + } + })) + ) +); diff --git a/projects/app/src/web/support/user/account/verification/error.ts b/projects/app/src/web/support/user/account/verification/error.ts new file mode 100644 index 000000000000..349ad80d7d69 --- /dev/null +++ b/projects/app/src/web/support/user/account/verification/error.ts @@ -0,0 +1,55 @@ +import { UserErrEnum } from '@fastgpt/global/common/error/code/user'; +import { getErrResponse, getErrText } from '@fastgpt/global/common/error/utils'; + +const accountVerificationRateLimitStatusTexts = new Set([ + UserErrEnum.sendVerificationCodeTooFrequently, + UserErrEnum.verifyCodeTooFrequently +]); +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 isAccountVerificationCodeError = (error: unknown) => + getErrResponse(error)?.statusText === UserErrEnum.invalidVerificationCode || + getErrText(error) === verificationCodeError; + +/** 统一识别账号验证码发送与校验阶段的频控错误,并兼容旧版 message 返回。 */ +export const isAccountVerificationRateLimitError = (error: unknown) => { + const statusText = getErrResponse(error)?.statusText; + return ( + accountVerificationRateLimitStatusTexts.has(statusText) || + legacyAccountVerificationRateLimitErrors.has(getErrText(error)) + ); +}; + +/** 识别轮询期间可恢复的 网络、超时、频控和服务端暂时错误。 */ +export const isRetryableAccountVerificationPollingError = (error: unknown) => { + const response = getErrResponse(error) as + | { status?: number; statusCode?: number; code?: number | string } + | undefined; + const errorCode = response?.code; + const status = response?.status ?? response?.statusCode; + const numericCode = typeof errorCode === 'number' ? errorCode : Number(errorCode); + const numericStatus = typeof status === 'number' ? status : Number(status); + const networkErrorCodes = new Set([ + 'ECONNABORTED', + 'ETIMEDOUT', + 'ERR_NETWORK', + 'ERR_CONNECTION_RESET' + ]); + const code = typeof errorCode === 'string' ? errorCode : undefined; + + return ( + numericStatus === 408 || + numericStatus === 429 || + (numericStatus >= 500 && numericStatus < 600) || + numericCode === 408 || + numericCode === 429 || + (numericCode >= 500 && numericCode < 600) || + networkErrorCodes.has(code ?? '') || + getErrText(error) === 'Network Error' + ); +}; diff --git a/projects/app/src/web/support/user/api.ts b/projects/app/src/web/support/user/api.ts index 1b64e1215937..5ab5ae3a1488 100644 --- a/projects/app/src/web/support/user/api.ts +++ b/projects/app/src/web/support/user/api.ts @@ -17,10 +17,7 @@ import type { SendAuthCodeBodyType, SendAuthCodeResponseType } from '@fastgpt/global/openapi/support/user/inform/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 { UpdateContactBodyType } from '@fastgpt/global/openapi/support/user/account/update/api'; import type { AccountRegisterBodyType } from '@fastgpt/global/openapi/support/user/account/register/api'; import type { CaptchaVerificationPurpose } from '@fastgpt/global/support/user/account/verification/type'; @@ -93,15 +90,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..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(); @@ -167,6 +189,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..a471808edf0c --- /dev/null +++ b/projects/app/test/api/support/user/account/password/update.test.ts @@ -0,0 +1,255 @@ +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 { createPasswordChangeSession } from '@fastgpt/service/support/user/account/password/service'; +import { MongoTmpData } from '@fastgpt/service/support/tmpData/schema'; +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'; +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), isRoot = false) => + ({ + userId, + teamId: String(testTeam._id), + tmbId: String(testTmb._id), + isRoot, + sessionId: 'current-session' + }) as any; + + const getBody = async ({ + userId = String(testUser._id), + newPsw = hashStr('new-password') + } = {}): Promise => ({ + newPsw, + passwordChangeSession: ( + await createPasswordChangeSession({ userId, loginSessionId: 'current-session' }) + ).sessionId + }); + + it('updates an existing password and its update time', async () => { + const body = await 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('consumes the session after a successful password update', async () => { + const body = await getBody(); + const response = await Call, undefined>( + updatePasswordApi, + { body, auth: getAuth() } + ); + + expect(response.code).toBe(200); + const reuseResponse = await Call, undefined>( + updatePasswordApi, + { body, auth: getAuth() } + ); + expect(reuseResponse.code).toBe(500); + }); + it('rejects a session issued for a different login Session', async () => { + const body = await getBody(); + const response = await Call, undefined>( + updatePasswordApi, + { + body, + auth: { ...getAuth(), sessionId: 'another-session' } + } + ); + + expect(response.code).toBe(500); + expect( + await MongoUser.exists({ _id: testUser._id, password: hashStr('old-password') }) + ).toBeTruthy(); + }); + + it('rejects an expired password change session', async () => { + const body = await getBody(); + await MongoTmpData.updateOne( + { data: { userId: String(testUser._id), loginSessionId: 'current-session' } }, + { $set: { expireAt: new Date(Date.now() - 1) } } + ); + + const response = await Call, undefined>( + updatePasswordApi, + { body, auth: getAuth() } + ); + + expect(response.code).toBe(500); + expect( + await MongoUser.exists({ _id: testUser._id, password: hashStr('old-password') }) + ).toBeTruthy(); + }); + + it('keeps the session available when the password update is rejected', async () => { + const body = await getBody({ newPsw: hashStr('old-password') }); + const rejectedResponse = await Call, undefined>( + updatePasswordApi, + { body, auth: getAuth() } + ); + + expect(rejectedResponse.code).toBe(500); + expect(rejectedResponse.error).toMatchObject({ + name: 'UserError', + message: UserErrEnum.newPasswordSameAsOld + }); + + const retryResponse = await Call, undefined>( + updatePasswordApi, + { + body: { ...body, newPsw: hashStr('retried-password') }, + auth: getAuth() + } + ); + + expect(retryResponse.code).toBe(200); + expect( + await MongoUser.exists({ _id: testUser._id, password: hashStr('retried-password') }) + ).toBeTruthy(); + }); + + 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 = await 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: await getBody({ newPsw: hashStr('old-password') }), + auth: getAuth() + } + ); + + 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(); + }); + + it('rejects a token issued for another user', async () => { + const response = await Call, undefined>( + updatePasswordApi, + { + body: await 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: '' }, + passwordChangeSession: ( + await createPasswordChangeSession({ + userId: String(testUser._id), + loginSessionId: 'current-session' + }) + ).sessionId + }, + auth: getAuth() + } + ); + + expect(missingTokenResponse.code).toBe(500); + expect(invalidPasswordResponse.code).toBe(500); + }); + + it('rejects root even with a valid password change session', async () => { + const body = await getBody(); + const response = await Call, undefined>( + updatePasswordApi, + { + body, + auth: getAuth(String(testUser._id), true) + } + ); + + expect(response.code).toBe(500); + expect( + await MongoUser.exists({ _id: testUser._id, password: hashStr('old-password') }) + ).toBeTruthy(); + }); + + it('rejects requests without a current Session', async () => { + const response = await Call, undefined>( + updatePasswordApi, + { + body: await 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 b400ed15410f..000000000000 --- a/projects/app/test/api/support/user/account/resetExpiredPsw.test.ts +++ /dev/null @@ -1,214 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import resetExpiredPswHandler from '@/pages/api/support/user/account/resetExpiredPsw'; -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 res = await Call(resetExpiredPswHandler, { - 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); - expect(res.error?.name).toBe('ApiRequestInputParseError'); - }); - - 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 twoMonthsAgo = new Date(); - twoMonthsAgo.setMonth(twoMonthsAgo.getMonth() - 2); - await MongoUser.findByIdAndUpdate(testUser._id, { - passwordUpdateTime: twoMonthsAgo - }); - - const res = await Call(resetExpiredPswHandler, { - 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); - expect(res.error?.name).toBe('ApiRequestInputParseError'); - }); - - it('should reject string body as input parse error', async () => { - vi.stubEnv('PASSWORD_EXPIRED_MONTH', '1'); - - const res = await Call(resetExpiredPswHandler, { - body: '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?.name).toBe('ApiRequestInputParseError'); - }); -}); 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 0abf78f8f2a4..000000000000 --- a/projects/app/test/api/support/user/account/updatePasswordByOld.test.ts +++ /dev/null @@ -1,155 +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'; -import { ApiRequestInputParseError } from '@fastgpt/service/common/zod/requestParseError'; - -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); - expect(res.error).toBeInstanceOf(ApiRequestInputParseError); - }); - - 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); - expect(res.error).toBeInstanceOf(ApiRequestInputParseError); - }); - - it('should reject string body as input parse error', async () => { - const res = await Call(updatePasswordApi.default, { - body: 'invalid body', - auth: makeAuth(testUser, testTeam, testTmb) as any - }); - - expect(res.code).toBe(500); - expect(res.error).toBeInstanceOf(ApiRequestInputParseError); - }); - - 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); - expect(res.error).toBeInstanceOf(ApiRequestInputParseError); - - // 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); - expect(res.error).toBeInstanceOf(ApiRequestInputParseError); - }); - - 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); - expect(res.error).toBeInstanceOf(ApiRequestInputParseError); - }); -}); diff --git a/projects/app/test/components/support/user/safe/SendCodeAuthModal.test.ts b/projects/app/test/components/support/user/safe/SendCodeAuthModal.test.ts index cd9ae88a0d5c..f4a79db86917 100644 --- a/projects/app/test/components/support/user/safe/SendCodeAuthModal.test.ts +++ b/projects/app/test/components/support/user/safe/SendCodeAuthModal.test.ts @@ -113,13 +113,13 @@ describe('SendCodeAuthModal', () => { expect(mocks.submit).toHaveBeenCalledTimes(1); }); - it('keeps the current captcha when sending fails', async () => { + it('handles sending failures without closing or refreshing the captcha', async () => { const onClose = vi.fn(); const onSendCode = vi.fn(async () => Promise.reject(new Error('send failed'))); renderModal({ onClose, onSendCode }); const onSubmit = mocks.handleSubmit.mock.calls[0][0]; - await expect(onSubmit({ code: 'captcha' })).rejects.toThrow('send failed'); + await expect(onSubmit({ code: 'captcha' })).resolves.toBeUndefined(); expect(mocks.refreshCaptcha).not.toHaveBeenCalled(); expect(onClose).not.toHaveBeenCalled(); 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/scripts/migration/authCodeToTmpData.test.ts b/projects/app/test/scripts/migration/authCodeToTmpData.test.ts index 313637af80dd..a0aa0907efdf 100644 --- a/projects/app/test/scripts/migration/authCodeToTmpData.test.ts +++ b/projects/app/test/scripts/migration/authCodeToTmpData.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; import { createHash } from 'node:crypto'; +import { CAPTCHA_VERIFICATION_PURPOSES } from '@fastgpt/global/support/user/account/verification/type'; import { mapLegacyAuthCode } from '../../../scripts/migration/authCodeToTmpData'; const now = new Date('2026-07-31T00:00:00.000Z'); @@ -81,7 +82,7 @@ describe('mapLegacyAuthCode', () => { ) ).toEqual({ kind: 'mapped', - records: ['register', 'forgetPassword', 'unsubscribe', 'bindNotification'].map((scene) => ({ + records: CAPTCHA_VERIFICATION_PURPOSES.map((scene) => ({ dataId: `verification:v1:${scene}:captcha:account@example.com`, data: { code: 'abc123' }, expireAt 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..52b62cac24ec --- /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('starts the password-change verification flow', async () => { + const body = { source: 'verificationMethod' } 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', + passwordChangeSession: 'password-change-session' + }); + + expect(POST).toHaveBeenCalledWith('/support/user/account/password/update', { + newPsw: hashStr('Strong-password-123'), + passwordChangeSession: 'password-change-session' + }); + }); +}); 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..2761396a5c8b --- /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 session store', () => { + beforeEach(() => { + usePasswordChangeStore.getState().setSession(undefined); + }); + + it('keeps the OAuth session only in the non-persisted process store', () => { + const session = { + sessionId: 'password-change-session', + expiredAt: '2026-07-22T08:05:00.000Z', + required: false + }; + + usePasswordChangeStore.getState().setSession(session); + + expect(usePasswordChangeStore.getState().session).toEqual(session); + expect('persist' in usePasswordChangeStore).toBe(false); + }); + + it('removes the session when the flow is closed or invalidated', () => { + usePasswordChangeStore.getState().setSession({ + sessionId: 'password-change-session', + expiredAt: '2026-07-22T08:05:00.000Z', + required: true + }); + + usePasswordChangeStore.getState().setSession(undefined); + + expect(usePasswordChangeStore.getState().session).toBeUndefined(); + }); +}); diff --git a/projects/app/test/web/support/user/account/verification/error.test.ts b/projects/app/test/web/support/user/account/verification/error.test.ts new file mode 100644 index 000000000000..4bd9d65c0839 --- /dev/null +++ b/projects/app/test/web/support/user/account/verification/error.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest'; +import { UserErrEnum } from '@fastgpt/global/common/error/code/user'; +import { + isAccountVerificationCodeError, + isAccountVerificationRateLimitError +} from '@/web/support/user/account/verification/error'; + +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( + isAccountVerificationCodeError({ + response: { data: { message: 'common:error.code_error' } } + }) + ).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 other send failures as verification code errors', () => { + expect(isAccountVerificationCodeError(new Error('common:error.send_failed'))).toBe(false); + }); +}); + +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( + isAccountVerificationRateLimitError({ + response: { data: { message: 'common:error.send_auth_code_too_frequently' } } + }) + ).toBe(true); + }); + + it.each([UserErrEnum.sendVerificationCodeTooFrequently, UserErrEnum.verifyCodeTooFrequently])( + 'recognizes stable statusText %s', + (statusText) => { + expect(isAccountVerificationRateLimitError({ statusText })).toBe(true); + } + ); + + it('does not classify unrelated verification failures as rate limits', () => { + expect(isAccountVerificationRateLimitError(new Error('common:error.code_error'))).toBe(false); + }); +}); diff --git a/projects/app/test/web/support/user/api.test.ts b/projects/app/test/web/support/user/api.test.ts index 2cb0d77cddbf..3ff49147e0f2 100644 --- a/projects/app/test/web/support/user/api.test.ts +++ b/projects/app/test/web/support/user/api.test.ts @@ -74,18 +74,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(); }); From 703db96c6b0c0994288306decd3807dedeb931dc Mon Sep 17 00:00:00 2001 From: shortlight5980 Date: Tue, 8 Sep 2026 16:44:57 +0800 Subject: [PATCH 2/7] fix(change-password): notice --- packages/web/i18n/en/common.json | 1 + packages/web/i18n/zh-CN/common.json | 1 + packages/web/i18n/zh-Hant/common.json | 1 + .../src/components/support/user/safe/PasswordChangeModal.tsx | 4 ++++ 4 files changed, 7 insertions(+) diff --git a/packages/web/i18n/en/common.json b/packages/web/i18n/en/common.json index 6f4a00468a63..474a9a50d6de 100644 --- a/packages/web/i18n/en/common.json +++ b/packages/web/i18n/en/common.json @@ -1276,6 +1276,7 @@ "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_expired": "Identity authentication expired. Please verify again.", "password_verification_retry": "Retry", "password_verification_title": "Identity verification", "password_verification_unavailable": "No identity verification method is available for this account", diff --git a/packages/web/i18n/zh-CN/common.json b/packages/web/i18n/zh-CN/common.json index ab7d6d2bf0d6..cb42ef758cd2 100644 --- a/packages/web/i18n/zh-CN/common.json +++ b/packages/web/i18n/zh-CN/common.json @@ -1276,6 +1276,7 @@ "password_update_error": "修改密码异常", "password_verification_description": "为保护账号安全,请先完成身份验证。", "password_verification_failed": "身份验证失败,请重试", + "password_verification_expired": "身份认证已过期,请重新验证", "password_verification_retry": "重试", "password_verification_title": "身份验证", "password_verification_unavailable": "当前账号没有可用的身份验证方式", diff --git a/packages/web/i18n/zh-Hant/common.json b/packages/web/i18n/zh-Hant/common.json index 4d5221e1c1b4..981fcff77ee9 100644 --- a/packages/web/i18n/zh-Hant/common.json +++ b/packages/web/i18n/zh-Hant/common.json @@ -1276,6 +1276,7 @@ "password_update_error": "修改密碼異常", "password_verification_description": "為保護帳號安全,請先完成身分驗證。", "password_verification_failed": "身分驗證失敗,請重試", + "password_verification_expired": "身分認證已過期,請重新驗證", "password_verification_retry": "重試", "password_verification_title": "身分驗證", "password_verification_unavailable": "目前帳號沒有可用的身分驗證方式", diff --git a/projects/app/src/components/support/user/safe/PasswordChangeModal.tsx b/projects/app/src/components/support/user/safe/PasswordChangeModal.tsx index ffe0f3e48f57..777dd053dabd 100644 --- a/projects/app/src/components/support/user/safe/PasswordChangeModal.tsx +++ b/projects/app/src/components/support/user/safe/PasswordChangeModal.tsx @@ -257,6 +257,10 @@ const PasswordChangeModal = ({ if (errorResponse?.statusText === UserErrEnum.passwordChangeAuthorizationInvalid) { reset(); setStoredSession(undefined); + toast({ + status: 'error', + title: t('common:password_verification_expired', '身份认证已过期,请重新验证') + }); setStage({ type: 'authorizing' }); return; } From 5226a6abf6212b611d64152b390dec438b65fc5f Mon Sep 17 00:00:00 2001 From: shortlight5980 Date: Wed, 9 Sep 2026 10:21:31 +0800 Subject: [PATCH 3/7] fix(change password): fix i18n --- .../support/user/safe/AccountVerificationPanel.tsx | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/projects/app/src/components/support/user/safe/AccountVerificationPanel.tsx b/projects/app/src/components/support/user/safe/AccountVerificationPanel.tsx index 4b67e4cb48fb..3c109dbd2c5e 100644 --- a/projects/app/src/components/support/user/safe/AccountVerificationPanel.tsx +++ b/projects/app/src/components/support/user/safe/AccountVerificationPanel.tsx @@ -635,11 +635,9 @@ export const AccountVerificationPanel = ({ ) : ( - {t( - createFailed - ? 'common:password_wechat_load_failed' - : 'common:password_wechat_expired' - )} + {createFailed + ? t('common:password_wechat_load_failed') + : t('common:password_wechat_expired')} diff --git a/projects/app/src/pages/api/support/user/account/checkPswExpired.ts b/projects/app/src/pages/api/support/user/account/password/checkPswExpired.ts similarity index 100% rename from projects/app/src/pages/api/support/user/account/checkPswExpired.ts rename to projects/app/src/pages/api/support/user/account/password/checkPswExpired.ts 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 937db7415d5d..f54d1bbaaf8a 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 @@ -2,12 +2,13 @@ import { ERROR_ENUM } from '@fastgpt/global/common/error/errorCode'; import type { ApiRequestProps } from '@fastgpt/next/type'; 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 { parseApiInput } from '@fastgpt/service/common/zod/requestParseError'; +import { serviceEnv } from '@fastgpt/service/env'; +import { assertPasswordUpdateRateLimit } from '@fastgpt/service/common/rateLimit/interface/accountVerification'; import { authCert } from '@fastgpt/service/support/permission/auth/common'; import { consumePasswordChangeSessionInTransaction } from '@fastgpt/service/support/user/account/password/service'; import { addAuditLog } from '@fastgpt/service/support/user/audit/util'; @@ -25,6 +26,11 @@ async function handler(req: ApiRequestProps): Promise { await consumePasswordChangeSessionInTransaction({ sessionId: body.passwordChangeSession, @@ -54,8 +60,6 @@ async function handler(req: ApiRequestProps): Promise { loginStore?.flow === 'passwordChange' ? t('common:password_verification_failed') : loginStore?.flow === 'accountCancellation' - ? t('account_info:account_cancellation_verification_failed', '身份验证失败,请重试') + ? t('account_info:account_cancellation_verification_failed') : getErrText(error, t('common:support.user.login.error')) }); setTimeout(() => { @@ -213,7 +213,7 @@ const provider = () => { loginStore?.flow === 'passwordChange' ? t('common:password_verification_failed') : loginStore?.flow === 'accountCancellation' - ? t('account_info:account_cancellation_verification_failed', '身份验证失败,请重试') + ? t('account_info:account_cancellation_verification_failed') : t('common:support.user.login.Provider error') }); router.replace(errorRedirectPage); @@ -241,7 +241,7 @@ const provider = () => { loginStore?.flow === 'passwordChange' ? t('common:password_verification_failed') : loginStore?.flow === 'accountCancellation' - ? t('account_info:account_cancellation_verification_failed', '身份验证失败,请重试') + ? t('account_info:account_cancellation_verification_failed') : t('common:support.user.login.security_failed') }); setTimeout(() => { diff --git a/projects/app/src/web/support/user/account/verification/error.ts b/projects/app/src/web/support/user/account/verification/error.ts index 349ad80d7d69..9c8625b7ee01 100644 --- a/projects/app/src/web/support/user/account/verification/error.ts +++ b/projects/app/src/web/support/user/account/verification/error.ts @@ -1,3 +1,4 @@ +import { i18nT } from '@fastgpt/global/common/i18n/utils'; import { UserErrEnum } from '@fastgpt/global/common/error/code/user'; import { getErrResponse, getErrText } from '@fastgpt/global/common/error/utils'; @@ -6,8 +7,8 @@ const accountVerificationRateLimitStatusTexts = new Set([ UserErrEnum.verifyCodeTooFrequently ]); const legacyAccountVerificationRateLimitErrors = new Set([ - 'common:error.send_auth_code_too_frequently', - 'common:error.verify_code_too_frequently' + i18nT('common:error.send_auth_code_too_frequently'), + i18nT('common:error.verify_code_too_frequently') ]); const verificationCodeError = 'common:error.code_error'; diff --git a/projects/app/src/web/support/user/api.ts b/projects/app/src/web/support/user/api.ts index 5ab5ae3a1488..542feeb71afa 100644 --- a/projects/app/src/web/support/user/api.ts +++ b/projects/app/src/web/support/user/api.ts @@ -91,7 +91,8 @@ export const postFindPassword = ({ password: hashStr(password) }); // Check the whether password has expired -export const getCheckPswExpired = () => GET('/support/user/account/checkPswExpired'); +export const getCheckPswExpired = () => + GET('/support/user/account/password/checkPswExpired'); /* ===== notification account ===== */ export const updateNotificationAccount = (data: { account: string; verifyCode: string }) => diff --git a/projects/app/test/api/support/user/account/checkPswExpired.test.ts b/projects/app/test/api/support/user/account/password/checkPswExpired.test.ts similarity index 98% rename from projects/app/test/api/support/user/account/checkPswExpired.test.ts rename to projects/app/test/api/support/user/account/password/checkPswExpired.test.ts index c0705608e45b..3340a6ea3ad4 100644 --- a/projects/app/test/api/support/user/account/checkPswExpired.test.ts +++ b/projects/app/test/api/support/user/account/password/checkPswExpired.test.ts @@ -9,7 +9,7 @@ import { Call } from '@test/utils/request'; const originalPasswordExpiredMonth = process.env.PASSWORD_EXPIRED_MONTH; const loadCheckPswExpiredApi = async () => { vi.resetModules(); - return import('@/pages/api/support/user/account/checkPswExpired'); + return import('@/pages/api/support/user/account/password/checkPswExpired'); }; describe('checkPswExpired API', () => { 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 6b47610c0ee1..04b1bdd52241 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 @@ -12,7 +12,12 @@ import updatePasswordApi from '@/pages/api/support/user/account/password/update' import { Call } from '@test/utils/request'; const mocks = vi.hoisted(() => ({ - withUserLock: vi.fn() + withUserLock: vi.fn(), + assertPasswordUpdateRateLimit: vi.fn() +})); + +vi.mock('@fastgpt/service/common/rateLimit/interface/accountVerification', () => ({ + assertPasswordUpdateRateLimit: mocks.assertPasswordUpdateRateLimit })); vi.mock('@fastgpt/service/support/user/lock', () => ({ @@ -26,6 +31,7 @@ describe('password/update API', () => { beforeEach(async () => { mocks.withUserLock.mockImplementation((_userId: string, fn: () => Promise) => fn()); + mocks.assertPasswordUpdateRateLimit.mockResolvedValue(undefined); testUser = await MongoUser.create({ username: 'password-update-user', password: hashStr('old-password') @@ -74,12 +80,32 @@ describe('password/update API', () => { ); expect(response.code).toBe(200); + expect(mocks.assertPasswordUpdateRateLimit).toHaveBeenCalledWith({ + account: String(testUser._id), + limit: expect.any(Number) + }); expect(mocks.withUserLock).toHaveBeenCalledWith(String(testUser._id), expect.any(Function)); 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('rejects password updates after the per-user rate limit is reached', async () => { + const body = await getBody(); + mocks.assertPasswordUpdateRateLimit.mockRejectedValueOnce(new Error('rate limited')); + + const response = await Call, undefined>( + updatePasswordApi, + { body, auth: getAuth() } + ); + + expect(response.code).toBe(500); + expect(mocks.withUserLock).not.toHaveBeenCalled(); + expect( + await MongoUser.exists({ _id: testUser._id, password: hashStr('old-password') }) + ).toBeTruthy(); + }); + it('consumes the session after a successful password update', async () => { const body = await getBody(); const response = await Call, undefined>( diff --git a/projects/app/test/pageComponents/account/info/password.test.ts b/projects/app/test/pageComponents/account/info/password.test.ts index 36da445e5d22..0b7f054875e6 100644 --- a/projects/app/test/pageComponents/account/info/password.test.ts +++ b/projects/app/test/pageComponents/account/info/password.test.ts @@ -2,8 +2,8 @@ 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('exposes password management for root accounts', () => { + expect(canManagePasswordFromAccountInfo({ isPlus: true, username: 'root' })).toBe(true); }); it('does not expose password management for WeCom users', () => {