From bd966d479fbe414d02679cf79f9eaaab3d100a2d Mon Sep 17 00:00:00 2001 From: Archer <545436317@qq.com> Date: Wed, 25 Mar 2026 14:45:38 +0800 Subject: [PATCH] fix: login secret (#6635) * fix: login secret * lock * env template * fix: ts * fix: ts * fix: ts --- .claude/CLAUDE.md | 11 +- .claude/plan/opensandbox-persistence-plan.md | 173 ++++++++++++ .../docs/self-host/upgrading/4-14/41410.mdx | 1 + .../docs/self-host/upgrading/4-14/4149.mdx | 1 + document/data/doc-last-modified.json | 4 +- packages/global/common/i18n/type.ts | 2 + .../openapi/admin/support/user/index.ts | 4 +- .../openapi/admin/support/user/login/api.ts | 15 ++ .../openapi/admin/support/user/login/index.ts | 30 +++ packages/global/openapi/index.ts | 7 +- .../openapi/support/user/account/index.ts | 10 + .../openapi/support/user/account/login/api.ts | 110 ++++++++ .../support/user/account/login/index.ts | 178 +++++++++++++ .../support/user/account/login/wecom/api.ts | 12 - .../support/user/account/password/api.ts | 62 +++++ .../support/user/account/password/index.ts | 102 +++++++ .../support/user/account/register/api.ts | 13 + .../support/user/account/register/index.ts | 30 +++ packages/global/openapi/support/user/index.ts | 4 +- packages/global/openapi/tag.ts | 1 + packages/global/support/user/api.ts | 33 --- packages/global/support/user/login/api.ts | 14 - packages/global/support/user/team/type.ts | 75 +++--- packages/global/support/user/type.ts | 31 +-- packages/service/package.json | 2 +- pnpm-lock.yaml | 252 +++++++++--------- projects/app/.env.template | 4 + .../app/src/global/support/api/userRes.ts | 4 - .../login/ForgetPasswordForm.tsx | 4 +- .../login/LoginForm/LoginForm.tsx | 4 +- .../login/LoginForm/WechatForm.tsx | 9 +- .../src/pageComponents/login/LoginModal.tsx | 4 +- .../src/pageComponents/login/RegisterForm.tsx | 4 +- .../app/src/pageComponents/login/index.tsx | 6 +- .../support/user/account/checkPswExpired.ts | 13 +- .../support/user/account/loginByPassword.ts | 20 +- .../api/support/user/account/preLogin.ts | 24 +- .../support/user/account/resetExpiredPsw.ts | 20 +- .../api/support/user/account/tokenLogin.ts | 11 +- .../user/account/updatePasswordByOld.ts | 17 +- projects/app/src/pages/chat/index.tsx | 4 +- projects/app/src/pages/login/fastlogin.tsx | 4 +- projects/app/src/pages/login/index.tsx | 4 +- projects/app/src/pages/login/provider.tsx | 4 +- .../src/web/common/system/useSystemStore.ts | 2 +- projects/app/src/web/support/user/api.ts | 94 ++++--- .../user/account/checkPswExpired.test.ts | 165 ++++++++++++ .../user/account/loginByPassword.test.ts | 237 ++++++++-------- .../api/support/user/account/loginout.test.ts | 55 ++++ .../api/support/user/account/preLogin.test.ts | 100 +++++++ .../user/account/resetExpiredPsw.test.ts | 185 +++++++++++++ .../support/user/account/tokenLogin.test.ts | 125 +++++++++ .../api/support/user/account/update.test.ts | 122 +++++++++ .../user/account/updatePasswordByOld.test.ts | 139 ++++++++++ 54 files changed, 2061 insertions(+), 500 deletions(-) create mode 100644 .claude/plan/opensandbox-persistence-plan.md create mode 100644 packages/global/openapi/admin/support/user/login/api.ts create mode 100644 packages/global/openapi/admin/support/user/login/index.ts create mode 100644 packages/global/openapi/support/user/account/index.ts create mode 100644 packages/global/openapi/support/user/account/login/api.ts create mode 100644 packages/global/openapi/support/user/account/login/index.ts delete mode 100644 packages/global/openapi/support/user/account/login/wecom/api.ts create mode 100644 packages/global/openapi/support/user/account/password/api.ts create mode 100644 packages/global/openapi/support/user/account/password/index.ts create mode 100644 packages/global/openapi/support/user/account/register/api.ts create mode 100644 packages/global/openapi/support/user/account/register/index.ts delete mode 100644 packages/global/support/user/login/api.ts create mode 100644 projects/app/test/api/support/user/account/checkPswExpired.test.ts create mode 100644 projects/app/test/api/support/user/account/loginout.test.ts create mode 100644 projects/app/test/api/support/user/account/preLogin.test.ts create mode 100644 projects/app/test/api/support/user/account/resetExpiredPsw.test.ts create mode 100644 projects/app/test/api/support/user/account/tokenLogin.test.ts create mode 100644 projects/app/test/api/support/user/account/update.test.ts create mode 100644 projects/app/test/api/support/user/account/updatePasswordByOld.test.ts diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index c0406a7d5b..22903ba5a1 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -5,11 +5,12 @@ ## 输出要求 1. 输出语言:中文 -2. 输出的设计文档位置:.claude/design,问题分析文档位置: .claude/issue,以 Markdown 文件为主。 -3. 输出 Plan 时,均需写入 .claude/plan 目录下,以 Markdown 文件为主。 -4. 相同需求文档,尽量写在一起(内容超过 300 行,可以分批写入),或者创建要给目录一起管理,不要随意平铺一堆不同版本的相同问题的文档。 -5. 文件输出,使用正确的编码格式,例如UTF-8。 -6. 如果用户未指明,不要随意编写总结报告。 +2. 输出文档位置: + 1. 设计文档:.claude/design,todo 跟在设计文档后面。 + 2. 问题分析文档: .claude/issue +3. 相同需求文档,尽量写在一起(内容超过 300 行,可以分批写入),或者创建要给目录一起管理,不要随意平铺一堆不同版本的相同问题的文档。 +4. 文件输出,使用正确的编码格式,例如UTF-8。 +5. 如果用户未指明,不要随意编写总结报告。 ## 项目概述 diff --git a/.claude/plan/opensandbox-persistence-plan.md b/.claude/plan/opensandbox-persistence-plan.md new file mode 100644 index 0000000000..b34dbd17b3 --- /dev/null +++ b/.claude/plan/opensandbox-persistence-plan.md @@ -0,0 +1,173 @@ +# Context + +当前 `opensandbox` 分支已经暴露了 OpenSandbox provider 环境变量,并在运行时通过 `SandboxClient` 走 provider 分支,但 OpenSandbox 的“持久化配置”仍然散落在 `SandboxClient` 内部,没有形成独立的配置抽象,也没有 volume-manager / `createConfig.volumes` / 实例详情落库这条完整链路。 + +相比之下,`agent-skill-dev` 已经把这部分能力拆成了独立配置模块,并通过 volume-manager 为 session/runtime 准备持久化卷,再将存储与运行时详情写入 sandbox 实例记录。 + +本次推荐方案的目标不是整包迁入 `agentSkills` 上层编排,而是:**保留当前分支以 `SandboxClient` 为唯一消费入口的形态,将 OpenSandbox 持久化配置能力下沉到 `packages/service/core/ai/sandbox` 域内完成抽离**。这样可以在最小扰动现有 workflow/tool 调用链的前提下,把持久化卷、配置解析和实例详情持久化补齐。 + +# Recommended Approach + +## 1. 先把配置抽象从 `SandboxClient` 构造函数中抽离 + +新增统一配置模块,建议放在: +- `packages/service/core/ai/sandbox/config.ts` + +从 `agent-skill-dev` 复用并改造以下能力,统一收敛到 sandbox 域: +- `getSandboxProviderConfig` +- `getVolumeManagerConfig` +- `buildVolumeConfig` +- `buildBaseContainerEnv` + +这样 `packages/service/core/ai/sandbox/controller.ts` 不再直接读取 env 并内联拼 provider 参数,而是只消费配置模块输出。 + +## 2. 补齐 provider 类型与实例详情结构 + +当前事实: +- `packages/service/env.ts` 已支持 `opensandbox` +- `packages/service/core/ai/sandbox/type.ts` 的 `SandboxProviderSchema` 仍只有 `sealosdevbox` + +需要在: +- `packages/service/core/ai/sandbox/type.ts` +- `packages/service/core/ai/sandbox/schema.ts` + +补齐以下结构: +- `SandboxProviderSchema` 至少包含 `opensandbox` +- 实例 `detail`:provider 运行时返回信息、endpoint、连接信息 +- 实例 `storage`:volume 标识、claimName、mountPath、provider storage payload +- 实例 `metadata`:session 维度标识、createConfig 摘要、迁移/兼容标记 + +要求:新字段全部可选,保证旧记录仍可读。 + +## 3. 在 `SandboxClient` 内接入持久化卷准备与详情落库 + +核心改造文件: +- `packages/service/core/ai/sandbox/controller.ts` + +推荐保留 `SandboxClient` 作为唯一消费入口,内部改造为: +1. 根据业务标识(`appId/userId/chatId` 或 `sandboxId`)构建稳定 session key +2. 调用 `config.ts` 读取 provider 配置 +3. 如果 provider 为 `opensandbox`,则调用 volume-manager 配置与卷构建逻辑 +4. 将 volume 信息塞入 `createConfig.volumes` +5. 创建/恢复实例后,把 `detail/storage/metadata` 写入 `MongoSandboxInstance` + +复用目标分支的关键能力时,优先迁“配置与卷构建逻辑”,不要直接把 `agentSkills` 生命周期编排整包搬入。 + +## 4. 保持业务入口不变,只做最小上下文透传 + +现有消费入口继续复用 `SandboxClient`: +- `packages/service/core/workflow/dispatch/ai/agent/sub/sandbox/index.ts` +- `packages/service/core/workflow/dispatch/ai/tool/toolCall.ts` + +这两个入口只做最小修改: +- 确保传入稳定的 `appId/userId/chatId` +- 不感知 provider 配置、volume-manager、存储细节 + +原则:**底层配置与持久化逻辑留在 sandbox 域,业务入口不扩散基础设施细节。** + +## 5. 分两阶段实施 + +### 第一阶段(必须先迁) +1. 新增 `packages/service/core/ai/sandbox/config.ts` +2. 扩展 `packages/service/env.ts` 中 OpenSandbox 持久化相关 env +3. 扩展 `packages/service/core/ai/sandbox/type.ts` +4. 扩展 `packages/service/core/ai/sandbox/schema.ts` +5. 改造 `packages/service/core/ai/sandbox/controller.ts` +6. 最小化调整 workflow / tool 入口透传上下文 + +### 第二阶段(可后补) +1. 引入独立 lifecycle 管理(参考 `agent-skill-dev` 的 `.../sandbox/lifecycle.ts`) +2. 区分 edit-debug 与 session-runtime 的 volume 策略 +3. 补充 volume 清理、回滚、观测与旧数据回填能力 + +# Critical Files to Modify + +必须修改: +- `packages/service/env.ts` +- `packages/service/core/ai/sandbox/type.ts` +- `packages/service/core/ai/sandbox/schema.ts` +- `packages/service/core/ai/sandbox/controller.ts` +- `packages/service/core/workflow/dispatch/ai/agent/sub/sandbox/index.ts` +- `packages/service/core/workflow/dispatch/ai/tool/toolCall.ts` + +建议新增: +- `packages/service/core/ai/sandbox/config.ts` + +目标分支中应重点参考、复用逻辑的来源文件: +- `packages/service/core/agentSkills/sandboxConfig.ts` +- `packages/service/core/agentSkills/sandboxController.ts` +- `packages/service/core/workflow/dispatch/ai/agent/sub/sandbox/lifecycle.ts` + +# Reuse Existing Functions and Patterns + +优先复用当前仓库已验证的模式: +- 现有单一消费入口模式:`packages/service/core/ai/sandbox/controller.ts` 中的 `SandboxClient` +- 现有运行时消费入口: + - `packages/service/core/workflow/dispatch/ai/agent/sub/sandbox/index.ts` + - `packages/service/core/workflow/dispatch/ai/tool/toolCall.ts` +- 目标分支中可迁入的配置抽离函数: + - `getSandboxProviderConfig` + - `getVolumeManagerConfig` + - `buildVolumeConfig` + - `buildBaseContainerEnv` + +复用原则: +- 复用“能力”与“结构”,不直接复制 `agentSkills` 上层调用链 +- 将通用配置逻辑沉入 `core/ai/sandbox`,避免基础设施层依赖业务层 + +# Key Risks and Compatibility Notes + +1. **类型不一致风险** + - 现在 env 支持 `opensandbox`,但 `SandboxProviderSchema` 不支持 + - 必须先统一类型层,再改控制器逻辑 + +2. **旧数据兼容风险** + - 历史 `agent_sandbox_instances` 没有 `detail/storage/metadata` + - 新字段必须 optional,读取逻辑必须支持旧数据回退到 `provider + sandboxId` + +3. **provider 分支串扰风险** + - OpenSandbox 的 volume 逻辑不能污染 `sealosdevbox` / `e2b` + - `createConfig.volumes` 只能在对应 provider 下启用 + +4. **依赖反转风险** + - 不要让 `core/ai/sandbox` 反向依赖 `agentSkills` + - 通用配置能力必须落在 sandbox 域内 + +5. **卷幂等与清理风险** + - 第一阶段至少保证 ensure volume 幂等 + - 清理/回滚放第二阶段补齐 + +# Verification + +## 单测 + +1. `config.ts` + - `getSandboxProviderConfig`:不同 provider 输出正确;缺失关键 env 时给出明确错误 + - `getVolumeManagerConfig`:能正确解析 volume-manager 配置 + - `buildVolumeConfig`:相同 session 上下文生成稳定 volume 配置 + - `buildBaseContainerEnv`:容器基础 env 正确且不泄漏无关敏感信息 + +2. `type.ts` / `schema.ts` + - 旧记录仅有基础字段时可通过读取 + - 新记录包含 `detail/storage/metadata` 时可通过校验 + +3. `controller.ts` + - OpenSandbox 创建时会注入 `createConfig.volumes` + - 创建后会写入 `detail/storage/metadata` + - 旧实例记录仍可兼容 + - 非 OpenSandbox provider 行为不变 + +## 集成测试 + +1. 通过 `dispatchSandboxShell` 触发首次 session sandbox 创建 +2. 检查 `agent_sandbox_instances` 中是否写入 `detail/storage/metadata` +3. 同一 `appId + userId + chatId` 再次进入时,验证 volume 信息可复用 +4. 从 `toolCall.ts` 链路触发一次 sandbox 执行,确认入口无需感知底层 volume 配置 + +## 人工验证 + +1. 配齐 OpenSandbox + volume-manager 环境变量后启动服务 +2. 首次会话进入 sandbox,写入一个测试文件 +3. 结束请求后使用相同 `appId/userId/chatId` 再次进入 +4. 验证测试文件仍然存在,且实例记录中保留 storage/detail 信息 +5. 切换到 `sealosdevbox` / `e2b` 时,验证旧链路行为不变 diff --git a/document/content/docs/self-host/upgrading/4-14/41410.mdx b/document/content/docs/self-host/upgrading/4-14/41410.mdx index 02315760cd..b42ebabbf7 100644 --- a/document/content/docs/self-host/upgrading/4-14/41410.mdx +++ b/document/content/docs/self-host/upgrading/4-14/41410.mdx @@ -7,6 +7,7 @@ description: 'FastGPT V4.14.10 更新说明' ## 🚀 新增内容 1. 飞书发布渠道,支持流输出。 +2. 目录最大上限,可通过环境变量配置。 ## ⚙️ 优化 diff --git a/document/content/docs/self-host/upgrading/4-14/4149.mdx b/document/content/docs/self-host/upgrading/4-14/4149.mdx index 3eba454701..9d73eaa7d0 100644 --- a/document/content/docs/self-host/upgrading/4-14/4149.mdx +++ b/document/content/docs/self-host/upgrading/4-14/4149.mdx @@ -67,6 +67,7 @@ CODE_SANDBOX_TOKEN=代码运行沙盒的凭证 13. 修复视频音频自定义文件类型流程开始无文件链接变量 14. 用户输入框消息不转义成 markdown 格式 15. 修复 AgentV2 部分上下文拼接错误。 +16. login 接口安全风险。 ## 代码优化 diff --git a/document/data/doc-last-modified.json b/document/data/doc-last-modified.json index 873e334286..d76ec3a567 100644 --- a/document/data/doc-last-modified.json +++ b/document/data/doc-last-modified.json @@ -220,7 +220,7 @@ "document/content/docs/self-host/upgrading/4-14/4140.mdx": "2026-03-03T17:39:47+08:00", "document/content/docs/self-host/upgrading/4-14/4141.en.mdx": "2026-03-03T17:39:47+08:00", "document/content/docs/self-host/upgrading/4-14/4141.mdx": "2026-03-03T17:39:47+08:00", - "document/content/docs/self-host/upgrading/4-14/41410.mdx": "2026-03-24T09:59:30+08:00", + "document/content/docs/self-host/upgrading/4-14/41410.mdx": "2026-03-24T18:02:38+08:00", "document/content/docs/self-host/upgrading/4-14/4142.en.mdx": "2026-03-03T17:39:47+08:00", "document/content/docs/self-host/upgrading/4-14/4142.mdx": "2026-03-03T17:39:47+08:00", "document/content/docs/self-host/upgrading/4-14/4143.en.mdx": "2026-03-03T17:39:47+08:00", @@ -240,7 +240,7 @@ "document/content/docs/self-host/upgrading/4-14/41481.en.mdx": "2026-03-09T12:02:02+08:00", "document/content/docs/self-host/upgrading/4-14/41481.mdx": "2026-03-09T17:39:53+08:00", "document/content/docs/self-host/upgrading/4-14/4149.en.mdx": "2026-03-23T12:17:04+08:00", - "document/content/docs/self-host/upgrading/4-14/4149.mdx": "2026-03-23T23:37:12+08:00", + "document/content/docs/self-host/upgrading/4-14/4149.mdx": "2026-03-25T13:54:15+08:00", "document/content/docs/self-host/upgrading/outdated/40.en.mdx": "2026-03-03T17:39:47+08:00", "document/content/docs/self-host/upgrading/outdated/40.mdx": "2026-03-03T17:39:47+08:00", "document/content/docs/self-host/upgrading/outdated/41.en.mdx": "2026-03-03T17:39:47+08:00", diff --git a/packages/global/common/i18n/type.ts b/packages/global/common/i18n/type.ts index f367026174..b49071c677 100644 --- a/packages/global/common/i18n/type.ts +++ b/packages/global/common/i18n/type.ts @@ -16,6 +16,8 @@ export enum LangEnum { export type localeType = `${LangEnum}`; export const LocaleList = ['en', 'zh-CN', 'zh-Hant'] as const; +export const LanguageSchema = z.enum(LocaleList).meta({ description: '用户语言偏好' }); + export const langMap = { [LangEnum.en]: { label: 'English(US)', diff --git a/packages/global/openapi/admin/support/user/index.ts b/packages/global/openapi/admin/support/user/index.ts index f50c00bff4..80560964a9 100644 --- a/packages/global/openapi/admin/support/user/index.ts +++ b/packages/global/openapi/admin/support/user/index.ts @@ -1,6 +1,8 @@ import { AdminInformPath } from './inform'; +import { AdminLoginPath } from './login'; import type { OpenAPIPath } from '../../../type'; export const AdminUserPath: OpenAPIPath = { - ...AdminInformPath + ...AdminInformPath, + ...AdminLoginPath }; diff --git a/packages/global/openapi/admin/support/user/login/api.ts b/packages/global/openapi/admin/support/user/login/api.ts new file mode 100644 index 0000000000..387d9c5e71 --- /dev/null +++ b/packages/global/openapi/admin/support/user/login/api.ts @@ -0,0 +1,15 @@ +import { z } from 'zod'; + +// Admin login request body +export const AdminLoginBodySchema = z.object({ + username: z.string().meta({ description: '用户名' }), + password: z.string().meta({ description: '密码' }) +}); +export type AdminLoginBodyType = z.infer; + +// Admin login response +export const AdminLoginResponseSchema = z.object({ + user: z.any().meta({ description: '用户信息' }), + token: z.string().meta({ description: '登录令牌' }) +}); +export type AdminLoginResponseType = z.infer; diff --git a/packages/global/openapi/admin/support/user/login/index.ts b/packages/global/openapi/admin/support/user/login/index.ts new file mode 100644 index 0000000000..cb52de6934 --- /dev/null +++ b/packages/global/openapi/admin/support/user/login/index.ts @@ -0,0 +1,30 @@ +import type { OpenAPIPath } from '../../../../type'; +import { AdminLoginBodySchema, AdminLoginResponseSchema } from './api'; +import { TagsMap } from '../../../../tag'; + +export const AdminLoginPath: OpenAPIPath = { + '/admin/support/user/login': { + post: { + summary: '管理员登录', + description: '管理员使用用户名和密码登录', + tags: [TagsMap.userLogin], + requestBody: { + content: { + 'application/json': { + schema: AdminLoginBodySchema + } + } + }, + responses: { + 200: { + description: '登录成功', + content: { + 'application/json': { + schema: AdminLoginResponseSchema + } + } + } + } + } + } +}; diff --git a/packages/global/openapi/index.ts b/packages/global/openapi/index.ts index 4434a15ff5..acf0fd04ff 100644 --- a/packages/global/openapi/index.ts +++ b/packages/global/openapi/index.ts @@ -54,7 +54,12 @@ export const openAPIDocument = createDocument({ }, { name: '用户体系', - tags: [TagsMap.userInform, TagsMap.walletBill, TagsMap.walletDiscountCoupon] + tags: [ + TagsMap.userInform, + TagsMap.walletBill, + TagsMap.walletDiscountCoupon, + TagsMap.userLogin + ] }, { name: '通用-核心功能', diff --git a/packages/global/openapi/support/user/account/index.ts b/packages/global/openapi/support/user/account/index.ts new file mode 100644 index 0000000000..b812405f16 --- /dev/null +++ b/packages/global/openapi/support/user/account/index.ts @@ -0,0 +1,10 @@ +import type { OpenAPIPath } from '../../../type'; +import { LoginPath } from './login'; +import { RegisterPath } from './register'; +import { PasswordPath } from './password'; + +export const UserAccountPath: OpenAPIPath = { + ...LoginPath, + ...RegisterPath, + ...PasswordPath +}; diff --git a/packages/global/openapi/support/user/account/login/api.ts b/packages/global/openapi/support/user/account/login/api.ts new file mode 100644 index 0000000000..dd8ffa9b7e --- /dev/null +++ b/packages/global/openapi/support/user/account/login/api.ts @@ -0,0 +1,110 @@ +import { z } from 'zod'; +import { OAuthEnum } from '../../../../../support/user/constant'; +import { TrackRegisterParamsSchema } from '../../../../../support/marketing/type'; +import { LanguageSchema } from '../../../../../common/i18n/type'; + +export const LoginSuccessResponseSchema = z.object({ + user: z.any().meta({ + description: '用户详情' + }), + token: z.string().meta({ + example: 'eyJhbGciOiJIUzI1NiIs...', + description: '登录令牌' + }) +}); +export type LoginSuccessResponseType = z.infer; + +// ===== Pre login - get login verification code ===== +export const PreLoginQuerySchema = z.object({ + username: z.string().meta({ + example: 'admin', + description: '用户名' + }) +}); +export type PreLoginQueryType = z.infer; + +export const PreLoginResponseSchema = z + .object({ + code: z.string().meta({ + example: 'a1b2c3', + description: '预登录验证码' + }) + }) + .meta({ + example: { + code: 'a1b2c3' + } + }); +export type PreLoginResponseType = z.infer; + +// ===== Login by password ===== +export const LoginByPasswordBodySchema = z + .object({ + username: z.string().meta({ + example: 'admin', + description: '用户名' + }), + password: z.string().meta({ + example: 'hashed_password', + description: '密码' + }), + code: z.string().meta({ + example: '123456', + description: '预登录验证码' + }), + language: LanguageSchema.optional().default('zh-CN').meta({ + example: 'zh-CN', + description: '用户语言偏好' + }) + }) + .meta({ + example: { + username: 'admin', + password: 'hashed_password', + code: '123456', + language: 'zh-CN' + } + }); +export type LoginByPasswordBodyType = z.infer; + +/* ===== Wecom Login ===== */ +export const WecomGetRedirectURLBodySchema = z.object({ + redirectUri: z.string(), + state: z.string(), + isWecomWorkTerminal: z.boolean() +}); +export const WecomGetRedirectURLResponseSchema = z.string(); +export type WecomGetRedirectURLBodyType = z.infer; +export type WecomGetRedirectURLResponseType = z.infer; + +// ===== OAuth Login ===== +export const OauthLoginBodySchema = TrackRegisterParamsSchema.extend({ + type: z.enum(OAuthEnum).meta({ description: 'OAuth 登录类型' }), + callbackUrl: z.string().meta({ description: '回调 URL' }), + props: z.record(z.string(), z.string()).meta({ description: '附加属性' }), + language: LanguageSchema.optional().meta({ description: '语言' }) +}); +export type OauthLoginBodyType = z.infer; + +// ===== Fast Login ===== +export const FastLoginBodySchema = z.object({ + token: z.string().meta({ description: 'Token' }), + code: z.string().meta({ description: 'Code' }) +}); +export type FastLoginBodyType = z.infer; + +// ===== WeChat Login Result ===== +export const WxLoginBodySchema = z.object({ + inviterId: z.string().optional().meta({ description: '邀请人 ID' }), + code: z.string().meta({ description: '微信登录 Code' }), + bd_vid: z.string().optional(), + msclkid: z.string().optional(), + fastgpt_sem: z.string().optional(), + sourceDomain: z.string().optional() +}); +export type WxLoginBodyType = z.infer; +export const GetWXLoginQRResponseSchema = z.object({ + code: z.string().meta({ description: '微信登录 Code' }), + codeUrl: z.string().meta({ description: '微信登录二维码 URL' }) +}); +export type GetWXLoginQRResponseType = z.infer; diff --git a/packages/global/openapi/support/user/account/login/index.ts b/packages/global/openapi/support/user/account/login/index.ts new file mode 100644 index 0000000000..b6531f5fa8 --- /dev/null +++ b/packages/global/openapi/support/user/account/login/index.ts @@ -0,0 +1,178 @@ +import type { OpenAPIPath } from '../../../../type'; +import { TagsMap } from '../../../../tag'; +import { + LoginByPasswordBodySchema, + PreLoginQuerySchema, + PreLoginResponseSchema, + OauthLoginBodySchema, + FastLoginBodySchema, + WxLoginBodySchema, + GetWXLoginQRResponseSchema, + LoginSuccessResponseSchema +} from './api'; +import { UserSchema } from '../../../../../support/user/type'; + +export const LoginPath: OpenAPIPath = { + '/support/user/account/tokenLogin': { + get: { + summary: 'Token 登录', + description: '通过已有的登录令牌获取用户信息', + tags: [TagsMap.userLogin], + responses: { + 200: { + description: '成功获取用户信息', + content: { + 'application/json': { + schema: UserSchema + } + } + } + } + } + }, + '/support/user/account/preLogin': { + get: { + summary: '预登录获取验证码', + description: '通过用户名获取预登录验证码,用于密码登录时的验证', + tags: [TagsMap.userLogin], + requestParams: { + query: PreLoginQuerySchema + }, + responses: { + 200: { + description: '成功获取预登录验证码', + content: { + 'application/json': { + schema: PreLoginResponseSchema + } + } + } + } + } + }, + '/support/user/account/loginByPassword': { + post: { + summary: '用户密码登录', + description: '通过用户名和密码进行登录,需要先获取预登录验证码', + tags: [TagsMap.userLogin], + requestBody: { + content: { + 'application/json': { + schema: LoginByPasswordBodySchema + } + } + }, + responses: { + 200: { + description: '登录成功,返回用户信息和令牌', + content: { + 'application/json': { + schema: LoginSuccessResponseSchema + } + } + } + } + } + }, + '/proApi/support/user/account/login/oauth': { + post: { + summary: 'OAuth 登录', + description: '使用第三方 OAuth 授权登录', + tags: [TagsMap.userLogin], + requestBody: { + content: { + 'application/json': { + schema: OauthLoginBodySchema + } + } + }, + responses: { + 200: { + description: '登录成功', + content: { + 'application/json': { + schema: LoginSuccessResponseSchema + } + } + } + } + } + }, + '/proApi/support/user/account/login/fastLogin': { + post: { + summary: '快捷登录', + description: '使用 Token 和 Code 进行快捷登录', + tags: [TagsMap.userLogin], + requestBody: { + content: { + 'application/json': { + schema: FastLoginBodySchema + } + } + }, + responses: { + 200: { + description: '登录成功', + content: { + 'application/json': { + schema: LoginSuccessResponseSchema + } + } + } + } + } + }, + '/proApi/support/user/account/login/wx/getQR': { + get: { + summary: '获取微信登录二维码', + description: '获取微信登录二维码', + tags: [TagsMap.userLogin], + responses: { + 200: { + description: '获取微信登录二维码成功', + content: { + 'application/json': { + schema: GetWXLoginQRResponseSchema + } + } + } + } + } + }, + '/proApi/support/user/account/login/wx/getResult': { + post: { + summary: '获取微信登录结果', + description: '提交微信登录 Code 以获取登录结果', + tags: [TagsMap.userLogin], + requestBody: { + content: { + 'application/json': { + schema: WxLoginBodySchema + } + } + }, + responses: { + 200: { + description: '登录成功', + content: { + 'application/json': { + schema: LoginSuccessResponseSchema + } + } + } + } + } + }, + '/support/user/account/loginout': { + get: { + summary: '退出登录', + description: '退出当前用户的所有会话并清除登录凭证', + tags: [TagsMap.userLogin], + responses: { + 200: { + description: '退出登录成功' + } + } + } + } +}; diff --git a/packages/global/openapi/support/user/account/login/wecom/api.ts b/packages/global/openapi/support/user/account/login/wecom/api.ts deleted file mode 100644 index c4f091f43a..0000000000 --- a/packages/global/openapi/support/user/account/login/wecom/api.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { z } from 'zod'; - -export const WecomGetRedirectURLBodySchema = z.object({ - redirectUri: z.string(), - state: z.string(), - isWecomWorkTerminal: z.boolean() -}); - -export const WecomGetRedirectURLResponseSchema = z.string(); - -export type WecomGetRedirectURLBodyType = z.infer; -export type WecomGetRedirectURLResponseType = z.infer; diff --git a/packages/global/openapi/support/user/account/password/api.ts b/packages/global/openapi/support/user/account/password/api.ts new file mode 100644 index 0000000000..d14f2a7ff0 --- /dev/null +++ b/packages/global/openapi/support/user/account/password/api.ts @@ -0,0 +1,62 @@ +import { z } from 'zod'; + +// ===== Update password by old password ===== +export const UpdatePasswordByOldBodySchema = z + .object({ + oldPsw: z.string().trim().min(1).meta({ + example: 'hashed_old_password', + description: '旧密码(已加密)' + }), + newPsw: z.string().trim().min(1).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; + +// ===== Check password expired ===== +export const CheckPswExpiredResponseSchema = z.boolean().meta({ + example: false, + description: '密码是否已过期' +}); +export type CheckPswExpiredResponseType = z.infer; + +// ===== Reset expired password ===== +export const ResetExpiredPswBodySchema = z + .object({ + newPsw: z.string().trim().min(1).meta({ + example: 'hashed_new_password', + description: '新密码(已加密)' + }) + }) + .meta({ + example: { + newPsw: 'hashed_new_password' + } + }); +export type ResetExpiredPswBodyType = z.infer; + +export const ResetExpiredPswResponseSchema = z.object({}).meta({ + description: '重置成功' +}); +export type ResetExpiredPswResponseType = z.infer; + +// ===== Find Password (update by code) ===== +export const UpdatePasswordByCodeBodySchema = z.object({ + username: z.string().trim().min(1).meta({ description: '用户名' }), + code: z.string().meta({ description: '验证码' }), + password: z.string().trim().min(1).meta({ description: '新密码' }), + tmbId: z.string().optional().meta({ description: '团队成员 ID(可选)' }) +}); + +export type UpdatePasswordByCodeBodyType = z.infer; diff --git a/packages/global/openapi/support/user/account/password/index.ts b/packages/global/openapi/support/user/account/password/index.ts new file mode 100644 index 0000000000..bdd7a98efd --- /dev/null +++ b/packages/global/openapi/support/user/account/password/index.ts @@ -0,0 +1,102 @@ +import type { OpenAPIPath } from '../../../../type'; +import { TagsMap } from '../../../../tag'; +import { + UpdatePasswordByOldBodySchema, + UpdatePasswordByOldResponseSchema, + CheckPswExpiredResponseSchema, + ResetExpiredPswBodySchema, + ResetExpiredPswResponseSchema, + UpdatePasswordByCodeBodySchema +} from './api'; + +export const PasswordPath: OpenAPIPath = { + '/support/user/account/updatePasswordByOld': { + post: { + summary: '通过旧密码修改密码', + description: '使用旧密码验证后修改为新密码,修改成功后其他会话将被注销', + tags: [TagsMap.userLogin], + requestBody: { + content: { + 'application/json': { + schema: UpdatePasswordByOldBodySchema + } + } + }, + responses: { + 200: { + description: '密码修改成功', + content: { + 'application/json': { + schema: UpdatePasswordByOldResponseSchema + } + } + } + } + } + }, + '/support/user/account/checkPswExpired': { + get: { + summary: '检查密码是否过期', + description: '检查当前用户的密码是否已过期,需要强制修改', + tags: [TagsMap.userLogin], + responses: { + 200: { + description: '返回密码是否过期', + content: { + 'application/json': { + schema: CheckPswExpiredResponseSchema + } + } + } + } + } + }, + '/support/user/account/resetExpiredPsw': { + post: { + summary: '重置过期密码', + description: '当密码过期时,使用此接口重置密码,重置后其他会话将被注销', + tags: [TagsMap.userLogin], + requestBody: { + content: { + 'application/json': { + schema: ResetExpiredPswBodySchema + } + } + }, + responses: { + 200: { + description: '密码重置成功', + content: { + 'application/json': { + schema: ResetExpiredPswResponseSchema + } + } + } + } + } + }, + '/support/user/account/password/updateByCode': { + post: { + summary: '通过验证码找回/修改密码', + description: '通过邮箱/手机验证码找回或修改密码', + tags: [TagsMap.userLogin], + requestBody: { + content: { + 'application/json': { + schema: UpdatePasswordByCodeBodySchema + } + } + }, + responses: { + 200: { + description: '修改成功', + content: { + 'application/json': { + schema: {} + } + } + } + } + } + } +}; diff --git a/packages/global/openapi/support/user/account/register/api.ts b/packages/global/openapi/support/user/account/register/api.ts new file mode 100644 index 0000000000..60b5678393 --- /dev/null +++ b/packages/global/openapi/support/user/account/register/api.ts @@ -0,0 +1,13 @@ +import { z } from 'zod'; +import { TrackRegisterParamsSchema } from '../../../../../support/marketing/type'; +import { LanguageSchema } from '../../../../../common/i18n/type'; + +// ===== Register by email or phone ===== +export const AccountRegisterBodySchema = TrackRegisterParamsSchema.extend({ + username: z.string().meta({ description: '用户名(邮箱或手机号)' }), + code: z.string().meta({ description: '验证码' }), + password: z.string().meta({ description: '密码(已加密)' }), + language: LanguageSchema.optional().meta({ description: '语言' }) +}); + +export type AccountRegisterBodyType = z.infer; diff --git a/packages/global/openapi/support/user/account/register/index.ts b/packages/global/openapi/support/user/account/register/index.ts new file mode 100644 index 0000000000..e035b42694 --- /dev/null +++ b/packages/global/openapi/support/user/account/register/index.ts @@ -0,0 +1,30 @@ +import type { OpenAPIPath } from '../../../../type'; +import { TagsMap } from '../../../../tag'; +import { AccountRegisterBodySchema } from './api'; + +export const RegisterPath: OpenAPIPath = { + '/support/user/account/register/emailAndPhone': { + post: { + summary: '邮箱/手机号注册', + description: '使用邮箱或手机号验证码注册新账号', + tags: [TagsMap.userLogin], + requestBody: { + content: { + 'application/json': { + schema: AccountRegisterBodySchema + } + } + }, + responses: { + 200: { + description: '注册成功', + content: { + 'application/json': { + schema: {} + } + } + } + } + } + } +}; diff --git a/packages/global/openapi/support/user/index.ts b/packages/global/openapi/support/user/index.ts index de81a6b486..e1e13a2be3 100644 --- a/packages/global/openapi/support/user/index.ts +++ b/packages/global/openapi/support/user/index.ts @@ -1,6 +1,8 @@ import { UserInformPath } from './inform'; import type { OpenAPIPath } from '../../type'; +import { UserAccountPath } from './account'; export const UserPath: OpenAPIPath = { - ...UserInformPath + ...UserInformPath, + ...UserAccountPath }; diff --git a/packages/global/openapi/tag.ts b/packages/global/openapi/tag.ts index 1152580b8a..36d53270ea 100644 --- a/packages/global/openapi/tag.ts +++ b/packages/global/openapi/tag.ts @@ -42,6 +42,7 @@ export const TagsMap = { customDomain: '自定义域名', // User userInform: '用户通知', + userLogin: '用户账号', /* Common */ // APIKey diff --git a/packages/global/support/user/api.ts b/packages/global/support/user/api.ts index ce7c919167..036beed1ec 100644 --- a/packages/global/support/user/api.ts +++ b/packages/global/support/user/api.ts @@ -1,39 +1,6 @@ import type { MemberGroupSchemaType } from '../permission/memberGroup/type'; -import { MemberGroupListItemType } from '../permission/memberGroup/type'; -import type { OAuthEnum } from './constant'; -import { TeamMemberStatusEnum } from './team/constant'; import type { OrgType } from './team/org/type'; import type { TeamMemberItemType } from './team/type'; -import type { LangEnum } from '../../common/i18n/type'; -import type { TrackRegisterParams } from '../marketing/type'; - -export type PostLoginProps = { - username: string; - password: string; - code: string; - language?: `${LangEnum}`; -}; - -export type OauthLoginProps = { - type: `${OAuthEnum}`; - callbackUrl: string; - props: Record; - language?: `${LangEnum}`; -} & TrackRegisterParams; - -export type WxLoginProps = { - inviterId?: string; - code: string; - bd_vid?: string; - msclkid?: string; - fastgpt_sem?: string; - sourceDomain?: string; -}; - -export type FastLoginProps = { - token: string; - code: string; -}; export type SearchResult = { members: Omit[]; diff --git a/packages/global/support/user/login/api.ts b/packages/global/support/user/login/api.ts deleted file mode 100644 index 6f2fe10282..0000000000 --- a/packages/global/support/user/login/api.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { LangEnum } from '../../../common/i18n/type'; -import type { TrackRegisterParams } from '../../marketing/type'; - -export type GetWXLoginQRResponse = { - code: string; - codeUrl: string; -}; - -export type AccountRegisterBody = { - username: string; - code: string; - password: string; - language?: `${LangEnum}`; -} & TrackRegisterParams; diff --git a/packages/global/support/user/team/type.ts b/packages/global/support/user/team/type.ts index 8d69e5f59f..46f0c67527 100644 --- a/packages/global/support/user/team/type.ts +++ b/packages/global/support/user/team/type.ts @@ -1,13 +1,28 @@ import type { TeamMetaType, UserModelSchema } from '../type'; -import type { TeamMemberRoleEnum, TeamMemberStatusEnum } from './constant'; +import { TeamMemberRoleEnum, TeamMemberStatusEnum } from './constant'; import type { GroupMemberRole } from '../../permission/memberGroup/constant'; -import type { TeamPermission } from '../../permission/user/controller'; +import { TeamPermission } from '../../permission/user/controller'; +import { z } from 'zod'; -export type ThirdPartyAccountType = { - lafAccount?: LafAccountType; - openaiAccount?: OpenaiAccountType; - externalWorkflowVariables?: Record; -}; +export const LafAccountSchema = z.object({ + appid: z.string(), + token: z.string(), + pat: z.string() +}); +export type LafAccountType = z.infer; + +export const OpenaiAccountSchema = z.object({ + key: z.string(), + baseUrl: z.string() +}); +export type OpenaiAccountType = z.infer; + +export const ThidPartyAccountSchema = z.object({ + lafAccount: LafAccountSchema.optional(), + openaiAccount: OpenaiAccountSchema.optional(), + externalWorkflowVariables: z.record(z.string(), z.string()).optional() +}); +export type ThirdPartyAccountType = z.infer; export type TeamSchema = { _id: string; @@ -45,7 +60,7 @@ export type TeamMemberSchema = { createTime: Date; updateTime?: Date; name: string; - role: `${TeamMemberRoleEnum}`; + role: TeamMemberRoleEnum; status: TeamMemberStatusEnum; avatar: string; }; @@ -55,22 +70,23 @@ export type TeamMemberWithTeamAndUserSchema = TeamMemberSchema & { user: UserModelSchema; }; -export type TeamTmbItemType = { - userId: string; - teamId: string; - teamAvatar?: string; - teamName: string; - memberName: string; - avatar: string; - balance?: number; - tmbId: string; - teamDomain: string; - role: `${TeamMemberRoleEnum}`; - status: `${TeamMemberStatusEnum}`; - notificationAccount?: string; - permission: TeamPermission; - isWecomTeam?: boolean; -} & ThirdPartyAccountType; +export const TeamTmbItemSchema = ThidPartyAccountSchema.extend({ + userId: z.string(), + teamId: z.string(), + teamAvatar: z.string().optional(), + teamName: z.string(), + memberName: z.string(), + avatar: z.string(), + balance: z.number().optional(), + tmbId: z.string(), + teamDomain: z.string(), + role: z.enum(TeamMemberRoleEnum), + status: z.enum(TeamMemberStatusEnum), + notificationAccount: z.string().optional(), + permission: z.instanceof(TeamPermission), + isWecomTeam: z.boolean().optional() +}); +export type TeamTmbItemType = z.infer; export type TeamMemberItemType< Options extends { @@ -110,17 +126,6 @@ export type TeamTagItemType = { key: string; }; -export type LafAccountType = { - appid: string; - token: string; - pat: string; -}; - -export type OpenaiAccountType = { - key: string; - baseUrl: string; -}; - export type TeamInvoiceHeaderType = { teamName: string; unifiedCreditCode: string; diff --git a/packages/global/support/user/type.ts b/packages/global/support/user/type.ts index 4a79744a0d..dcf4982715 100644 --- a/packages/global/support/user/type.ts +++ b/packages/global/support/user/type.ts @@ -1,9 +1,9 @@ import z from 'zod'; -import type { LangEnum } from '../../common/i18n/type'; -import type { TeamPermission } from '../permission/user/controller'; +import { LanguageSchema, type LangEnum } from '../../common/i18n/type'; +import { TeamPermission } from '../permission/user/controller'; import type { UserStatusEnum } from './constant'; import { TeamMemberStatusEnum } from './team/constant'; -import type { TeamTmbItemType } from './team/type'; +import { TeamTmbItemSchema } from './team/type'; export const UserTagsEnum = z.enum(['wecom']); export type UserTagsEnum = z.infer; @@ -33,18 +33,19 @@ export type UserModelSchema = { meta?: UserMetaType; }; -export type UserType = { - _id: string; - username: string; - avatar: string; // it should be team member's avatar after 4.8.18 - timezone: string; - language?: `${LangEnum}`; - promotionRate: UserModelSchema['promotionRate']; - team: TeamTmbItemType; - permission: TeamPermission; - contact?: string; - tags?: UserTagsEnum[]; -}; +export const UserSchema = z.object({ + _id: z.string(), + username: z.string(), + avatar: z.string(), + timezone: z.string(), + language: LanguageSchema.optional(), + promotionRate: z.number(), + team: TeamTmbItemSchema, + permission: z.instanceof(TeamPermission), + contact: z.string().optional(), + tags: z.array(UserTagsEnum).optional() +}); +export type UserType = z.infer; export const SourceMemberSchema = z.object({ name: z.string().meta({ example: '张三', description: '成员名称' }), diff --git a/packages/service/package.json b/packages/service/package.json index 62047b7798..0e0098a496 100644 --- a/packages/service/package.json +++ b/packages/service/package.json @@ -8,7 +8,7 @@ }, "dependencies": { "@apidevtools/json-schema-ref-parser": "^11.7.2", - "@fastgpt-sdk/sandbox-adapter": "^0.0.28", + "@fastgpt-sdk/sandbox-adapter": "^0.0.31", "@fastgpt-sdk/otel": "catalog:", "@fastgpt-sdk/storage": "catalog:", "@fastgpt/global": "workspace:*", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5ba4c7d94f..0cbf2d68d8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,120 +4,6 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false -catalogs: - default: - '@chakra-ui/anatomy': - specifier: ^2 - version: 2.3.6 - '@chakra-ui/icons': - specifier: ^2 - version: 2.1.1 - '@chakra-ui/next-js': - specifier: ^2 - version: 2.4.2 - '@chakra-ui/react': - specifier: ^2 - version: 2.10.7 - '@chakra-ui/styled-system': - specifier: ^2 - version: 2.12.2 - '@chakra-ui/system': - specifier: ^2 - version: 2.6.1 - '@emotion/react': - specifier: ^11 - version: 11.11.1 - '@emotion/styled': - specifier: ^11 - version: 11.11.0 - '@fastgpt-sdk/logger': - specifier: 0.1.2 - version: 0.1.2 - '@fastgpt-sdk/otel': - specifier: 0.1.0 - version: 0.1.0 - '@fastgpt-sdk/storage': - specifier: 0.6.15 - version: 0.6.15 - '@modelcontextprotocol/sdk': - specifier: ^1 - version: 1.26.0 - '@types/lodash': - specifier: ^4 - version: 4.17.16 - '@types/node': - specifier: ^20 - version: 20.17.24 - '@types/react': - specifier: ^18 - version: 18.3.1 - '@types/react-dom': - specifier: ^18 - version: 18.3.0 - axios: - specifier: 1.13.6 - version: 1.13.6 - date-fns: - specifier: ^3.6.0 - version: 3.6.0 - dayjs: - specifier: 1.11.19 - version: 1.11.19 - eslint: - specifier: ^8 - version: 8.57.1 - eslint-config-next: - specifier: 15.5.12 - version: 15.5.12 - express: - specifier: ^4 - version: 4.22.1 - i18next: - specifier: 23.16.8 - version: 23.16.8 - js-yaml: - specifier: ^4.1.1 - version: 4.1.1 - json5: - specifier: ^2.2.3 - version: 2.2.3 - lodash: - specifier: 4.17.23 - version: 4.17.23 - minio: - specifier: 8.0.7 - version: 8.0.7 - next: - specifier: 16.1.6 - version: 16.1.6 - next-i18next: - specifier: 15.4.2 - version: 15.4.2 - next-rspack: - specifier: 16.1.6 - version: 16.1.6 - proxy-agent: - specifier: ^6 - version: 6.5.0 - react: - specifier: ^18 - version: 18.3.1 - react-dom: - specifier: ^18 - version: 18.3.1 - react-i18next: - specifier: 14.1.2 - version: 14.1.2 - tsdown: - specifier: 0.21.4 - version: 0.21.4 - typescript: - specifier: ^5.9.3 - version: 5.9.3 - zod: - specifier: ^4 - version: 4.1.12 - importers: .: @@ -250,8 +136,8 @@ importers: specifier: 'catalog:' version: 0.1.0 '@fastgpt-sdk/sandbox-adapter': - specifier: ^0.0.28 - version: 0.0.28 + specifier: ^0.0.31 + version: 0.0.31 '@fastgpt-sdk/storage': specifier: 'catalog:' version: 0.6.15(@opentelemetry/api@1.9.0)(@types/node@24.0.13)(jiti@2.6.0)(lightningcss@1.30.1)(proxy-agent@6.5.0)(sass@1.85.1)(terser@5.39.0)(tsx@4.20.6)(yaml@2.8.1) @@ -1185,10 +1071,6 @@ importers: packages: - '@alibaba-group/opensandbox@0.1.4': - resolution: {integrity: sha512-hTgzsBRYCoNM5A3cM0Rgsq4q996pWzHNk2MDClhXsPoeg7ArYXkqYJAylh2c2iM8dV6IB7PwCe7/y9eeYn9kOA==} - engines: {node: '>=20'} - '@alloc/quick-lru@5.2.0': resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} @@ -2723,8 +2605,8 @@ packages: '@fastgpt-sdk/plugin@0.3.8': resolution: {integrity: sha512-GjKrXMHxeF5UMkYGXawrUpzZjVRw3DICNYODeYwsUVOy+/ltu5zuwsqLkuuGQ7Arp/SBCmYRjG/MHmeNp4xxfw==} - '@fastgpt-sdk/sandbox-adapter@0.0.28': - resolution: {integrity: sha512-sqOnv/4hnxbjE8HGNqauHnhuzHxsXTHvkNDIFz+XV/we/Jj7h25nt+hnEtO1zOYmXKcuhbGC0zmrYwBPoIE3tQ==} + '@fastgpt-sdk/sandbox-adapter@0.0.31': + resolution: {integrity: sha512-Ex1nQNJo0BCFhy1FqsjOyTys1b+tDvzYexDHXUo+34rVF0AFYXhp7KRcoKlCyF2LFvFtHxPK2ggbUUP3Mr5lRQ==} engines: {node: '>=18'} '@fastgpt-sdk/storage@0.6.15': @@ -11673,12 +11555,121 @@ packages: zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} -snapshots: +catalogs: + default: + '@chakra-ui/anatomy': + specifier: ^2 + version: 2.3.6 + '@chakra-ui/icons': + specifier: ^2 + version: 2.1.1 + '@chakra-ui/next-js': + specifier: ^2 + version: 2.4.2 + '@chakra-ui/react': + specifier: ^2 + version: 2.10.7 + '@chakra-ui/styled-system': + specifier: ^2 + version: 2.12.2 + '@chakra-ui/system': + specifier: ^2 + version: 2.6.1 + '@emotion/react': + specifier: ^11 + version: 11.11.1 + '@emotion/styled': + specifier: ^11 + version: 11.11.0 + '@fastgpt-sdk/logger': + specifier: 0.1.2 + version: 0.1.2 + '@fastgpt-sdk/otel': + specifier: 0.1.0 + version: 0.1.0 + '@fastgpt-sdk/storage': + specifier: 0.6.15 + version: 0.6.15 + '@modelcontextprotocol/sdk': + specifier: ^1 + version: 1.26.0 + '@types/lodash': + specifier: ^4 + version: 4.17.16 + '@types/node': + specifier: ^20 + version: 20.17.24 + '@types/react': + specifier: ^18 + version: 18.3.1 + '@types/react-dom': + specifier: ^18 + version: 18.3.0 + axios: + specifier: 1.13.6 + version: 1.13.6 + date-fns: + specifier: ^3 + version: 3.6.0 + dayjs: + specifier: 1.11.19 + version: 1.11.19 + eslint: + specifier: ^8 + version: 8.57.1 + eslint-config-next: + specifier: 15.5.12 + version: 15.5.12 + express: + specifier: ^4 + version: 4.22.1 + i18next: + specifier: 23.16.8 + version: 23.16.8 + js-yaml: + specifier: ^4.1.1 + version: 4.1.1 + json5: + specifier: ^2.2.3 + version: 2.2.3 + lodash: + specifier: 4.17.23 + version: 4.17.23 + minio: + specifier: 8.0.7 + version: 8.0.7 + next: + specifier: 16.1.6 + version: 16.1.6 + next-i18next: + specifier: 15.4.2 + version: 15.4.2 + next-rspack: + specifier: 16.1.6 + version: 16.1.6 + proxy-agent: + specifier: ^6 + version: 6.5.0 + react: + specifier: ^18 + version: 18.3.1 + react-dom: + specifier: ^18 + version: 18.3.1 + react-i18next: + specifier: 14.1.2 + version: 14.1.2 + tsdown: + specifier: 0.21.4 + version: 0.21.4 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + zod: + specifier: ^4 + version: 4.1.12 - '@alibaba-group/opensandbox@0.1.4': - dependencies: - openapi-fetch: 0.14.1 - undici: 7.18.2 +snapshots: '@alloc/quick-lru@5.2.0': {} @@ -13675,9 +13666,8 @@ snapshots: '@fortaine/fetch-event-source': 3.0.6 zod: 4.1.12 - '@fastgpt-sdk/sandbox-adapter@0.0.28': + '@fastgpt-sdk/sandbox-adapter@0.0.31': dependencies: - '@alibaba-group/opensandbox': 0.1.4 '@e2b/code-interpreter': 2.3.3 '@fastgpt-sdk/storage@0.6.15(@opentelemetry/api@1.9.0)(@types/node@20.17.24)(jiti@2.6.0)(lightningcss@1.30.1)(sass@1.85.1)(terser@5.39.0)(tsx@4.20.6)(yaml@2.8.1)': @@ -18557,7 +18547,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.8.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.9.0)(eslint@8.57.1): + eslint-module-utils@2.12.1(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.8.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.9.0(eslint-plugin-import@2.32.0)(eslint@8.57.1))(eslint@8.57.1): dependencies: debug: 3.2.7 optionalDependencies: @@ -18579,7 +18569,7 @@ snapshots: doctrine: 2.1.0 eslint: 8.57.1 eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.8.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.9.0)(eslint@8.57.1) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.8.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.9.0(eslint-plugin-import@2.32.0)(eslint@8.57.1))(eslint@8.57.1) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 diff --git a/projects/app/.env.template b/projects/app/.env.template index f3cc229721..4d0546a628 100644 --- a/projects/app/.env.template +++ b/projects/app/.env.template @@ -126,6 +126,10 @@ WORKFLOW_MAX_LOOP_TIMES=50 SERVICE_REQUEST_MAX_CONTENT_LENGTH=10 # 启用内网 IP 检查 CHECK_INTERNAL_IP=false +# 应用文件夹最大数量 +APP_FOLDER_MAX_AMOUNT=1000 +# 数据集文件夹最大数量 +DATASET_FOLDER_MAX_AMOUNT=1000 # ==================== 上传与账号策略 ==================== # 最大上传文件大小(MB) diff --git a/projects/app/src/global/support/api/userRes.ts b/projects/app/src/global/support/api/userRes.ts index e218392deb..0c83062798 100644 --- a/projects/app/src/global/support/api/userRes.ts +++ b/projects/app/src/global/support/api/userRes.ts @@ -1,9 +1,5 @@ import type { UserType } from '@fastgpt/global/support/user/type'; import type { PromotionRecordSchema } from '@fastgpt/global/support/activity/type'; -export interface LoginSuccessResponse { - user: UserType; - token: string; -} export interface PromotionRecordType { _id: PromotionRecordSchema['_id']; diff --git a/projects/app/src/pageComponents/login/ForgetPasswordForm.tsx b/projects/app/src/pageComponents/login/ForgetPasswordForm.tsx index acc4111a96..4d9b1cd0bc 100644 --- a/projects/app/src/pageComponents/login/ForgetPasswordForm.tsx +++ b/projects/app/src/pageComponents/login/ForgetPasswordForm.tsx @@ -4,16 +4,16 @@ import { useForm } from 'react-hook-form'; import { LoginPageTypeEnum } from '@/web/support/user/login/constants'; import { postFindPassword } from '@/web/support/user/api'; import { useSendCode } from '@/web/support/user/hooks/useSendCode'; -import type { LoginSuccessResponse } from '@/global/support/api/userRes'; import { useToast } from '@fastgpt/web/hooks/useToast'; import { useSystemStore } from '@/web/common/system/useSystemStore'; import { useTranslation } from 'next-i18next'; 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'; interface Props { setPageType: Dispatch<`${LoginPageTypeEnum}`>; - loginSuccess: (e: LoginSuccessResponse) => void; + loginSuccess: (e: LoginSuccessResponseType) => void; } interface RegisterType { diff --git a/projects/app/src/pageComponents/login/LoginForm/LoginForm.tsx b/projects/app/src/pageComponents/login/LoginForm/LoginForm.tsx index a1a4445278..14d77ce6dd 100644 --- a/projects/app/src/pageComponents/login/LoginForm/LoginForm.tsx +++ b/projects/app/src/pageComponents/login/LoginForm/LoginForm.tsx @@ -3,7 +3,6 @@ import { FormControl, Flex, Input, Button, Box } from '@chakra-ui/react'; import { useForm } from 'react-hook-form'; import { LoginPageTypeEnum } from '@/web/support/user/login/constants'; import { postLogin, getPreLogin } from '@/web/support/user/api'; -import type { LoginSuccessResponse } from '@/global/support/api/userRes'; import { useToast } from '@fastgpt/web/hooks/useToast'; import { useSystemStore } from '@/web/common/system/useSystemStore'; import { useTranslation } from 'next-i18next'; @@ -15,10 +14,11 @@ import { UserErrEnum } from '@fastgpt/global/common/error/code/user'; import { useRouter } from 'next/router'; import { useMount } from 'ahooks'; import type { LangEnum } from '@fastgpt/global/common/i18n/type'; +import type { LoginSuccessResponseType } from '@fastgpt/global/openapi/support/user/account/login/api'; interface Props { setPageType: Dispatch<`${LoginPageTypeEnum}`>; - loginSuccess: (e: LoginSuccessResponse) => void; + loginSuccess: (e: LoginSuccessResponseType) => void; } interface LoginFormType { diff --git a/projects/app/src/pageComponents/login/LoginForm/WechatForm.tsx b/projects/app/src/pageComponents/login/LoginForm/WechatForm.tsx index 13b648d457..ebd62ae15d 100644 --- a/projects/app/src/pageComponents/login/LoginForm/WechatForm.tsx +++ b/projects/app/src/pageComponents/login/LoginForm/WechatForm.tsx @@ -1,7 +1,6 @@ import React, { type Dispatch } from 'react'; import { LoginPageTypeEnum } from '@/web/support/user/login/constants'; -import type { LoginSuccessResponse } from '@/global/support/api/userRes'; -import { Box, Center, Flex, Link } from '@chakra-ui/react'; +import { Box, Center } from '@chakra-ui/react'; import { useQuery } from '@tanstack/react-query'; import { getWXLoginQR, getWXLoginResult } from '@/web/support/user/api'; import { getErrText } from '@fastgpt/global/common/error/utils'; @@ -18,12 +17,12 @@ import { removeFastGPTSem, getInviterId } from '@/web/support/marketing/utils'; -import { getDocPath } from '@/web/common/system/doc'; import { useSystemStore } from '@/web/common/system/useSystemStore'; import PolicyTip from './PolicyTip'; +import type { LoginSuccessResponseType } from '@fastgpt/global/openapi/support/user/account/login/api'; interface Props { - loginSuccess: (e: LoginSuccessResponse) => void; + loginSuccess: (e: LoginSuccessResponseType) => void; setPageType: Dispatch<`${LoginPageTypeEnum}`>; } @@ -55,7 +54,7 @@ const WechatForm = ({ setPageType, loginSuccess }: Props) => { { refetchInterval: 3 * 1000, enabled: !!wechatInfo?.code, - onSuccess(data: LoginSuccessResponse | undefined) { + onSuccess(data: LoginSuccessResponseType | undefined) { if (data) { removeFastGPTSem(); loginSuccess(data); diff --git a/projects/app/src/pageComponents/login/LoginModal.tsx b/projects/app/src/pageComponents/login/LoginModal.tsx index ec71e1fc19..642dce8391 100644 --- a/projects/app/src/pageComponents/login/LoginModal.tsx +++ b/projects/app/src/pageComponents/login/LoginModal.tsx @@ -4,10 +4,10 @@ import { LoginContainer } from '@/pageComponents/login'; import I18nLngSelector from '@/components/Select/I18nLngSelector'; import { useSystem } from '@fastgpt/web/hooks/useSystem'; import { getWebReqUrl } from '@fastgpt/web/common/system/utils'; -import type { LoginSuccessResponse } from '@/global/support/api/userRes'; +import { type LoginSuccessResponseType } from '@fastgpt/global/openapi/support/user/account/login/api'; type LoginModalProps = { - onSuccess: (e: LoginSuccessResponse) => any; + onSuccess: (e: LoginSuccessResponseType) => any; }; const LoginModal = ({ onSuccess }: LoginModalProps) => { diff --git a/projects/app/src/pageComponents/login/RegisterForm.tsx b/projects/app/src/pageComponents/login/RegisterForm.tsx index b6752cae9b..f2a3111012 100644 --- a/projects/app/src/pageComponents/login/RegisterForm.tsx +++ b/projects/app/src/pageComponents/login/RegisterForm.tsx @@ -4,7 +4,6 @@ import { useForm } from 'react-hook-form'; import { LoginPageTypeEnum } from '@/web/support/user/login/constants'; import { postRegister } from '@/web/support/user/api'; import { useSendCode } from '@/web/support/user/hooks/useSendCode'; -import type { LoginSuccessResponse } from '@/global/support/api/userRes'; import { useToast } from '@fastgpt/web/hooks/useToast'; import { useSystemStore } from '@/web/common/system/useSystemStore'; import { useTranslation } from 'next-i18next'; @@ -18,9 +17,10 @@ import { removeFastGPTSem } from '@/web/support/marketing/utils'; import { checkPasswordRule } from '@fastgpt/global/common/string/password'; +import type { LoginSuccessResponseType } from '@fastgpt/global/openapi/support/user/account/login/api'; interface Props { - loginSuccess: (e: LoginSuccessResponse) => void; + loginSuccess: (e: LoginSuccessResponseType) => void; setPageType: Dispatch<`${LoginPageTypeEnum}`>; } diff --git a/projects/app/src/pageComponents/login/index.tsx b/projects/app/src/pageComponents/login/index.tsx index 3298c4f564..156707e727 100644 --- a/projects/app/src/pageComponents/login/index.tsx +++ b/projects/app/src/pageComponents/login/index.tsx @@ -10,7 +10,6 @@ import { } from '@chakra-ui/react'; import { LoginPageTypeEnum } from '@/web/support/user/login/constants'; import { useSystemStore } from '@/web/common/system/useSystemStore'; -import type { LoginSuccessResponse } from '@/global/support/api/userRes'; import { useChatStore } from '@/web/core/chat/context/useChatStore'; import dynamic from 'next/dynamic'; import Script from 'next/script'; @@ -20,6 +19,7 @@ import { useTranslation } from 'next-i18next'; import LoginForm from '@/pageComponents/login/LoginForm/LoginForm'; import { GET } from '@/web/common/api/request'; import { getDocPath } from '@/web/common/system/doc'; +import type { LoginSuccessResponseType } from '@fastgpt/global/openapi/support/user/account/login/api'; const RegisterForm = dynamic(() => import('@/pageComponents/login/RegisterForm')); const ForgetPasswordForm = dynamic(() => import('@/pageComponents/login/ForgetPasswordForm')); @@ -184,7 +184,7 @@ export const LoginContainer = ({ onSuccess }: { children?: React.ReactNode; - onSuccess: (res: LoginSuccessResponse) => void; + onSuccess: (res: LoginSuccessResponseType) => void; }) => { const { t } = useTranslation(); const { feConfigs } = useSystemStore(); @@ -195,7 +195,7 @@ export const LoginContainer = ({ // login success handler const loginSuccess = useCallback( - (res: LoginSuccessResponse) => { + (res: LoginSuccessResponseType) => { onSuccess?.(res); }, [onSuccess] 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 031d03319e..d3fded064d 100644 --- a/projects/app/src/pages/api/support/user/account/checkPswExpired.ts +++ b/projects/app/src/pages/api/support/user/account/checkPswExpired.ts @@ -3,17 +3,12 @@ import { NextAPI } from '@/service/middleware/entry'; 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'; - -export type getTimeQuery = {}; - -export type getTimeBody = {}; - -export type getTimeResponse = boolean; +import type { CheckPswExpiredResponseType } from '@fastgpt/global/openapi/support/user/account/password/api'; async function handler( - req: ApiRequestProps, - res: ApiResponseType -): Promise { + req: ApiRequestProps, + res: ApiResponseType +): Promise { const { userId } = await authCert({ req, authToken: true }); const user = await MongoUser.findById(userId, 'passwordUpdateTime'); diff --git a/projects/app/src/pages/api/support/user/account/loginByPassword.ts b/projects/app/src/pages/api/support/user/account/loginByPassword.ts index ee5dad4bd5..5e8c91839d 100644 --- a/projects/app/src/pages/api/support/user/account/loginByPassword.ts +++ b/projects/app/src/pages/api/support/user/account/loginByPassword.ts @@ -1,12 +1,9 @@ -import type { NextApiRequest, NextApiResponse } from 'next'; import { MongoUser } from '@fastgpt/service/support/user/schema'; import { getUserDetail } from '@fastgpt/service/support/user/controller'; -import type { PostLoginProps } from '@fastgpt/global/support/user/api'; import { UserStatusEnum } from '@fastgpt/global/support/user/constant'; import { NextAPI } from '@/service/middleware/entry'; import { useIPFrequencyLimit } from '@fastgpt/service/common/middle/reqFrequencyLimit'; import { pushTrack } from '@fastgpt/service/common/middle/tracks/utils'; -import { CommonErrEnum } from '@fastgpt/global/common/error/code/common'; import { UserErrEnum } from '@fastgpt/global/common/error/code/user'; import { addAuditLog } from '@fastgpt/service/support/user/audit/util'; import { AuditEventEnum } from '@fastgpt/global/support/user/audit/constants'; @@ -16,13 +13,18 @@ import { createUserSession } from '@fastgpt/service/support/user/session'; import requestIp from 'request-ip'; import { setCookie } from '@fastgpt/service/support/permission/auth/common'; import { UserError } from '@fastgpt/global/common/error/utils'; +import { + LoginByPasswordBodySchema, + type LoginByPasswordBodyType, + type LoginSuccessResponseType +} from '@fastgpt/global/openapi/support/user/account/login/api'; +import type { ApiRequestProps, ApiResponseType } from '@fastgpt/service/type/next'; -async function handler(req: NextApiRequest, res: NextApiResponse) { - const { username, password, code, language = 'zh-CN' } = req.body as PostLoginProps; - - if (!username || !password || !code) { - return Promise.reject(CommonErrEnum.invalidParams); - } +async function handler( + req: ApiRequestProps, + res: ApiResponseType +): Promise { + const { username, password, code, language } = LoginByPasswordBodySchema.parse(req.body); // Auth prelogin code await authCode({ diff --git a/projects/app/src/pages/api/support/user/account/preLogin.ts b/projects/app/src/pages/api/support/user/account/preLogin.ts index e976a81aea..cbe48082fe 100644 --- a/projects/app/src/pages/api/support/user/account/preLogin.ts +++ b/projects/app/src/pages/api/support/user/account/preLogin.ts @@ -4,25 +4,17 @@ import { UserAuthTypeEnum } from '@fastgpt/global/support/user/auth/constants'; import { getNanoid } from '@fastgpt/global/common/string/tools'; import { addSeconds } from 'date-fns'; import { addAuthCode } from '@fastgpt/service/support/user/auth/controller'; -import { UserError } from '@fastgpt/global/common/error/utils'; - -export type preLoginQuery = { - username: string; -}; - -export type preLoginBody = {}; - -export type preLoginResponse = { code: string }; +import { + PreLoginQuerySchema, + type PreLoginQueryType, + type PreLoginResponseType +} from '@fastgpt/global/openapi/support/user/account/login/api'; async function handler( - req: ApiRequestProps, + req: ApiRequestProps<{}, PreLoginQueryType>, res: ApiResponseType -): Promise { - const { username } = req.query; - - if (!username) { - return Promise.reject(new UserError('username is required')); - } +): Promise { + const { username } = PreLoginQuerySchema.parse(req.query); const code = getNanoid(6); diff --git a/projects/app/src/pages/api/support/user/account/resetExpiredPsw.ts b/projects/app/src/pages/api/support/user/account/resetExpiredPsw.ts index 2dcf781859..6f419f28a3 100644 --- a/projects/app/src/pages/api/support/user/account/resetExpiredPsw.ts +++ b/projects/app/src/pages/api/support/user/account/resetExpiredPsw.ts @@ -5,20 +5,16 @@ import { NextAPI } from '@/service/middleware/entry'; import { i18nT } from '@fastgpt/web/i18n/utils'; import { checkPswExpired } from '@/service/support/user/account/password'; import { delUserAllSession } from '@fastgpt/service/support/user/session'; - -export type resetExpiredPswQuery = {}; - -export type resetExpiredPswBody = { - newPsw: string; -}; - -export type resetExpiredPswResponse = {}; +import { + ResetExpiredPswBodySchema, + type ResetExpiredPswResponseType +} from '@fastgpt/global/openapi/support/user/account/password/api'; async function resetExpiredPswHandler( - req: ApiRequestProps, - res: ApiResponseType -): Promise { - const newPsw = req.body.newPsw; + req: ApiRequestProps, + res: ApiResponseType +): Promise { + const { newPsw } = ResetExpiredPswBodySchema.parse(req.body); const { userId, sessionId } = await authCert({ req, authToken: true }); const user = await MongoUser.findById(userId, 'passwordUpdateTime').lean(); diff --git a/projects/app/src/pages/api/support/user/account/tokenLogin.ts b/projects/app/src/pages/api/support/user/account/tokenLogin.ts index 37b8ab9587..1ba521492b 100644 --- a/projects/app/src/pages/api/support/user/account/tokenLogin.ts +++ b/projects/app/src/pages/api/support/user/account/tokenLogin.ts @@ -2,17 +2,10 @@ import { authCert } from '@fastgpt/service/support/permission/auth/common'; import { getUserDetail } from '@fastgpt/service/support/user/controller'; import type { ApiRequestProps, ApiResponseType } from '@fastgpt/service/type/next'; import { NextAPI } from '@/service/middleware/entry'; -import { type UserType } from '@fastgpt/global/support/user/type'; import { pushTrack } from '@fastgpt/service/common/middle/tracks/utils'; +import type { UserType } from '@fastgpt/global/support/user/type'; -export type TokenLoginQuery = {}; -export type TokenLoginBody = {}; -export type TokenLoginResponse = UserType; - -async function handler( - req: ApiRequestProps, - _res: ApiResponseType -): Promise { +async function handler(req: ApiRequestProps, _res: ApiResponseType): Promise { const { tmbId, userId, teamId } = await authCert({ req, authToken: true }); const user = await getUserDetail({ tmbId }); diff --git a/projects/app/src/pages/api/support/user/account/updatePasswordByOld.ts b/projects/app/src/pages/api/support/user/account/updatePasswordByOld.ts index f0b1da3f7d..d4db1cab7d 100644 --- a/projects/app/src/pages/api/support/user/account/updatePasswordByOld.ts +++ b/projects/app/src/pages/api/support/user/account/updatePasswordByOld.ts @@ -1,4 +1,3 @@ -import type { NextApiRequest, NextApiResponse } from 'next'; import { authCert } from '@fastgpt/service/support/permission/auth/common'; import { MongoUser } from '@fastgpt/service/support/user/schema'; @@ -8,12 +7,18 @@ 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'; -async function handler(req: NextApiRequest, res: NextApiResponse) { - const { oldPsw, newPsw } = req.body as { oldPsw: string; newPsw: string }; +import { + UpdatePasswordByOldBodySchema, + type UpdatePasswordByOldBodyType, + type UpdatePasswordByOldResponseType +} from '@fastgpt/global/openapi/support/user/account/password/api'; +import type { ApiRequestProps, ApiResponseType } from '@fastgpt/service/type/next'; - if (!oldPsw || !newPsw) { - return Promise.reject('Params is missing'); - } +async function handler( + req: ApiRequestProps, + res: ApiResponseType +): Promise { + const { oldPsw, newPsw } = UpdatePasswordByOldBodySchema.parse(req.body); const { tmbId, teamId, sessionId } = await authCert({ req, authToken: true }); const tmb = await MongoTeamMember.findById(tmbId); diff --git a/projects/app/src/pages/chat/index.tsx b/projects/app/src/pages/chat/index.tsx index 71e735bf88..adfd5c4e9f 100644 --- a/projects/app/src/pages/chat/index.tsx +++ b/projects/app/src/pages/chat/index.tsx @@ -23,10 +23,10 @@ import { ChatPageContext, ChatPageContextProvider } from '@/web/core/chat/contex import ChatTeamApp from '@/pageComponents/chat/ChatTeamApp'; import ChatFavouriteApp from '@/pageComponents/chat/ChatFavouriteApp'; import { useUserStore } from '@/web/support/user/useUserStore'; -import type { LoginSuccessResponse } from '@/global/support/api/userRes'; import { MongoOutLink } from '@fastgpt/service/support/outLink/schema'; import { getLogger, LogCategories } from '@fastgpt/service/common/logger'; import { PublishChannelEnum } from '@fastgpt/global/support/outLink/constant'; +import type { LoginSuccessResponseType } from '@fastgpt/global/openapi/support/user/account/login/api'; const logger = getLogger(LogCategories.MODULE.CHAT.ITEM); @@ -121,7 +121,7 @@ const ChatContent = (props: ChatPageProps) => { }, [appId, chatId]); const loginSuccess = useCallback( - async (res: LoginSuccessResponse) => { + async (res: LoginSuccessResponseType) => { setUserInfo(res.user); }, [setUserInfo] diff --git a/projects/app/src/pages/login/fastlogin.tsx b/projects/app/src/pages/login/fastlogin.tsx index 1a19be22bd..aa44771042 100644 --- a/projects/app/src/pages/login/fastlogin.tsx +++ b/projects/app/src/pages/login/fastlogin.tsx @@ -1,6 +1,5 @@ import React, { useCallback, useEffect } from 'react'; import { useRouter } from 'next/router'; -import type { LoginSuccessResponse } from '@/global/support/api/userRes'; import { useUserStore } from '@/web/support/user/useUserStore'; import { clearToken } from '@/web/support/user/auth'; import { postFastLogin } from '@/web/support/user/api'; @@ -10,6 +9,7 @@ import { serviceSideProps } from '@/web/common/i18n/utils'; import { getErrText } from '@fastgpt/global/common/error/utils'; import { useTranslation } from 'next-i18next'; import { validateRedirectUrl } from '@/web/common/utils/uri'; +import type { LoginSuccessResponseType } from '@fastgpt/global/openapi/support/user/account/login/api'; const FastLogin = ({ code, @@ -25,7 +25,7 @@ const FastLogin = ({ const { toast } = useToast(); const { t } = useTranslation(); const loginSuccess = useCallback( - (res: LoginSuccessResponse) => { + (res: LoginSuccessResponseType) => { setUserInfo(res.user); setTimeout(() => { diff --git a/projects/app/src/pages/login/index.tsx b/projects/app/src/pages/login/index.tsx index 3116957f32..15a34056a9 100644 --- a/projects/app/src/pages/login/index.tsx +++ b/projects/app/src/pages/login/index.tsx @@ -5,12 +5,12 @@ import { clearToken } from '@/web/support/user/auth'; import { useMount } from 'ahooks'; import LoginModal from '@/pageComponents/login/LoginModal'; import { postAcceptInvitationLink } from '@/web/support/user/team/api'; -import type { LoginSuccessResponse } from '@/global/support/api/userRes'; import { useToast } from '@fastgpt/web/hooks/useToast'; import { useTranslation } from 'next-i18next'; import { useUserStore } from '@/web/support/user/useUserStore'; import { subRoute } from '@fastgpt/web/common/system/utils'; import { validateRedirectUrl } from '@/web/common/utils/uri'; +import type { LoginSuccessResponseType } from '@fastgpt/global/openapi/support/user/account/login/api'; const Login = () => { const router = useRouter(); @@ -20,7 +20,7 @@ const Login = () => { const { setUserInfo } = useUserStore(); const loginSuccess = useCallback( - async (res: LoginSuccessResponse) => { + async (res: LoginSuccessResponseType) => { setUserInfo(res.user); const decodeLastRoute = validateRedirectUrl(lastRoute); diff --git a/projects/app/src/pages/login/provider.tsx b/projects/app/src/pages/login/provider.tsx index 9767fc33ec..37496a1b32 100644 --- a/projects/app/src/pages/login/provider.tsx +++ b/projects/app/src/pages/login/provider.tsx @@ -1,7 +1,6 @@ import React, { useCallback, useEffect } from 'react'; import { useRouter } from 'next/router'; import { useSystemStore } from '@/web/common/system/useSystemStore'; -import type { LoginSuccessResponse } from '@/global/support/api/userRes'; import { useUserStore } from '@/web/support/user/useUserStore'; import { clearToken } from '@/web/support/user/auth'; import { oauthLogin } from '@/web/support/user/api'; @@ -23,6 +22,7 @@ import { postAcceptInvitationLink } from '@/web/support/user/team/api'; import { retryFn } from '@fastgpt/global/common/system/utils'; import type { LangEnum } from '@fastgpt/global/common/i18n/type'; import { validateRedirectUrl } from '@/web/common/utils/uri'; +import type { LoginSuccessResponseType } from '@fastgpt/global/openapi/support/user/account/login/api'; let isOauthLogging = false; @@ -40,7 +40,7 @@ const provider = () => { const errorRedirectPage = lastRoute.startsWith('/chat') ? lastRoute : '/login'; const loginSuccess = useCallback( - async (res: LoginSuccessResponse) => { + async (res: LoginSuccessResponseType) => { const decodeLastRoute = validateRedirectUrl(lastRoute); setUserInfo(res.user); diff --git a/projects/app/src/web/common/system/useSystemStore.ts b/projects/app/src/web/common/system/useSystemStore.ts index c9e2996239..9302092cf3 100644 --- a/projects/app/src/web/common/system/useSystemStore.ts +++ b/projects/app/src/web/common/system/useSystemStore.ts @@ -22,7 +22,7 @@ import { } from '@fastgpt/global/core/ai/provider'; import { getMyModels, getOperationalAd } from './api'; -type LoginStoreType = { provider: `${OAuthEnum}`; lastRoute: string; state: string }; +type LoginStoreType = { provider: OAuthEnum; lastRoute: string; state: string }; export type NotSufficientModalType = | TeamErrEnum.datasetSizeNotEnough diff --git a/projects/app/src/web/support/user/api.ts b/projects/app/src/web/support/user/api.ts index e7db3cc546..ad402cd2a4 100644 --- a/projects/app/src/web/support/user/api.ts +++ b/projects/app/src/web/support/user/api.ts @@ -1,23 +1,23 @@ import { GET, POST, PUT } from '@/web/common/api/request'; import { hashStr } from '@fastgpt/global/common/string/tools'; -import type { LoginSuccessResponse } from '@/global/support/api/userRes'; import type { UserAuthTypeEnum } from '@fastgpt/global/support/user/auth/constants'; import type { UserUpdateParams } from '@/types/user'; import type { UserType } from '@fastgpt/global/support/user/type'; +import type { SearchResult } from '@fastgpt/global/support/user/api'; import type { - FastLoginProps, - OauthLoginProps, - PostLoginProps, - SearchResult -} from '@fastgpt/global/support/user/api'; -import type { - AccountRegisterBody, - GetWXLoginQRResponse -} from '@fastgpt/global/support/user/login/api'; -import type { preLoginResponse } from '@/pages/api/support/user/account/preLogin'; -import type { WxLoginProps } from '@fastgpt/global/support/user/api'; + PreLoginResponseType, + LoginByPasswordBodyType, + OauthLoginBodyType, + FastLoginBodyType, + WxLoginBodyType, + GetWXLoginQRResponseType +} from '@fastgpt/global/openapi/support/user/account/login/api'; +import type { UpdatePasswordByOldBodyType } from '@fastgpt/global/openapi/support/user/account/password/api'; +import type { AccountRegisterBodyType } from '@fastgpt/global/openapi/support/user/account/register/api'; import type { LangEnum } from '@fastgpt/global/common/i18n/type'; +import type { LoginSuccessResponseType } from '@fastgpt/global/openapi/support/user/account/login/api'; +/* ===== Auth code ===== */ export const sendAuthCode = (data: { username: string; type: `${UserAuthTypeEnum}`; @@ -25,16 +25,37 @@ export const sendAuthCode = (data: { captcha: string; lang: `${LangEnum}`; }) => POST(`/proApi/support/user/inform/sendAuthCode`, data); +export const getCaptchaPic = (username: string) => + GET<{ + captchaImage: string; + }>('/proApi/support/user/account/captcha/getImgCaptcha', { username }); + +/* ===== login ===== */ +export const getPreLogin = (username: string) => + GET('/support/user/account/preLogin', { username }); export const getTokenLogin = () => GET('/support/user/account/tokenLogin', {}, { maxQuantity: 1 }); -export const oauthLogin = (params: OauthLoginProps) => - POST('/proApi/support/user/account/login/oauth', params); -export const postFastLogin = (params: FastLoginProps) => - POST('/proApi/support/user/account/login/fastLogin', params); +export const oauthLogin = (params: OauthLoginBodyType) => + POST('/proApi/support/user/account/login/oauth', params); +export const postFastLogin = (params: FastLoginBodyType) => + POST('/proApi/support/user/account/login/fastLogin', params); export const ssoLogin = (params: any) => - GET('/proApi/support/user/account/sso', params); + GET('/proApi/support/user/account/sso', params); +export const postLogin = ({ password, ...props }: LoginByPasswordBodyType) => + POST('/support/user/account/loginByPassword', { + ...props, + password: hashStr(password) + }); +// wx login +export const getWXLoginQR = () => + GET('/proApi/support/user/account/login/wx/getQR'); +export const getWXLoginResult = (params: WxLoginBodyType) => + POST(`/proApi/support/user/account/login/wx/getResult`, params); +export const loginOut = () => GET('/support/user/account/loginout'); + +/* ===== register ===== */ export const postRegister = ({ username, password, @@ -43,8 +64,8 @@ export const postRegister = ({ bd_vid, msclkid, fastgpt_sem -}: AccountRegisterBody) => - POST(`/proApi/support/user/account/register/emailAndPhone`, { +}: AccountRegisterBodyType) => + POST(`/proApi/support/user/account/register/emailAndPhone`, { username, code, inviterId, @@ -54,6 +75,7 @@ export const postRegister = ({ password: hashStr(password) }); +/* ===== password ===== */ export const postFindPassword = ({ username, code, @@ -63,57 +85,33 @@ export const postFindPassword = ({ code: string; password: string; }) => - POST(`/proApi/support/user/account/password/updateByCode`, { + POST(`/proApi/support/user/account/password/updateByCode`, { username, code, password: hashStr(password) }); - -export const updatePasswordByOld = ({ oldPsw, newPsw }: { oldPsw: string; newPsw: string }) => +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 */ +// Check the whether password has expired export const getCheckPswExpired = () => GET('/support/user/account/checkPswExpired'); +/* ===== notification account ===== */ export const updateNotificationAccount = (data: { account: string; verifyCode: string }) => PUT('/proApi/support/user/team/updateNotificationAccount', data); - export const updateContact = (data: { contact: string; verifyCode: string }) => { return PUT('/proApi/support/user/account/updateContact', data); }; -export const postLogin = ({ password, ...props }: PostLoginProps) => - POST('/support/user/account/loginByPassword', { - ...props, - password: hashStr(password) - }); - -export const loginOut = () => GET('/support/user/account/loginout'); - +/* ===== user info ===== */ export const putUserInfo = (data: UserUpdateParams) => PUT('/support/user/account/update', data); -export const getWXLoginQR = () => - GET('/proApi/support/user/account/login/wx/getQR'); - -export const getWXLoginResult = (params: WxLoginProps) => - POST(`/proApi/support/user/account/login/wx/getResult`, params); - -export const getCaptchaPic = (username: string) => - GET<{ - captchaImage: string; - }>('/proApi/support/user/account/captcha/getImgCaptcha', { username }); - -export const getPreLogin = (username: string) => - GET('/support/user/account/preLogin', { username }); - export const postSyncMembers = () => POST('/proApi/support/user/sync'); export const GetSearchUserGroupOrg = ( diff --git a/projects/app/test/api/support/user/account/checkPswExpired.test.ts b/projects/app/test/api/support/user/account/checkPswExpired.test.ts new file mode 100644 index 0000000000..b101def85d --- /dev/null +++ b/projects/app/test/api/support/user/account/checkPswExpired.test.ts @@ -0,0 +1,165 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import * as checkPswExpiredApi from '@/pages/api/support/user/account/checkPswExpired'; +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 { Call } from '@test/utils/request'; + +describe('checkPswExpired API', () => { + let testUser: any; + let testTeam: any; + let testTmb: any; + const originalEnv = process.env.PASSWORD_EXPIRED_MONTH; + + beforeEach(async () => { + testUser = await MongoUser.create({ + username: 'testuser', + password: 'testpassword', + status: UserStatusEnum.active, + passwordUpdateTime: new Date() + }); + 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(() => { + process.env.PASSWORD_EXPIRED_MONTH = originalEnv; + }); + + it('should return false when PASSWORD_EXPIRED_MONTH is not set', async () => { + delete process.env.PASSWORD_EXPIRED_MONTH; + + 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 return false when PASSWORD_EXPIRED_MONTH=0', async () => { + process.env.PASSWORD_EXPIRED_MONTH = '0'; + + 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 return false when password was updated recently', async () => { + process.env.PASSWORD_EXPIRED_MONTH = '3'; + + // Update password time to now + await MongoUser.findByIdAndUpdate(testUser._id, { + passwordUpdateTime: new Date() + }); + + 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 return true when password has expired (update time older than expiry period)', async () => { + process.env.PASSWORD_EXPIRED_MONTH = '1'; + + // Set password update time to 2 months ago + const twoMonthsAgo = new Date(); + twoMonthsAgo.setMonth(twoMonthsAgo.getMonth() - 2); + await MongoUser.findByIdAndUpdate(testUser._id, { + passwordUpdateTime: twoMonthsAgo + }); + + 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(true); + }); + + it('should return true when passwordUpdateTime is not set and env is configured', async () => { + process.env.PASSWORD_EXPIRED_MONTH = '3'; + + // Remove passwordUpdateTime + 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: false, + sessionId: 'session123' + } as any + }); + + expect(res.code).toBe(200); + expect(res.data).toBe(true); + }); + + it('should return false when user is not found', async () => { + const nonExistentId = '000000000000000000000001'; + + const res = await Call(checkPswExpiredApi.default, { + auth: { + userId: nonExistentId, + 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 res = await Call(checkPswExpiredApi.default, {}); + + expect(res.code).toBe(500); + }); +}); diff --git a/projects/app/test/api/support/user/account/loginByPassword.test.ts b/projects/app/test/api/support/user/account/loginByPassword.test.ts index c44aec4a3e..db55c3409c 100644 --- a/projects/app/test/api/support/user/account/loginByPassword.test.ts +++ b/projects/app/test/api/support/user/account/loginByPassword.test.ts @@ -5,14 +5,12 @@ import { UserStatusEnum } from '@fastgpt/global/support/user/constant'; import { MongoTeam } from '@fastgpt/service/support/user/team/teamSchema'; import { MongoTeamMember } from '@fastgpt/service/support/user/team/teamMemberSchema'; import { authCode } from '@fastgpt/service/support/user/auth/controller'; -import { createUserSession } from '@fastgpt/service/support/user/session'; import { setCookie } from '@fastgpt/service/support/permission/auth/common'; import { pushTrack } from '@fastgpt/service/common/middle/tracks/utils'; import { addAuditLog } from '@fastgpt/service/support/user/audit/util'; -import { CommonErrEnum } from '@fastgpt/global/common/error/code/common'; import { UserErrEnum } from '@fastgpt/global/common/error/code/user'; import { Call } from '@test/utils/request'; -import type { PostLoginProps } from '@fastgpt/global/support/user/api'; +import type { LoginByPasswordBodyType } from '@fastgpt/global/openapi/support/user/account/login/api'; import { initTeamFreePlan } from '@fastgpt/service/support/wallet/sub/utils'; describe('loginByPassword API', () => { @@ -21,7 +19,6 @@ describe('loginByPassword API', () => { let testTmb: any; beforeEach(async () => { - // Create test user and team testUser = await MongoUser.create({ username: 'testuser', password: 'testpassword', @@ -48,12 +45,11 @@ describe('loginByPassword API', () => { lastLoginTmbId: testTmb._id }); - // Reset mocks before each test vi.clearAllMocks(); }); it('should login successfully with valid credentials', async () => { - const res = await Call(loginApi.default, { + const res = await Call(loginApi.default, { body: { username: 'testuser', password: 'testpassword', @@ -64,83 +60,66 @@ describe('loginByPassword API', () => { expect(res.code).toBe(200); expect(res.error).toBeUndefined(); - expect(res.data).toBeDefined(); expect(res.data.user).toBeDefined(); expect(res.data.user.team).toBeDefined(); + expect(res.data.user.team.teamId).toBe(String(testTeam._id)); + expect(res.data.user.team.tmbId).toBe(String(testTmb._id)); expect(res.data.token).toBeDefined(); expect(typeof res.data.token).toBe('string'); expect(res.data.token.length).toBeGreaterThan(0); - // Verify authCode was called expect(authCode).toHaveBeenCalledWith({ key: 'testuser', code: '123456', type: expect.any(String) }); - - // Verify setCookie was called expect(setCookie).toHaveBeenCalled(); - - // Verify tracking was called expect(pushTrack.login).toHaveBeenCalledWith({ type: 'password', uid: testUser._id, teamId: String(testTeam._id), tmbId: String(testTmb._id) }); - - // Verify audit log was called expect(addAuditLog).toHaveBeenCalled(); }); - it('should reject login when username is missing', async () => { - const res = await Call(loginApi.default, { + it('should reject login when username is empty', async () => { + const res = await Call(loginApi.default, { body: { username: '', password: 'testpassword', - code: '123456' + code: '123456', + language: 'zh-CN' } }); expect(res.code).toBe(500); - expect(res.error).toBe(CommonErrEnum.invalidParams); }); - it('should reject login when password is missing', async () => { - const res = await Call(loginApi.default, { + it('should reject login when password is empty', async () => { + const res = await Call(loginApi.default, { body: { username: 'testuser', password: '', - code: '123456' + code: '123456', + language: 'zh-CN' } }); + // Empty password passes zod z.string() but won't match any user record expect(res.code).toBe(500); - expect(res.error).toBe(CommonErrEnum.invalidParams); - }); - - it('should reject login when code is missing', async () => { - const res = await Call(loginApi.default, { - body: { - username: 'testuser', - password: 'testpassword', - code: '' - } - }); - - expect(res.code).toBe(500); - expect(res.error).toBe(CommonErrEnum.invalidParams); + expect(res.error).toBe(UserErrEnum.account_psw_error); }); it('should reject login when auth code verification fails', async () => { - // Mock authCode to reject vi.mocked(authCode).mockRejectedValueOnce(new Error('Invalid code')); - const res = await Call(loginApi.default, { + const res = await Call(loginApi.default, { body: { username: 'testuser', password: 'testpassword', - code: 'wrongcode' + code: 'wrongcode', + language: 'zh-CN' } }); @@ -149,11 +128,12 @@ describe('loginByPassword API', () => { }); it('should reject login when user does not exist', async () => { - const res = await Call(loginApi.default, { + const res = await Call(loginApi.default, { body: { username: 'nonexistentuser', password: 'testpassword', - code: '123456' + code: '123456', + language: 'zh-CN' } }); @@ -162,16 +142,16 @@ describe('loginByPassword API', () => { }); it('should reject login when user is forbidden', async () => { - // Update user status to forbidden await MongoUser.findByIdAndUpdate(testUser._id, { status: UserStatusEnum.forbidden }); - const res = await Call(loginApi.default, { + const res = await Call(loginApi.default, { body: { username: 'testuser', password: 'testpassword', - code: '123456' + code: '123456', + language: 'zh-CN' } }); @@ -180,11 +160,12 @@ describe('loginByPassword API', () => { }); it('should reject login when password is incorrect', async () => { - const res = await Call(loginApi.default, { + const res = await Call(loginApi.default, { body: { username: 'testuser', password: 'wrongpassword', - code: '123456' + code: '123456', + language: 'zh-CN' } }); @@ -192,8 +173,8 @@ describe('loginByPassword API', () => { expect(res.error).toBe(UserErrEnum.account_psw_error); }); - it('should accept language parameter on successful login', async () => { - const res = await Call(loginApi.default, { + it('should update language on successful login', async () => { + const res = await Call(loginApi.default, { body: { username: 'testuser', password: 'testpassword', @@ -203,16 +184,13 @@ describe('loginByPassword API', () => { }); expect(res.code).toBe(200); - expect(res.error).toBeUndefined(); - // Verify user was updated with the language const updatedUser = await MongoUser.findById(testUser._id); expect(updatedUser?.language).toBe('en'); expect(updatedUser?.lastLoginTmbId).toEqual(testTmb._id); }); it('should handle root user login correctly', async () => { - // Create root user const rootUser = await MongoUser.create({ username: 'root', password: 'rootpassword', @@ -239,88 +217,125 @@ describe('loginByPassword API', () => { lastLoginTmbId: rootTmb._id }); - const res = await Call(loginApi.default, { + const res = await Call(loginApi.default, { body: { username: 'root', password: 'rootpassword', - code: '123456' + code: '123456', + language: 'zh-CN' } }); expect(res.code).toBe(200); - expect(res.error).toBeUndefined(); - expect(res.data).toBeDefined(); expect(res.data.token).toBeDefined(); expect(typeof res.data.token).toBe('string'); }); - it('should use default language when language is not provided', async () => { - const res = await Call(loginApi.default, { - body: { - username: 'testuser', - password: 'testpassword', - code: '123456' - } + // ===== Security: NoSQL injection prevention (GHSA-jxvr-h2vx-p73r) ===== + + describe('NoSQL injection prevention', () => { + it('should reject password as object with MongoDB operator ($ne)', async () => { + // GHSA-jxvr-h2vx-p73r Step 2: password: {"$ne": ""} bypasses password check + const res = await Call(loginApi.default, { + body: { + username: 'testuser', + password: { $ne: '' }, + code: '123456', + language: 'zh-CN' + } + }); + + // Zod z.string() must reject object-type password + expect(res.code).toBe(500); + expect(res.data?.token).toBeUndefined(); + expect(res.data?.user).toBeUndefined(); }); - expect(res.code).toBe(200); - expect(res.error).toBeUndefined(); + it('should reject password with $regex operator', async () => { + const res = await Call(loginApi.default, { + body: { + username: 'testuser', + password: { $regex: '.*' }, + code: '123456', + language: 'zh-CN' + } + }); - // Verify user was updated with the default language 'zh-CN' - const updatedUser = await MongoUser.findById(testUser._id); - expect(updatedUser?.language).toBe('zh-CN'); - }); - - it('should update lastLoginTmbId on successful login', async () => { - const updateOneSpy = vi.spyOn(MongoUser, 'updateOne'); - - const res = await Call(loginApi.default, { - body: { - username: 'testuser', - password: 'testpassword', - code: '123456' - } + expect(res.code).toBe(500); }); - expect(res.code).toBe(200); - expect(res.error).toBeUndefined(); + it('should reject password with $where injection', async () => { + const res = await Call(loginApi.default, { + body: { + username: 'testuser', + password: { $where: 'return true' }, + code: '123456', + language: 'zh-CN' + } + }); - // Verify user was updated with lastLoginTmbId - const updatedUser = await MongoUser.findById(testUser._id); - expect(updatedUser?.lastLoginTmbId).toEqual(testTmb._id); - }); - - it('should verify user authentication flow', async () => { - const res = await Call(loginApi.default, { - body: { - username: 'testuser', - password: 'testpassword', - code: '123456' - } + expect(res.code).toBe(500); }); - expect(res.code).toBe(200); + it('should reject username as object with MongoDB operator', async () => { + const res = await Call(loginApi.default, { + body: { + username: { $ne: '' }, + password: 'testpassword', + code: '123456', + language: 'zh-CN' + } + }); - // Verify the full authentication flow - expect(authCode).toHaveBeenCalled(); - expect(setCookie).toHaveBeenCalled(); - expect(pushTrack.login).toHaveBeenCalled(); - expect(addAuditLog).toHaveBeenCalled(); - }); - - it('should return user details with correct structure', async () => { - const res = await Call(loginApi.default, { - body: { - username: 'testuser', - password: 'testpassword', - code: '123456' - } + expect(res.code).toBe(500); }); - expect(res.code).toBe(200); - expect(res.data.user).toBeDefined(); - expect(res.data.user.team).toBeDefined(); - expect(res.data.user.team.teamId).toBe(String(testTeam._id)); - expect(res.data.user.team.tmbId).toBe(String(testTmb._id)); + it('should reject code as object with MongoDB operator', async () => { + const res = await Call(loginApi.default, { + body: { + username: 'testuser', + password: 'testpassword', + code: { $regex: '.*' }, + language: 'zh-CN' + } + }); + + expect(res.code).toBe(500); + }); + + it('should reject all fields as injection objects simultaneously', async () => { + const res = await Call(loginApi.default, { + body: { + username: { $ne: '' }, + password: { $ne: '' }, + code: { $ne: '' }, + language: 'zh-CN' + } + }); + + expect(res.code).toBe(500); + }); + + it('should reject password as non-string types (array, number)', async () => { + const arrayRes = await Call(loginApi.default, { + body: { + username: 'testuser', + password: ['testpassword'], + code: '123456', + language: 'zh-CN' + } + }); + expect(arrayRes.code).toBe(500); + + const numberRes = await Call(loginApi.default, { + body: { + username: 'testuser', + password: 12345, + code: '123456', + language: 'zh-CN' + } + }); + expect(numberRes.code).toBe(500); + }); }); }); diff --git a/projects/app/test/api/support/user/account/loginout.test.ts b/projects/app/test/api/support/user/account/loginout.test.ts new file mode 100644 index 0000000000..d86fd28cfe --- /dev/null +++ b/projects/app/test/api/support/user/account/loginout.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import * as loginoutApi from '@/pages/api/support/user/account/loginout'; +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 { Call } from '@test/utils/request'; + +describe('loginout API', () => { + let testUser: any; + let testTeam: any; + let testTmb: any; + + beforeEach(async () => { + testUser = await MongoUser.create({ + username: 'testuser', + password: 'testpassword', + 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(); + }); + + it('should logout successfully with valid auth', async () => { + const res = await Call(loginoutApi.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); + }); + + it('should succeed even when unauthenticated (auth errors are caught)', async () => { + // loginout catches auth errors and always calls clearCookie + const res = await Call(loginoutApi.default, {}); + + expect(res.code).toBe(200); + }); +}); diff --git a/projects/app/test/api/support/user/account/preLogin.test.ts b/projects/app/test/api/support/user/account/preLogin.test.ts new file mode 100644 index 0000000000..1a914c29e2 --- /dev/null +++ b/projects/app/test/api/support/user/account/preLogin.test.ts @@ -0,0 +1,100 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import * as preLoginApi from '@/pages/api/support/user/account/preLogin'; +import { MongoUserAuth } from '@fastgpt/service/support/user/auth/schema'; +import { UserAuthTypeEnum } from '@fastgpt/global/support/user/auth/constants'; +import { Call } from '@test/utils/request'; + +describe('preLogin API', () => { + beforeEach(async () => { + await MongoUserAuth.deleteMany({}); + }); + + it('should return a 6-char verification code for valid username', async () => { + const res = await Call(preLoginApi.default, { + query: { username: 'testuser' } + }); + + expect(res.code).toBe(200); + expect(res.data).toBeDefined(); + expect(typeof res.data.code).toBe('string'); + expect(res.data.code.length).toBe(6); + }); + + it('should store the verification code in database', async () => { + const res = await Call(preLoginApi.default, { + query: { username: 'testuser' } + }); + + expect(res.code).toBe(200); + const record = await MongoUserAuth.findOne({ + key: 'testuser', + type: UserAuthTypeEnum.login + }); + expect(record).toBeDefined(); + expect(record?.code).toBe(res.data.code); + }); + + it('should generate different codes for different usernames', async () => { + const res1 = await Call(preLoginApi.default, { + query: { username: 'user1' } + }); + const res2 = await Call(preLoginApi.default, { + query: { username: 'user2' } + }); + + expect(res1.code).toBe(200); + expect(res2.code).toBe(200); + + const record1 = await MongoUserAuth.findOne({ key: 'user1', type: UserAuthTypeEnum.login }); + const record2 = await MongoUserAuth.findOne({ key: 'user2', type: UserAuthTypeEnum.login }); + expect(record1?.key).toBe('user1'); + expect(record2?.key).toBe('user2'); + }); + + it('should overwrite previous code for the same username', async () => { + await Call(preLoginApi.default, { query: { username: 'testuser' } }); + const res2 = await Call(preLoginApi.default, { query: { username: 'testuser' } }); + + const records = await MongoUserAuth.find({ + key: 'testuser', + type: UserAuthTypeEnum.login + }); + // upsert: only one record per key+type + expect(records.length).toBe(1); + expect(records[0].code).toBe(res2.data.code); + }); + + it('should set code expiredTime about 30 seconds from now', async () => { + const before = new Date(); + const res = await Call(preLoginApi.default, { + query: { username: 'testuser' } + }); + const after = new Date(); + + expect(res.code).toBe(200); + const record = await MongoUserAuth.findOne({ key: 'testuser', type: UserAuthTypeEnum.login }); + expect(record?.expiredTime).toBeDefined(); + const expiredTime = new Date(record!.expiredTime!).getTime(); + // Should expire ~30 seconds from now (allow ±2s for test execution) + expect(expiredTime).toBeGreaterThanOrEqual(before.getTime() + 28000); + expect(expiredTime).toBeLessThanOrEqual(after.getTime() + 32000); + }); + + it('should reject when username is missing', async () => { + const res = await Call(preLoginApi.default, { + query: {} + }); + + expect(res.code).toBe(500); + }); + + it('should handle root username', async () => { + const res = await Call(preLoginApi.default, { + query: { username: 'root' } + }); + + expect(res.code).toBe(200); + expect(res.data.code).toBeDefined(); + expect(res.data.code.length).toBe(6); + }); +}); diff --git a/projects/app/test/api/support/user/account/resetExpiredPsw.test.ts b/projects/app/test/api/support/user/account/resetExpiredPsw.test.ts new file mode 100644 index 0000000000..3e7a9ce033 --- /dev/null +++ b/projects/app/test/api/support/user/account/resetExpiredPsw.test.ts @@ -0,0 +1,185 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import * as resetExpiredPswApi 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'; + +describe('resetExpiredPsw API', () => { + let testUser: any; + let testTeam: any; + let testTmb: any; + const originalEnv = process.env.PASSWORD_EXPIRED_MONTH; + + 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(() => { + process.env.PASSWORD_EXPIRED_MONTH = originalEnv; + }); + + it('should successfully reset password when expired', async () => { + process.env.PASSWORD_EXPIRED_MONTH = '1'; + + // 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 () => { + delete process.env.PASSWORD_EXPIRED_MONTH; + + 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 () => { + process.env.PASSWORD_EXPIRED_MONTH = '3'; + + // 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 () => { + process.env.PASSWORD_EXPIRED_MONTH = '1'; + + const res = await Call(resetExpiredPswApi.default, { + body: {}, + auth: { + userId: String(testUser._id), + teamId: String(testTeam._id), + tmbId: String(testTmb._id), + isRoot: false, + sessionId: 'session123' + } as any + }); + + expect(res.code).toBe(500); + }); + + it('should reject when user is not found', async () => { + process.env.PASSWORD_EXPIRED_MONTH = '1'; + + 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 res = await Call(resetExpiredPswApi.default, { + body: { newPsw: 'newhashedpassword' } + }); + + expect(res.code).toBe(500); + }); + + it('should reject newPsw as non-string (injection guard)', async () => { + process.env.PASSWORD_EXPIRED_MONTH = '1'; + + const twoMonthsAgo = new Date(); + twoMonthsAgo.setMonth(twoMonthsAgo.getMonth() - 2); + await MongoUser.findByIdAndUpdate(testUser._id, { + passwordUpdateTime: twoMonthsAgo + }); + + const res = await Call(resetExpiredPswApi.default, { + body: { newPsw: { $ne: '' } }, + auth: { + userId: String(testUser._id), + teamId: String(testTeam._id), + tmbId: String(testTmb._id), + isRoot: false, + sessionId: 'session123' + } as any + }); + + expect(res.code).toBe(500); + }); +}); diff --git a/projects/app/test/api/support/user/account/tokenLogin.test.ts b/projects/app/test/api/support/user/account/tokenLogin.test.ts new file mode 100644 index 0000000000..221dbe6c0c --- /dev/null +++ b/projects/app/test/api/support/user/account/tokenLogin.test.ts @@ -0,0 +1,125 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import * as tokenLoginApi from '@/pages/api/support/user/account/tokenLogin'; +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 { pushTrack } from '@fastgpt/service/common/middle/tracks/utils'; +import { initTeamFreePlan } from '@fastgpt/service/support/wallet/sub/utils'; +import { Call } from '@test/utils/request'; + +describe('tokenLogin API', () => { + let testUser: any; + let testTeam: any; + let testTmb: any; + + beforeEach(async () => { + testUser = await MongoUser.create({ + username: 'testuser', + password: 'testpassword', + 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' + }); + await MongoUser.findByIdAndUpdate(testUser._id, { + lastLoginTmbId: testTmb._id + }); + vi.clearAllMocks(); + }); + + it('should return user detail on valid token', async () => { + const res = await Call(tokenLoginApi.default, { + auth: { + userId: String(testUser._id), + teamId: String(testTeam._id), + tmbId: String(testTmb._id), + isRoot: false, + sessionId: 'session123' + } as any + }); + + expect(res.code).toBe(200); + expect(res.data).toBeDefined(); + expect(res.data.team).toBeDefined(); + expect(res.data.team.teamId).toBe(String(testTeam._id)); + expect(res.data.team.tmbId).toBe(String(testTmb._id)); + }); + + it('should call pushTrack.dailyUserActive', async () => { + await Call(tokenLoginApi.default, { + auth: { + userId: String(testUser._id), + teamId: String(testTeam._id), + tmbId: String(testTmb._id), + isRoot: false, + sessionId: 'session123' + } as any + }); + + expect(pushTrack.dailyUserActive).toHaveBeenCalledWith({ + uid: String(testUser._id), + teamId: String(testTeam._id), + tmbId: String(testTmb._id) + }); + }); + + it('should mask openaiAccount key but keep baseUrl', async () => { + await MongoTeamMember.findByIdAndUpdate(testTmb._id, { + openaiAccount: { key: 'sk-secret-key', baseUrl: 'https://api.openai.com' } + }); + + const res = await Call(tokenLoginApi.default, { + auth: { + userId: String(testUser._id), + teamId: String(testTeam._id), + tmbId: String(testTmb._id), + isRoot: false, + sessionId: 'session123' + } as any + }); + + expect(res.code).toBe(200); + if (res.data.team.openaiAccount) { + expect(res.data.team.openaiAccount.key).toBe(''); + expect(res.data.team.openaiAccount.baseUrl).toBe('https://api.openai.com'); + } + }); + + it('should mask all values in externalWorkflowVariables', async () => { + await MongoTeamMember.findByIdAndUpdate(testTmb._id, { + externalWorkflowVariables: { SECRET: 'top-secret', API_KEY: 'sk-123' } + }); + + const res = await Call(tokenLoginApi.default, { + auth: { + userId: String(testUser._id), + teamId: String(testTeam._id), + tmbId: String(testTmb._id), + isRoot: false, + sessionId: 'session123' + } as any + }); + + expect(res.code).toBe(200); + if (res.data.team.externalWorkflowVariables) { + Object.values(res.data.team.externalWorkflowVariables).forEach((val) => { + expect(val).toBe(''); + }); + } + }); + + it('should reject request without authentication', async () => { + const res = await Call(tokenLoginApi.default, {}); + + expect(res.code).toBe(500); + }); +}); diff --git a/projects/app/test/api/support/user/account/update.test.ts b/projects/app/test/api/support/user/account/update.test.ts new file mode 100644 index 0000000000..c49f77901c --- /dev/null +++ b/projects/app/test/api/support/user/account/update.test.ts @@ -0,0 +1,122 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import * as updateApi from '@/pages/api/support/user/account/update'; +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 { Call } from '@test/utils/request'; + +describe('update (user account) API', () => { + let testUser: any; + let testTeam: any; + let testTmb: any; + + beforeEach(async () => { + testUser = await MongoUser.create({ + username: 'testuser', + password: 'testpassword', + status: UserStatusEnum.active, + language: 'zh-CN', + timezone: 'Asia/Shanghai' + }); + 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 language successfully', async () => { + const res = await Call(updateApi.default, { + body: { language: 'en' }, + auth: makeAuth(testUser, testTeam, testTmb) as any + }); + + expect(res.code).toBe(200); + const updatedUser = await MongoUser.findById(testUser._id); + expect(updatedUser?.language).toBe('en'); + }); + + it('should update timezone successfully', async () => { + const res = await Call(updateApi.default, { + body: { timezone: 'America/New_York' }, + auth: makeAuth(testUser, testTeam, testTmb) as any + }); + + expect(res.code).toBe(200); + const updatedUser = await MongoUser.findById(testUser._id); + expect(updatedUser?.timezone).toBe('America/New_York'); + }); + + it('should update both language and timezone simultaneously', async () => { + const res = await Call(updateApi.default, { + body: { language: 'zh-Hant', timezone: 'Asia/Tokyo' }, + auth: makeAuth(testUser, testTeam, testTmb) as any + }); + + expect(res.code).toBe(200); + const updatedUser = await MongoUser.findById(testUser._id); + expect(updatedUser?.language).toBe('zh-Hant'); + expect(updatedUser?.timezone).toBe('Asia/Tokyo'); + }); + + it('should update avatar on team member', async () => { + const newAvatar = '/avatar/test-avatar.png'; + + const res = await Call(updateApi.default, { + body: { avatar: newAvatar }, + auth: makeAuth(testUser, testTeam, testTmb) as any + }); + + expect(res.code).toBe(200); + const updatedTmb = await MongoTeamMember.findById(testTmb._id); + expect(updatedTmb?.avatar).toBe(newAvatar); + }); + + it('should return empty object on success', async () => { + const res = await Call(updateApi.default, { + body: { language: 'en' }, + auth: makeAuth(testUser, testTeam, testTmb) as any + }); + + expect(res.code).toBe(200); + expect(res.data).toEqual({}); + }); + + it('should handle empty body without error', async () => { + const res = await Call(updateApi.default, { + body: {}, + auth: makeAuth(testUser, testTeam, testTmb) as any + }); + + expect(res.code).toBe(200); + // Nothing should change + const updatedUser = await MongoUser.findById(testUser._id); + expect(updatedUser?.language).toBe('zh-CN'); + expect(updatedUser?.timezone).toBe('Asia/Shanghai'); + }); + + it('should reject request without authentication', async () => { + const res = await Call(updateApi.default, { + body: { language: 'en' } + }); + + expect(res.code).toBe(500); + }); +}); diff --git a/projects/app/test/api/support/user/account/updatePasswordByOld.test.ts b/projects/app/test/api/support/user/account/updatePasswordByOld.test.ts new file mode 100644 index 0000000000..07e5f289af --- /dev/null +++ b/projects/app/test/api/support/user/account/updatePasswordByOld.test.ts @@ -0,0 +1,139 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import * as updatePasswordApi from '@/pages/api/support/user/account/updatePasswordByOld'; +import { MongoUser } from '@fastgpt/service/support/user/schema'; +import { MongoTeam } from '@fastgpt/service/support/user/team/teamSchema'; +import { MongoTeamMember } from '@fastgpt/service/support/user/team/teamMemberSchema'; +import { UserStatusEnum } from '@fastgpt/global/support/user/constant'; +import { initTeamFreePlan } from '@fastgpt/service/support/wallet/sub/utils'; +import type { UpdatePasswordByOldBodyType } from '@fastgpt/global/openapi/support/user/account/password/api'; +import { Call } from '@test/utils/request'; + +describe('updatePasswordByOld API', () => { + let testUser: any; + let testTeam: any; + let testTmb: any; + + beforeEach(async () => { + testUser = await MongoUser.create({ + username: 'testuser', + password: 'oldhashpassword', + status: UserStatusEnum.active + }); + testTeam = await MongoTeam.create({ + name: 'Test Team', + ownerId: testUser._id + }); + await initTeamFreePlan({ teamId: String(testTeam._id) }); + testTmb = await MongoTeamMember.create({ + teamId: testTeam._id, + userId: testUser._id, + status: 'active', + role: 'owner' + }); + vi.clearAllMocks(); + }); + + const makeAuth = (user: any, team: any, tmb: any) => ({ + userId: String(user._id), + teamId: String(team._id), + tmbId: String(tmb._id), + isRoot: false, + sessionId: 'session123' + }); + + it('should update password successfully with correct old password', async () => { + const res = await Call(updatePasswordApi.default, { + body: { oldPsw: 'oldhashpassword', newPsw: 'newhashpassword' }, + auth: makeAuth(testUser, testTeam, testTmb) as any + }); + + expect(res.code).toBe(200); + + const updatedUser = await MongoUser.findById(testUser._id).select('+password'); + expect(updatedUser?.password).toBeDefined(); + expect(updatedUser?.passwordUpdateTime).toBeDefined(); + }); + + it('should reject when old password is incorrect', async () => { + const res = await Call(updatePasswordApi.default, { + body: { oldPsw: 'wrongpassword', newPsw: 'newhashpassword' }, + auth: makeAuth(testUser, testTeam, testTmb) as any + }); + + expect(res.code).toBe(500); + + // Password should not change + const user = await MongoUser.findById(testUser._id).select('+passwordUpdateTime'); + expect(user?.passwordUpdateTime).toBeUndefined(); // we didn't set it initially + }); + + it('should reject when old and new passwords are the same', async () => { + const res = await Call(updatePasswordApi.default, { + body: { oldPsw: 'oldhashpassword', newPsw: 'oldhashpassword' }, + auth: makeAuth(testUser, testTeam, testTmb) as any + }); + + expect(res.code).toBe(500); + }); + + it('should reject when oldPsw is missing', async () => { + const res = await Call(updatePasswordApi.default, { + body: { newPsw: 'newhashpassword' }, + auth: makeAuth(testUser, testTeam, testTmb) as any + }); + + expect(res.code).toBe(500); + }); + + it('should reject when newPsw is missing', async () => { + const res = await Call(updatePasswordApi.default, { + body: { oldPsw: 'oldhashpassword' }, + auth: makeAuth(testUser, testTeam, testTmb) as any + }); + + expect(res.code).toBe(500); + }); + + it('should reject request without authentication', async () => { + const res = await Call(updatePasswordApi.default, { + body: { oldPsw: 'oldhashpassword', newPsw: 'newhashpassword' } + }); + + expect(res.code).toBe(500); + }); + + // ===== Security: NoSQL injection prevention (GHSA-jxvr-h2vx-p73r Step 3) ===== + + it('should reject oldPsw as MongoDB operator object ($ne injection)', async () => { + // GHSA-jxvr-h2vx-p73r Step 3: oldPsw: {"$ne": ""} bypasses old password check + const res = await Call(updatePasswordApi.default, { + body: { oldPsw: { $ne: '' }, newPsw: 'newhashpassword' }, + auth: makeAuth(testUser, testTeam, testTmb) as any + }); + + // Zod z.string() must reject object-type oldPsw + expect(res.code).toBe(500); + + // Password must NOT be changed + const user = await MongoUser.findById(testUser._id).select('+passwordUpdateTime'); + expect(user?.passwordUpdateTime).toBeUndefined(); + }); + + it('should reject oldPsw with $regex injection', async () => { + const res = await Call(updatePasswordApi.default, { + body: { oldPsw: { $regex: '.*' }, newPsw: 'newhashpassword' }, + auth: makeAuth(testUser, testTeam, testTmb) as any + }); + + expect(res.code).toBe(500); + }); + + it('should reject newPsw as non-string type', async () => { + const res = await Call(updatePasswordApi.default, { + body: { oldPsw: 'oldhashpassword', newPsw: { $ne: '' } }, + auth: makeAuth(testUser, testTeam, testTmb) as any + }); + + expect(res.code).toBe(500); + }); +});