diff --git a/packages/global/common/error/code/user.ts b/packages/global/common/error/code/user.ts index 00b0408d2f0b..963a375449e4 100644 --- a/packages/global/common/error/code/user.ts +++ b/packages/global/common/error/code/user.ts @@ -6,7 +6,10 @@ export enum UserErrEnum { userExist = 'userExist', unAuthRole = 'unAuthRole', account_psw_error = 'account_psw_error', - unAuthSso = 'unAuthSso' + unAuthSso = 'unAuthSso', + invalidVerificationCode = 'invalidVerificationCode', + sendVerificationCodeTooFrequently = 'sendVerificationCodeTooFrequently', + verifyCodeTooFrequently = 'verifyCodeTooFrequently' } const errList = [ { @@ -24,6 +27,21 @@ const errList = [ { statusText: UserErrEnum.unAuthSso, message: i18nT('user:sso_auth_failed') + }, + { + statusText: UserErrEnum.invalidVerificationCode, + message: i18nT('common:error.code_error'), + httpStatus: 400 + }, + { + statusText: UserErrEnum.sendVerificationCodeTooFrequently, + message: i18nT('common:error.send_auth_code_too_frequently'), + httpStatus: 429 + }, + { + statusText: UserErrEnum.verifyCodeTooFrequently, + message: i18nT('common:error.verify_code_too_frequently'), + httpStatus: 429 } ]; export default errList.reduce((acc, cur, index) => { @@ -33,7 +51,8 @@ export default errList.reduce((acc, cur, index) => { code: 503000 + index, statusText: cur.statusText, message: cur.message, - data: null + data: null, + ...(cur.httpStatus !== undefined ? { httpStatus: cur.httpStatus } : {}) } }; }, {} as ErrType<`${UserErrEnum}`>); diff --git a/packages/global/common/system/types/index.ts b/packages/global/common/system/types/index.ts index f9dd2a8a0abc..fe32821b50fc 100644 --- a/packages/global/common/system/types/index.ts +++ b/packages/global/common/system/types/index.ts @@ -54,7 +54,6 @@ export type FastGPTFeConfigsType = { login_method?: FastGPTRegisterMethodType[]; // Attention: login method is different with oauth find_password_method?: FastGPTRegisterMethodType[]; bind_notification_method?: FastGPTRegisterMethodType[]; - googleClientVerKey?: string; /** * @deprecated MCP SSE 代理地址已迁移到环境变量 SSE_MCP_SERVER_PROXY_ENDPOINT。 * 运行时配置以环境变量为准,admin 不再支持写入该字段。 diff --git a/packages/global/openapi/support/user/account/password/api.ts b/packages/global/openapi/support/user/account/password/api.ts index 8d885788047a..9c600fe1d958 100644 --- a/packages/global/openapi/support/user/account/password/api.ts +++ b/packages/global/openapi/support/user/account/password/api.ts @@ -1,5 +1,6 @@ import { z } from 'zod'; import { LanguageSchema } from '../../../../../common/i18n/type'; +import { AccountContactUsernameSchema } from '../../../../../support/user/account/verification/type'; // ===== Update password by old password ===== export const UpdatePasswordByOldBodySchema = z @@ -54,7 +55,7 @@ export type ResetExpiredPswResponseType = z.infer; + +/** 注册验证码接口,purpose 由后端固定为 register。 */ +export const SendRegisterAuthCodeBodySchema = SendAuthCodeBodySchema.extend({ + type: z.literal(UserAuthTypeEnum.register).meta({ + description: '验证码类型', + example: UserAuthTypeEnum.register + }) +}).strict(); +export type SendRegisterAuthCodeBodyType = z.infer; + +/** 找回密码验证码接口,purpose 由后端固定为 forgetPassword。 */ +export const SendForgetPasswordAuthCodeBodySchema = SendAuthCodeBodySchema.extend({ + type: z.literal(UserAuthTypeEnum.findPassword).meta({ + description: '验证码类型', + example: UserAuthTypeEnum.findPassword + }) +}).strict(); +export type SendForgetPasswordAuthCodeBodyType = z.infer< + typeof SendForgetPasswordAuthCodeBodySchema +>; + +/** 绑定通知账号验证码接口,purpose 由后端固定为 bindNotification。 */ +export const SendBindNotificationAuthCodeBodySchema = SendAuthCodeBodySchema.extend({ + type: z.literal(UserAuthTypeEnum.bindNotification).meta({ + description: '验证码类型', + example: UserAuthTypeEnum.bindNotification + }) +}).strict(); +export type SendBindNotificationAuthCodeBodyType = z.infer< + typeof SendBindNotificationAuthCodeBodySchema +>; + +export const SendAuthCodeResponseSchema = z + .object({ + message: z.string().meta({ description: '发送结果说明', example: '发送验证码成功' }) + }) + .strict(); +export type SendAuthCodeResponseType = z.infer; diff --git a/packages/global/openapi/support/user/inform/index.ts b/packages/global/openapi/support/user/inform/index.ts index 9b6c71b44305..52d03e48e2ca 100644 --- a/packages/global/openapi/support/user/inform/index.ts +++ b/packages/global/openapi/support/user/inform/index.ts @@ -5,8 +5,33 @@ import { ActivityAdResponseSchema } from '../../../admin/support/user/inform/api'; import { DevApiTagsMap } from '../../../tag'; +import { SendAuthCodeResponseSchema, SendBindNotificationAuthCodeBodySchema } from './api'; export const UserInformPath: OpenAPIPath = { + '/proApi/support/user/inform/sendAuthCode': { + post: { + summary: '发送绑定通知验证码', + description: '发送绑定通知账号使用的邮箱/短信验证码', + tags: [DevApiTagsMap.userInform], + requestBody: { + content: { + 'application/json': { + schema: SendBindNotificationAuthCodeBodySchema + } + } + }, + responses: { + 200: { + description: '验证码发送成功', + content: { + 'application/json': { + schema: SendAuthCodeResponseSchema + } + } + } + } + } + }, '/proApi/support/user/inform/getSystemMsgModal': { get: { summary: '获取系统弹窗内容', diff --git a/packages/global/support/user/account/verification/type.ts b/packages/global/support/user/account/verification/type.ts new file mode 100644 index 000000000000..f5519f365bb7 --- /dev/null +++ b/packages/global/support/user/account/verification/type.ts @@ -0,0 +1,8 @@ +import { z } from 'zod'; + +export const AccountEmailUsernameSchema = z.email().max(254); +export const AccountPhoneUsernameSchema = z.string().regex(/^1[3456789]\d{9}$/); +export const AccountContactUsernameSchema = z.union([ + AccountEmailUsernameSchema, + AccountPhoneUsernameSchema +]); diff --git a/packages/global/support/user/auth/constants.ts b/packages/global/support/user/auth/constants.ts index 7d909b71613e..a0f1c60f017a 100644 --- a/packages/global/support/user/auth/constants.ts +++ b/packages/global/support/user/auth/constants.ts @@ -6,12 +6,3 @@ export enum UserAuthTypeEnum { captcha = 'captcha', login = 'login' } - -export const userAuthTypeMap = { - [UserAuthTypeEnum.register]: 'register', - [UserAuthTypeEnum.findPassword]: 'findPassword', - [UserAuthTypeEnum.wxLogin]: 'wxLogin', - [UserAuthTypeEnum.bindNotification]: 'bindNotification', - [UserAuthTypeEnum.captcha]: 'captcha', - [UserAuthTypeEnum.login]: 'login' -}; diff --git a/packages/global/support/user/auth/type.ts b/packages/global/support/user/auth/type.ts deleted file mode 100644 index 829acf106294..000000000000 --- a/packages/global/support/user/auth/type.ts +++ /dev/null @@ -1,10 +0,0 @@ -import type { UserAuthTypeEnum } from './constants'; - -export type UserAuthSchemaType = { - key: string; - type: `${UserAuthTypeEnum}`; - code?: string; - openid?: string; - createTime: Date; - expiredTime: Date; -}; diff --git a/packages/service/common/http/entry.ts b/packages/service/common/http/entry.ts index 3d4fc769e3b4..4c0ebc68b84a 100644 --- a/packages/service/common/http/entry.ts +++ b/packages/service/common/http/entry.ts @@ -167,14 +167,17 @@ export const createApiEntry = < }); } - span.setAttribute('http.response.status_code', 500); - setSpanError(span, error); - - return jsonRes(res, { + const response = jsonRes(res, { code: 500, error, url: req.url }); + span.setAttribute('http.response.status_code', res.statusCode); + if (res.statusCode >= 500) { + setSpanError(span, error); + } + + return response; } } ) diff --git a/packages/service/common/response/index.ts b/packages/service/common/response/index.ts index bea76e79f214..24a123feb121 100644 --- a/packages/service/common/response/index.ts +++ b/packages/service/common/response/index.ts @@ -39,6 +39,15 @@ function resolveHttpStatusForApiError( const { code: propsCode = 200, error } = props; const bc = processedError.code; + const explicitErrorStatus = error?.httpStatus; + if ( + typeof explicitErrorStatus === 'number' && + explicitErrorStatus >= 400 && + explicitErrorStatus <= 599 + ) { + return explicitErrorStatus; + } + if (typeof bc === 'number' && bc >= 400 && bc <= 499) { return bc; } @@ -57,6 +66,15 @@ function resolveHttpStatusForApiError( } const raw = typeof error === 'string' ? error : error?.message; + const configuredHttpStatus = ERROR_RESPONSE[raw]?.httpStatus; + if ( + typeof configuredHttpStatus === 'number' && + configuredHttpStatus >= 400 && + configuredHttpStatus <= 599 + ) { + return configuredHttpStatus; + } + if (raw === 'EntityTooLarge') { return 413; } diff --git a/packages/service/support/permission/auth/common.ts b/packages/service/support/permission/auth/common.ts index 83dd1dee9201..88a85ae2a554 100644 --- a/packages/service/support/permission/auth/common.ts +++ b/packages/service/support/permission/auth/common.ts @@ -9,6 +9,12 @@ import { authOpenApiKey } from '../../../support/openapi/auth'; import { AuthUserTypeEnum } from '@fastgpt/global/support/permission/constant'; import { serviceEnv } from '../../../env'; +/** 复用 service 包内的 Cookie 解析实现,供跨 workspace 的服务端模块使用。 */ +export const parseCookie = (value?: string) => Cookie.parse(value || ''); + +/** 复用 service 包内的 Cookie 序列化实现,避免调用方重复声明 cookie 依赖。 */ +export const serializeCookie = Cookie.serialize; + export const authCert = async (props: AuthModeType) => { const result = await parseHeaderCert(props); @@ -35,7 +41,7 @@ export async function parseHeaderCert({ // parse jwt async function authCookieToken(cookie?: string, token?: string) { // 获取 cookie - const cookies = Cookie.parse(cookie || ''); + const cookies = parseCookie(cookie); const cookieToken = token || cookies[TokenName]; if (!cookieToken) { diff --git a/packages/service/support/tmpData/verification.ts b/packages/service/support/tmpData/verification.ts new file mode 100644 index 000000000000..2b2925283147 --- /dev/null +++ b/packages/service/support/tmpData/verification.ts @@ -0,0 +1,142 @@ +import { MongoTmpData } from './schema'; +import type { ClientSession } from '../../common/mongo'; + +export type Scene = + | 'login' + | 'register' + | 'forgetPassword' + | 'changePassword' + | 'unsubscribe' + | 'bindNotification'; + +export type Type = 'password' | 'code' | 'captcha' | 'wechat' | 'oauth'; + +export type VerificationConsumeMatch = Record; + +/** 构造身份验证材料在 tmp_datas 中使用的稳定 ID。 */ +export const getDataId = (scene: Scene, type: Type, key: string) => + `verification:v1:${scene}:${type}:${key}`; + +/** 身份验证材料的临时存取包装,不包含具体业务场景的接入逻辑。 */ +export const verification = { + /** 覆盖同一场景、类型和 key 的材料,并刷新过期时间。 */ + upsert: ( + scene: Scene, + type: Type, + key: string, + data: T, + expiredAt: Date, + session?: ClientSession + ) => { + const dataId = getDataId(scene, type, key); + + return MongoTmpData.updateOne( + { dataId }, + { + dataId, + data, + expireAt: expiredAt + }, + { upsert: true, ...(session ? { session } : {}) } + ); + }, + + /** 只更新仍在有效期内的已有材料,避免回调重新创建或刷新过期材料。 */ + updateIfActive: ( + scene: Scene, + type: Type, + key: string, + data: T, + expiredAt: Date, + session?: ClientSession + ) => + MongoTmpData.updateOne( + { + dataId: getDataId(scene, type, key), + expireAt: { $gt: new Date() } + }, + { + $set: { + data, + expireAt: expiredAt + } + }, + { ...(session ? { session } : {}) } + ), + + /** 只删除仍匹配当前材料内容的记录,避免清理并发请求新写入的验证码。 */ + deleteIfMatch: async ( + scene: Scene, + type: Type, + key: string, + match: VerificationConsumeMatch = {}, + session?: ClientSession + ) => { + const dataMatch = Object.fromEntries( + Object.entries(match).map(([field, value]) => [`data.${field}`, value]) + ); + + return MongoTmpData.deleteOne( + { + dataId: getDataId(scene, type, key), + ...dataMatch + }, + { ...(session ? { session } : {}) } + ); + }, + + /** 读取仍在有效期内的材料,不主动改变材料生命周期。 */ + get: async (scene: Scene, type: Type, key: string): Promise => { + const result = await MongoTmpData.findOne({ + dataId: getDataId(scene, type, key), + expireAt: { $gt: new Date() } + }).lean(); + + return result ? (result.data as T | null) : null; + }, + + /** 原子消费仍在有效期内的材料,并返回材料内容。 */ + consume: async ( + scene: Scene, + type: Type, + key: string, + match: VerificationConsumeMatch = {} + ): Promise => { + const dataMatch = Object.fromEntries( + Object.entries(match).map(([field, value]) => [`data.${field}`, value]) + ); + const result = await MongoTmpData.findOneAndDelete({ + dataId: getDataId(scene, type, key), + expireAt: { $gt: new Date() }, + ...dataMatch + }).lean(); + + return result ? (result.data as T) : null; + }, + + /** 读取有效的微信材料;仅在已有 openId 时原子消费,未扫码时保留材料。 */ + getAndDelete: async ( + scene: Scene, + type: Type, + key: string + ): Promise => { + const result = await MongoTmpData.findOne({ + dataId: getDataId(scene, type, key), + expireAt: { $gt: new Date() } + }).lean(); + + if (!result) { + return null; + } + + if (!(result.data as T | null)?.openId) { + return; + } + + return ( + (await verification.consume(scene, type, key, { + openId: { $exists: true } + })) ?? undefined + ); + } +}; diff --git a/packages/service/support/user/account/verification/password/service.ts b/packages/service/support/user/account/verification/password/service.ts index e2f955ce6004..bb6a9489c6d4 100644 --- a/packages/service/support/user/account/verification/password/service.ts +++ b/packages/service/support/user/account/verification/password/service.ts @@ -1,9 +1,10 @@ import { UserErrEnum } from '@fastgpt/global/common/error/code/user'; +import { UserError } from '@fastgpt/global/common/error/utils'; import { getNanoid } from '@fastgpt/global/common/string/tools'; -import { UserAuthTypeEnum } from '@fastgpt/global/support/user/auth/constants'; import { addSeconds } from 'date-fns'; -import { addAuthCode, authCode } from '../../../auth/controller'; +import { verification } from '../../../../tmpData/verification'; import { MongoUser } from '../../../schema'; +import { assertCodeVerificationConsumeFrequency } from '../utils'; import type { IssuePreLoginCodeParams, IssuePreLoginCodeResult, @@ -14,19 +15,20 @@ import type { const defaultDependencies: PasswordVerificationDependencies = { generateCode: getNanoid, now: () => new Date(), - savePreLoginCode: ({ username, code, expiredTime }) => - addAuthCode({ - type: UserAuthTypeEnum.login, - key: username, - code, - expiredTime - }), - verifyPreLoginCode: ({ username, code }) => - authCode({ - key: username, - code, - type: UserAuthTypeEnum.login - }), + assertConsumeFrequency: assertCodeVerificationConsumeFrequency, + savePreLoginCode: ({ purpose, username, code, expiredTime }) => + verification.upsert(purpose, 'password', username, { preLoginCode: code }, expiredTime), + verifyPreLoginCode: async ({ purpose, username, code }) => { + const material = await verification.consume(purpose, 'password', username, { + preLoginCode: code + }); + + if (!material) { + return Promise.reject(new UserError(UserErrEnum.invalidVerificationCode)); + } + + return 'SUCCESS'; + }, findUserByCredentials: ({ username, password }) => MongoUser.findOne({ username, password }) }; @@ -45,20 +47,25 @@ export class PasswordVerificationService { }; } - async issuePreLoginCode({ username }: IssuePreLoginCodeParams): Promise { + async issuePreLoginCode({ + username, + purpose + }: IssuePreLoginCodeParams): Promise { const code = this.dependencies.generateCode(6); await this.dependencies.savePreLoginCode({ username, code, + purpose, expiredTime: addSeconds(this.dependencies.now(), 30) }); return { code }; } - async verifyCredentials({ username, password, code }: VerifyPasswordCredentialsParams) { - await this.dependencies.verifyPreLoginCode({ username, code }); + async verifyCredentials({ username, password, code, purpose }: VerifyPasswordCredentialsParams) { + await this.dependencies.assertConsumeFrequency({ account: username, scene: purpose }); + await this.dependencies.verifyPreLoginCode({ username, code, purpose }); const user = await this.dependencies.findUserByCredentials({ username, password }); if (!user) { diff --git a/packages/service/support/user/account/verification/password/type.ts b/packages/service/support/user/account/verification/password/type.ts index 5f3f89328462..d74b96f2920c 100644 --- a/packages/service/support/user/account/verification/password/type.ts +++ b/packages/service/support/user/account/verification/password/type.ts @@ -3,8 +3,17 @@ import type { UserModelSchema } from '@fastgpt/global/support/user/type'; export type PasswordVerificationUser = HydratedDocument; +export type PasswordVerificationPurpose = + | 'login' + | 'register' + | 'forgetPassword' + | 'changePassword' + | 'unsubscribe' + | 'bindNotification'; + export type IssuePreLoginCodeParams = { username: string; + purpose: PasswordVerificationPurpose; }; export type IssuePreLoginCodeResult = { @@ -15,17 +24,27 @@ export type VerifyPasswordCredentialsParams = { username: string; password: string; code: string; + purpose: PasswordVerificationPurpose; }; export type PasswordVerificationDependencies = { generateCode: (length: number) => string; now: () => Date; + assertConsumeFrequency: (params: { + account: string; + scene: PasswordVerificationPurpose; + }) => Promise; savePreLoginCode: (params: { username: string; code: string; + purpose: PasswordVerificationPurpose; expiredTime: Date; }) => Promise; - verifyPreLoginCode: (params: { username: string; code: string }) => Promise; + verifyPreLoginCode: (params: { + username: string; + code: string; + purpose: PasswordVerificationPurpose; + }) => Promise; findUserByCredentials: (params: { username: string; password: string; diff --git a/packages/service/support/user/account/verification/utils.ts b/packages/service/support/user/account/verification/utils.ts new file mode 100644 index 000000000000..c86d6f1d2482 --- /dev/null +++ b/packages/service/support/user/account/verification/utils.ts @@ -0,0 +1,25 @@ +import { UserErrEnum } from '@fastgpt/global/common/error/code/user'; +import { UserError } from '@fastgpt/global/common/error/utils'; +import { checkFixedWindowQpmLimit } from '../../../../common/system/frequencyLimit/redisFixedWindow'; + +const CodeVerificationConsumeQpm = 10; +const CodeVerificationConsumeWindowSeconds = 60; + +/** 按账号和场景限制验证码消费次数,错误和成功提交都占用同一固定窗口。 */ +export const assertCodeVerificationConsumeFrequency = async ({ + account, + scene +}: { + account: string; + scene: string; +}) => { + const allowed = await checkFixedWindowQpmLimit({ + key: `account-verification:code:consume:${scene}:${account}`, + limit: CodeVerificationConsumeQpm, + seconds: CodeVerificationConsumeWindowSeconds + }); + + if (!allowed) { + throw new UserError(UserErrEnum.verifyCodeTooFrequently); + } +}; diff --git a/packages/service/support/user/auth/controller.ts b/packages/service/support/user/auth/controller.ts deleted file mode 100644 index 101aa62d4fca..000000000000 --- a/packages/service/support/user/auth/controller.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { UserAuthTypeEnum } from '@fastgpt/global/support/user/auth/constants'; -import { MongoUserAuth } from './schema'; -import { i18nT } from '@fastgpt/global/common/i18n/utils'; -import { mongoSessionRun } from '../../../common/mongo/sessionRun'; -import { UserError } from '@fastgpt/global/common/error/utils'; -import { z } from 'zod'; - -export const addAuthCode = async ({ - key, - code, - openid, - type, - expiredTime -}: { - key: string; - code?: string; - openid?: string; - type: `${UserAuthTypeEnum}`; - expiredTime?: Date; -}) => { - return MongoUserAuth.updateOne( - { - key, - type - }, - { - code, - openid, - expiredTime - }, - { - upsert: true - } - ); -}; - -const authCodeSchema = z.object({ - key: z.string(), - type: z.enum(UserAuthTypeEnum), - code: z.string() -}); -export const authCode = async (props: z.infer) => { - const { key, type, code } = authCodeSchema.parse(props); - return mongoSessionRun(async (session) => { - const result = await MongoUserAuth.findOne( - { - key, - type, - code: { $regex: new RegExp(`^${code}$`, 'i') } - }, - undefined, - { session } - ); - - if (!result) { - return Promise.reject(new UserError(i18nT('common:error.code_error'))); - } - - await result.deleteOne(); - - return 'SUCCESS'; - }); -}; diff --git a/packages/service/support/user/auth/schema.ts b/packages/service/support/user/auth/schema.ts deleted file mode 100644 index abe78acdfdfa..000000000000 --- a/packages/service/support/user/auth/schema.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { defineIndex, connectionMongo, getMongoModel } from '../../../common/mongo'; -const { Schema } = connectionMongo; -import type { UserAuthSchemaType } from '@fastgpt/global/support/user/auth/type'; -import { userAuthTypeMap } from '@fastgpt/global/support/user/auth/constants'; -import { addMinutes } from 'date-fns'; - -const UserAuthSchema = new Schema({ - key: { - type: String, - required: true - }, - code: { - // auth code - type: String, - length: 6 - }, - // wx openid - openid: String, - type: { - type: String, - enum: Object.keys(userAuthTypeMap), - required: true - }, - createTime: { - type: Date, - default: () => new Date() - }, - expiredTime: { - type: Date, - default: () => addMinutes(new Date(), 5) - } -}); - -defineIndex(UserAuthSchema, { key: { key: 1, type: 1 } }); -defineIndex(UserAuthSchema, { - key: { expiredTime: 1 }, - options: { expireAfterSeconds: 0 } -}); - -export const MongoUserAuth = getMongoModel('auth_codes', UserAuthSchema); diff --git a/packages/service/test/common/response/index.test.ts b/packages/service/test/common/response/index.test.ts index 5dd5820ed18b..88753b7aff26 100644 --- a/packages/service/test/common/response/index.test.ts +++ b/packages/service/test/common/response/index.test.ts @@ -21,7 +21,7 @@ vi.mock('@fastgpt/service/common/logger', () => ({ } })); -const { getSseErrorResponse, processError } = await import('../../../common/response'); +const { getSseErrorResponse, jsonRes, processError } = await import('../../../common/response'); function buildZodError() { try { @@ -97,6 +97,33 @@ describe('processError HTTP status mapping', () => { }); }); +describe('jsonRes HTTP status mapping', () => { + it('does not trust a third-party statusCode field', () => { + const res = { + status: vi.fn().mockReturnThis(), + json: vi.fn() + } as unknown as Parameters[0]; + const error = Object.assign(new Error('Upstream failure'), { statusCode: 404 }); + + jsonRes(res, { code: 500, error }); + + expect(res.status).toHaveBeenCalledWith(500); + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ code: 500 })); + }); + + it('uses an explicitly provided httpStatus', () => { + const res = { + status: vi.fn().mockReturnThis(), + json: vi.fn() + } as unknown as Parameters[0]; + const error = Object.assign(new Error('OAuth failure'), { httpStatus: 401 }); + + jsonRes(res, { error }); + + expect(res.status).toHaveBeenCalledWith(401); + }); +}); + describe('getSseErrorResponse logging', () => { beforeEach(() => { logger.info.mockClear(); diff --git a/packages/service/test/support/tmpData/verification.test.ts b/packages/service/test/support/tmpData/verification.test.ts new file mode 100644 index 000000000000..55799b09cfc5 --- /dev/null +++ b/packages/service/test/support/tmpData/verification.test.ts @@ -0,0 +1,222 @@ +import { addMinutes } from 'date-fns'; +import { beforeEach, describe, expect, it } from 'vitest'; +import { MongoTmpData } from '@fastgpt/service/support/tmpData/schema'; +import { getDataId, verification } from '@fastgpt/service/support/tmpData/verification'; + +describe('tmp data verification wrapper', () => { + beforeEach(async () => { + await MongoTmpData.deleteMany({}); + }); + + it('builds a scene and type scoped data id', () => { + expect(getDataId('login', 'code', 'user@example.com')).toBe( + 'verification:v1:login:code:user@example.com' + ); + }); + + it('upserts the complete material and refreshes its expiration', async () => { + const firstExpiredAt = addMinutes(new Date(), 1); + const secondExpiredAt = addMinutes(new Date(), 2); + + await verification.upsert( + 'register', + 'code', + 'user@example.com', + { code: '111111' }, + firstExpiredAt + ); + await verification.upsert( + 'register', + 'code', + 'user@example.com', + { code: '222222' }, + secondExpiredAt + ); + + await expect(MongoTmpData.find({}).lean()).resolves.toMatchObject([ + { + dataId: 'verification:v1:register:code:user@example.com', + data: { code: '222222' }, + expireAt: secondExpiredAt + } + ]); + }); + + it('updates only an existing material that is still active', async () => { + const originalExpiredAt = addMinutes(new Date(), 1); + const updatedExpiredAt = addMinutes(new Date(), 2); + const key = 'active-qr'; + + await verification.upsert('login', 'wechat', key, null, originalExpiredAt); + await verification.updateIfActive( + 'login', + 'wechat', + key, + { openId: 'openid-1' }, + updatedExpiredAt + ); + + await expect(MongoTmpData.find({}).lean()).resolves.toMatchObject([ + { + dataId: getDataId('login', 'wechat', key), + data: { openId: 'openid-1' }, + expireAt: updatedExpiredAt + } + ]); + }); + + it('does not create or refresh missing and expired material', async () => { + const key = 'inactive-qr'; + const expiredAt = new Date(Date.now() - 1000); + const refreshedExpiredAt = addMinutes(new Date(), 1); + + await verification.updateIfActive( + 'login', + 'wechat', + key, + { openId: 'missing-openid' }, + refreshedExpiredAt + ); + await expect( + MongoTmpData.countDocuments({ dataId: getDataId('login', 'wechat', key) }) + ).resolves.toBe(0); + + await verification.upsert('login', 'wechat', key, null, expiredAt); + await verification.updateIfActive( + 'login', + 'wechat', + key, + { openId: 'expired-openid' }, + refreshedExpiredAt + ); + + await expect( + MongoTmpData.findOne({ dataId: getDataId('login', 'wechat', key) }).lean() + ).resolves.toMatchObject({ + data: null, + expireAt: expiredAt + }); + }); + + it('consumes valid material once and ignores expired material', async () => { + await verification.upsert( + 'login', + 'oauth', + 'temporary-key', + { provider: 'github' }, + addMinutes(new Date(), 1) + ); + + await expect( + verification.consume<{ provider: string }>('login', 'oauth', 'temporary-key') + ).resolves.toEqual({ + provider: 'github' + }); + await expect(verification.consume('login', 'oauth', 'temporary-key')).resolves.toBeNull(); + + await verification.upsert( + 'login', + 'password', + 'expired-key', + { password: 'secret' }, + new Date(Date.now() - 1) + ); + + await expect(verification.consume('login', 'password', 'expired-key')).resolves.toBeNull(); + await expect( + MongoTmpData.countDocuments({ dataId: getDataId('login', 'password', 'expired-key') }) + ).resolves.toBe(1); + }); + + it('reads valid material without deleting it', async () => { + const key = 'read-only-key'; + + await verification.upsert( + 'login', + 'wechat', + key, + { openId: 'openid-1' }, + addMinutes(new Date(), 1) + ); + + await expect(verification.get<{ openId: string }>('login', 'wechat', key)).resolves.toEqual({ + openId: 'openid-1' + }); + await expect( + MongoTmpData.countDocuments({ dataId: getDataId('login', 'wechat', key) }) + ).resolves.toBe(1); + }); + + it('does not consume material when nested verification fields do not match', async () => { + await verification.upsert( + 'login', + 'oauth', + 'state-hash', + { state: 'state-value', transactionId: 'transaction-a' }, + addMinutes(new Date(), 1) + ); + + await expect( + verification.consume('login', 'oauth', 'state-hash', { + state: 'state-value', + transactionId: 'transaction-b' + }) + ).resolves.toBeNull(); + + await expect( + verification.consume('login', 'oauth', 'state-hash', { + state: 'state-value', + transactionId: 'transaction-a' + }) + ).resolves.toEqual({ state: 'state-value', transactionId: 'transaction-a' }); + }); + + it('deletes material only when the current code matches', async () => { + const expiredAt = addMinutes(new Date(), 1); + await verification.upsert( + 'register', + 'code', + 'user@example.com', + { code: '111111' }, + expiredAt + ); + + await verification.deleteIfMatch('register', 'code', 'user@example.com', { code: '222222' }); + await expect( + MongoTmpData.countDocuments({ dataId: getDataId('register', 'code', 'user@example.com') }) + ).resolves.toBe(1); + + await verification.deleteIfMatch('register', 'code', 'user@example.com', { code: '111111' }); + await expect( + MongoTmpData.countDocuments({ dataId: getDataId('register', 'code', 'user@example.com') }) + ).resolves.toBe(0); + }); + + it('keeps unread QR material and consumes it after an openId is written', async () => { + const expiredAt = addMinutes(new Date(), 1); + const key = 'scene-hash'; + + await verification.upsert('login', 'wechat', key, null, expiredAt); + await expect(verification.getAndDelete('login', 'wechat', key)).resolves.toBeUndefined(); + await expect( + MongoTmpData.countDocuments({ dataId: getDataId('login', 'wechat', key) }) + ).resolves.toBe(1); + + await verification.upsert('login', 'wechat', key, { openId: 'openid-1' }, expiredAt); + await expect( + verification.getAndDelete<{ openId: string }>('login', 'wechat', key) + ).resolves.toEqual({ openId: 'openid-1' }); + await expect(verification.getAndDelete('login', 'wechat', key)).resolves.toBeNull(); + }); + + it('returns null for an expired QR material without deleting it', async () => { + const key = 'expired-scene-hash'; + + await verification.upsert('login', 'wechat', key, null, new Date(Date.now() - 1)); + + await expect(verification.getAndDelete('login', 'wechat', key)).resolves.toBeNull(); + await expect( + MongoTmpData.countDocuments({ dataId: getDataId('login', 'wechat', key) }) + ).resolves.toBe(1); + }); +}); diff --git a/packages/service/test/support/user/account/verification/password/service.test.ts b/packages/service/test/support/user/account/verification/password/service.test.ts index 36e11c2cf6a0..5790c0ec52fc 100644 --- a/packages/service/test/support/user/account/verification/password/service.test.ts +++ b/packages/service/test/support/user/account/verification/password/service.test.ts @@ -1,14 +1,13 @@ import { UserErrEnum } from '@fastgpt/global/common/error/code/user'; -import { UserAuthTypeEnum } from '@fastgpt/global/support/user/auth/constants'; import type { UserModelSchema } from '@fastgpt/global/support/user/type'; -import { authCode } from '@fastgpt/service/support/user/auth/controller'; -import { MongoUserAuth } from '@fastgpt/service/support/user/auth/schema'; import { PasswordVerificationService } from '@fastgpt/service/support/user/account/verification/password/service'; import type { PasswordVerificationDependencies, PasswordVerificationUser } from '@fastgpt/service/support/user/account/verification/password/type'; import { MongoUser } from '@fastgpt/service/support/user/schema'; +import { MongoTmpData } from '@fastgpt/service/support/tmpData/schema'; +import { getDataId } from '@fastgpt/service/support/tmpData/verification'; import { describe, expect, it, vi } from 'vitest'; const createUser = (): PasswordVerificationUser => @@ -31,6 +30,7 @@ const createDependencies = ( ): PasswordVerificationDependencies => ({ generateCode: vi.fn(() => 'ABC123'), now: vi.fn(() => new Date('2026-07-28T00:00:00.000Z')), + assertConsumeFrequency: vi.fn(async () => undefined), savePreLoginCode: vi.fn(async () => undefined), verifyPreLoginCode: vi.fn(async () => undefined), findUserByCredentials: vi.fn(async () => createUser()), @@ -42,13 +42,14 @@ describe('PasswordVerificationService.issuePreLoginCode', () => { const dependencies = createDependencies(); const service = new PasswordVerificationService(dependencies); - await expect(service.issuePreLoginCode({ username: 'test@example.com' })).resolves.toEqual({ - code: 'ABC123' - }); + await expect( + service.issuePreLoginCode({ username: 'test@example.com', purpose: 'login' }) + ).resolves.toEqual({ code: 'ABC123' }); expect(dependencies.generateCode).toHaveBeenCalledWith(6); expect(dependencies.savePreLoginCode).toHaveBeenCalledWith({ username: 'test@example.com', code: 'ABC123', + purpose: 'login', expiredTime: new Date('2026-07-28T00:00:30.000Z') }); }); @@ -62,6 +63,9 @@ describe('PasswordVerificationService.verifyCredentials', () => { verifyPreLoginCode: vi.fn(async () => { calls.push('verify-code'); }), + assertConsumeFrequency: vi.fn(async () => { + calls.push('consume-frequency'); + }), findUserByCredentials: vi.fn(async () => { calls.push('find-user'); return user; @@ -73,10 +77,11 @@ describe('PasswordVerificationService.verifyCredentials', () => { service.verifyCredentials({ username: 'test@example.com', password: 'hashed-password', - code: 'ABC123' + code: 'ABC123', + purpose: 'login' }) ).resolves.toBe(user); - expect(calls).toEqual(['verify-code', 'find-user']); + expect(calls).toEqual(['consume-frequency', 'verify-code', 'find-user']); expect(dependencies.findUserByCredentials).toHaveBeenCalledWith({ username: 'test@example.com', password: 'hashed-password' @@ -93,7 +98,8 @@ describe('PasswordVerificationService.verifyCredentials', () => { service.verifyCredentials({ username: 'missing@example.com', password: 'wrong-password', - code: 'ABC123' + code: 'ABC123', + purpose: 'login' }) ).rejects.toBe(UserErrEnum.account_psw_error); }); @@ -113,7 +119,8 @@ describe('PasswordVerificationService.verifyCredentials', () => { service.verifyCredentials({ username: 'wecom-openid', password: 'hashed-password', - code: 'ABC123' + code: 'ABC123', + purpose: 'login' }) ).resolves.toBe(user); }); @@ -129,45 +136,59 @@ describe('PasswordVerificationService.verifyCredentials', () => { service.verifyCredentials({ username: 'test@example.com', password: 'hashed-password', - code: 'invalid' + code: 'invalid', + purpose: 'login' }) ).rejects.toBe(error); expect(dependencies.findUserByCredentials).not.toHaveBeenCalled(); }); + + it('does not consume the pre-login material when frequency is exceeded', async () => { + const error = new Error('too frequent'); + const dependencies = createDependencies({ + assertConsumeFrequency: vi.fn(async () => Promise.reject(error)) + }); + const service = new PasswordVerificationService(dependencies); + + await expect( + service.verifyCredentials({ + username: 'test@example.com', + password: 'hashed-password', + code: 'ABC123', + purpose: 'login' + }) + ).rejects.toBe(error); + expect(dependencies.verifyPreLoginCode).not.toHaveBeenCalled(); + }); }); describe('PasswordVerificationService default adapters', () => { - it('keeps the existing auth-code storage and credential query behavior', async () => { + it('stores and consumes password material in tmp_datas', async () => { const username = 'default-adapters@example.com'; const password = 'hashed-password'; const beforeIssue = Date.now(); const service = new PasswordVerificationService(); - vi.mocked(authCode).mockClear(); - const { code } = await service.issuePreLoginCode({ username }); + const { code } = await service.issuePreLoginCode({ username, purpose: 'login' }); const afterIssue = Date.now(); - const authMaterial = await MongoUserAuth.findOne({ - key: username, - type: UserAuthTypeEnum.login + const authMaterial = await MongoTmpData.findOne({ + dataId: getDataId('login', 'password', username) }).lean(); expect(code).toMatch(/^[a-z][a-zA-Z0-9]{5}$/); expect(authMaterial).toMatchObject({ - key: username, - type: UserAuthTypeEnum.login, - code + dataId: getDataId('login', 'password', username), + data: { preLoginCode: code } }); - expect(authMaterial?.expiredTime.getTime()).toBeGreaterThanOrEqual(beforeIssue + 30_000); - expect(authMaterial?.expiredTime.getTime()).toBeLessThanOrEqual(afterIssue + 30_000); + expect(authMaterial?.expireAt.getTime()).toBeGreaterThanOrEqual(beforeIssue + 30_000); + expect(authMaterial?.expireAt.getTime()).toBeLessThanOrEqual(afterIssue + 30_000); const storedUser = await MongoUser.create({ username, password }); - const result = await service.verifyCredentials({ username, password, code }); + const result = await service.verifyCredentials({ username, password, code, purpose: 'login' }); - expect(authCode).toHaveBeenCalledWith({ - key: username, - code, - type: UserAuthTypeEnum.login - }); + await expect( + MongoTmpData.findOne({ dataId: getDataId('login', 'password', username) }) + ).resolves.toBeNull(); expect(String(result._id)).toBe(String(storedUser._id)); }); }); diff --git a/packages/service/test/support/user/account/verification/utils.test.ts b/packages/service/test/support/user/account/verification/utils.test.ts new file mode 100644 index 000000000000..bc297d370fcf --- /dev/null +++ b/packages/service/test/support/user/account/verification/utils.test.ts @@ -0,0 +1,39 @@ +import { UserErrEnum } from '@fastgpt/global/common/error/code/user'; +import { getGlobalRedisConnection } from '@fastgpt/service/common/redis'; +import { describe, expect, it, beforeEach } from 'vitest'; +import { assertCodeVerificationConsumeFrequency } from '@fastgpt/service/support/user/account/verification/utils'; + +describe('assertCodeVerificationConsumeFrequency', () => { + const account = 'verification-rate-limit@example.com'; + const key = `account-verification:code:consume:register:${account}`; + + beforeEach(async () => { + await getGlobalRedisConnection().del(key); + }); + + it('allows 10 attempts and rejects the 11th attempt', async () => { + const params = { account, scene: 'register' }; + + for (let index = 0; index < 10; index++) { + await expect(assertCodeVerificationConsumeFrequency(params)).resolves.toBeUndefined(); + } + + await expect(assertCodeVerificationConsumeFrequency(params)).rejects.toThrow( + UserErrEnum.verifyCodeTooFrequently + ); + }); + + it('keeps accounts and scenes independent', async () => { + const params = { account, scene: 'register' }; + for (let index = 0; index < 10; index++) { + await assertCodeVerificationConsumeFrequency(params); + } + + await expect( + assertCodeVerificationConsumeFrequency({ account: 'other@example.com', scene: 'register' }) + ).resolves.toBeUndefined(); + await expect( + assertCodeVerificationConsumeFrequency({ account, scene: 'findPassword' }) + ).resolves.toBeUndefined(); + }); +}); diff --git a/packages/web/i18n/en/common.json b/packages/web/i18n/en/common.json index 06668dd1cfb4..4a972d1f584f 100644 --- a/packages/web/i18n/en/common.json +++ b/packages/web/i18n/en/common.json @@ -799,6 +799,7 @@ "error.missingParams": "Insufficient parameters", "error.s3_upload_invalid_file_type": "Unsupported file content or file extension does not match", "error.send_auth_code_too_frequently": "Please do not obtain verification code frequently", + "error.verify_code_too_frequently": "Too many verification attempts. Please try again later.", "error.too_many_request": "Too many request", "error.tool_not_exist": "Tool deleted", "error.unAuthFile": "Unauthorized to read this file", diff --git a/packages/web/i18n/zh-CN/common.json b/packages/web/i18n/zh-CN/common.json index acd5245d910e..36fff1aadcf4 100644 --- a/packages/web/i18n/zh-CN/common.json +++ b/packages/web/i18n/zh-CN/common.json @@ -799,6 +799,7 @@ "error.missingParams": "参数缺失", "error.s3_upload_invalid_file_type": "文件内容不受支持,或文件后缀与内容不匹配", "error.send_auth_code_too_frequently": "请勿频繁获取验证码", + "error.verify_code_too_frequently": "验证过于频繁,请稍后再试", "error.too_many_request": "请求太频繁了,请稍后重试", "error.tool_not_exist": "工具已删除", "error.unAuthFile": "无权读取该文件", diff --git a/packages/web/i18n/zh-Hant/common.json b/packages/web/i18n/zh-Hant/common.json index 6ccff470c32f..41575a4dc591 100644 --- a/packages/web/i18n/zh-Hant/common.json +++ b/packages/web/i18n/zh-Hant/common.json @@ -793,6 +793,7 @@ "error.missingParams": "參數不足", "error.s3_upload_invalid_file_type": "文件內容不受支援,或副檔名與內容不匹配", "error.send_auth_code_too_frequently": "請勿頻繁取得驗證碼", + "error.verify_code_too_frequently": "驗證過於頻繁,請稍後再試", "error.too_many_request": "請求太頻繁了,請稍後重試", "error.tool_not_exist": "工具已刪除", "error.unAuthFile": "無權讀取該文件", diff --git a/pro b/pro index 7027ac732193..055604e78f43 160000 --- a/pro +++ b/pro @@ -1 +1 @@ -Subproject commit 7027ac732193b6997eac387f556f23eb2e3bab22 +Subproject commit 055604e78f430eef5b4d5569b45fad19af9ff18d diff --git a/projects/app/package.json b/projects/app/package.json index f52ffe0b8ba0..acbba6a14a46 100644 --- a/projects/app/package.json +++ b/projects/app/package.json @@ -16,6 +16,7 @@ "build:webpack": "pnpm run build:workers && next build --webpack --debug", "analyze": "next experimental-analyze", "start": "next start", + "migrate:auth-code": "tsx scripts/migration/authCodeToTmpData.ts", "test": "vitest run -c vitest.config.ts", "test:watch": "vitest -c vitest.config.ts", "build:workers": "tsx scripts/build-workers.ts", diff --git a/projects/app/scripts/migration/authCodeToTmpData.ts b/projects/app/scripts/migration/authCodeToTmpData.ts new file mode 100644 index 000000000000..b5ac6a3284cb --- /dev/null +++ b/projects/app/scripts/migration/authCodeToTmpData.ts @@ -0,0 +1,341 @@ +import { createHash } from 'node:crypto'; +import { pathToFileURL } from 'node:url'; +import mongoose from 'mongoose'; + +const SOURCE_COLLECTION = 'auth_codes'; +const TARGET_COLLECTION = 'tmp_datas'; +const LEGACY_EXPIRE_AFTER_MS = 5 * 60 * 1000; + +const legacyAuthCodeTypes = new Set([ + 'register', + 'findPassword', + 'wxLogin', + 'bindNotification', + 'captcha', + 'login' +]); + +export type LegacyAuthCodeRecord = { + key?: unknown; + type?: unknown; + code?: unknown; + openid?: unknown; + createTime?: unknown; + expiredTime?: unknown; +}; + +type LegacyAuthCodeType = + | 'register' + | 'findPassword' + | 'wxLogin' + | 'bindNotification' + | 'captcha' + | 'login'; + +type VerificationRecord = { + dataId: string; + data: Record; + expireAt: Date; +}; + +export type LegacyAuthCodeMapping = + | { + kind: 'mapped'; + record: VerificationRecord; + } + | { + kind: 'skipped'; + reason: + | 'unsupported-type' + | 'missing-key' + | 'missing-code' + | 'missing-openid' + | 'missing-expiry' + | 'expired'; + }; + +type MigrationOptions = { + dryRun: boolean; + uri: string; +}; + +type SkipReason = + | 'unsupported-type' + | 'missing-key' + | 'missing-code' + | 'missing-openid' + | 'missing-expiry' + | 'expired'; + +type MigrationStats = { + scanned: number; + mapped: number; + inserted: number; + existing: number; + wouldInsert: number; + duplicateSource: number; + skipped: Record; +}; + +const createStats = (): MigrationStats => ({ + scanned: 0, + mapped: 0, + inserted: 0, + existing: 0, + wouldInsert: 0, + duplicateSource: 0, + skipped: { + 'unsupported-type': 0, + 'missing-key': 0, + 'missing-code': 0, + 'missing-openid': 0, + 'missing-expiry': 0, + expired: 0 + } +}); + +const isNonEmptyString = (value: unknown): value is string => + typeof value === 'string' && value.length > 0; + +const toDate = (value: unknown) => { + const date = value instanceof Date ? value : new Date(value as string | number); + return Number.isNaN(date.getTime()) ? undefined : date; +}; + +const getExpireAt = (record: LegacyAuthCodeRecord) => { + const expiredTime = toDate(record.expiredTime); + if (expiredTime) return expiredTime; + + const createTime = toDate(record.createTime); + return createTime ? new Date(createTime.getTime() + LEGACY_EXPIRE_AFTER_MS) : undefined; +}; + +const getDataId = (scene: string, type: string, key: string) => + `verification:v1:${scene}:${type}:${key}`; + +const hashKey = (key: string) => createHash('sha256').update(key).digest('hex'); + +const isLegacyAuthCodeType = (value: unknown): value is LegacyAuthCodeType => + typeof value === 'string' && legacyAuthCodeTypes.has(value); + +/** Convert one legacy record without exposing its key or verification material. */ +export const mapLegacyAuthCode = ( + record: LegacyAuthCodeRecord, + now = new Date() +): LegacyAuthCodeMapping => { + if (!isLegacyAuthCodeType(record.type)) { + return { kind: 'skipped', reason: 'unsupported-type' }; + } + + if (!isNonEmptyString(record.key)) { + return { kind: 'skipped', reason: 'missing-key' }; + } + + const expireAt = getExpireAt(record); + if (!expireAt) { + return { kind: 'skipped', reason: 'missing-expiry' }; + } + if (expireAt.getTime() <= now.getTime()) { + return { kind: 'skipped', reason: 'expired' }; + } + + if (record.type === 'captcha') { + if (!isNonEmptyString(record.code)) { + return { kind: 'skipped', reason: 'missing-code' }; + } + + return { + kind: 'mapped', + record: { + dataId: getDataId('register', 'captcha', record.key), + data: { code: record.code.toLowerCase() }, + expireAt + } + }; + } + + if (record.type === 'wxLogin') { + if (!isNonEmptyString(record.openid)) { + return { kind: 'skipped', reason: 'missing-openid' }; + } + + return { + kind: 'mapped', + record: { + dataId: getDataId('login', 'wechat', hashKey(record.key)), + data: { openId: record.openid }, + expireAt + } + }; + } + + if (!isNonEmptyString(record.code)) { + return { kind: 'skipped', reason: 'missing-code' }; + } + + if (record.type === 'login') { + return { + kind: 'mapped', + record: { + dataId: getDataId('login', 'password', record.key), + data: { preLoginCode: record.code }, + expireAt + } + }; + } + + const scene = record.type === 'findPassword' ? 'forgetPassword' : record.type; + return { + kind: 'mapped', + record: { + dataId: getDataId(scene, 'code', record.key), + data: { code: record.code }, + expireAt + } + }; +}; + +const parseOptions = (args: string[]): MigrationOptions => { + let dryRun = true; + + for (const arg of args[0] === '--' ? args.slice(1) : args) { + if (arg === '--execute') { + dryRun = false; + continue; + } + if (arg === '--dry-run') { + dryRun = true; + continue; + } + if (arg === '--help' || arg === '-h') { + console.log( + [ + 'Usage:', + ' MONGODB_URI= pnpm --filter @fastgpt/app run migrate:auth-code -- [--dry-run|--execute]', + '', + 'The default mode is dry-run. Use --execute to write to tmp_datas.', + 'The source auth_codes collection is never deleted.' + ].join('\n') + ); + process.exit(0); + } + throw new Error(`Unknown argument: ${arg}`); + } + + const uri = process.env.MONGODB_URI; + if (!uri) { + throw new Error('MONGODB_URI is required'); + } + + return { dryRun, uri }; +}; + +const isDuplicateKeyError = (error: unknown) => + typeof error === 'object' && + error !== null && + 'code' in error && + (error as { code?: unknown }).code === 11000; + +type MongoCollection = ReturnType['collection']>; + +const upsertOnInsert = async (collection: MongoCollection, record: VerificationRecord) => { + try { + const result = await collection.updateOne( + { dataId: record.dataId }, + { + $setOnInsert: record + }, + { upsert: true } + ); + + return result.upsertedCount > 0; + } catch (error) { + if (!isDuplicateKeyError(error)) throw error; + + const existing = await collection.findOne( + { dataId: record.dataId }, + { projection: { _id: 1 } } + ); + if (!existing) throw error; + + return false; + } +}; + +const run = async ({ dryRun, uri }: MigrationOptions) => { + await mongoose.connect(uri); + + try { + const database = mongoose.connection.db; + if (!database) throw new Error('MongoDB database connection is unavailable'); + + const sourceExists = await database + .listCollections({ name: SOURCE_COLLECTION }, { nameOnly: true }) + .hasNext(); + if (!sourceExists) { + console.log(`Collection ${SOURCE_COLLECTION} does not exist; nothing to migrate.`); + return; + } + + const sourceCollection = database.collection(SOURCE_COLLECTION); + const targetCollection = database.collection(TARGET_COLLECTION); + const stats = createStats(); + const seenDataIds = new Set(); + const cursor = sourceCollection.find({}).sort({ createTime: -1, _id: -1 }); + + for await (const rawRecord of cursor) { + stats.scanned += 1; + const mapping = mapLegacyAuthCode(rawRecord as LegacyAuthCodeRecord); + + if (mapping.kind === 'skipped') { + stats.skipped[mapping.reason] += 1; + continue; + } + + stats.mapped += 1; + if (seenDataIds.has(mapping.record.dataId)) { + stats.duplicateSource += 1; + continue; + } + seenDataIds.add(mapping.record.dataId); + + if (dryRun) { + stats.wouldInsert += 1; + continue; + } + + if (await upsertOnInsert(targetCollection, mapping.record)) { + stats.inserted += 1; + } else { + stats.existing += 1; + } + } + + console.log( + JSON.stringify( + { + mode: dryRun ? 'dry-run' : 'execute', + sourceCollection: SOURCE_COLLECTION, + targetCollection: TARGET_COLLECTION, + stats + }, + null, + 2 + ) + ); + } finally { + await mongoose.disconnect(); + } +}; + +const isMain = + process.argv[1] !== undefined && pathToFileURL(process.argv[1]).href === import.meta.url; + +if (isMain) { + const main = async () => run(parseOptions(process.argv.slice(2))); + + void main().catch((error: unknown) => { + console.error(error); + process.exitCode = 1; + }); +} diff --git a/projects/app/src/components/support/user/inform/UpdateContactModal.tsx b/projects/app/src/components/support/user/inform/UpdateContactModal.tsx index 6aae58ddf138..76ea19a3a2f9 100644 --- a/projects/app/src/components/support/user/inform/UpdateContactModal.tsx +++ b/projects/app/src/components/support/user/inform/UpdateContactModal.tsx @@ -62,7 +62,10 @@ const UpdateContactModal = ({ } ); - const { SendCodeBox } = useSendCode({ type: UserAuthTypeEnum.bindNotification }); + const { SendCodeBox } = useSendCode({ + type: UserAuthTypeEnum.bindNotification, + purpose: 'bindNotification' + }); const placeholder = feConfigs?.bind_notification_method ?.map((item) => { diff --git a/projects/app/src/components/support/user/safe/SendCodeAuthModal.tsx b/projects/app/src/components/support/user/safe/SendCodeAuthModal.tsx index c4e137e26987..3a1f44586545 100644 --- a/projects/app/src/components/support/user/safe/SendCodeAuthModal.tsx +++ b/projects/app/src/components/support/user/safe/SendCodeAuthModal.tsx @@ -1,4 +1,4 @@ -import { getCaptchaPic } from '@/web/support/user/api'; +import { getCaptchaPic, type UserVerificationPurpose } from '@/web/support/user/api'; import { Button, Input, ModalBody, ModalFooter, Skeleton } from '@chakra-ui/react'; import MyImage from '@fastgpt/web/components/common/Image/MyImage'; import MyModal from '@fastgpt/web/components/common/MyModal'; @@ -8,11 +8,13 @@ import { useForm } from 'react-hook-form'; const SendCodeAuthModal = ({ username, + purpose, onClose, onSending, onSendCode }: { username: string; + purpose: UserVerificationPurpose; onClose: () => void; onSending: boolean; @@ -30,7 +32,7 @@ const SendCodeAuthModal = ({ data, loading, runAsync: getCaptcha - } = useRequest(() => getCaptchaPic(username), { manual: false }); + } = useRequest(() => getCaptchaPic(username, purpose), { manual: false }); const onSubmit = async ({ code }: { code: string }) => { await onSendCode({ username, captcha: code }); @@ -43,7 +45,7 @@ const SendCodeAuthModal = ({ const handleEnterKeyDown = (e: React.KeyboardEvent) => { e.stopPropagation(); - if (e.key.toLowerCase() !== 'enter') return; + if (e.nativeEvent.isComposing || e.keyCode === 229 || e.key.toLowerCase() !== 'enter') return; handleSubmit(onSubmit, onError)(); }; diff --git a/projects/app/src/pageComponents/login/ForgetPasswordForm.tsx b/projects/app/src/pageComponents/login/ForgetPasswordForm.tsx index a6b5da6f8abd..4e4adb9c7d1e 100644 --- a/projects/app/src/pageComponents/login/ForgetPasswordForm.tsx +++ b/projects/app/src/pageComponents/login/ForgetPasswordForm.tsx @@ -11,6 +11,7 @@ import { useRequest } from '@fastgpt/web/hooks/useRequest'; import { checkPasswordRule } from '@fastgpt/global/common/string/password'; import type { LoginSuccessResponseType } from '@fastgpt/global/openapi/support/user/account/login/api'; import type { LangEnum } from '@fastgpt/global/common/i18n/type'; +import { UserAuthTypeEnum } from '@fastgpt/global/support/user/auth/constants'; type LoginSuccessHandler = (res: LoginSuccessResponseType) => void | Promise; @@ -41,7 +42,10 @@ const RegisterForm = ({ setPageType, loginSuccess }: Props) => { }); const username = watch('username'); - const { SendCodeBox } = useSendCode({ type: 'findPassword' }); + const { SendCodeBox } = useSendCode({ + type: UserAuthTypeEnum.findPassword, + purpose: 'forgetPassword' + }); const placeholder = feConfigs?.find_password_method ?.map((item) => { @@ -89,7 +93,13 @@ const RegisterForm = ({ setPageType, loginSuccess }: Props) => { return ( <> - + {t('user:password.retrieved_account', { account: feConfigs?.systemTitle })} { bg={'myGray.50'} type={'password'} size={'lg'} - placeholder={t('login:password_tip')} + placeholder={t('common:support.user.login.Password')} + _invalid={{ + borderColor: 'red.500', + boxShadow: '0 0 0 1px #F04438' + }} {...register('password', { required: true, validate: (val) => { @@ -149,7 +163,18 @@ const RegisterForm = ({ setPageType, loginSuccess }: Props) => { return true; } })} - > + /> + + {t('login:reset_password_tip')} + { mt={12} w={'100%'} size={['md', 'md']} - rounded={['md', 'md']} - h={[10, 10]} + rounded={['sm', 'md']} + h={['34px', '40px']} fontWeight={['medium', 'medium']} colorScheme="blue" isLoading={requesting} @@ -181,6 +206,7 @@ const RegisterForm = ({ setPageType, loginSuccess }: Props) => { { const onClickOauth = useCallback( async (item: OAuthItem) => { + if (item.pageType) { + setPageType(item.pageType); + return; + } + if (item.provider === OAuthEnum.sso) { const redirectUrl = await POST('/proApi/support/user/account/login/getAuthURL', { redirectUri, @@ -164,7 +169,6 @@ const FormLayout = ({ children, setPageType, pageType }: Props) => { }); router.replace(item.redirectUrl, '_self'); } - item.pageType && setPageType(item.pageType); }, [ computedLastRoute, diff --git a/projects/app/src/pageComponents/login/LoginForm/WechatForm.tsx b/projects/app/src/pageComponents/login/LoginForm/WechatForm.tsx index 23f2e70910cf..ec9abcceec02 100644 --- a/projects/app/src/pageComponents/login/LoginForm/WechatForm.tsx +++ b/projects/app/src/pageComponents/login/LoginForm/WechatForm.tsx @@ -55,9 +55,9 @@ const WechatForm = ({ setPageType, loginSuccess }: Props) => { refetchInterval: 3 * 1000, enabled: !!wechatInfo?.code, async onSuccess(data: LoginSuccessResponseType | undefined) { - if (data) { - await onFastGPTLoginSuccess(loginSuccess, data); - } + if (!data) return; + + await onFastGPTLoginSuccess(loginSuccess, data); } } ); diff --git a/projects/app/src/pageComponents/login/RegisterForm.tsx b/projects/app/src/pageComponents/login/RegisterForm.tsx index 3b1afc802e92..906aa27b9f8a 100644 --- a/projects/app/src/pageComponents/login/RegisterForm.tsx +++ b/projects/app/src/pageComponents/login/RegisterForm.tsx @@ -19,6 +19,7 @@ import { checkPasswordRule } from '@fastgpt/global/common/string/password'; import type { LoginSuccessResponseType } from '@fastgpt/global/openapi/support/user/account/login/api'; import type { LangEnum } from '@fastgpt/global/common/i18n/type'; import { getRegisterMethods } from '@/web/common/system/utils'; +import { UserAuthTypeEnum } from '@fastgpt/global/support/user/auth/constants'; type LoginSuccessHandler = (res: LoginSuccessResponseType) => void | Promise; @@ -50,7 +51,10 @@ const RegisterForm = ({ setPageType, loginSuccess }: Props) => { }); const username = watch('username'); - const { SendCodeBox, openCodeAuthModal } = useSendCode({ type: 'register' }); + const { SendCodeBox, openCodeAuthModal } = useSendCode({ + type: UserAuthTypeEnum.register, + purpose: 'register' + }); const { runAsync: onclickRegister, loading: requesting } = useRequest( async ({ username, password, code }: RegisterType) => { @@ -101,7 +105,13 @@ const RegisterForm = ({ setPageType, loginSuccess }: Props) => { return ( <> - + {t('user:register.register_account', { account: feConfigs?.systemTitle })} { bg={'myGray.50'} size={'lg'} type={'password'} - placeholder={t('login:password_tip')} + placeholder={t('common:support.user.login.Password')} + _invalid={{ + borderColor: 'red.500', + boxShadow: '0 0 0 1px #F04438' + }} {...register('password', { required: true, validate: (val) => { @@ -161,7 +175,18 @@ const RegisterForm = ({ setPageType, loginSuccess }: Props) => { return true; } })} - > + /> + + {t('login:password_tip')} + { mt={12} w={'100%'} size={['md', 'md']} - rounded={['md', 'md']} - h={[10, 10]} + rounded={['sm', 'md']} + h={['34px', '40px']} fontWeight={['medium', 'medium']} colorScheme="blue" isLoading={requesting} @@ -192,6 +217,7 @@ const RegisterForm = ({ setPageType, loginSuccess }: Props) => { - {/* Google reCAPTCHA Script */} - {feConfigs.googleClientVerKey && ( -