Commit 4b88d6e2 by Archer Committed by GitHub

Account verification (#7452)

* Account verification (#7390)

* feat: add account verification service

* feat: login access account verification service

* fix(account-verification): remove googleAuthToken, username with prefix

* Account verification (#7418)

* Account verification (#7390)

* feat: add account verification service

* feat: login access account verification service

* fix(account-verification): remove googleAuthToken, username with prefix

* feat(account-verify): add tmp_datas verification, remove auth_code

* fix account-verification

* fix account-verification

* fix account-verification

* fix account-verification

* fix account-verification

* Refine account verification behavior

* fix(auth): support independent verification codes

* refactor: migrate frequency limits to redis

* refactor: centralize rate limit policies

* update version

* update doc

* doc

* perf: snadbox

* test: sync agent reminder assertions

* fix: reject unknown password reset accounts

* fix(account-verify): ui (#7458)

* fix(account-verify): ui

* fix(account-verify): fix off email register

* submodule

* fix(account-verify): refine send code validation

---------

Co-authored-by: light5980 <148292178+shortlight5980@users.noreply.github.com>
parent f05c0fff
# Rate Limit 模块拆分设计
## 背景
当前限流由 `packages/service/common/system/frequencyLimit/redisFixedWindow.ts` 暴露
`group + id` 通用接口。虽然公共函数补充了统一前缀,但业务调用方仍然负责拼装
`id`,导致 Redis key 结构、动作隔离、主体口径和故障策略泄漏到 API 层。
本次改造将限流基础能力收口到 `packages/service/common/rateLimit`,并通过场景接口
向业务层暴露语义函数。业务层只传账号、团队、成员或 IP 等业务标识,不维护 key。
## 模块边界
### DAL
`packages/dal/redis/caches/rateLimit.ts` 提供 Redis 限流 Cache:
- 校验额度、窗口和增量。
- 原子递增计数并设置固定窗口 TTL。
- 返回当前计数、剩余额度和窗口重置时间。
- 不识别业务场景,不处理业务错误。
固定窗口仍是 Redis adapter 的内部算法,因此 adapter 的 `consumeFixedWindow` 命名保留。
### Service Rate Limit Core
`packages/service/common/rateLimit` 负责:
- 使用 `rate-limit` 作为统一逻辑 key namespace。
- 使用 `createRedisLogicalKey` 编码所有 key segment。
- 统一执行 Cache 调用和故障策略。
- 不向业务层暴露任意字符串 key。
- 不依赖 HTTP request/response、NextAPI 或中间件启停配置。
### 场景接口
`packages/service/common/rateLimit/interface` 按场景维护 key 和限流策略:
- `ip.ts`
- `accountVerification.ts`
- `enterpriseAuth.ts`
- `outLink.ts`
- `upload.ts`
- `member.ts`
- `team.ts`
每个文件使用 `type.ts` 中的统一定义创建接口,保证场景、动作、主体和执行结果的
结构一致。API 和业务 Service 只能调用这些语义接口。
`ip.ts` 只导出通用 IP 限流接口,接收接口标识、已解析 IP、额度和窗口。真实 IP
解析、环境开关、强制启用和 HTTP 429 响应继续由
`packages/service/common/middle/reqFrequencyLimit.ts` 封装,不能下沉到 rateLimit 模块。
## Key 规则
逻辑 key:
```text
rate-limit:<scene>:<policy/action>:<subject-type>:<subject-id...>
```
DAL 写入 Redis 后的物理 key:
```text
fastgpt:rate-limit:<scene>:<policy/action>:<subject-type>:<subject-id...>
```
示例:
```text
rate-limit:ip:wechat-login-qrcode:ip:192.0.2.1
rate-limit:account-verification:captcha-create:register:account:user@example.com
rate-limit:enterprise-auth:start:team:team-id
rate-limit:out-link:request:out-link:out-link-id:uid:visitor-id
rate-limit:upload:presign:identity:member-id
rate-limit:member:export-dataset:member:member-id
```
动作层不能省略。账号验证的生成、消费和不同材料必须使用独立计数窗口。
## 接口约束
`type.ts` 定义:
- `RateLimitScene`:允许的一级场景。
- `RateLimitFailureMode`:Redis 故障时放行或拒绝。
- `RateLimitInterfaceDefinition<TInput>`:场景接口定义。
- `RateLimitInterface<TInput>`:统一的 `consume`、`check` 和 `assert` 执行接口。
- `defineRateLimitInterface`:创建强类型场景接口,集中生成 key。
`check` 原子增加计数并返回是否允许;`assert` 使用同一次消费结果判断并抛出定义的
错误。禁止把“增加统计”和“读取校验”拆成两次 Redis 操作,避免并发竞态。
成员限流的 policy、额度和窗口由 `member.ts` 集中维护,业务层只传 `policy` 和
`memberId`,不能在路由中重复声明额度。
| Member policy | 额度 | 窗口 |
| --- | ---: | ---: |
| `get-llm-request-record` | 1 | 1 秒 |
| `chat-agent-helper-completions` | 10 | 60 秒 |
| `transcriptions` | 1 | 1 秒 |
| `redeem-coupon` | 1 | 1 秒 |
| `refund-bill` | 1 | 1 秒 |
| `create-bill` | 1 | 1 秒 |
| `export-members` | 1 | 60 秒 |
| `check-pay-result` | 60 | 60 秒 |
| `export-usage` | 1 | 60 秒 |
| `export-dataset` | 1 | 60 秒 |
| `export-chat-logs` | 1 | 60 秒 |
`search-test` 不创建独立 policy,复用团队 `chat-qpm`,与聊天请求共同消费团队套餐 QPM。
## 故障策略
故障策略在场景接口定义时固定,调用方不能临时选择:
- 迁移自历史 Mongo 限流的普通业务保持 fail-open。
- 明确要求保护认证或成本资源的场景可以定义为 fail-closed。
- 非法限额、窗口或增量属于配置错误,始终抛出。
本次迁移先保持各调用方现有行为,不借重构改变产品策略。
## 迁移映射
| 当前调用 | 新接口 |
| --- | --- |
| `useIPFrequencyLimit({ id })` | 中间件保持不变,内部改用通用 IP rateLimit 接口 |
| 账号验证 `group + action + scene + account` | 账号验证语义接口 |
| 企业认证手写 group/id | 企业认证 start/verifyAmount 接口 |
| 上传手写 member id | 按身份限制每分钟签发上传 URL 次数 |
| 外链 `_id + ip` | 按 `outLinkId + outLinkUid` 限制外链 QPM |
| 成员接口把 action 写入 id | 强类型 member policy 接口 |
## TODO
- [x] 重命名 DAL Cache 文件、类型和导出。
- [x] 新建 Service rateLimit core、type 和 interface 目录。
- [x] 迁移主仓库和 Pro 调用方。
- [x] 删除旧 frequencyLimit 通用实现和无效类型。
- [x] 更新 Redis mock、单元测试和 key 断言。
- [x] 运行 DAL、Service、App 和 Pro 相关测试。
......@@ -96,11 +96,11 @@ workspace claim 重建 OpenSandbox provider。这样旧 client 既不能覆盖 r
检测到该 checkpoint 且缺少 workspace claim 时,按旧命名规则恢复 claim,并以同阶段 CAS 原子补写
storage,再继续安装归档。`claimed` 阶段仍使用 generation `0` 的新命名规则。
### 镜像环境变量兼容
### 镜像环境变量
`AGENT_SANDBOX_OPENSANDBOX_IMAGE` 是新配置入口;未配置时回退到旧的
`AGENT_SANDBOX_OPENSANDBOX_IMAGE_REPO` 和 `AGENT_SANDBOX_OPENSANDBOX_IMAGE_TAG`。
新变量优先,旧 tag 缺失时沿用 `latest`。只有新旧入口都没有可用 repository 时才报告缺失。
`AGENT_SANDBOX_OPENSANDBOX_IMAGE` 是 OpenSandbox 唯一的运行态镜像配置入口,必须配置完整镜像地址(包括
tag)。`AGENT_SANDBOX_OPENSANDBOX_IMAGE_REPO` 和 `AGENT_SANDBOX_OPENSANDBOX_IMAGE_TAG` 已移除,不再作为
兼容回退;启用 `opensandbox` 时缺少完整镜像变量必须阻止服务启动。
### volume 删除边界
......
......@@ -166,22 +166,22 @@ These variables are mainly validated by `packages/service/env.ts` and apply to `
### Security
| Variable | Default | Description |
| ----------------------------- | ------- | --------------------------------------------------------------------------------------------------- |
| `USE_IP_LIMIT` | `false` | Whether IP rate limiting is enabled for selected APIs. |
| `CHECK_INTERNAL_IP` | `false` | Whether internal IP checks are enabled to reduce SSRF risk. |
| `AUTH_COOKIE_SECURE` | `false` | Whether login cookies use the `Secure` attribute. Enable only when the site is HTTPS-only. |
| `TRUSTED_PROXY_ENABLE` | `false` | Whether trusted reverse proxy client IP validation is enabled. Disabled keeps legacy behavior. |
| `TRUSTED_PROXY_IPS` | Empty | Trusted reverse proxy IP/CIDR list, separated by commas or whitespace. |
| `PASSWORD_LOGIN_LOCK_SECONDS` | `120` | Lock duration after failed password login attempts, in seconds. |
| `MAX_LOGIN_SESSION` | `10` | Maximum login clients per account. |
| `ALLOWED_ORIGINS` | Empty | Allowed CORS origins. Use commas to separate multiple origins. Empty allows all origins by default. |
| `MULTIPLE_DATA_TO_BASE64` | `false` | Whether images are forced into base64 before being sent to models. |
| `DISABLE_CACHE` | `false` | Whether system cache hits are disabled, mainly for debugging. |
| `HTTP_PROXY` | Empty | Outbound HTTP proxy for Node and workers. |
| `HTTPS_PROXY` | Empty | Outbound HTTPS proxy for Node and workers. |
| `NO_PROXY` | Empty | Address list that bypasses proxies. |
| `ALL_PROXY` | Empty | General outbound proxy. |
| Variable | Default | Description |
| ----------------------------------- | ------- | --------------------------------------------------------------------------------------------------- |
| `USE_IP_LIMIT` | `false` | Whether IP rate limiting is enabled for selected APIs. |
| `CHECK_INTERNAL_IP` | `false` | Whether internal IP checks are enabled to reduce SSRF risk. |
| `AUTH_COOKIE_SECURE` | `false` | Whether login cookies use the `Secure` attribute. Enable only when the site is HTTPS-only. |
| `TRUSTED_PROXY_ENABLE` | `false` | Whether trusted reverse proxy client IP validation is enabled. Disabled keeps legacy behavior. |
| `TRUSTED_PROXY_IPS` | Empty | Trusted reverse proxy IP/CIDR list, separated by commas or whitespace. |
| `PASSWORD_LOGIN_MINUTE_LIMIT_COUNT` | `10` | Maximum password login requests per account per minute. |
| `MAX_LOGIN_SESSION` | `10` | Maximum login clients per account. |
| `ALLOWED_ORIGINS` | Empty | Allowed CORS origins. Use commas to separate multiple origins. Empty allows all origins by default. |
| `MULTIPLE_DATA_TO_BASE64` | `false` | Whether images are forced into base64 before being sent to models. |
| `DISABLE_CACHE` | `false` | Whether system cache hits are disabled, mainly for debugging. |
| `HTTP_PROXY` | Empty | Outbound HTTP proxy for Node and workers. |
| `HTTPS_PROXY` | Empty | Outbound HTTPS proxy for Node and workers. |
| `NO_PROXY` | Empty | Address list that bypasses proxies. |
| `ALL_PROXY` | Empty | General outbound proxy. |
### Feature Flags and Limits
......
......@@ -166,22 +166,22 @@ description: projects/app、projects/code-sandbox 与 pro/admin 环境变量说
### 安全配置
| 变量 | 默认值 | 说明 |
| ----------------------------- | ------- | -------------------------------------------------------------- |
| `USE_IP_LIMIT` | `false` | 是否启用部分接口的 IP 限流。 |
| `CHECK_INTERNAL_IP` | `false` | 是否启用内网 IP 检查,用于降低 SSRF 风险。 |
| `AUTH_COOKIE_SECURE` | `false` | 是否为登录 Cookie 添加 `Secure` 属性;仅在全站 HTTPS 时启用。 |
| `TRUSTED_PROXY_ENABLE` | `false` | 是否启用可信反向代理客户端 IP 校验;关闭时兼容旧逻辑。 |
| `TRUSTED_PROXY_IPS` | 空 | 可信反向代理 IP/CIDR 列表,逗号或空白分隔。 |
| `PASSWORD_LOGIN_LOCK_SECONDS` | `120` | 密码登录错误后的锁定时长,单位秒。 |
| `MAX_LOGIN_SESSION` | `10` | 单账号最大登录客户端数量。 |
| `ALLOWED_ORIGINS` | 空 | 允许跨域来源,多个来源使用英文逗号分隔;为空默认允许所有跨域。 |
| `MULTIPLE_DATA_TO_BASE64` | `false` | 是否强制将图片转成 base64 传递给模型。 |
| `DISABLE_CACHE` | `false` | 是否关闭系统缓存命中,主要用于调试。 |
| `HTTP_PROXY` | 空 | Node/worker 出站 HTTP 代理。 |
| `HTTPS_PROXY` | 空 | Node/worker 出站 HTTPS 代理。 |
| `NO_PROXY` | 空 | 不走代理的地址列表。 |
| `ALL_PROXY` | 空 | 通用出站代理。 |
| 变量 | 默认值 | 说明 |
| ----------------------------------- | ------- | -------------------------------------------------------------- |
| `USE_IP_LIMIT` | `false` | 是否启用部分接口的 IP 限流。 |
| `CHECK_INTERNAL_IP` | `false` | 是否启用内网 IP 检查,用于降低 SSRF 风险。 |
| `AUTH_COOKIE_SECURE` | `false` | 是否为登录 Cookie 添加 `Secure` 属性;仅在全站 HTTPS 时启用。 |
| `TRUSTED_PROXY_ENABLE` | `false` | 是否启用可信反向代理客户端 IP 校验;关闭时兼容旧逻辑。 |
| `TRUSTED_PROXY_IPS` | 空 | 可信反向代理 IP/CIDR 列表,逗号或空白分隔。 |
| `PASSWORD_LOGIN_MINUTE_LIMIT_COUNT` | `10` | 单账号每分钟允许的密码登录请求次数。 |
| `MAX_LOGIN_SESSION` | `10` | 单账号最大登录客户端数量。 |
| `ALLOWED_ORIGINS` | 空 | 允许跨域来源,多个来源使用英文逗号分隔;为空默认允许所有跨域。 |
| `MULTIPLE_DATA_TO_BASE64` | `false` | 是否强制将图片转成 base64 传递给模型。 |
| `DISABLE_CACHE` | `false` | 是否关闭系统缓存命中,主要用于调试。 |
| `HTTP_PROXY` | 空 | Node/worker 出站 HTTP 代理。 |
| `HTTPS_PROXY` | 空 | Node/worker 出站 HTTPS 代理。 |
| `NO_PROXY` | 空 | 不走代理的地址列表。 |
| `ALL_PROXY` | 空 | 通用出站代理。 |
### 功能开关与限制
......
......@@ -5,22 +5,39 @@ description: 'FastGPT V4.16.0-beta1 更新说明'
## 📦 升级指南
### 1. 更新 Agent Sandbox 配置
### 1. 更新 Agent-sandbox-proxy 环境变量(可选)
启用 Agent Sandbox 的环境必须在 `fastgpt-app` 和 `fastgpt-pro` 中新增浏览器可访问的预览代理地址:
4.16.0 需要依赖 proxy 进行静态资源代理访问,如果网关支持 ws 和 http 在同一个端口,则可以只开放一个端口。如果不支持,可以通过设置 `PREVIEW_PORT` 来设置 http 访问端口。
```dotenv
# 浏览器访问 Sandbox 文件预览的 HTTP(S) 地址
AGENT_SANDBOX_PREVIEW_PROXY_URL=https://sandbox-proxy.example.com
# ws和http服务的端口
PORT=1006
# http服务的端口,可以覆盖 PORT
PREVIEW_PORT=1007
```
该地址必须以 `http://` 或 `https://` 开头。默认单端口部署时,它可以与 `AGENT_SANDBOX_PROXY_URL` 指向同一域名和端口,但协议分别使用 HTTP(S) 和 WebSocket(S)。
访问地址以 `http://` 或 `https://` 开头。单端口部署时,它可以与 `AGENT_SANDBOX_PROXY_URL` 指向同一域名和端口,但协议分别使用 HTTP(S) 和 WebSocket(S)。强烈建议配置的域名与 FastGPT 主站使用不同的 origin。同源部署会让这些脚本进入主站的同源安全边界,可能访问主站凭证或接口。系统当前不会强制检查 origin 是否隔离。
强烈建议预览地址与 FastGPT 主站使用不同的 origin。Sandbox HTML 可能包含用户生成的脚本;同源部署会让这些脚本进入主站的同源安全边界,可能访问主站凭证或接口。系统当前不会强制检查 origin 是否隔离。
可以通过访问: `https://{{host}}/health` 来确认是否可访问。
预览 URL 是短期只读 bearer capability,不只授权 URL 中的单个文件。获得链接的人可以在有效期内修改 URL 路径,读取同一 Sandbox Workspace 中的其他文件,请勿将链接分享给不应访问该 Workspace 的用户。
### 2. 更新 fastgpt-app 环境变量(启用沙盒的需更新)
在 `fastgpt-app` 和 `fastgpt-pro` 同时修改变量。
1. 增加环境变量
```dotenv
# 浏览器访问 Sandbox 文件预览的 HTTP(S) 地址,这个地址从第一步取。
AGENT_SANDBOX_PREVIEW_PROXY_URL=https://sandbox-proxy.example.com
# opensandbox 需配置,存储全前缀名(之前是配置在 volumn 镜像环境变量里)
VM_VOLUME_NAME_PREFIX=fastgpt-session
```
本版本还新增以下可选配置:
2. 弃用的沙盒环境变量
`AGENT_SANDBOX_DISK_MB`,E2B 相关变量。
3. 新增的可选的沙盒配置变量
| 变量 | 默认值 | 说明 |
| ------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------ |
......@@ -34,18 +51,16 @@ E2B Sandbox Provider 已移除。此前配置过 E2B 的环境需要切换为 `o
> FastGPT、`fastgpt-agent-sandbox-proxy` 和 `fastgpt-agent-sandbox` 的预览协议已同步变更。启用 Agent Sandbox 时必须使用本版本配套镜像,不支持新旧版本混合部署。
### 2. 更新 Agent-sandbox-proxy 环境变量
### 3. 镜像更新
4.16.0 需要依赖 proxy 进行静态资源代理访问,如果网关支持 ws 和 http 在同一个端口,则可以只开放一个端口。如果不支持,可以通过设置 `PREVIEW_PORT` 来设置 http 访问端口。
待补充……
```dotenv
# ws和http服务的端口
PORT=1006
# http服务的端口,可以覆盖 PORT
PREVIEW_PORT=1007
```
1. app
2. pro
3. agent-sandbox-volumn
4. agent-sandbox-proxy
### 3. 迁移 Agent Sandbox 数据
### 4. 迁移 Agent Sandbox 数据
本版本将 App Chat 的 Agent Sandbox 从“每个对话一个实例”调整为“同一 App、同一用户共享一个实例”。不同对话的文件仍分别保存在 `sessions/<chatId>` 目录中,已发布 Skill 则统一保存在共享的 `projects` 目录中。
......@@ -84,7 +99,7 @@ curl -X POST 'https://你的域名/api/admin/4160/initUserSandbox' \
请检查返回结果中的 `normalization.pendingCount`、`normalizationBlocked`、`failedCount`、`failures`、`skippedCount` 和 `skipped`。只有 `normalization.pendingCount` 和 `failedCount` 均为 `0`,且 `normalizationBlocked` 为 `false` 时,才表示所有未跳过的 Sandbox 迁移完成;`skipped` 中的 Legacy 记录会保留且不会迁移。
### 4. 迁移手动 HTTP 工具数据
### 5. 迁移 HTTP 工具数据
本版本将手动模式 HTTP 工具的数组参数改为标准 JSON Schema。升级前创建过手动 HTTP 工具的环境需要执行此迁移;OpenAPI 模式的 HTTP 工具无需迁移,脚本会自动跳过。
......@@ -108,6 +123,10 @@ curl -X POST 'https://你的域名/api/admin/4160/initHttpToolSchema' \
脚本会先按 HTTP 工具类型筛选应用,再根据这些应用的 `appId` 迁移对应的历史版本。仅 `apiSchemaStr` 不存在的手动模式会被处理,其他应用及 OpenAPI 模式不会修改。迁移按批次执行且可安全重试;返回结果中 `total.changedDocumentCount` 表示发现的待处理文档数,正式执行后可再次 dry-run,确认该值为 `0`。
### 6. 其他变更(可选)
1. 弃用 `PASSWORD_LOGIN_LOCK_SECONDS` 变量,改成 `PASSWORD_LOGIN_MINUTE_LIMIT_COUNT` , 用于控制每分钟登录频率控制。
## 🚀 新增内容
1. Agent Sandbox 改为 App 用户级实例,同一 App、同一用户的多个对话复用 Sandbox,并通过独立 session 目录隔离各对话文件。
......@@ -118,6 +137,7 @@ curl -X POST 'https://你的域名/api/admin/4160/initHttpToolSchema' \
6. 知识库数据支持自定义 `metadata`,可通过 API、CSV 或 Excel 模板导入 JSON 元数据;检索结果和备份导出会保留该字段。模板导入和备份导入均支持 `.csv` 和 `.xlsx` 文件,使用 `q`、`a`、`index`、`metadata` 表头;`q`、`a`、`metadata` 各一列,`index` 可多列且顺序任意。Excel 文件仅支持单个工作表且不能包含合并单元格,无法正确解析的 CSV 或 Excel 文件会提示文件格式异常。
7. 大文件分块上传。
8. 管理员配置系统工具密钥时,加密(兼容已配置的密钥)。
9. 技能列表空状态引导,以及选择技能时联动。
## ⚙️ 优化
......@@ -152,3 +172,5 @@ curl -X POST 'https://你的域名/api/admin/4160/initHttpToolSchema' \
4. 扩展工具 JSON Schema,支持更多数据类型。
5. 统一服务文件读取超时时间。
6. 优化系统工具多进程权限安全问题。
7. 重构登录与身份验证代码。
8. 重构限流模块。
......@@ -169,8 +169,8 @@
"content/plugin/model-presets.mdx": "2026-06-04T16:10:15+08:00",
"content/plugin/system-tool-development.en.mdx": "2026-07-02T11:54:55+08:00",
"content/plugin/system-tool-development.mdx": "2026-07-02T11:54:55+08:00",
"content/self-host/config/env.en.mdx": "2026-08-04T12:18:23+08:00",
"content/self-host/config/env.mdx": "2026-08-04T12:18:23+08:00",
"content/self-host/config/env.en.mdx": "2026-08-05T00:04:49+08:00",
"content/self-host/config/env.mdx": "2026-08-05T00:04:49+08:00",
"content/self-host/config/model/intro.en.mdx": "2026-06-04T16:10:15+08:00",
"content/self-host/config/model/intro.mdx": "2026-06-04T16:10:15+08:00",
"content/self-host/config/model/minimax.en.mdx": "2026-06-03T10:40:17+08:00",
......@@ -183,8 +183,8 @@
"content/self-host/config/remote-debug-suite.mdx": "2026-06-27T22:05:51+08:00",
"content/self-host/config/sandbox/common.en.mdx": "2026-07-30T15:22:52+08:00",
"content/self-host/config/sandbox/common.mdx": "2026-07-30T15:22:52+08:00",
"content/self-host/config/sandbox/opensandbox.en.mdx": "2026-08-04T12:18:23+08:00",
"content/self-host/config/sandbox/opensandbox.mdx": "2026-08-04T12:18:23+08:00",
"content/self-host/config/sandbox/opensandbox.en.mdx": "2026-08-05T18:25:06+08:00",
"content/self-host/config/sandbox/opensandbox.mdx": "2026-08-05T18:25:06+08:00",
"content/self-host/config/sandbox/sealosdevbox.en.mdx": "2026-07-30T15:22:52+08:00",
"content/self-host/config/sandbox/sealosdevbox.mdx": "2026-07-30T15:22:52+08:00",
"content/self-host/config/signoz.en.mdx": "2026-04-26T21:08:47+08:00",
......@@ -205,8 +205,8 @@
"content/self-host/custom-models/ollama.mdx": "2026-04-26T21:08:47+08:00",
"content/self-host/custom-models/xinference.en.mdx": "2026-04-26T21:08:47+08:00",
"content/self-host/custom-models/xinference.mdx": "2026-04-26T21:08:47+08:00",
"content/self-host/deploy/docker.en.mdx": "2026-08-03T18:37:56+08:00",
"content/self-host/deploy/docker.mdx": "2026-08-03T18:37:56+08:00",
"content/self-host/deploy/docker.en.mdx": "2026-08-05T18:25:06+08:00",
"content/self-host/deploy/docker.mdx": "2026-08-05T18:25:06+08:00",
"content/self-host/deploy/sealos.en.mdx": "2026-06-30T22:10:03+08:00",
"content/self-host/deploy/sealos.mdx": "2026-06-30T22:10:03+08:00",
"content/self-host/design/dataset.en.mdx": "2026-04-26T21:08:47+08:00",
......@@ -333,8 +333,8 @@
"content/self-host/upgrading/4-15/4155.mdx": "2026-07-30T11:22:58+08:00",
"content/self-host/upgrading/4-15/4156.en.mdx": "2026-07-31T17:46:31+08:00",
"content/self-host/upgrading/4-15/4156.mdx": "2026-07-31T17:46:31+08:00",
"content/self-host/upgrading/4-16/41601.en.mdx": "2026-08-04T22:08:29+08:00",
"content/self-host/upgrading/4-16/41601.mdx": "2026-08-04T22:08:29+08:00",
"content/self-host/upgrading/4-16/41601.en.mdx": "2026-08-05T19:17:13+08:00",
"content/self-host/upgrading/4-16/41601.mdx": "2026-08-05T19:17:13+08:00",
"content/self-host/upgrading/outdated/40.en.mdx": "2026-07-25T00:27:20+08:00",
"content/self-host/upgrading/outdated/40.mdx": "2026-07-25T00:27:20+08:00",
"content/self-host/upgrading/outdated/41.en.mdx": "2026-07-25T00:27:20+08:00",
......
......@@ -130,17 +130,24 @@ export class RedisCacheAdapter {
/** 原子递增固定窗口计数,并在同一事务中建立窗口 TTL 后返回窗口剩余秒数。 */
consumeFixedWindow = ({
key,
windowSeconds
windowSeconds,
increment = 1
}: {
key: RedisLogicalKey;
windowSeconds: number;
increment?: number;
}) => {
const operation = 'fixedWindow.consume';
const operation = 'rateLimit.consume';
const parsedWindowSeconds = parsePositiveInteger({
value: windowSeconds,
operation,
field: 'windowSeconds'
});
const parsedIncrement = parsePositiveInteger({
value: increment,
operation,
field: 'increment'
});
return this.operationExecutor.uncertainWrite({
operation,
......@@ -148,7 +155,7 @@ export class RedisCacheAdapter {
const physicalKey = toPhysicalRedisKey(key);
const result = await this.getCommandClient()
.multi()
.incr(physicalKey)
.incrby(physicalKey, parsedIncrement)
.expire(physicalKey, parsedWindowSeconds, 'NX')
.ttl(physicalKey)
.exec();
......@@ -170,7 +177,9 @@ export class RedisCacheAdapter {
return entry[1];
};
const currentCount = NonNegativeSafeIntegerSchema.safeParse(parseResult(result[0], 'INCR'));
const currentCount = NonNegativeSafeIntegerSchema.safeParse(
parseResult(result[0], 'INCRBY')
);
const expireResult = z
.union([z.literal(0), z.literal(1)])
.safeParse(parseResult(result[1], 'EXPIRE'));
......
......@@ -7,11 +7,8 @@ export type { DingtalkAccessTokenCacheOptions } from './dingtalkAccessToken';
export { SystemVersionCache, systemVersionCache } from './systemVersion';
export type { SystemVersionCacheOptions } from './systemVersion';
export { FixedWindowRateLimitCache, fixedWindowRateLimitCache } from './fixedWindowRateLimit';
export type {
FixedWindowRateLimitCacheOptions,
FixedWindowRateLimitResult
} from './fixedWindowRateLimit';
export { RateLimitCache, rateLimitCache } from './rateLimit';
export type { RateLimitCacheOptions, RateLimitResult } from './rateLimit';
export { TeamQpmCache, teamQpmCache } from './teamQpm';
export type { TeamQpmCacheOptions } from './teamQpm';
......
......@@ -6,7 +6,7 @@ import {
} from '../adapter';
import { PositiveSafeIntegerSchema } from '../runtime/schema';
export type FixedWindowRateLimitResult = {
export type RateLimitResult = {
allowed: boolean;
currentCount: number;
remaining: number;
......@@ -14,25 +14,22 @@ export type FixedWindowRateLimitResult = {
resetAt: number;
};
export type FixedWindowRateLimitCacheOptions = {
export type RateLimitCacheOptions = {
redis?: RedisCacheAdapter;
now?: () => number;
};
/**
* 固定窗口限流 Cache。
* Redis 限流 Cache。
*
* 计数与 TTL 的原子性由 adapter 保证;Cache 只负责限制值校验和业务决策结果。
* Redis 执行错误向上抛出,由认证或 API service 统一映射为 fail-closed。
* 当前使用固定窗口算法。计数与 TTL 的原子性由 adapter 保证;Cache 只负责限制值校验
* 和业务决策结果。Redis 执行错误向上抛出,由 Service 层按场景映射故障策略。
*/
export class FixedWindowRateLimitCache {
export class RateLimitCache {
private readonly redis: RedisCacheAdapter;
private readonly now: () => number;
constructor({
redis = redisCacheAdapter,
now = Date.now
}: FixedWindowRateLimitCacheOptions = {}) {
constructor({ redis = redisCacheAdapter, now = Date.now }: RateLimitCacheOptions = {}) {
this.redis = redis;
this.now = now;
}
......@@ -40,23 +37,34 @@ export class FixedWindowRateLimitCache {
async consume({
key,
limit,
windowSeconds = 60
windowSeconds = 60,
increment = 1
}: {
key: string;
limit: number;
windowSeconds?: number;
}): Promise<FixedWindowRateLimitResult> {
increment?: number;
}): Promise<RateLimitResult> {
const parsedLimit = PositiveSafeIntegerSchema.safeParse(limit);
if (!parsedLimit.success) {
throw new RedisInvalidArgumentError({
operation: 'fixedWindow.consume',
operation: 'rateLimit.consume',
message: 'limit must be a positive safe integer'
});
}
const parsedIncrement = PositiveSafeIntegerSchema.safeParse(increment);
if (!parsedIncrement.success) {
throw new RedisInvalidArgumentError({
operation: 'rateLimit.consume',
message: 'increment must be a positive safe integer'
});
}
const { currentCount, ttlSeconds } = await this.redis.consumeFixedWindow({
key: asRedisLogicalKey(key),
windowSeconds
windowSeconds,
increment: parsedIncrement.data
});
return {
......@@ -69,4 +77,4 @@ export class FixedWindowRateLimitCache {
}
}
export const fixedWindowRateLimitCache = new FixedWindowRateLimitCache();
export const rateLimitCache = new RateLimitCache();
import Redis from 'ioredis';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { RedisCacheAdapter } from '@fastgpt/dal/redis/adapter';
import { FixedWindowRateLimitCache } from '@fastgpt/dal/redis/caches';
import { RateLimitCache } from '@fastgpt/dal/redis/caches';
const redisUrl = process.env.REDIS_INTEGRATION_URL;
const describeWithRedis = redisUrl ? describe : describe.skip;
describeWithRedis('FixedWindowRateLimitCache Redis 7.2 integration', () => {
describeWithRedis('RateLimitCache Redis 7.2 integration', () => {
const key = `integration-fixed-window-${process.pid}-${Date.now()}`;
const physicalKey = `fastgpt:${key}`;
let client: Redis;
......@@ -27,7 +27,7 @@ describeWithRedis('FixedWindowRateLimitCache Redis 7.2 integration', () => {
it('assigns unique counts under concurrency and keeps one fixed TTL', async () => {
const adapter = new RedisCacheAdapter({ getCommandClient: () => client });
const cache = new FixedWindowRateLimitCache({ redis: adapter });
const cache = new RateLimitCache({ redis: adapter });
const results = await Promise.all(
Array.from({ length: 64 }, () => cache.consume({ key, limit: 32, windowSeconds: 60 }))
......
......@@ -114,7 +114,7 @@ describe('RedisCacheAdapter', () => {
[null, 58]
]);
const multi = {
incr: vi.fn().mockReturnThis(),
incrby: vi.fn().mockReturnThis(),
expire: vi.fn().mockReturnThis(),
ttl: vi.fn().mockReturnThis(),
exec
......@@ -122,12 +122,14 @@ describe('RedisCacheAdapter', () => {
client.multi.mockReturnValue(multi);
const adapter = new RedisCacheAdapter({ getCommandClient: () => client as any });
await expect(adapter.consumeFixedWindow({ key, windowSeconds: 60 })).resolves.toEqual({
await expect(
adapter.consumeFixedWindow({ key, windowSeconds: 60, increment: 2 })
).resolves.toEqual({
currentCount: 2,
ttlSeconds: 58
});
expect(client.multi).toHaveBeenCalledTimes(1);
expect(multi.incr).toHaveBeenCalledWith('fastgpt:cache:string');
expect(multi.incrby).toHaveBeenCalledWith('fastgpt:cache:string', 2);
expect(multi.expire).toHaveBeenCalledWith('fastgpt:cache:string', 60, 'NX');
expect(multi.ttl).toHaveBeenCalledWith('fastgpt:cache:string');
expect(exec).toHaveBeenCalledTimes(1);
......@@ -165,7 +167,7 @@ describe('RedisCacheAdapter', () => {
])('rejects malformed fixed window transaction response %#', async (result) => {
const exec = vi.fn().mockResolvedValue(result);
const multi = {
incr: vi.fn().mockReturnThis(),
incrby: vi.fn().mockReturnThis(),
expire: vi.fn().mockReturnThis(),
ttl: vi.fn().mockReturnThis(),
exec
......@@ -175,7 +177,7 @@ describe('RedisCacheAdapter', () => {
await expect(adapter.consumeFixedWindow({ key, windowSeconds: 60 })).rejects.toMatchObject({
code: 'REDIS_INVALID_RESPONSE',
operation: 'fixedWindow.consume'
operation: 'rateLimit.consume'
});
});
......@@ -188,6 +190,15 @@ describe('RedisCacheAdapter', () => {
expect(client.multi).not.toHaveBeenCalled();
});
it.each([0, -1, 1.5, '2'])('rejects invalid fixed window increment %s', (increment) => {
const adapter = new RedisCacheAdapter({ getCommandClient: () => client as any });
expect(() =>
adapter.consumeFixedWindow({ key, windowSeconds: 60, increment: increment as any })
).toThrow('increment must be a positive safe integer');
expect(client.multi).not.toHaveBeenCalled();
});
it('reads a pair of strings in one transaction', async () => {
const exec = vi.fn().mockResolvedValue([
[null, 'surplus'],
......
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { FixedWindowRateLimitCache } from '@fastgpt/dal/redis/caches';
import { RateLimitCache } from '@fastgpt/dal/redis/caches';
describe('FixedWindowRateLimitCache', () => {
describe('RateLimitCache', () => {
const consumeFixedWindow = vi.fn();
const now = vi.fn(() => 1_000_000);
const cache = new FixedWindowRateLimitCache({
const cache = new RateLimitCache({
redis: { consumeFixedWindow } as any,
now
});
......@@ -17,7 +17,7 @@ describe('FixedWindowRateLimitCache', () => {
it('returns an allow decision, remaining quota and reset timestamp', async () => {
await expect(
cache.consume({
key: 'frequency:chat:team-1',
key: 'rate-limit:team:chat-qpm:team:team-1',
limit: 5,
windowSeconds: 60
})
......@@ -29,15 +29,18 @@ describe('FixedWindowRateLimitCache', () => {
resetAt: 1_058_000
});
expect(consumeFixedWindow).toHaveBeenCalledWith({
key: 'frequency:chat:team-1',
windowSeconds: 60
key: 'rate-limit:team:chat-qpm:team:team-1',
windowSeconds: 60,
increment: 1
});
});
it('blocks after the limit and clamps remaining quota to zero', async () => {
consumeFixedWindow.mockResolvedValue({ currentCount: 6, ttlSeconds: 4 });
await expect(cache.consume({ key: 'frequency:chat:team-1', limit: 5 })).resolves.toMatchObject({
await expect(
cache.consume({ key: 'rate-limit:team:chat-qpm:team:team-1', limit: 5 })
).resolves.toMatchObject({
allowed: false,
currentCount: 6,
remaining: 0,
......@@ -45,8 +48,24 @@ describe('FixedWindowRateLimitCache', () => {
resetAt: 1_004_000
});
expect(consumeFixedWindow).toHaveBeenCalledWith({
key: 'frequency:chat:team-1',
windowSeconds: 60
key: 'rate-limit:team:chat-qpm:team:team-1',
windowSeconds: 60,
increment: 1
});
});
it('passes a custom increment to Redis', async () => {
await cache.consume({
key: 'rate-limit:upload:file-count:identity:member-1',
limit: 10,
windowSeconds: 60,
increment: 3
});
expect(consumeFixedWindow).toHaveBeenCalledWith({
key: 'rate-limit:upload:file-count:identity:member-1',
windowSeconds: 60,
increment: 3
});
});
......@@ -54,10 +73,27 @@ describe('FixedWindowRateLimitCache', () => {
'rejects invalid rate limit %s before Redis access',
async (limit) => {
await expect(
cache.consume({ key: 'frequency:chat:team-1', limit: limit as any })
cache.consume({ key: 'rate-limit:team:chat-qpm:team:team-1', limit: limit as any })
).rejects.toMatchObject({
code: 'REDIS_INVALID_ARGUMENT',
operation: 'rateLimit.consume'
});
expect(consumeFixedWindow).not.toHaveBeenCalled();
}
);
it.each([0, -1, 1.5, Number.MAX_SAFE_INTEGER + 1, '2'])(
'rejects invalid increment %s before Redis access',
async (increment) => {
await expect(
cache.consume({
key: 'rate-limit:team:chat-qpm:team:team-1',
limit: 5,
increment: increment as any
})
).rejects.toMatchObject({
code: 'REDIS_INVALID_ARGUMENT',
operation: 'fixedWindow.consume'
operation: 'rateLimit.consume'
});
expect(consumeFixedWindow).not.toHaveBeenCalled();
}
......@@ -67,6 +103,8 @@ describe('FixedWindowRateLimitCache', () => {
const error = new Error('redis down');
consumeFixedWindow.mockRejectedValue(error);
await expect(cache.consume({ key: 'frequency:chat:team-1', limit: 5 })).rejects.toBe(error);
await expect(
cache.consume({ key: 'rate-limit:team:chat-qpm:team:team-1', limit: 5 })
).rejects.toBe(error);
});
});
......@@ -6,7 +6,12 @@ export enum UserErrEnum {
userExist = 'userExist',
unAuthRole = 'unAuthRole',
account_psw_error = 'account_psw_error',
unAuthSso = 'unAuthSso'
unAuthSso = 'unAuthSso',
invalidVerificationCode = 'invalidVerificationCode',
sendVerificationCodeTooFrequently = 'sendVerificationCodeTooFrequently',
verifyCodeTooFrequently = 'verifyCodeTooFrequently',
invalidAccount = 'invalidAccount',
registrationMethodNotSupported = 'registrationMethodNotSupported'
}
const errList = [
{
......@@ -24,6 +29,30 @@ const errList = [
{
statusText: UserErrEnum.unAuthSso,
message: i18nT('user:sso_auth_failed')
},
{
statusText: UserErrEnum.invalidVerificationCode,
message: i18nT('common:error.code_error'),
httpStatus: 400
},
{
statusText: UserErrEnum.sendVerificationCodeTooFrequently,
message: i18nT('common:error.send_auth_code_too_frequently'),
httpStatus: 429
},
{
statusText: UserErrEnum.verifyCodeTooFrequently,
message: i18nT('common:error.verify_code_too_frequently'),
httpStatus: 429
},
{
statusText: UserErrEnum.invalidAccount,
message: i18nT('common:code_error.invalid_account')
},
{
statusText: UserErrEnum.registrationMethodNotSupported,
message: i18nT('common:error.registration_method_not_supported'),
httpStatus: 403
}
];
export default errList.reduce((acc, cur, index) => {
......@@ -33,7 +62,8 @@ export default errList.reduce((acc, cur, index) => {
code: 503000 + index,
statusText: cur.statusText,
message: cur.message,
data: null
data: null,
...(cur.httpStatus !== undefined ? { httpStatus: cur.httpStatus } : {})
}
};
}, {} as ErrType<`${UserErrEnum}`>);
export type AuthFrequencyLimitProps = {
eventId: string;
maxAmount: number;
expiredTime: Date;
num?: number;
};
export type AuthGoogleTokenProps = { googleToken: string; remoteip?: string | null };
......@@ -54,7 +54,6 @@ export type FastGPTFeConfigsType = {
login_method?: FastGPTRegisterMethodType[]; // Attention: login method is different with oauth
find_password_method?: FastGPTRegisterMethodType[];
bind_notification_method?: FastGPTRegisterMethodType[];
googleClientVerKey?: string;
/**
* @deprecated MCP SSE 代理地址已迁移到环境变量 SSE_MCP_SERVER_PROXY_ENDPOINT。
* 运行时配置以环境变量为准,admin 不再支持写入该字段。
......
import { z } from 'zod';
import {
AccountContactUsernameSchema,
CaptchaVerificationPurposeSchema
} from '../../../../../support/user/account/verification/type';
/* ============================================================================
* API: 获取图片验证码
* Route: GET /proApi/support/user/account/captcha/getImgCaptcha
* Method: GET
* Description: 为指定账号和业务场景生成图片验证码
* Tags: ['User', 'Account', 'Verification']
* ============================================================================ */
export const GetImgCaptchaQuerySchema = z.object({
username: AccountContactUsernameSchema.meta({
example: 'user@example.com',
description: '待验证的账号'
}),
purpose: CaptchaVerificationPurposeSchema.meta({
example: 'register',
description: '图片验证码业务场景'
})
});
export type GetImgCaptchaQuery = z.infer<typeof GetImgCaptchaQuerySchema>;
export const GetImgCaptchaResponseSchema = z.object({
captchaImage: z.string().meta({
example: 'data:image/png;base64,...',
description: 'Base64 编码的图片验证码'
})
});
export type GetImgCaptchaResponse = z.infer<typeof GetImgCaptchaResponseSchema>;
import type { OpenAPIPath } from '../../../../type';
import { DevApiTagsMap } from '../../../../tag';
import { GetImgCaptchaQuerySchema, GetImgCaptchaResponseSchema } from './api';
export const CaptchaPath: OpenAPIPath = {
'/proApi/support/user/account/captcha/getImgCaptcha': {
get: {
summary: '获取图片验证码',
description: '为指定账号和业务场景生成图片验证码',
tags: [DevApiTagsMap.userLogin],
requestParams: {
query: GetImgCaptchaQuerySchema
},
responses: {
200: {
description: '获取图片验证码成功',
content: {
'application/json': {
schema: GetImgCaptchaResponseSchema
}
}
}
}
}
}
};
export * from './api';
import type { z } from 'zod';
import { FastGPT_SEM_Schema, TrackRegisterParamsSchema } from '../../../../support/marketing/type';
import { ExternalAuthStringSchema } from '../../../../support/user/account/verification/type';
const PublicAuthOptionalExternalStringSchema = ExternalAuthStringSchema.optional();
const PublicAuthFastGPTSemSchema = FastGPT_SEM_Schema.extend({
shortUrlSource: PublicAuthOptionalExternalStringSchema,
shortUrlMedium: PublicAuthOptionalExternalStringSchema,
shortUrlContent: PublicAuthOptionalExternalStringSchema,
keyword: PublicAuthOptionalExternalStringSchema,
search: PublicAuthOptionalExternalStringSchema,
sourceDomain: PublicAuthOptionalExternalStringSchema
});
/** 公共认证接口使用的营销参数,避免认证约束影响其他营销数据入口。 */
export const PublicAuthTrackRegisterParamsSchema = TrackRegisterParamsSchema.extend({
inviterId: PublicAuthOptionalExternalStringSchema,
bd_vid: PublicAuthOptionalExternalStringSchema,
msclkid: PublicAuthOptionalExternalStringSchema,
fastgpt_sem: PublicAuthFastGPTSemSchema.optional()
});
export type PublicAuthTrackRegisterParams = z.infer<typeof PublicAuthTrackRegisterParamsSchema>;
......@@ -2,9 +2,11 @@ import type { OpenAPIPath } from '../../../type';
import { LoginPath } from './login';
import { RegisterPath } from './register';
import { PasswordPath } from './password';
import { CaptchaPath } from './captcha';
export const UserAccountPath: OpenAPIPath = {
...LoginPath,
...RegisterPath,
...PasswordPath
...PasswordPath,
...CaptchaPath
};
import { z } from 'zod';
import { OAuthEnum } from '../../../../../support/user/constant';
import { TrackRegisterParamsSchema } from '../../../../../support/marketing/type';
import { LanguageSchema } from '../../../../../common/i18n/type';
import { UserSchema } from '../../../../../support/user/type';
import { TeamTmbItemSchema } from '../../../../../support/user/team/type';
import {
AccountLoginUsernameSchema,
AccountPasswordSchema,
ExternalAuthStringSchema,
ShortAuthStringSchema
} from '../../../../../support/user/account/verification/type';
import { PublicAuthTrackRegisterParamsSchema } from '../common';
const OpenAPITeamTmbItemSchema = TeamTmbItemSchema.omit({
permission: true
......@@ -34,9 +40,24 @@ export const LoginSuccessResponseSchema = z.object({
});
export type LoginSuccessResponseType = z.infer<typeof LoginSuccessResponseSchema>;
export const WxLoginExpiredResponseSchema = z.object({
expired: z.literal(true).meta({
description: '微信登录二维码是否已过期'
})
});
export const WxLoginPendingResponseSchema = z.null().meta({
description: '二维码仍在等待扫码'
});
export const WxLoginResultResponseSchema = z.union([
LoginSuccessResponseSchema,
WxLoginExpiredResponseSchema,
WxLoginPendingResponseSchema
]);
export type WxLoginResultResponseType = z.infer<typeof WxLoginResultResponseSchema>;
// ===== Pre login - get login verification code =====
export const PreLoginQuerySchema = z.object({
username: z.string().meta({
username: AccountLoginUsernameSchema.meta({
example: 'admin',
description: '用户名'
})
......@@ -58,16 +79,16 @@ export const PreLoginResponseSchema = z
export type PreLoginResponseType = z.infer<typeof PreLoginResponseSchema>;
// ===== Login by password =====
export const LoginByPasswordBodySchema = TrackRegisterParamsSchema.extend({
username: z.string().meta({
export const LoginByPasswordBodySchema = PublicAuthTrackRegisterParamsSchema.extend({
username: AccountLoginUsernameSchema.meta({
example: 'admin',
description: '用户名'
}),
password: z.string().meta({
password: AccountPasswordSchema.meta({
example: 'hashed_password',
description: '密码'
}),
code: z.string().meta({
code: ShortAuthStringSchema.meta({
example: '123456',
description: '预登录验证码'
}),
......@@ -87,34 +108,56 @@ export type LoginByPasswordBodyType = z.infer<typeof LoginByPasswordBodySchema>;
/* ===== Wecom Login ===== */
export const WecomGetRedirectURLBodySchema = z.object({
redirectUri: z.string(),
state: z.string(),
redirectUri: ExternalAuthStringSchema,
state: ShortAuthStringSchema,
isWecomWorkTerminal: z.boolean()
});
export const WecomGetRedirectURLResponseSchema = z.string();
export type WecomGetRedirectURLBodyType = z.infer<typeof WecomGetRedirectURLBodySchema>;
export type WecomGetRedirectURLResponseType = z.infer<typeof WecomGetRedirectURLResponseSchema>;
/* ===== SSO Authorization URL ===== */
export const SsoGetAuthorizationURLBodySchema = z.object({
redirectUri: ExternalAuthStringSchema.meta({
example: 'https://fastgpt.example.com/login',
description: 'SSO 登录完成后的回调地址'
}),
isWecomWorkTerminal: z.boolean().meta({
example: false,
description: '当前是否为企业微信工作台环境'
})
});
export const SsoGetAuthorizationURLResponseSchema = z.string().meta({
example: 'https://sso.example.com/oauth/authorize',
description: 'SSO 授权跳转地址'
});
export type SsoGetAuthorizationURLBodyType = z.infer<typeof SsoGetAuthorizationURLBodySchema>;
export type SsoGetAuthorizationURLResponseType = z.infer<
typeof SsoGetAuthorizationURLResponseSchema
>;
// ===== OAuth Login =====
export const OauthLoginBodySchema = TrackRegisterParamsSchema.extend({
export const OauthLoginBodySchema = PublicAuthTrackRegisterParamsSchema.extend({
type: z.enum(OAuthEnum).meta({ description: 'OAuth 登录类型' }),
callbackUrl: z.string().meta({ description: '回调 URL' }),
props: z.record(z.string(), z.string()).meta({ description: '附加属性' }),
callbackUrl: ExternalAuthStringSchema.meta({ description: '回调 URL' }),
props: z
.record(ExternalAuthStringSchema, ExternalAuthStringSchema)
.meta({ description: '附加属性' }),
language: LanguageSchema.optional().meta({ description: '语言' })
});
export type OauthLoginBodyType = z.infer<typeof OauthLoginBodySchema>;
// ===== Fast Login =====
export const FastLoginBodySchema = TrackRegisterParamsSchema.extend({
token: z.string().meta({ description: 'Token' }),
code: z.string().meta({ description: 'Code' }),
export const FastLoginBodySchema = PublicAuthTrackRegisterParamsSchema.extend({
token: ExternalAuthStringSchema.meta({ description: 'Token' }),
code: ExternalAuthStringSchema.meta({ description: '外部快速登录配置键' }),
language: LanguageSchema.optional().meta({ description: '语言' })
});
export type FastLoginBodyType = z.infer<typeof FastLoginBodySchema>;
// ===== WeChat Login Result =====
export const WxLoginBodySchema = TrackRegisterParamsSchema.extend({
code: z.string().meta({ description: '微信登录 Code' }),
export const WxLoginBodySchema = PublicAuthTrackRegisterParamsSchema.extend({
code: ShortAuthStringSchema.meta({ description: '微信登录 Code' }),
language: LanguageSchema.optional().meta({ description: '语言' })
});
export type WxLoginBodyType = z.infer<typeof WxLoginBodySchema>;
......
......@@ -9,7 +9,10 @@ import {
WxLoginBodySchema,
GetWXLoginQRResponseSchema,
LoginSuccessResponseSchema,
OpenAPIUserSchema
WxLoginResultResponseSchema,
OpenAPIUserSchema,
SsoGetAuthorizationURLBodySchema,
SsoGetAuthorizationURLResponseSchema
} from './api';
export const LoginPath: OpenAPIPath = {
......@@ -98,6 +101,30 @@ export const LoginPath: OpenAPIPath = {
}
}
},
'/proApi/support/user/account/login/getAuthURL': {
post: {
summary: '获取 SSO 授权地址',
description: '根据当前登录回调地址生成 SSO 授权跳转地址',
tags: [DevApiTagsMap.userLogin],
requestBody: {
content: {
'application/json': {
schema: SsoGetAuthorizationURLBodySchema
}
}
},
responses: {
200: {
description: '成功生成 SSO 授权地址',
content: {
'application/json': {
schema: SsoGetAuthorizationURLResponseSchema
}
}
}
}
}
},
'/proApi/support/user/account/login/fastLogin': {
post: {
summary: '快捷登录',
......@@ -153,10 +180,10 @@ export const LoginPath: OpenAPIPath = {
},
responses: {
200: {
description: '登录成功',
description: '登录成功或二维码已过期',
content: {
'application/json': {
schema: LoginSuccessResponseSchema
schema: WxLoginResultResponseSchema
}
}
}
......
import { z } from 'zod';
import { LanguageSchema } from '../../../../../common/i18n/type';
import {
AccountContactUsernameSchema,
AccountPasswordSchema,
ShortAuthStringSchema
} from '../../../../../support/user/account/verification/type';
// ===== Update password by old password =====
export const UpdatePasswordByOldBodySchema = z
.object({
oldPsw: z.string().trim().min(1).meta({
oldPsw: AccountPasswordSchema.meta({
example: 'hashed_old_password',
description: '旧密码(已加密)'
}),
newPsw: z.string().trim().min(1).meta({
newPsw: AccountPasswordSchema.meta({
example: 'hashed_new_password',
description: '新密码(已加密)'
})
......@@ -35,7 +40,7 @@ export type CheckPswExpiredResponseType = z.infer<typeof CheckPswExpiredResponse
// ===== Reset expired password =====
export const ResetExpiredPswBodySchema = z
.object({
newPsw: z.string().trim().min(1).meta({
newPsw: AccountPasswordSchema.meta({
example: 'hashed_new_password',
description: '新密码(已加密)'
})
......@@ -54,10 +59,9 @@ export type ResetExpiredPswResponseType = z.infer<typeof ResetExpiredPswResponse
// ===== 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(可选)' }),
username: AccountContactUsernameSchema.meta({ description: '用户名(邮箱或手机号)' }),
code: ShortAuthStringSchema.meta({ description: '验证码' }),
password: AccountPasswordSchema.meta({ description: '新密码' }),
language: LanguageSchema.optional().meta({ description: '语言' })
});
......
import { z } from 'zod';
import { TrackRegisterParamsSchema } from '../../../../../support/marketing/type';
import type { z } from 'zod';
import { LanguageSchema } from '../../../../../common/i18n/type';
import {
AccountContactUsernameSchema,
AccountPasswordSchema,
ShortAuthStringSchema
} from '../../../../../support/user/account/verification/type';
import { PublicAuthTrackRegisterParamsSchema } from '../common';
// ===== 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: '密码(已加密)' }),
export const AccountRegisterBodySchema = PublicAuthTrackRegisterParamsSchema.extend({
username: AccountContactUsernameSchema.meta({ description: '用户名(邮箱或手机号)' }),
code: ShortAuthStringSchema.meta({ description: '验证码' }),
password: AccountPasswordSchema.meta({ description: '密码(已加密)' }),
language: LanguageSchema.optional().meta({ description: '语言' })
});
......
import { z } from 'zod';
import { LanguageSchema } from '../../../../common/i18n/type';
import { VerificationCodeTypeEnum } from '../../../../support/user/account/verification/constants';
import {
AccountContactUsernameSchema,
ShortAuthStringSchema,
VERIFICATION_CODE_PURPOSES_BY_TYPE
} from '../../../../support/user/account/verification/type';
const SendAuthCodeCommonSchema = z
.object({
username: AccountContactUsernameSchema.meta({
description: '接收验证码的邮箱或手机号',
example: 'user@example.com'
}),
captcha: ShortAuthStringSchema.max(64).meta({
description: '图片验证码答案',
example: 'A1B2C3'
}),
lang: LanguageSchema.meta({
description: '验证码消息语言',
example: 'zh-CN'
})
})
.strict();
export const SendAuthCodeBodySchema = z.discriminatedUnion('type', [
SendAuthCodeCommonSchema.extend({
type: z.literal(VerificationCodeTypeEnum.register).meta({
description: '验证码类型',
example: VerificationCodeTypeEnum.register
}),
purpose: z.literal(VERIFICATION_CODE_PURPOSES_BY_TYPE[VerificationCodeTypeEnum.register]).meta({
description: '验证码业务场景',
example: 'register'
})
}).strict(),
SendAuthCodeCommonSchema.extend({
type: z.literal(VerificationCodeTypeEnum.findPassword).meta({
description: '验证码类型',
example: VerificationCodeTypeEnum.findPassword
}),
purpose: z
.literal(VERIFICATION_CODE_PURPOSES_BY_TYPE[VerificationCodeTypeEnum.findPassword])
.meta({
description: '验证码业务场景',
example: 'forgetPassword'
})
}).strict(),
SendAuthCodeCommonSchema.extend({
type: z.literal(VerificationCodeTypeEnum.bindNotification).meta({
description: '验证码类型',
example: VerificationCodeTypeEnum.bindNotification
}),
purpose: z
.literal(VERIFICATION_CODE_PURPOSES_BY_TYPE[VerificationCodeTypeEnum.bindNotification])
.meta({
description: '验证码业务场景',
example: 'bindNotification'
})
}).strict()
]);
export type SendAuthCodeBodyType = z.infer<typeof SendAuthCodeBodySchema>;
export const SendAuthCodeResponseSchema = z
.object({
message: z.string().meta({ description: '发送结果说明', example: '发送验证码成功' })
})
.strict();
export type SendAuthCodeResponseType = z.infer<typeof SendAuthCodeResponseSchema>;
......@@ -5,8 +5,33 @@ import {
ActivityAdResponseSchema
} from '../../../admin/support/user/inform/api';
import { DevApiTagsMap } from '../../../tag';
import { SendAuthCodeBodySchema, SendAuthCodeResponseSchema } from './api';
export const UserInformPath: OpenAPIPath = {
'/proApi/support/user/inform/sendAuthCode': {
post: {
summary: '发送验证码',
description: '发送注册、找回密码或绑定通知账号使用的邮箱/短信验证码',
tags: [DevApiTagsMap.userInform],
requestBody: {
content: {
'application/json': {
schema: SendAuthCodeBodySchema
}
}
},
responses: {
200: {
description: '验证码发送成功',
content: {
'application/json': {
schema: SendAuthCodeResponseSchema
}
}
}
}
}
},
'/proApi/support/user/inform/getSystemMsgModal': {
get: {
summary: '获取系统弹窗内容',
......
......@@ -6,7 +6,7 @@ export type AuthOutLinkInitProps = {
outLinkUid: string;
tokenUrl?: string;
};
export type AuthOutLinkChatProps = { ip?: string | null; outLinkUid: string; question: string };
export type AuthOutLinkChatProps = { outLinkUid: string; question: string };
export type AuthOutLinkLimitProps = AuthOutLinkChatProps & { outLink: OutLinkSchemaType };
export type AuthOutLinkResponse = {
uid: string;
......
/** 邮件和短信验证码的业务模板类型。 */
export enum VerificationCodeTypeEnum {
register = 'register',
findPassword = 'findPassword',
bindNotification = 'bindNotification'
}
import { z } from 'zod';
import { VerificationCodeTypeEnum } from './constants';
export const ACCOUNT_VERIFICATION_PURPOSES = [
'login',
'register',
'forgetPassword',
'changePassword',
'unsubscribe',
'bindNotification'
] as const;
export const AccountVerificationPurposeSchema = z.enum(ACCOUNT_VERIFICATION_PURPOSES);
export type AccountVerificationPurpose = z.infer<typeof AccountVerificationPurposeSchema>;
export const VerificationTtlSeconds = {
short: 30,
medium: 5 * 60,
long: 60 * 60
} as const;
export type VerificationTtlPreset = keyof typeof VerificationTtlSeconds;
/**
* Temporary verification material types and the scenes in which each material is valid.
* Keeping this map in the shared package prevents the service and Pro package from
* independently declaring incompatible scene unions.
*/
export const VERIFICATION_TYPES = ['password', 'code', 'captcha', 'wechat', 'oauth'] as const;
export type VerificationType = (typeof VERIFICATION_TYPES)[number];
export const VERIFICATION_SCENES_BY_TYPE = {
password: ['login'],
code: ['register', 'forgetPassword', 'bindNotification'],
captcha: ['register', 'forgetPassword', 'bindNotification'],
// The callback adapter discovers the scene from all active QR materials.
wechat: ACCOUNT_VERIFICATION_PURPOSES,
oauth: ['login']
} as const satisfies Record<VerificationType, readonly AccountVerificationPurpose[]>;
// Compatibility exports point at the shared scene map; they do not redeclare purpose values.
export const CODE_VERIFICATION_PURPOSES = VERIFICATION_SCENES_BY_TYPE.code;
export const CAPTCHA_VERIFICATION_PURPOSES = VERIFICATION_SCENES_BY_TYPE.captcha;
export type VerificationScene<T extends VerificationType = VerificationType> =
T extends VerificationType ? (typeof VERIFICATION_SCENES_BY_TYPE)[T][number] : never;
export type VerificationMaterialByType = {
password: {
preLoginCode: string;
};
code: {
code: string;
/** Distinguishes different issuances when the same numeric code is generated again. */
issueId?: string;
};
captcha: {
code: string;
};
wechat: {
openId?: string;
} | null;
oauth: {
provider?: string;
state?: string;
redirectUri?: string;
transactionId?: string;
};
};
export type VerificationMaterial<T extends VerificationType> = VerificationMaterialByType[T];
/** Mongo match values may be a stored value or a small existence predicate. */
export type VerificationMaterialMatch<T extends VerificationType> = Partial<{
[K in keyof NonNullable<VerificationMaterial<T>>]:
| NonNullable<VerificationMaterial<T>>[K]
| { $exists: boolean };
}>;
export const VERIFICATION_CODE_TYPES = [
VerificationCodeTypeEnum.register,
VerificationCodeTypeEnum.findPassword,
VerificationCodeTypeEnum.bindNotification
] as const;
export const VerificationCodeTypeSchema = z.enum(VERIFICATION_CODE_TYPES);
export type VerificationCodeType = z.infer<typeof VerificationCodeTypeSchema>;
export const CodeVerificationPurposeSchema = AccountVerificationPurposeSchema.extract(
VERIFICATION_SCENES_BY_TYPE.code
);
export type CodeVerificationPurpose = z.infer<typeof CodeVerificationPurposeSchema>;
/** Each public code template has exactly one account-verification purpose. */
export const VERIFICATION_CODE_PURPOSES_BY_TYPE = {
[VerificationCodeTypeEnum.register]: 'register',
[VerificationCodeTypeEnum.findPassword]: 'forgetPassword',
[VerificationCodeTypeEnum.bindNotification]: 'bindNotification'
} as const satisfies Record<VerificationCodeType, CodeVerificationPurpose>;
/** Backwards-compatible singular alias for callers that use the map as a lookup. */
export const VERIFICATION_CODE_PURPOSE_BY_TYPE = VERIFICATION_CODE_PURPOSES_BY_TYPE;
export type VerificationCodePurposeForType<T extends VerificationCodeType> =
(typeof VERIFICATION_CODE_PURPOSES_BY_TYPE)[T];
/** Correlates the code template discriminator with its only valid purpose. */
export type VerificationCodeRequest = {
[T in VerificationCodeType]: {
type: T;
purpose: VerificationCodePurposeForType<T>;
};
}[VerificationCodeType];
// Captcha and code materials intentionally share the same purpose set.
export const CaptchaVerificationPurposeSchema = AccountVerificationPurposeSchema.extract(
VERIFICATION_SCENES_BY_TYPE.captcha
);
export type CaptchaVerificationPurpose = z.infer<typeof CaptchaVerificationPurposeSchema>;
export const PasswordVerificationPurposeSchema = AccountVerificationPurposeSchema.extract(
VERIFICATION_SCENES_BY_TYPE.password
);
export type PasswordVerificationPurpose = z.infer<typeof PasswordVerificationPurposeSchema>;
export const WechatPurposeSchema = AccountVerificationPurposeSchema.extract(['login']);
export type WechatPurpose = z.infer<typeof WechatPurposeSchema>;
export const ShortAuthStringSchema = z.string().trim().min(1).max(100);
export const ExternalAuthStringSchema = z.string().trim().min(1);
export const AccountUsernameSchema = z.string().trim().min(1).max(100);
export const AccountPasswordSchema = z.string().trim().min(1).max(100);
export const AccountEmailUsernameSchema = z.string().trim().min(1).max(256).pipe(z.email());
export const AccountPhoneUsernameSchema = z
.string()
.trim()
.min(1)
.regex(/^1[3456789]\d{9}$/);
export const AccountContactUsernameSchema = z.union([
AccountEmailUsernameSchema,
AccountPhoneUsernameSchema
]);
export const AccountLoginUsernameSchema = z.union([
AccountContactUsernameSchema,
AccountUsernameSchema
]);
export enum UserAuthTypeEnum {
register = 'register',
findPassword = 'findPassword',
wxLogin = 'wxLogin',
bindNotification = 'bindNotification',
captcha = 'captcha',
login = 'login'
}
export const userAuthTypeMap = {
[UserAuthTypeEnum.register]: 'register',
[UserAuthTypeEnum.findPassword]: 'findPassword',
[UserAuthTypeEnum.wxLogin]: 'wxLogin',
[UserAuthTypeEnum.bindNotification]: 'bindNotification',
[UserAuthTypeEnum.captcha]: 'captcha',
[UserAuthTypeEnum.login]: 'login'
};
import type { UserAuthTypeEnum } from './constants';
export type UserAuthSchemaType = {
key: string;
type: `${UserAuthTypeEnum}`;
code?: string;
openid?: string;
createTime: Date;
expiredTime: Date;
};
import { describe, expect, it } from 'vitest';
import { openAPIDocument } from '../../../../../../openapi/provider/devapi';
import { openAPIPaths } from '../../../../../../openapi/path';
import {
FastLoginBodySchema,
LoginByPasswordBodySchema,
LoginSuccessResponseSchema,
OauthLoginBodySchema,
PreLoginQuerySchema,
SsoGetAuthorizationURLBodySchema,
WecomGetRedirectURLBodySchema,
WxLoginBodySchema,
WxLoginResultResponseSchema
} from '../../../../../../openapi/support/user/account/login/api';
import { GetImgCaptchaQuerySchema } from '../../../../../../openapi/support/user/account/captcha/api';
import {
ResetExpiredPswBodySchema,
UpdatePasswordByCodeBodySchema,
UpdatePasswordByOldBodySchema
} from '../../../../../../openapi/support/user/account/password/api';
import { AccountRegisterBodySchema } from '../../../../../../openapi/support/user/account/register/api';
import {
AccountEmailUsernameSchema,
ExternalAuthStringSchema,
ShortAuthStringSchema
} from '../../../../../../support/user/account/verification/type';
const captchaPath = '/proApi/support/user/account/captcha/getImgCaptcha';
const ssoAuthorizationPath = '/proApi/support/user/account/login/getAuthURL';
describe('user account OpenAPI contracts', () => {
it('registers the image captcha route in the generated Dev API document', () => {
expect(openAPIPaths[captchaPath]).toBeDefined();
expect(openAPIDocument.paths?.[captchaPath]).toBeDefined();
expect(openAPIPaths['/api/support/user/account/captcha/getImgCaptcha']).toBeUndefined();
});
it('registers and validates the SSO authorization URL route', () => {
expect(openAPIPaths[ssoAuthorizationPath]).toBeDefined();
expect(openAPIDocument.paths?.[ssoAuthorizationPath]).toBeDefined();
expect(
SsoGetAuthorizationURLBodySchema.parse({
redirectUri: 'https://fastgpt.example.com/login',
isWecomWorkTerminal: false
})
).toEqual({
redirectUri: 'https://fastgpt.example.com/login',
isWecomWorkTerminal: false
});
expect(() =>
SsoGetAuthorizationURLBodySchema.parse({
redirectUri: 'https://fastgpt.example.com/login'
})
).toThrow();
});
it('declares null while the WeChat QR login is waiting for a scan', () => {
expect(WxLoginResultResponseSchema.parse(null)).toBeNull();
});
it('uses independent boundaries for short, external, and email authentication values', () => {
const longExternalValue = 'a'.repeat(300);
const longEmail = `${'a'.repeat(120)}@example.com`;
expect(ShortAuthStringSchema.parse(' auth-value ')).toBe('auth-value');
expect(ShortAuthStringSchema.parse('a'.repeat(100))).toHaveLength(100);
expect(() => ShortAuthStringSchema.parse('a'.repeat(101))).toThrow();
expect(() => ShortAuthStringSchema.parse(' ')).toThrow();
expect(ExternalAuthStringSchema.parse(longExternalValue)).toBe(longExternalValue);
expect(() => ExternalAuthStringSchema.parse(' ')).toThrow();
expect(AccountEmailUsernameSchema.parse(` ${longEmail} `)).toBe(longEmail);
expect(() => AccountEmailUsernameSchema.parse(`${'a'.repeat(245)}@example.com`)).toThrow();
});
it('limits known short authentication material without truncating external values', () => {
const longExternalValue = 'a'.repeat(300);
const longEmail = `${'a'.repeat(120)}@example.com`;
const tooLongShortValue = 'a'.repeat(101);
expect(PreLoginQuerySchema.parse({ username: ' admin ' })).toEqual({ username: 'admin' });
expect(PreLoginQuerySchema.parse({ username: longEmail })).toEqual({ username: longEmail });
expect(() =>
LoginByPasswordBodySchema.parse({
username: 'admin',
password: tooLongShortValue,
code: 'code'
})
).toThrow();
expect(GetImgCaptchaQuerySchema.parse({ username: longEmail, purpose: 'register' })).toEqual({
username: longEmail,
purpose: 'register'
});
expect(() =>
UpdatePasswordByCodeBodySchema.parse({
username: 'user@example.com',
code: tooLongShortValue,
password: 'password'
})
).toThrow();
expect(
AccountRegisterBodySchema.parse({
username: 'user@example.com',
code: 'code',
password: 'password',
inviterId: longExternalValue
})
).toMatchObject({ inviterId: longExternalValue });
expect(
OauthLoginBodySchema.parse({
type: 'github',
callbackUrl: longExternalValue,
props: { access_token: longExternalValue }
})
).toMatchObject({ callbackUrl: longExternalValue, props: { access_token: longExternalValue } });
expect(
FastLoginBodySchema.parse({ token: longExternalValue, code: longExternalValue })
).toMatchObject({
token: longExternalValue,
code: longExternalValue
});
expect(() => WxLoginBodySchema.parse({ code: tooLongShortValue })).toThrow();
expect(
WecomGetRedirectURLBodySchema.parse({
redirectUri: longExternalValue,
state: 'state',
isWecomWorkTerminal: false
})
).toMatchObject({ redirectUri: longExternalValue });
expect(() =>
WecomGetRedirectURLBodySchema.parse({
redirectUri: 'https://fastgpt.example.com',
state: tooLongShortValue,
isWecomWorkTerminal: false
})
).toThrow();
});
it.each([
['old password', UpdatePasswordByOldBodySchema, { oldPsw: 'a'.repeat(101), newPsw: 'new' }],
['new password', UpdatePasswordByOldBodySchema, { oldPsw: 'old', newPsw: 'a'.repeat(101) }],
['expired password', ResetExpiredPswBodySchema, { newPsw: 'a'.repeat(101) }]
] as const)('rejects an overlong %s', (_name, schema, body) => {
expect(() => schema.parse(body)).toThrow();
});
it('strips a client-supplied team member ID from password reset input', () => {
expect(
UpdatePasswordByCodeBodySchema.parse({
username: 'user@example.com',
code: '123456',
password: 'password',
tmbId: 'another-user-team-member-id'
})
).toEqual({
username: 'user@example.com',
code: '123456',
password: 'password'
});
});
it.each([
['old password', UpdatePasswordByOldBodySchema, { oldPsw: ' ', newPsw: 'new' }],
['new password', UpdatePasswordByOldBodySchema, { oldPsw: 'old', newPsw: ' ' }],
['expired password', ResetExpiredPswBodySchema, { newPsw: ' ' }]
] as const)('rejects a blank %s', (_name, schema, body) => {
expect(() => schema.parse(body)).toThrow();
});
it('does not apply request limits to authentication response strings', () => {
const longToken = 't'.repeat(101);
expect(
LoginSuccessResponseSchema.parse({
user: {},
token: longToken
}).token
).toBe(longToken);
});
});
import { describe, expect, it } from 'vitest';
import { VerificationCodeTypeEnum } from '@fastgpt/global/support/user/account/verification/constants';
import { SendAuthCodeBodySchema } from '@fastgpt/global/openapi/support/user/inform/api';
const common = {
username: 'user@example.com',
captcha: 'A1B2C3',
lang: 'zh-CN' as const
};
describe('SendAuthCodeBodySchema', () => {
it.each([
[VerificationCodeTypeEnum.register, 'register'],
[VerificationCodeTypeEnum.findPassword, 'forgetPassword'],
[VerificationCodeTypeEnum.bindNotification, 'bindNotification']
] as const)('accepts the purpose assigned to %s', (type, purpose) => {
expect(SendAuthCodeBodySchema.parse({ ...common, type, purpose })).toMatchObject({
type,
purpose
});
});
it.each([
[VerificationCodeTypeEnum.register, 'forgetPassword'],
[VerificationCodeTypeEnum.findPassword, 'register'],
[VerificationCodeTypeEnum.bindNotification, 'login'],
[VerificationCodeTypeEnum.register, 'arbitrary-purpose']
] as const)('rejects %s with an invalid purpose %s', (type, purpose) => {
expect(() => SendAuthCodeBodySchema.parse({ ...common, type, purpose })).toThrow();
});
it('rejects code types outside the public send-code capabilities', () => {
expect(() =>
SendAuthCodeBodySchema.parse({
...common,
type: 'login',
purpose: 'login'
})
).toThrow();
});
it('trims captcha input and keeps its stricter 64-character limit', () => {
expect(
SendAuthCodeBodySchema.parse({
...common,
captcha: ' A1B2C3 ',
type: VerificationCodeTypeEnum.register,
purpose: 'register'
}).captcha
).toBe('A1B2C3');
expect(() =>
SendAuthCodeBodySchema.parse({
...common,
captcha: 'a'.repeat(65),
type: VerificationCodeTypeEnum.register,
purpose: 'register'
})
).toThrow();
});
it('rejects a blank captcha after trimming', () => {
expect(() =>
SendAuthCodeBodySchema.parse({
...common,
captcha: ' ',
type: VerificationCodeTypeEnum.register,
purpose: 'register'
})
).toThrow();
});
});
/* 基于 Team 的限流 */
import {
fixedWindowRateLimitCache,
type FixedWindowRateLimitCache
} from '@fastgpt/dal/redis/caches';
import { RedisInvalidArgumentError } from '@fastgpt/dal/redis';
import { jsonRes } from '../../common/response';
import type { NodeApiResponse } from '../../types/http';
import { teamQPM } from '../../support/wallet/sub/utils';
import z from 'zod';
import { getLogger, LogCategories } from '../logger';
import { UserError } from '@fastgpt/global/common/error/utils';
import { consumeTeamChatRateLimit } from '../rateLimit/interface/team';
const logger = getLogger(LogCategories.HTTP.RESPONSE);
......@@ -47,11 +43,9 @@ const getLimitData = async (data: FrequencyLimitOption) => {
export const teamFrequencyLimit = async ({
teamId,
type,
res,
cache = fixedWindowRateLimitCache
res
}: FrequencyLimitOption & {
res: NodeApiResponse;
cache?: Pick<FixedWindowRateLimitCache, 'consume'>;
}) => {
let data: Awaited<ReturnType<typeof getLimitData>>;
try {
......@@ -68,12 +62,12 @@ export const teamFrequencyLimit = async ({
const { limit, seconds } = data;
let result: Awaited<ReturnType<FixedWindowRateLimitCache['consume']>>;
let result: Awaited<ReturnType<typeof consumeTeamChatRateLimit>>;
try {
result = await cache.consume({
key: `frequency:${type}:${teamId}`,
result = await consumeTeamChatRateLimit({
teamId,
limit,
windowSeconds: seconds
seconds
});
} catch (error) {
if (error instanceof RedisInvalidArgumentError) throw error;
......
import type { DeepRagSearchProps, SearchDatasetDataResponse } from '../../core/dataset/search';
import type { AuthOpenApiLimitProps } from '../../support/openapi/auth';
import type {
CreateUsageProps,
ConcatUsageProps,
......@@ -9,7 +8,6 @@ import type {
declare global {
var textCensorHandler: (params: { text: string }) => Promise<{ code: number; message?: string }>;
var deepRagHandler: (data: DeepRagSearchProps) => Promise<SearchDatasetDataResponse>;
var authOpenApiHandler: (data: AuthOpenApiLimitProps) => Promise<any>;
var createUsageHandler: (data: CreateUsageProps) => any;
var concatUsageHandler: (data: ConcatUsageProps) => any;
var pushUsageItemsHandler: (data: PushUsageItemsProps) => any;
......
......@@ -167,14 +167,17 @@ export const createApiEntry = <
});
}
span.setAttribute('http.response.status_code', 500);
setSpanError(span, error);
return jsonRes(res, {
const response = jsonRes(res, {
code: 500,
error,
url: req.url
});
span.setAttribute('http.response.status_code', res.statusCode);
if (res.statusCode >= 500) {
setSpanError(span, error);
}
return response;
}
}
)
......
import { authFrequencyLimit } from '../system/frequencyLimit/utils';
import { addSeconds } from 'date-fns';
import { jsonRes } from '../response';
import { serviceEnv } from '../../env';
import { getClientIpFromRequest } from '../security/clientIp';
import type { NodeApiResponse, NodeHttpRequest } from '../../types/http';
import { checkIPRateLimit } from '../rateLimit/interface/ip';
// unit: times/s
// how to use?
......@@ -25,14 +24,15 @@ export function useIPFrequencyLimit({
}
const ip = getClientIpFromRequest(req) ?? 'unknown';
try {
await authFrequencyLimit({
eventId: `ip-qps-limit-${id}-` + ip,
maxAmount: limit,
expiredTime: addSeconds(new Date(), seconds)
});
} catch (_) {
jsonRes(res, {
const allowed = await checkIPRateLimit({
id,
ip,
limit,
seconds
});
if (!allowed) {
return jsonRes(res, {
code: 429,
error: `Too many request, request ${limit} times every ${seconds} seconds`
});
......
import { retryFn } from '@fastgpt/global/common/system/utils';
import { getLogger, LogCategories } from '../logger';
import { connectionMongo, type ClientSession } from './index';
......@@ -6,29 +5,23 @@ const logger = getLogger(LogCategories.INFRA.MONGO);
const timeout = 60000;
/**
* 在 Mongo session 中执行事务,并交给 Mongo driver 处理事务级重试。
*
* driver 只会因 TransientTransactionError 重跑事务回调,并会单独处理
* UnknownTransactionCommitResult;业务错误不会触发整个回调重试。
*/
export const mongoSessionRun = async <T = unknown>(fn: (session: ClientSession) => Promise<T>) => {
return retryFn(async () => {
const session = await connectionMongo.startSession();
const session = await connectionMongo.startSession();
try {
session.startTransaction({
maxCommitTimeMS: timeout
});
const result = await fn(session);
await session.commitTransaction();
return result as T;
} catch (error) {
if (!session.transaction.isCommitted) {
await session.abortTransaction();
logger.warn('MongoDB session transaction aborted', { error });
} else {
logger.warn('Unexpected MongoDB session error after commit', { error });
}
return Promise.reject(error);
} finally {
await session.endSession();
}
});
try {
return await session.withTransaction(() => fn(session), {
maxCommitTimeMS: timeout
});
} catch (error) {
logger.warn('MongoDB session transaction failed', { error });
throw error;
} finally {
await session.endSession();
}
};
import { RedisInvalidArgumentError } from '@fastgpt/dal/redis';
import { rateLimitCache } from '@fastgpt/dal/redis/caches';
import { createRedisLogicalKey } from '@fastgpt/dal/redis/runtime';
import { getLogger, LogCategories } from '../logger';
import type { RateLimitInterface, RateLimitInterfaceDefinition } from './type';
export const RATE_LIMIT_KEY_PREFIX = 'rate-limit';
const logger = getLogger(LogCategories.INFRA.REDIS);
/**
* 创建统一的场景限流接口。
*
* definition 持有 key、额度和故障策略,调用方只能提交业务输入,不能直接传 Redis key。
*/
export const defineRateLimitInterface = <TInput>(
definition: RateLimitInterfaceDefinition<TInput>
): RateLimitInterface<TInput> => {
const getKey = (input: TInput) => {
const policy =
typeof definition.policy === 'function' ? definition.policy(input) : definition.policy;
return createRedisLogicalKey({
namespace: RATE_LIMIT_KEY_PREFIX,
segments: [definition.scene, policy, ...definition.getKeySegments(input)]
});
};
const consume: RateLimitInterface<TInput>['consume'] = (input) =>
rateLimitCache.consume({
key: getKey(input),
limit: definition.getLimit(input),
windowSeconds: definition.getWindowSeconds(input),
increment: definition.getIncrement?.(input) ?? 1
});
const check: RateLimitInterface<TInput>['check'] = async (input) => {
try {
return (await consume(input)).allowed;
} catch (error) {
if (error instanceof RedisInvalidArgumentError) throw error;
logger.error('Rate limit execution failed', {
key: getKey(input),
failureMode: definition.failureMode,
error
});
return definition.failureMode === 'open';
}
};
const assert: RateLimitInterface<TInput>['assert'] = async (input) => {
if (!(await check(input))) {
throw definition.createError(input);
}
};
return { consume, check, assert };
};
export * from './interface';
export { RateLimitSceneEnum } from './type';
export type {
RateLimitFailureMode,
RateLimitInterface,
RateLimitInterfaceDefinition,
RateLimitKeySegment,
RateLimitScene
} from './type';
import { UserErrEnum } from '@fastgpt/global/common/error/code/user';
import { UserError } from '@fastgpt/global/common/error/utils';
import type { VerificationScene } from '@fastgpt/global/support/user/account/verification/type';
import { defineRateLimitInterface } from '../core';
import { RateLimitSceneEnum } from '../type';
const CodeVerificationConsumeQpm = 10;
const CodeVerificationConsumeWindowSeconds = 60;
type AccountVerificationRateLimitAction =
| 'code-consume'
| 'captcha-create'
| 'captcha-consume'
| 'password-create'
| 'password-consume';
type AccountVerificationRateLimitParams = {
account: string;
scene: string;
action: AccountVerificationRateLimitAction;
limit?: number;
seconds?: number;
};
type PasswordRateLimitScene = VerificationScene<'password'> | 'admin-login';
const accountVerificationRateLimit = defineRateLimitInterface<AccountVerificationRateLimitParams>({
scene: RateLimitSceneEnum.AccountVerification,
policy: ({ action }) => action,
failureMode: 'open',
getKeySegments: ({ scene, account }) => [scene, 'account', account],
getLimit: ({ limit }) => limit ?? CodeVerificationConsumeQpm,
getWindowSeconds: ({ seconds }) => seconds ?? CodeVerificationConsumeWindowSeconds,
createError: () => new UserError(UserErrEnum.verifyCodeTooFrequently)
});
/** 按账号和场景限制验证码消费次数,错误和成功提交都占用同一固定窗口。 */
export const assertCodeVerificationConsumeRateLimit = (params: {
account: string;
scene: VerificationScene<'code'>;
}) => accountVerificationRateLimit.assert({ ...params, action: 'code-consume' });
/** 按账号和场景限制图片验证码生成次数。 */
export const assertCaptchaVerificationCreateRateLimit = (params: {
account: string;
scene: VerificationScene<'captcha'>;
}) => accountVerificationRateLimit.assert({ ...params, action: 'captcha-create' });
/** 按账号和场景限制图片验证码确认次数。 */
export const assertCaptchaVerificationConsumeRateLimit = (params: {
account: string;
scene: VerificationScene<'captcha'>;
}) => accountVerificationRateLimit.assert({ ...params, action: 'captcha-consume' });
/** 按账号限制每分钟预登录码生成次数。 */
export const assertPasswordVerificationCreateRateLimit = (params: {
account: string;
scene: PasswordRateLimitScene;
limit: number;
}) => accountVerificationRateLimit.assert({ ...params, action: 'password-create' });
/** 按账号限制每分钟预登录码和密码的联合校验次数。 */
export const assertPasswordVerificationConsumeRateLimit = (params: {
account: string;
scene: PasswordRateLimitScene;
limit: number;
}) => accountVerificationRateLimit.assert({ ...params, action: 'password-consume' });
import { EnterpriseAuthErrEnum } from '@fastgpt/global/support/user/team/enterpriseAuth/constant';
import { defineRateLimitInterface } from '../core';
import { RateLimitSceneEnum } from '../type';
const EnterpriseAuthStartQpm = 5;
const EnterpriseAuthVerifyAmountQpm = 3;
const EnterpriseAuthWindowSeconds = 60;
const enterpriseAuthStartRateLimit = defineRateLimitInterface<{ teamId: string }>({
scene: RateLimitSceneEnum.EnterpriseAuth,
policy: 'start',
failureMode: 'open',
getKeySegments: ({ teamId }) => ['team', teamId],
getLimit: () => EnterpriseAuthStartQpm,
getWindowSeconds: () => EnterpriseAuthWindowSeconds,
createError: () => new Error(EnterpriseAuthErrEnum.tooFrequent)
});
const enterpriseAuthVerifyAmountRateLimit = defineRateLimitInterface<{ teamId: string }>({
scene: RateLimitSceneEnum.EnterpriseAuth,
policy: 'verify-amount',
failureMode: 'open',
getKeySegments: ({ teamId }) => ['team', teamId],
getLimit: () => EnterpriseAuthVerifyAmountQpm,
getWindowSeconds: () => EnterpriseAuthWindowSeconds,
createError: () => new Error(EnterpriseAuthErrEnum.tooFrequent)
});
/** 消费企业认证发起额度;Redis 故障时保持原行为并放行。 */
export const checkEnterpriseAuthStartRateLimit = enterpriseAuthStartRateLimit.check;
/** 按团队限制企业认证金额校验次数。 */
export const assertEnterpriseAuthVerifyAmountRateLimit = enterpriseAuthVerifyAmountRateLimit.assert;
export { checkIPRateLimit } from './ip';
export type { CheckIPRateLimitParams } from './ip';
export {
assertCaptchaVerificationConsumeRateLimit,
assertCaptchaVerificationCreateRateLimit,
assertCodeVerificationConsumeRateLimit,
assertPasswordVerificationConsumeRateLimit,
assertPasswordVerificationCreateRateLimit
} from './accountVerification';
export {
assertEnterpriseAuthVerifyAmountRateLimit,
checkEnterpriseAuthStartRateLimit
} from './enterpriseAuth';
export { assertOutLinkRateLimit } from './outLink';
export { assertUploadRateLimit } from './upload';
export { assertMemberRateLimit, MemberRateLimitPolicy } from './member';
export { consumeTeamChatRateLimit } from './team';
import { ERROR_ENUM } from '@fastgpt/global/common/error/errorCode';
import { defineRateLimitInterface } from '../core';
import { RateLimitSceneEnum } from '../type';
export type CheckIPRateLimitParams = {
id: string;
ip: string;
limit: number;
seconds: number;
increment?: number;
};
const ipRateLimit = defineRateLimitInterface<CheckIPRateLimitParams>({
scene: RateLimitSceneEnum.Ip,
policy: ({ id }) => id,
failureMode: 'open',
getKeySegments: ({ ip }) => ['ip', ip],
getLimit: ({ limit }) => limit,
getWindowSeconds: ({ seconds }) => seconds,
getIncrement: ({ increment }) => increment ?? 1,
createError: () => ERROR_ENUM.tooManyRequest
});
/** 原子增加指定 IP 的接口计数,并返回是否仍在额度内。 */
export const checkIPRateLimit = ipRateLimit.check;
import { ERROR_ENUM } from '@fastgpt/global/common/error/errorCode';
import { defineRateLimitInterface } from '../core';
import { RateLimitSceneEnum } from '../type';
export const MemberRateLimitPolicy = {
GetLlmRequestRecord: 'get-llm-request-record',
ChatAgentHelperCompletions: 'chat-agent-helper-completions',
Transcriptions: 'transcriptions',
RedeemCoupon: 'redeem-coupon',
RefundBill: 'refund-bill',
CreateBill: 'create-bill',
ExportMembers: 'export-members',
CheckPayResult: 'check-pay-result',
ExportUsage: 'export-usage',
ExportDataset: 'export-dataset',
ExportChatLogs: 'export-chat-logs'
} as const;
type MemberRateLimitPolicy = (typeof MemberRateLimitPolicy)[keyof typeof MemberRateLimitPolicy];
type MemberRateLimitParams = {
policy: MemberRateLimitPolicy;
memberId: string;
};
/** 成员场景的额度统一在接口层维护,避免业务路由自行声明窗口或额度。 */
const memberRateLimitConfig = {
// 查看单条 LLM 请求记录,避免高频查询明细。
[MemberRateLimitPolicy.GetLlmRequestRecord]: { limit: 1, seconds: 1 },
// Chat Agent 辅助生成,限制同一成员触发补全的频率。
[MemberRateLimitPolicy.ChatAgentHelperCompletions]: { limit: 10, seconds: 60 },
// 音频转写,限制同一成员提交语音识别任务的频率。
[MemberRateLimitPolicy.Transcriptions]: { limit: 1, seconds: 1 },
// 兑换优惠券,避免同一成员并发重复核销。
[MemberRateLimitPolicy.RedeemCoupon]: { limit: 1, seconds: 1 },
// 管理端退款;rootkey 请求按订单所属成员归组。
[MemberRateLimitPolicy.RefundBill]: { limit: 1, seconds: 1 },
// 创建支付订单,避免同一成员短时间重复下单。
[MemberRateLimitPolicy.CreateBill]: { limit: 1, seconds: 1 },
// 导出团队成员列表。
[MemberRateLimitPolicy.ExportMembers]: { limit: 1, seconds: 60 },
// 轮询支付结果,允许前端在一分钟内持续查询。
[MemberRateLimitPolicy.CheckPayResult]: { limit: 60, seconds: 60 },
// 导出用量明细。
[MemberRateLimitPolicy.ExportUsage]: { limit: 1, seconds: 60 },
// 导出知识库集合数据。
[MemberRateLimitPolicy.ExportDataset]: { limit: 1, seconds: 60 },
// 导出应用对话日志。
[MemberRateLimitPolicy.ExportChatLogs]: { limit: 1, seconds: 60 }
} satisfies Record<MemberRateLimitPolicy, { limit: number; seconds: number }>;
const memberRateLimit = defineRateLimitInterface<MemberRateLimitParams>({
scene: RateLimitSceneEnum.Member,
policy: ({ policy }) => policy,
failureMode: 'open',
getKeySegments: ({ memberId }) => ['member', memberId],
getLimit: ({ policy }) => memberRateLimitConfig[policy].limit,
getWindowSeconds: ({ policy }) => memberRateLimitConfig[policy].seconds,
createError: () => ERROR_ENUM.tooManyRequest
});
/** 按受约束的成员业务策略消费额度,超限时抛出统一请求频繁错误。 */
export const assertMemberRateLimit = memberRateLimit.assert;
import { UserError } from '@fastgpt/global/common/error/utils';
import { defineRateLimitInterface } from '../core';
import { RateLimitSceneEnum } from '../type';
type OutLinkRateLimitParams = {
outLinkId: string;
uid: string;
limit: number;
};
const outLinkRateLimit = defineRateLimitInterface<OutLinkRateLimitParams>({
scene: RateLimitSceneEnum.OutLink,
policy: 'request',
failureMode: 'open',
getKeySegments: ({ outLinkId, uid }) => ['out-link', outLinkId, 'uid', uid],
getLimit: ({ limit }) => limit,
getWindowSeconds: () => 60,
createError: ({ limit }) => new UserError(`每分钟仅能请求 ${limit} 次~`)
});
/** 按外链 ID 和访问者 UID 的组合限制每分钟访问次数。 */
export const assertOutLinkRateLimit = outLinkRateLimit.assert;
import { ERROR_ENUM } from '@fastgpt/global/common/error/errorCode';
import { defineRateLimitInterface } from '../core';
import { RateLimitSceneEnum } from '../type';
type TeamChatRateLimitParams = {
teamId: string;
limit: number;
seconds: number;
};
const teamChatRateLimit = defineRateLimitInterface<TeamChatRateLimitParams>({
scene: RateLimitSceneEnum.Team,
policy: 'chat-qpm',
failureMode: 'closed',
getKeySegments: ({ teamId }) => ['team', teamId],
getLimit: ({ limit }) => limit,
getWindowSeconds: ({ seconds }) => seconds,
createError: () => ERROR_ENUM.tooManyRequest
});
/** 返回团队聊天 QPM 的原始计数结果,供统一 API 包装层保留现有错误映射。 */
export const consumeTeamChatRateLimit = teamChatRateLimit.consume;
import { ERROR_ENUM } from '@fastgpt/global/common/error/errorCode';
import { defineRateLimitInterface } from '../core';
import { RateLimitSceneEnum } from '../type';
type UploadRateLimitParams = {
identity: string;
limit: number;
increment?: number;
};
const uploadRateLimit = defineRateLimitInterface<UploadRateLimitParams>({
scene: RateLimitSceneEnum.Upload,
policy: 'presign',
failureMode: 'open',
getKeySegments: ({ identity }) => ['identity', identity],
getLimit: ({ limit }) => limit,
getWindowSeconds: () => 60,
getIncrement: ({ increment }) => increment ?? 1,
createError: () => ERROR_ENUM.tooManyRequest
});
/** 按调用方已解析的上传身份,限制每分钟签发上传 URL 的次数。 */
export const assertUploadRateLimit = uploadRateLimit.assert;
import type { RateLimitResult } from '@fastgpt/dal/redis/caches';
export const RateLimitSceneEnum = {
Ip: 'ip',
AccountVerification: 'account-verification',
EnterpriseAuth: 'enterprise-auth',
OutLink: 'out-link',
Upload: 'upload',
Member: 'member',
Team: 'team'
} as const;
export type RateLimitScene = (typeof RateLimitSceneEnum)[keyof typeof RateLimitSceneEnum];
export type RateLimitFailureMode = 'open' | 'closed';
export type RateLimitKeySegment = string | number;
export type RateLimitInterfaceDefinition<TInput> = {
scene: RateLimitScene;
policy: string | ((input: TInput) => string);
failureMode: RateLimitFailureMode;
getKeySegments: (input: TInput) => readonly RateLimitKeySegment[];
getLimit: (input: TInput) => number;
getWindowSeconds: (input: TInput) => number;
getIncrement?: (input: TInput) => number;
createError: (input: TInput) => Error | string;
};
/** 所有场景接口统一提供原始消费、布尔判断和业务断言三种调用方式。 */
export type RateLimitInterface<TInput> = {
consume: (input: TInput) => Promise<RateLimitResult>;
check: (input: TInput) => Promise<boolean>;
assert: (input: TInput) => Promise<void>;
};
import {
fixedWindowRateLimitCache,
type FixedWindowRateLimitCache
} from '@fastgpt/dal/redis/caches';
import { RedisInvalidArgumentError } from '@fastgpt/dal/redis';
import { getLogger, LogCategories } from '../../logger';
const logger = getLogger(LogCategories.INFRA.REDIS);
/**
* 基于 Redis 固定窗口的轻量 QPM 限流。
*
* 该 helper 只负责窗口内计数并返回是否允许继续执行,不绑定任何业务错误码。
* 调用方需要根据自身语义决定超限后的错误响应。
*/
export const createFixedWindowQpmLimitChecker =
({
cache = fixedWindowRateLimitCache
}: {
cache?: Pick<FixedWindowRateLimitCache, 'consume'>;
} = {}) =>
async ({ key, limit, seconds = 60 }: { key: string; limit: number; seconds?: number }) => {
try {
return (
await cache.consume({
key,
limit,
windowSeconds: seconds
})
).allowed;
} catch (error) {
if (error instanceof RedisInvalidArgumentError) throw error;
logger.error('Fixed window rate limit failed closed', { key, error });
return false;
}
};
export const checkFixedWindowQpmLimit = createFixedWindowQpmLimitChecker();
import { defineIndex, getMongoModel, Schema } from '../../mongo';
import type { FrequencyLimitSchemaType } from './type';
const FrequencyLimitSchema = new Schema({
eventId: {
type: String,
required: true
},
amount: {
type: Number,
default: 0
},
expiredTime: {
type: Date,
required: true
}
});
defineIndex(FrequencyLimitSchema, { key: { eventId: 1, expiredTime: 1 } });
defineIndex(FrequencyLimitSchema, {
key: { expiredTime: 1 },
options: { expireAfterSeconds: 0 }
});
export const MongoFrequencyLimit = getMongoModel<FrequencyLimitSchemaType>(
'frequency_limit',
FrequencyLimitSchema
);
export type FrequencyLimitSchemaType = {
_id: string;
eventId: string; // 事件ID
amount: number; // 当前数量
expiredTime: Date; // 什么时候过期,过期则重置
};
import { type AuthFrequencyLimitProps } from '@fastgpt/global/common/frequenctLimit/type';
import { ERROR_ENUM } from '@fastgpt/global/common/error/errorCode';
import { MongoFrequencyLimit } from './schema';
import { getLogger, LogCategories } from '../../logger';
const logger = getLogger(LogCategories.SYSTEM);
export const authFrequencyLimit = async ({
eventId,
maxAmount,
expiredTime,
num = 1
}: AuthFrequencyLimitProps) => {
try {
// 对应 eventId 的 account+1, 不存在的话,则创建一个
const result = await MongoFrequencyLimit.findOneAndUpdate(
{
eventId,
expiredTime: { $gte: new Date() }
},
{
$inc: { amount: num },
// If not exist, set the expiredTime
$setOnInsert: { expiredTime }
},
{
upsert: true,
new: true
}
).lean();
// 因为始终会返回+1的结果,所以这里不能直接等,需要多一个。
if (result.amount > maxAmount) {
throw ERROR_ENUM.tooManyRequest;
}
} catch (error) {
if (error === ERROR_ENUM.tooManyRequest) {
throw error;
}
logger.error('Failed to update auth frequency limit', { eventId, error });
}
};
......@@ -597,7 +597,8 @@ export async function restoreArchivedSandboxBeforeUse(params: {
if (
!initial ||
initial.status === SandboxInstanceStatusEnum.running ||
initial.status === SandboxInstanceStatusEnum.stopped
initial.status === SandboxInstanceStatusEnum.stopped ||
initial.status === SandboxInstanceStatusEnum.provisioning
) {
return;
}
......
......@@ -538,6 +538,12 @@ export const getSandboxClient = async (
return sandbox;
} catch (error) {
if (isRedisLeaseError(error)) throw createAgentSandboxInitializingError();
if (
error instanceof SandboxLifecycleStateError &&
error.state === SandboxInstanceStatusEnum.provisioning
) {
throw createAgentSandboxInitializingError();
}
throw error;
}
};
......@@ -17,7 +17,10 @@ export const sandboxLsTool = defineTool({
execute: async ({ sandboxInstance, params }) => {
await sandboxInstance.ensureAvailable();
const limit = params.limit ?? DEFAULT_LS_LIMIT;
const entries = await sandboxInstance.provider.listDirectory(params.path ?? '.');
const providerPath = sandboxInstance.resolveRuntimePath(params.path, {
allowAbsolutePath: true
});
const entries = await sandboxInstance.provider.listDirectory(providerPath);
const results = entries
.map((entry) => `${entry.name}${entry.isDirectory ? '/' : ''}`)
.sort((a, b) => a.localeCompare(b));
......
......@@ -30,17 +30,7 @@ const OPEN_SANDBOX_DOCKER_LOCAL_NETWORK_POLICY = {
*/
export function buildOpenSandboxRuntimeProfile(): SandboxRuntimeProfile {
const workDirectory = OPEN_SANDBOX_DEFAULT_ROOT_PATH;
const defaultImage = (() => {
const image = serviceEnv.AGENT_SANDBOX_OPENSANDBOX_IMAGE?.trim();
if (image) return parseImageSpec(image);
const repository = serviceEnv.AGENT_SANDBOX_OPENSANDBOX_IMAGE_REPO?.trim() ?? '';
if (!repository) return { repository };
return {
repository,
tag: serviceEnv.AGENT_SANDBOX_OPENSANDBOX_IMAGE_TAG?.trim() || 'latest'
};
})();
const defaultImage = parseImageSpec(serviceEnv.AGENT_SANDBOX_OPENSANDBOX_IMAGE?.trim());
return {
provider: 'opensandbox',
......
......@@ -28,6 +28,7 @@ export const buildAgentLoopCoreInputFilesPrompt = (files: AgentLoopCoreInputFile
if (files.length === 0) return '';
return `## 对话文件
用户本次对话上传的文件,用途:
1. 可通过 ${READ_FILES_TOOL_NAME} 读取文档内容。
2. 可把 url 作为模型参数。
......@@ -53,10 +54,7 @@ export function buildAgentLoopCoreSkillsPrompt(skillInfos: DeployedSkillInfo[] =
return `## 技能
以下技能为特定任务提供专门的操作说明:
- 当用户任务与某个技能的描述匹配时,先使用 ${SANDBOX_READ_FILE_TOOL_NAME} 读取完整的技能文件,再继续执行。不要仅凭技能描述推断完整工作流。
- 当技能文件引用相对路径时,以该技能文件所在目录为基准解析,并在工具调用中使用解析后的路径。
当用户任务与某个技能的描述匹配时,先使用 ${SANDBOX_READ_FILE_TOOL_NAME} 读取完整的技能文件,然后依据技能完整描述来执行任务。
<available_skills>
${skillInfos
......@@ -73,24 +71,13 @@ ${skillInfos
</available_skills>`;
}
const buildAgentLoopCoreSandboxWriteBoundaryPrompt = (currentWorkingDirectory?: string) => {
if (!currentWorkingDirectory) return '';
return `## Sandbox 文件写入边界
生成或修改文件时,必须严格区分系统目录和用户产物目录:
- 用户 Skill 产物根目录:${currentWorkingDirectory}/skills
- 如果任务需要创建或修改用户 Skill,只能写入:${currentWorkingDirectory}/skills/<skill-name>/
- 用户 Skill 主文件必须是:${currentWorkingDirectory}/skills/<skill-name>/SKILL.md
- 禁止写入:${currentWorkingDirectory}/<skill-name>/ 或 ${currentWorkingDirectory}/SKILL.md
- 禁止写入:/home/sandbox/.fastgpt/skills/、~/.fastgpt/skills/ 或任何 .fastgpt/skills/ 路径;这些路径只用于系统内置 Skill。`;
};
const buildAgentLoopCoreInputDatasetsPrompt = (
selectedDataset: AgentLoopCoreSelectedDatasetContext[] = []
) => {
if (selectedDataset.length === 0) return '';
return `## 知识库
用户当前可用的知识库:
${selectedDataset
......@@ -116,8 +103,9 @@ const buildAgentLoopCoreEnvPrompt = ({
if (!currentTime && !currentWorkingDirectory) return '';
return `## 背景信息
${currentTime ? `当前时间: ${currentTime}` : ''}
${currentWorkingDirectory ? `当前 sandbox 工作目录: ${currentWorkingDirectory}` : ''}`;
${currentWorkingDirectory ? `当前沙盒的工作目录: ${currentWorkingDirectory}` : ''}`;
};
/**
......@@ -144,7 +132,6 @@ export const buildAgentLoopCoreUserReminderInput = ({
}) => {
const reminder = [
buildAgentLoopCoreSkillsPrompt(skillInfos),
buildAgentLoopCoreSandboxWriteBoundaryPrompt(currentWorkingDirectory),
buildAgentLoopCoreInputFilesPrompt(filesInfo),
buildAgentLoopCoreInputDatasetsPrompt(selectedDataset),
buildAgentLoopCoreEnvPrompt({ currentTime, currentWorkingDirectory })
......@@ -155,7 +142,7 @@ export const buildAgentLoopCoreUserReminderInput = ({
if (!reminder) return query || '';
return `<system-reminder>
依据以下内容完成任务
可以借助以下信息来完成任务
${reminder}
</system-reminder>
......
......@@ -114,14 +114,6 @@ export const serviceEnv = createEnv({
AGENT_SANDBOX_OPENSANDBOX_IMAGE: z.string().optional().meta({
description: 'OpenSandbox 使用的运行态镜像;启用 opensandbox 时必填'
}),
AGENT_SANDBOX_OPENSANDBOX_IMAGE_REPO: z.string().optional().meta({
description: 'Deprecated OpenSandbox image repository fallback',
deprecated: true
}),
AGENT_SANDBOX_OPENSANDBOX_IMAGE_TAG: z.string().optional().meta({
description: 'Deprecated OpenSandbox image tag fallback',
deprecated: true
}),
AGENT_SANDBOX_OPENSANDBOX_USE_SERVER_PROXY: BoolSchema.default(true),
AGENT_SANDBOX_OPENSANDBOX_VOLUME_MANAGER_URL: UrlSchema.optional(),
AGENT_SANDBOX_OPENSANDBOX_VOLUME_MANAGER_TOKEN: z.string().optional(),
......@@ -178,9 +170,6 @@ export const serviceEnv = createEnv({
TEXTIN_SECRET_CODE: z.string().optional().meta({
description: '合合信息 Textin 服务 Secret Code'
}),
HOME_CHAT_CUSTOM_PDF_PARSE: BoolSchema.default(false).meta({
description: '首页聊天是否启用 PDF 增强解析'
}),
// ==================== 数据库与缓存 ====================
// Redisg
......@@ -307,8 +296,8 @@ export const serviceEnv = createEnv({
description:
'可信反向代理 IP/CIDR 列表,逗号或空白分隔。仅 TRUSTED_PROXY_ENABLE=true 时生效;仅显式可信代理传入的 X-Forwarded-For/X-Real-IP 会用于客户端 IP 解析'
}),
PASSWORD_LOGIN_LOCK_SECONDS: defaultableIntSchema(120).meta({
description: '密码错误锁定时长(秒)'
PASSWORD_LOGIN_MINUTE_LIMIT_COUNT: defaultableIntSchema(10).meta({
description: '密码登录每分钟次数限制'
}),
MAX_LOGIN_SESSION: IntSchema.default(10).meta({ description: '最大登录客户端数量(默认 10)' }),
ALLOWED_ORIGINS: z
......
......@@ -108,13 +108,8 @@ export const getAgentSandboxMissingRequiredEnvKeys = (env: NodeJS.ProcessEnv): s
}
return agentSandboxProviderRequiredEnvKeys[provider].filter((key) => {
if (key !== 'AGENT_SANDBOX_OPENSANDBOX_IMAGE') return !env[key];
// 升级窗口内允许旧的 repo/tag 配置提供默认镜像;新 IMAGE 始终优先。
return (
!env.AGENT_SANDBOX_OPENSANDBOX_IMAGE?.trim() &&
!env.AGENT_SANDBOX_OPENSANDBOX_IMAGE_REPO?.trim()
);
const value = env[key];
return key === 'AGENT_SANDBOX_OPENSANDBOX_IMAGE' ? !value?.trim() : !value;
});
};
......
......@@ -2,8 +2,8 @@ import { ERROR_ENUM } from '@fastgpt/global/common/error/errorCode';
import { updateApiKeyUsedTime } from './tools';
import { MongoOpenApi } from './schema';
import type { OpenApiSchema } from '@fastgpt/global/support/openapi/type';
export type AuthOpenApiLimitProps = { openApi: OpenApiSchema };
import { UserError } from '@fastgpt/global/common/error/utils';
import { isProVersion } from '../../common/system/constants';
const ApiKeyAppIdCredentialReg = /^(.+)-([a-fA-F0-9]{24})$/;
......@@ -30,6 +30,20 @@ export function resolveOpenApiCredential(rawCredential: string) {
};
}
/** 校验商业版 API Key 的有效期和用量额度。 */
export function assertOpenApiLimit(openApi: OpenApiSchema) {
if (openApi.limit?.expiredTime && new Date(openApi.limit.expiredTime).getTime() < Date.now()) {
throw new UserError(`Key ${openApi.apiKey} is expired`);
}
if (
openApi.limit?.maxUsagePoints &&
openApi.limit.maxUsagePoints > -1 &&
openApi.usagePoints > openApi.limit.maxUsagePoints
) {
throw new UserError(`Key ${openApi.apiKey} is over usage`);
}
}
export async function authOpenApiKey({
apikey,
authApiKey = true
......@@ -51,10 +65,9 @@ export async function authOpenApiKey({
return Promise.reject(ERROR_ENUM.unAuthApiKey);
}
// auth limit
await global.authOpenApiHandler({
openApi
});
if (isProVersion()) {
assertOpenApiLimit(openApi);
}
updateApiKeyUsedTime(openApi._id);
......
import { authFrequencyLimit } from '../../../common/system/frequencyLimit/utils';
import { assertOutLinkRateLimit } from '../../../common/rateLimit/interface/outLink';
import type {
AuthOutLinkInitProps,
AuthOutLinkLimitProps,
......@@ -7,7 +7,6 @@ import type {
import { axios } from '../../../common/api/axios';
import { OutLinkErrEnum } from '@fastgpt/global/common/error/code/outLink';
import type { OutLinkSchemaType } from '@fastgpt/global/support/outLink/type';
import { addMinutes } from 'date-fns';
import { UserError } from '@fastgpt/global/common/error/utils';
import { S3_KEY_PATH_INVALID_CHARS } from '../../../common/s3/config/constants';
......@@ -49,25 +48,20 @@ export const authOutLinkInit = async ({
return { uid };
};
const authIpLimit = async ({ ip, outLink }: { ip: string; outLink: OutLinkSchemaType }) => {
const assertOutLinkQpmLimit = async (outLink: OutLinkSchemaType, uid: string) => {
if (!outLink.limit || !outLink.limit.QPM) {
return;
}
try {
await authFrequencyLimit({
eventId: `${outLink._id}-${ip}`,
maxAmount: outLink.limit.QPM,
expiredTime: addMinutes(new Date(), 1)
});
} catch (error) {
return Promise.reject(new UserError(`每分钟仅能请求 ${outLink.limit.QPM} 次~`));
}
await assertOutLinkRateLimit({
outLinkId: String(outLink._id),
uid,
limit: outLink.limit.QPM
});
};
export async function authOutLinkLimit({
outLink,
ip,
outLinkUid,
question
}: AuthOutLinkLimitProps): Promise<AuthOutLinkResponse> {
......@@ -88,10 +82,7 @@ export async function authOutLinkLimit({
return Promise.reject(new UserError('链接超出使用限制'));
}
// ip limit
if (ip) {
await authIpLimit({ ip, outLink });
}
await assertOutLinkQpmLimit(outLink, outLinkUid);
// url auth. send request
if (!outLink.limit.hookUrl) {
......@@ -113,7 +104,7 @@ export async function authOutLinkLimit({
}
return { uid: data?.data?.uid || outLinkUid };
} catch (error) {
} catch {
return Promise.reject(new UserError('身份校验失败'));
}
}
......@@ -195,8 +195,7 @@ export async function outlinkInvokeChat<T extends OutlinkAppType>({
await authOutLinkLimit({
outLinkUid: chatUserId,
outLink: outLinkConfig as any, // HACK, we do not need to provide app: T
question: userQuestion,
ip: chatId
question: userQuestion
});
const enableStreaming = !!streamId || !!onStreamChunk;
......
......@@ -35,7 +35,7 @@ export async function parseHeaderCert({
// parse jwt
async function authCookieToken(cookie?: string, token?: string) {
// 获取 cookie
const cookies = Cookie.parse(cookie || '');
const cookies = Cookie.parse(cookie ?? '');
const cookieToken = token || cookies[TokenName];
if (!cookieToken) {
......
import { MongoTmpData } from './schema';
import type { ClientSession } from '../../common/mongo';
import { mongoSessionRun } from '../../common/mongo/sessionRun';
import {
VerificationTtlSeconds,
type AccountVerificationPurpose,
type VerificationMaterial,
type VerificationMaterialMatch,
type VerificationScene,
type VerificationType,
type VerificationTtlPreset
} from '@fastgpt/global/support/user/account/verification/type';
import { hashStr } from '@fastgpt/global/common/string/tools';
export type Scene = AccountVerificationPurpose;
export type Type = VerificationType;
export type VerificationConsumeMatch<T extends Type = Type> = VerificationMaterialMatch<T>;
type VerificationDataIdParamsByType<T extends Type> = {
scene: VerificationScene<T>;
type: T;
key: string;
};
export type VerificationDataIdParams = {
[T in Type]: VerificationDataIdParamsByType<T>;
}[Type];
type VerificationGetParams<T extends Type> = VerificationDataIdParamsByType<T> & {
match?: VerificationConsumeMatch<T>;
session?: ClientSession;
};
type VerificationUpsertParams<T extends Type> = VerificationDataIdParamsByType<T> & {
data: VerificationMaterial<T>;
ttlPreset: VerificationTtlPreset;
session?: ClientSession;
};
type VerificationCreateParams<T extends Type> = VerificationUpsertParams<T>;
type VerificationUpdateParams<T extends Type> = VerificationUpsertParams<T>;
type VerificationDeleteParams<T extends Type> = VerificationDataIdParamsByType<T> & {
match?: VerificationConsumeMatch<T>;
session?: ClientSession;
};
export type VerificationConsumeParams<T extends Type> = {
scene: VerificationScene<T>;
type: T;
key: string;
match?: VerificationConsumeMatch<T>;
};
export type VerificationConsumeContext<T extends Type> = {
material: VerificationMaterial<T>;
session: ClientSession;
};
export class VerificationMaterialError extends Error {
constructor() {
super('Verification material is invalid or already consumed');
this.name = 'VerificationMaterialError';
}
}
/** 构造身份验证材料在 tmp_datas 中使用的稳定 ID,并绑定合法场景和材料类型。 */
export const getDataId = <T extends Type>({
scene,
type,
key
}: VerificationDataIdParamsByType<T>) => `verification:v1:${scene}:${type}:${key}`;
/** 为账号下的单个验证码生成稳定且不暴露验证码明文的材料 key。 */
export const getCodeVerificationKey = ({ account, code }: { account: string; code: string }) =>
`${account}:${hashStr(code.toLowerCase())}`;
/** 将材料字段转换为 Mongo 查询字段,字段名受具体材料类型约束。 */
const getDataMatch = (match: VerificationConsumeMatch) =>
Object.fromEntries(Object.entries(match).map(([field, value]) => [`data.${field}`, value]));
const getActiveFilter = <T extends Type>(params: VerificationConsumeParams<T>) => ({
dataId: getDataId(params),
expireAt: { $gt: new Date() },
...getDataMatch(params.match ?? {})
});
const findActiveRecord = async <T extends Type>(
params: VerificationConsumeParams<T>,
session?: ClientSession
) => {
const query = MongoTmpData.findOne(getActiveFilter(params));
if (session) query.session(session);
return query.lean();
};
/** 根据统一 TTL 档位计算材料过期时间,避免业务层自行构造 Date。 */
const getExpireAt = (ttlPreset: VerificationTtlPreset) =>
new Date(Date.now() + VerificationTtlSeconds[ttlPreset] * 1000);
const isMongoDuplicateKeyError = (error: unknown) =>
!!error && typeof error === 'object' && 'code' in error && error.code === 11000;
/**
* 身份验证材料的临时存取包装。
*
* 每个方法通过同一个类型参数关联 scene、type 和 data/match,调用方不再能
* 通过无关的泛型把验证码材料当成其它材料读取,也不能拼写不存在的字段。
*/
export const verification = {
/**
* 仅在同 ID 不存在有效材料时创建,用于允许同账号的不同验证码并存。
* 过期记录可能尚未被 TTL 索引清理,因此创建前会精确删除同 ID 的过期记录。
*/
createIfInactive: async <T extends Type>(params: VerificationCreateParams<T>) => {
const dataId = getDataId(params);
const sessionOptions = params.session ? { session: params.session } : {};
await MongoTmpData.deleteOne(
{
dataId,
expireAt: { $lte: new Date() }
},
sessionOptions
);
try {
await MongoTmpData.create(
[
{
dataId,
data: params.data,
expireAt: getExpireAt(params.ttlPreset)
}
],
sessionOptions
);
return true;
} catch (error) {
if (isMongoDuplicateKeyError(error)) return false;
throw error;
}
},
/** 覆盖同一场景、类型和 key 的材料,并刷新过期时间。 */
upsert: async <T extends Type>(params: VerificationUpsertParams<T>) => {
const dataId = getDataId(params);
return MongoTmpData.updateOne(
{ dataId },
{
dataId,
data: params.data,
expireAt: getExpireAt(params.ttlPreset)
},
{ upsert: true, ...(params.session ? { session: params.session } : {}) }
);
},
/** 只更新仍在有效期内的已有材料,避免回调重新创建或刷新过期材料。 */
updateIfActive: async <T extends Type>(params: VerificationUpdateParams<T>) => {
return MongoTmpData.updateOne(
{
dataId: getDataId(params),
expireAt: { $gt: new Date() }
},
{
$set: {
data: params.data,
expireAt: getExpireAt(params.ttlPreset)
}
},
{ ...(params.session ? { session: params.session } : {}) }
);
},
/** 只删除仍有效且匹配当前材料内容的记录,避免清理并发请求新写入的验证码。 */
deleteIfMatch: async <T extends Type>(params: VerificationDeleteParams<T>) => {
const { session, ...filterParams } = params;
return MongoTmpData.deleteOne(getActiveFilter(filterParams), {
...(session ? { session } : {})
});
},
/** 读取仍在有效期内的材料,不主动改变材料生命周期。 */
get: async <T extends Type>(
params: VerificationGetParams<T>
): Promise<VerificationMaterial<T> | null> => {
const { session, ...filterParams } = params;
const result = await findActiveRecord(filterParams, session);
return result ? (result.data as VerificationMaterial<T>) : null;
},
/** 判断材料是否仍在有效期内,用于区分未完成状态和已过期状态。 */
hasActive: async <T extends Type>(params: VerificationGetParams<T>): Promise<boolean> => {
const { session, ...filterParams } = params;
const result = await findActiveRecord(filterParams, session);
return Boolean(result);
},
/** 通过精确 dataId 候选查找唯一有效材料;多个候选命中视为数据冲突。 */
findUniqueActiveDataId: async (dataIds: readonly string[]): Promise<string | null> => {
const results = await MongoTmpData.find({
dataId: { $in: [...new Set(dataIds)] },
expireAt: { $gt: new Date() }
})
.select({ dataId: 1 })
.limit(2)
.lean();
if (results.length > 1) {
throw new Error('Verification material data id conflict');
}
return results[0]?.dataId ?? null;
},
/** 在同一 Mongo 事务内读取材料、执行业务回调,并在回调成功后消费材料。 */
consumeInTransaction: async <T extends Type, R>(
params: VerificationConsumeParams<T>,
handler: (context: VerificationConsumeContext<T>) => Promise<R>
): Promise<R> => {
return mongoSessionRun(async (session) => {
const record = await findActiveRecord(params, session);
if (!record) {
throw new VerificationMaterialError();
}
const result = await handler({
material: record.data as VerificationMaterial<T>,
session
});
const deleted = await MongoTmpData.deleteOne(getActiveFilter(params), { session });
if (deleted.deletedCount !== 1) {
throw new VerificationMaterialError();
}
return result;
});
}
};
import { UserErrEnum } from '@fastgpt/global/common/error/code/user';
import { UserError } from '@fastgpt/global/common/error/utils';
import { getNanoid } from '@fastgpt/global/common/string/tools';
import { verification, VerificationMaterialError } from '../../../../tmpData/verification';
import { MongoUser } from '../../../schema';
import { serviceEnv } from '../../../../../env';
import {
assertPasswordVerificationConsumeRateLimit,
assertPasswordVerificationCreateRateLimit
} from '../../../../../common/rateLimit/interface/accountVerification';
import type {
IssuePreLoginCodeParams,
IssuePreLoginCodeResult,
PasswordVerificationDependencies,
PasswordVerificationHandler,
VerifyPasswordCredentialsParams
} from './type';
const defaultDependencies: PasswordVerificationDependencies = {
generateCode: getNanoid,
assertCreateFrequency: ({ account, scene }) =>
assertPasswordVerificationCreateRateLimit({
account,
scene,
limit: serviceEnv.PASSWORD_LOGIN_MINUTE_LIMIT_COUNT
}),
assertConsumeFrequency: ({ account, scene }) =>
assertPasswordVerificationConsumeRateLimit({
account,
scene,
limit: serviceEnv.PASSWORD_LOGIN_MINUTE_LIMIT_COUNT
}),
savePreLoginCode: ({ purpose, username, code, ttlPreset }) =>
verification.upsert({
scene: purpose,
type: 'password',
key: username,
data: { preLoginCode: code },
ttlPreset
}),
findUserByCredentials: ({ username, password, session }) => {
const query = MongoUser.findOne({ username, password });
if (session) query.session(session);
return query;
},
consumeInTransaction: verification.consumeInTransaction
};
/**
* 只负责预登录验证码和用户名密码匹配,并返回匹配到的用户。
* forbidden、WeCom 账号限制以及团队、Session、Cookie 等登录策略由业务层负责。
*/
export class PasswordVerificationService {
private readonly dependencies: PasswordVerificationDependencies;
constructor(dependencies: Partial<PasswordVerificationDependencies> = {}) {
this.dependencies = {
...defaultDependencies,
...dependencies
};
}
async issuePreLoginCode({
username,
purpose
}: IssuePreLoginCodeParams): Promise<IssuePreLoginCodeResult> {
await this.dependencies.assertCreateFrequency({ account: username, scene: purpose });
const code = this.dependencies.generateCode(6);
await this.dependencies.savePreLoginCode({
username,
code,
purpose,
ttlPreset: 'short'
});
return { code };
}
/** 在同一事务内完成凭据校验、业务回调和预登录材料消费。 */
async withVerifiedCredentials<T>(
params: VerifyPasswordCredentialsParams,
handler: PasswordVerificationHandler<T>
) {
await this.dependencies.assertConsumeFrequency({
account: params.username,
scene: params.purpose
});
try {
return await this.dependencies.consumeInTransaction(
{
scene: params.purpose,
type: 'password',
key: params.username,
match: { preLoginCode: params.code }
},
async ({ session }) => {
const user = await this.dependencies.findUserByCredentials({
username: params.username,
password: params.password,
session
});
if (!user) {
return Promise.reject(UserErrEnum.account_psw_error);
}
return handler({ user, session });
}
);
} catch (error) {
if (error instanceof VerificationMaterialError) {
return Promise.reject(new UserError(UserErrEnum.invalidVerificationCode));
}
throw error;
}
}
}
export const passwordVerificationService = new PasswordVerificationService();
import type { HydratedDocument } from 'mongoose';
import type { UserModelSchema } from '@fastgpt/global/support/user/type';
import type {
PasswordVerificationPurpose,
VerificationTtlPreset,
VerificationType
} from '@fastgpt/global/support/user/account/verification/type';
import type { ClientSession } from '../../../../../common/mongo';
import type {
VerificationConsumeContext,
VerificationConsumeParams
} from '../../../../tmpData/verification';
export type PasswordVerificationUser = HydratedDocument<UserModelSchema>;
export type { PasswordVerificationPurpose } from '@fastgpt/global/support/user/account/verification/type';
export type IssuePreLoginCodeParams = {
username: string;
purpose: PasswordVerificationPurpose;
};
export type IssuePreLoginCodeResult = {
code: string;
};
export type VerifyPasswordCredentialsParams = {
username: string;
password: string;
code: string;
purpose: PasswordVerificationPurpose;
};
export type PasswordVerificationHandler<T> = (params: {
user: PasswordVerificationUser;
session: ClientSession;
}) => Promise<T>;
export type PasswordVerificationDependencies = {
generateCode: (length: number) => string;
assertCreateFrequency: (params: {
account: string;
scene: PasswordVerificationPurpose;
}) => Promise<unknown>;
assertConsumeFrequency: (params: {
account: string;
scene: PasswordVerificationPurpose;
}) => Promise<unknown>;
savePreLoginCode: (params: {
username: string;
code: string;
purpose: PasswordVerificationPurpose;
ttlPreset: VerificationTtlPreset;
}) => Promise<unknown>;
findUserByCredentials: (params: {
username: string;
password: string;
session?: ClientSession;
}) => Promise<PasswordVerificationUser | null>;
consumeInTransaction: <T extends VerificationType, R>(
params: VerificationConsumeParams<T>,
handler: (context: VerificationConsumeContext<T>) => Promise<R>
) => Promise<R>;
};
import { UserAuthTypeEnum } from '@fastgpt/global/support/user/auth/constants';
import { MongoUserAuth } from './schema';
import { i18nT } from '@fastgpt/global/common/i18n/utils';
import { mongoSessionRun } from '../../../common/mongo/sessionRun';
import { UserError } from '@fastgpt/global/common/error/utils';
import { z } from 'zod';
export const addAuthCode = async ({
key,
code,
openid,
type,
expiredTime
}: {
key: string;
code?: string;
openid?: string;
type: `${UserAuthTypeEnum}`;
expiredTime?: Date;
}) => {
return MongoUserAuth.updateOne(
{
key,
type
},
{
code,
openid,
expiredTime
},
{
upsert: true
}
);
};
const authCodeSchema = z.object({
key: z.string(),
type: z.enum(UserAuthTypeEnum),
code: z.string()
});
export const authCode = async (props: z.infer<typeof authCodeSchema>) => {
const { key, type, code } = authCodeSchema.parse(props);
return mongoSessionRun(async (session) => {
const result = await MongoUserAuth.findOne(
{
key,
type,
code: { $regex: new RegExp(`^${code}$`, 'i') }
},
undefined,
{ session }
);
if (!result) {
return Promise.reject(new UserError(i18nT('common:error.code_error')));
}
await result.deleteOne();
return 'SUCCESS';
});
};
import { defineIndex, connectionMongo, getMongoModel } from '../../../common/mongo';
const { Schema } = connectionMongo;
import type { UserAuthSchemaType } from '@fastgpt/global/support/user/auth/type';
import { userAuthTypeMap } from '@fastgpt/global/support/user/auth/constants';
import { addMinutes } from 'date-fns';
const UserAuthSchema = new Schema({
key: {
type: String,
required: true
},
code: {
// auth code
type: String,
length: 6
},
// wx openid
openid: String,
type: {
type: String,
enum: Object.keys(userAuthTypeMap),
required: true
},
createTime: {
type: Date,
default: () => new Date()
},
expiredTime: {
type: Date,
default: () => addMinutes(new Date(), 5)
}
});
defineIndex(UserAuthSchema, { key: { key: 1, type: 1 } });
defineIndex(UserAuthSchema, {
key: { expiredTime: 1 },
options: { expireAfterSeconds: 0 }
});
export const MongoUserAuth = getMongoModel<UserAuthSchemaType>('auth_codes', UserAuthSchema);
......@@ -3,6 +3,7 @@ import { MongoUser } from './schema';
import { getTmbInfoByTmbId, getUserDefaultTeam } from './team/controller';
import { ERROR_ENUM } from '@fastgpt/global/common/error/errorCode';
import { TeamPermission } from '@fastgpt/global/support/permission/user/controller';
import type { ClientSession } from '../../common/mongo';
export async function authUserExist({ userId, username }: { userId?: string; username?: string }) {
if (userId) {
......@@ -17,25 +18,29 @@ export async function authUserExist({ userId, username }: { userId?: string; use
export async function getUserDetail({
tmbId,
userId,
isRoot = false
isRoot = false,
session
}: {
tmbId?: string;
userId?: string;
isRoot?: boolean;
session?: ClientSession;
}): Promise<UserType> {
const tmb = await (async () => {
if (tmbId) {
try {
const result = await getTmbInfoByTmbId({ tmbId });
const result = await getTmbInfoByTmbId({ tmbId, session });
return result;
} catch (error) {}
}
if (userId) {
return getUserDefaultTeam({ userId });
return getUserDefaultTeam({ userId, session });
}
return Promise.reject(ERROR_ENUM.unAuthorization);
})();
const user = await MongoUser.findById(tmb.userId);
const query = MongoUser.findById(tmb.userId);
if (session) query.session(session);
const user = await query;
if (!user) {
return Promise.reject(ERROR_ENUM.unAuthorization);
......
......@@ -22,8 +22,13 @@ import { getLogger, LogCategories } from '../../../common/logger';
const logger = getLogger(LogCategories.MODULE.USER.TEAM);
async function getTeamMember(match: Record<string, any>): Promise<TeamTmbItemType> {
const tmb = await MongoTeamMember.findOne(match).populate<{ team: TeamSchema }>('team').lean();
async function getTeamMember(
match: Record<string, any>,
session?: ClientSession
): Promise<TeamTmbItemType> {
const query = MongoTeamMember.findOne(match).populate<{ team: TeamSchema }>('team');
if (session) query.session(session);
const tmb = await query.lean();
if (!tmb) {
return Promise.reject('member not exist');
}
......@@ -66,23 +71,36 @@ export const getTeamOwner = async (teamId: string) => {
return tmb;
};
export async function getTmbInfoByTmbId({ tmbId }: { tmbId: string }) {
export async function getTmbInfoByTmbId({
tmbId,
session
}: {
tmbId: string;
session?: ClientSession;
}) {
if (!tmbId) {
return Promise.reject('tmbId or userId is required');
}
return getTeamMember({
_id: new Types.ObjectId(String(tmbId)),
status: notLeaveStatus
});
return getTeamMember(
{
_id: new Types.ObjectId(String(tmbId)),
status: notLeaveStatus
},
session
);
}
export async function getUserDefaultTeam({ userId }: { userId: string }) {
export async function getUserDefaultTeam({
userId,
session
}: {
userId: string;
session?: ClientSession;
}) {
if (!userId) {
return Promise.reject('tmbId or userId is required');
}
return getTeamMember({
userId: new Types.ObjectId(userId)
});
return getTeamMember({ userId: new Types.ObjectId(userId) }, session);
}
export async function createDefaultTeam({
......
......@@ -119,7 +119,7 @@ describe('plusRequest', () => {
});
const { POST } = await importPlusRequest();
await expect(POST('/support/openapi/authLimit', {})).rejects.toMatchObject({
await expect(POST('/support/test-user-error', {})).rejects.toMatchObject({
name: 'UserError',
message: 'API key has expired'
});
......
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { useIPFrequencyLimit } from '@fastgpt/service/common/middle/reqFrequencyLimit';
import { MongoFrequencyLimit } from '@fastgpt/service/common/system/frequencyLimit/schema';
import { jsonRes } from '@fastgpt/service/common/response';
import { serviceEnv } from '@fastgpt/service/env';
import {
createRedisLogicalKey,
getRedisRuntime,
toPhysicalRedisKey
} from '@fastgpt/dal/redis/runtime';
import { RATE_LIMIT_KEY_PREFIX } from '@fastgpt/service/common/rateLimit/core';
const originalUseIpLimit = serviceEnv.USE_IP_LIMIT;
const originalTrustedProxyEnable = serviceEnv.TRUSTED_PROXY_ENABLE;
const getIPFrequencyLimitKey = (id: string, ip: string) =>
toPhysicalRedisKey(
createRedisLogicalKey({
namespace: RATE_LIMIT_KEY_PREFIX,
segments: ['ip', id, 'ip', ip]
})
);
const getRedisConnection = () => getRedisRuntime().getCommandConnection();
const setUseIpLimit = (value: boolean) => {
serviceEnv.USE_IP_LIMIT = value;
};
......@@ -40,9 +55,7 @@ const createReq = ({
describe('useIPFrequencyLimit', () => {
beforeEach(async () => {
vi.clearAllMocks();
await MongoFrequencyLimit.deleteMany({
eventId: /^ip-qps-limit-ip-spoof-test-/
});
await getRedisConnection().flushdb();
});
afterEach(() => {
......@@ -65,11 +78,11 @@ describe('useIPFrequencyLimit', () => {
createRes()
);
const record = await MongoFrequencyLimit.findOne({
eventId: 'ip-qps-limit-ip-spoof-test-toggle-enabled-198.51.100.40'
}).lean();
const count = await getRedisConnection().get(
getIPFrequencyLimitKey('ip-spoof-test-toggle-enabled', '198.51.100.40')
);
expect(record?.amount).toBe(1);
expect(Number(count)).toBe(1);
});
it('should skip IP limit when USE_IP_LIMIT is disabled without force', async () => {
......@@ -87,11 +100,11 @@ describe('useIPFrequencyLimit', () => {
createRes()
);
const record = await MongoFrequencyLimit.findOne({
eventId: 'ip-qps-limit-ip-spoof-test-toggle-disabled-198.51.100.41'
}).lean();
const count = await getRedisConnection().get(
getIPFrequencyLimitKey('ip-spoof-test-toggle-disabled', '198.51.100.41')
);
expect(record).toBeNull();
expect(count).toBeNull();
});
it('should enforce IP limit when force is true even if USE_IP_LIMIT is disabled', async () => {
......@@ -110,11 +123,11 @@ describe('useIPFrequencyLimit', () => {
createRes()
);
const record = await MongoFrequencyLimit.findOne({
eventId: 'ip-qps-limit-ip-spoof-test-toggle-forced-198.51.100.42'
}).lean();
const count = await getRedisConnection().get(
getIPFrequencyLimitKey('ip-spoof-test-toggle-forced', '198.51.100.42')
);
expect(record?.amount).toBe(1);
expect(Number(count)).toBe(1);
});
it('should ignore spoofed forwarding headers from untrusted direct clients', async () => {
......@@ -138,15 +151,15 @@ describe('useIPFrequencyLimit', () => {
createRes()
);
const realIpRecord = await MongoFrequencyLimit.findOne({
eventId: 'ip-qps-limit-ip-spoof-test-direct-198.51.100.20'
}).lean();
const spoofedIpRecord = await MongoFrequencyLimit.findOne({
eventId: 'ip-qps-limit-ip-spoof-test-direct-203.0.113.50'
}).lean();
const realIpCount = await getRedisConnection().get(
getIPFrequencyLimitKey('ip-spoof-test-direct', '198.51.100.20')
);
const spoofedIpCount = await getRedisConnection().get(
getIPFrequencyLimitKey('ip-spoof-test-direct', '203.0.113.50')
);
expect(realIpRecord?.amount).toBe(1);
expect(spoofedIpRecord).toBeNull();
expect(Number(realIpCount)).toBe(1);
expect(spoofedIpCount).toBeNull();
});
it('should use X-Forwarded-For as the limit key when trusted proxy parsing is disabled', async () => {
......@@ -170,15 +183,15 @@ describe('useIPFrequencyLimit', () => {
createRes()
);
const forwardedIpRecord = await MongoFrequencyLimit.findOne({
eventId: 'ip-qps-limit-ip-spoof-test-compat-60.186.209.23'
}).lean();
const remoteIpRecord = await MongoFrequencyLimit.findOne({
eventId: 'ip-qps-limit-ip-spoof-test-compat-172.16.0.119'
}).lean();
const forwardedIpCount = await getRedisConnection().get(
getIPFrequencyLimitKey('ip-spoof-test-compat', '60.186.209.23')
);
const remoteIpCount = await getRedisConnection().get(
getIPFrequencyLimitKey('ip-spoof-test-compat', '172.16.0.119')
);
expect(forwardedIpRecord?.amount).toBe(1);
expect(remoteIpRecord).toBeNull();
expect(Number(forwardedIpCount)).toBe(1);
expect(remoteIpCount).toBeNull();
});
it('should use proxy-addr result for trusted proxy forwarding chains', async () => {
......@@ -201,15 +214,15 @@ describe('useIPFrequencyLimit', () => {
createRes()
);
const clientIpRecord = await MongoFrequencyLimit.findOne({
eventId: 'ip-qps-limit-ip-spoof-test-proxy-203.0.113.50'
}).lean();
const spoofedIpRecord = await MongoFrequencyLimit.findOne({
eventId: 'ip-qps-limit-ip-spoof-test-proxy-6.6.6.6'
}).lean();
const clientIpCount = await getRedisConnection().get(
getIPFrequencyLimitKey('ip-spoof-test-proxy', '203.0.113.50')
);
const spoofedIpCount = await getRedisConnection().get(
getIPFrequencyLimitKey('ip-spoof-test-proxy', '6.6.6.6')
);
expect(clientIpRecord?.amount).toBe(1);
expect(spoofedIpRecord).toBeNull();
expect(Number(clientIpCount)).toBe(1);
expect(spoofedIpCount).toBeNull();
});
it('should use a shared fail-closed key when client IP cannot be resolved', async () => {
......@@ -231,15 +244,15 @@ describe('useIPFrequencyLimit', () => {
createRes()
);
const unknownRecord = await MongoFrequencyLimit.findOne({
eventId: 'ip-qps-limit-ip-spoof-test-unknown-unknown'
}).lean();
const spoofedIpRecord = await MongoFrequencyLimit.findOne({
eventId: 'ip-qps-limit-ip-spoof-test-unknown-203.0.113.50'
}).lean();
const unknownCount = await getRedisConnection().get(
getIPFrequencyLimitKey('ip-spoof-test-unknown', 'unknown')
);
const spoofedIpCount = await getRedisConnection().get(
getIPFrequencyLimitKey('ip-spoof-test-unknown', '203.0.113.50')
);
expect(unknownRecord?.amount).toBe(1);
expect(spoofedIpRecord).toBeNull();
expect(Number(unknownCount)).toBe(1);
expect(spoofedIpCount).toBeNull();
});
it('should block requests after the IP limit is exceeded', async () => {
......@@ -267,4 +280,22 @@ describe('useIPFrequencyLimit', () => {
})
);
});
it('should allow requests when Redis is unavailable', async () => {
const redis = getRedisConnection();
vi.mocked(redis.multi).mockImplementationOnce(() => {
throw new Error('Redis unavailable');
});
const middleware = useIPFrequencyLimit({
id: 'ip-spoof-test-redis-failure',
seconds: 60,
limit: 1,
force: true
});
await middleware(createReq({ remoteAddress: '198.51.100.31' }), createRes());
expect(jsonRes).not.toHaveBeenCalled();
});
});
import type { ClientSession } from '@fastgpt/service/common/mongo';
import { connectionMongo } from '@fastgpt/service/common/mongo';
import { mongoSessionRun } from '@fastgpt/service/common/mongo/sessionRun';
import { afterEach, describe, expect, it, vi } from 'vitest';
vi.unmock(import('@fastgpt/service/common/mongo/sessionRun'));
const createSession = () =>
({
withTransaction: vi.fn(),
endSession: vi.fn(async () => undefined),
commitTransaction: vi.fn()
}) as unknown as ClientSession;
describe('mongoSessionRun', () => {
afterEach(() => {
vi.restoreAllMocks();
});
it('does not retry a business error or rerun its callback', async () => {
const session = createSession();
const businessError = new Error('business error');
const handler = vi.fn(async () => Promise.reject(businessError));
session.withTransaction = vi.fn(async (callback) => callback());
vi.spyOn(connectionMongo, 'startSession').mockResolvedValue(session);
await expect(mongoSessionRun(handler)).rejects.toBe(businessError);
expect(handler).toHaveBeenCalledTimes(1);
expect(session.withTransaction).toHaveBeenCalledWith(expect.any(Function), {
maxCommitTimeMS: 60000
});
expect(session.endSession).toHaveBeenCalledTimes(1);
});
it('lets commit retry without rerunning the transaction callback', async () => {
const session = createSession();
const handler = vi.fn(async () => 'completed');
const unknownCommitError = Object.assign(new Error('unknown commit result'), {
errorLabels: ['UnknownTransactionCommitResult']
});
session.commitTransaction = vi
.fn()
.mockRejectedValueOnce(unknownCommitError)
.mockResolvedValueOnce(undefined);
session.withTransaction = vi.fn(async (callback) => {
const result = await callback();
let committed = false;
while (!committed) {
try {
await session.commitTransaction();
committed = true;
} catch (error) {
if (
!(error as { errorLabels?: string[] }).errorLabels?.includes(
'UnknownTransactionCommitResult'
)
) {
throw error;
}
}
}
return result;
});
vi.spyOn(connectionMongo, 'startSession').mockResolvedValue(session);
await expect(mongoSessionRun(handler)).resolves.toBe('completed');
expect(handler).toHaveBeenCalledTimes(1);
expect(session.commitTransaction).toHaveBeenCalledTimes(2);
});
});
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { getRedisRuntime, toPhysicalRedisKey } from '@fastgpt/dal/redis/runtime';
import {
RATE_LIMIT_KEY_PREFIX,
defineRateLimitInterface
} from '@fastgpt/service/common/rateLimit/core';
import { checkIPRateLimit } from '@fastgpt/service/common/rateLimit/interface/ip';
import { RateLimitSceneEnum } from '@fastgpt/service/common/rateLimit/type';
const getRedisConnection = () => getRedisRuntime().getCommandConnection();
describe('rateLimit core', () => {
beforeEach(async () => {
await getRedisConnection().flushdb();
});
it('统一生成 rate-limit 场景 key,并按接口和 IP 隔离', async () => {
const params = {
id: 'wechat-login-qrcode',
ip: '192.0.2.1',
limit: 1,
seconds: 60
};
await expect(checkIPRateLimit(params)).resolves.toBe(true);
await expect(checkIPRateLimit(params)).resolves.toBe(false);
await expect(checkIPRateLimit({ ...params, ip: '192.0.2.2' })).resolves.toBe(true);
const key = toPhysicalRedisKey(`${RATE_LIMIT_KEY_PREFIX}:ip:wechat-login-qrcode:ip:192.0.2.1`);
await expect(getRedisConnection().get(key)).resolves.toBe(2);
});
it('对动态 key segment 进行编码', async () => {
await checkIPRateLimit({
id: 'encoded-policy',
ip: 'user:name@example.com',
limit: 1,
seconds: 60
});
const key = toPhysicalRedisKey(
`${RATE_LIMIT_KEY_PREFIX}:ip:encoded-policy:ip:user%3Aname%40example.com`
);
await expect(getRedisConnection().get(key)).resolves.toBe(1);
});
it('支持按增量原子消费额度', async () => {
const rateLimit = defineRateLimitInterface<{ increment: number }>({
scene: RateLimitSceneEnum.Upload,
policy: 'increment-test',
failureMode: 'closed',
getKeySegments: () => ['identity', 'member-1'],
getLimit: () => 3,
getWindowSeconds: () => 60,
getIncrement: ({ increment }) => increment,
createError: () => new Error('rate limited')
});
await expect(rateLimit.assert({ increment: 2 })).resolves.toBeUndefined();
await expect(rateLimit.assert({ increment: 2 })).rejects.toBeTruthy();
});
it('fail-open 接口在 Redis 故障时放行', async () => {
vi.mocked(getRedisConnection().multi).mockImplementationOnce(() => {
throw new Error('Redis unavailable');
});
await expect(
checkIPRateLimit({ id: 'redis-failure', ip: '192.0.2.3', limit: 1, seconds: 60 })
).resolves.toBe(true);
});
it('fail-closed 接口在 Redis 故障时拒绝', async () => {
const rateLimit = defineRateLimitInterface<{ account: string }>({
scene: RateLimitSceneEnum.AccountVerification,
policy: 'fail-closed-test',
failureMode: 'closed',
getKeySegments: ({ account }) => ['account', account],
getLimit: () => 1,
getWindowSeconds: () => 60,
createError: () => new Error('rate limited')
});
vi.mocked(getRedisConnection().multi).mockImplementationOnce(() => {
throw new Error('Redis unavailable');
});
await expect(rateLimit.check({ account: 'test@example.com' })).resolves.toBe(false);
});
it('非法额度配置不会被故障策略隐藏', async () => {
await expect(
checkIPRateLimit({ id: 'invalid-limit', ip: '192.0.2.4', limit: 0, seconds: 60 })
).rejects.toMatchObject({
code: 'REDIS_INVALID_ARGUMENT',
operation: 'rateLimit.consume'
});
});
});
import { UserErrEnum } from '@fastgpt/global/common/error/code/user';
import {
createRedisLogicalKey,
getRedisRuntime,
toPhysicalRedisKey
} from '@fastgpt/dal/redis/runtime';
import { describe, expect, it, beforeEach } from 'vitest';
import {
assertCaptchaVerificationConsumeRateLimit,
assertCaptchaVerificationCreateRateLimit,
assertCodeVerificationConsumeRateLimit,
assertPasswordVerificationCreateRateLimit,
assertPasswordVerificationConsumeRateLimit
} from '@fastgpt/service/common/rateLimit/interface/accountVerification';
import { RATE_LIMIT_KEY_PREFIX } from '@fastgpt/service/common/rateLimit/core';
const getVerificationFrequencyLimitKey = (...segments: string[]) =>
toPhysicalRedisKey(
createRedisLogicalKey({
namespace: RATE_LIMIT_KEY_PREFIX,
segments: ['account-verification', ...segments]
})
);
const getRedisConnection = () => getRedisRuntime().getCommandConnection();
describe('assertCodeVerificationConsumeFrequency', () => {
const account = 'verification-rate-limit@example.com';
const key = getVerificationFrequencyLimitKey('code-consume', 'register', 'account', account);
beforeEach(async () => {
await getRedisConnection().del(key);
});
it('allows 10 attempts and rejects the 11th attempt', async () => {
const params = { account, scene: 'register' };
for (let index = 0; index < 10; index++) {
await expect(assertCodeVerificationConsumeRateLimit(params)).resolves.toBeUndefined();
}
await expect(assertCodeVerificationConsumeRateLimit(params)).rejects.toThrow(
UserErrEnum.verifyCodeTooFrequently
);
});
it('keeps accounts and scenes independent', async () => {
const params = { account, scene: 'register' };
for (let index = 0; index < 10; index++) {
await assertCodeVerificationConsumeRateLimit(params);
}
await expect(
assertCodeVerificationConsumeRateLimit({ account: 'other@example.com', scene: 'register' })
).resolves.toBeUndefined();
await expect(
assertCodeVerificationConsumeRateLimit({ account, scene: 'findPassword' })
).resolves.toBeUndefined();
});
});
describe('account verification frequency actions', () => {
const account = 'verification-actions@example.com';
const keys = [
getVerificationFrequencyLimitKey('captcha-create', 'register', 'account', account),
getVerificationFrequencyLimitKey('captcha-consume', 'register', 'account', account),
getVerificationFrequencyLimitKey('password-create', 'login', 'account', account),
getVerificationFrequencyLimitKey('password-consume', 'login', 'account', account)
];
beforeEach(async () => {
await getRedisConnection().del(...keys);
});
it.each([
['captcha create', assertCaptchaVerificationCreateRateLimit, { account, scene: 'register' }],
['captcha consume', assertCaptchaVerificationConsumeRateLimit, { account, scene: 'register' }],
[
'password create',
assertPasswordVerificationCreateRateLimit,
{ account, scene: 'login', limit: 10 }
],
[
'password consume',
assertPasswordVerificationConsumeRateLimit,
{ account, scene: 'login', limit: 10 }
]
] as const)('limits %s to 10 attempts', async (_name, assertFrequency, params) => {
for (let index = 0; index < 10; index++) {
await expect(assertFrequency(params)).resolves.toBeUndefined();
}
await expect(assertFrequency(params)).rejects.toThrow(UserErrEnum.verifyCodeTooFrequently);
});
it('keeps captcha and password actions independent', async () => {
for (let index = 0; index < 10; index++) {
await assertCaptchaVerificationCreateRateLimit({ account, scene: 'register' });
}
await expect(
assertCaptchaVerificationConsumeRateLimit({ account, scene: 'register' })
).resolves.toBeUndefined();
await expect(
assertPasswordVerificationCreateRateLimit({ account, scene: 'login', limit: 10 })
).resolves.toBeUndefined();
await expect(
assertPasswordVerificationConsumeRateLimit({ account, scene: 'login', limit: 10 })
).resolves.toBeUndefined();
});
it('uses the configured request limit with a fixed one-minute window', async () => {
const params = { account, scene: 'login', limit: 1 } as const;
await expect(assertPasswordVerificationCreateRateLimit(params)).resolves.toBeUndefined();
await expect(assertPasswordVerificationCreateRateLimit(params)).rejects.toThrow(
UserErrEnum.verifyCodeTooFrequently
);
const ttl = await getRedisConnection().ttl(
getVerificationFrequencyLimitKey('password-create', 'login', 'account', account)
);
expect(ttl).toBeGreaterThan(0);
expect(ttl).toBeLessThanOrEqual(60);
});
});
import {
createRedisLogicalKey,
getRedisRuntime,
toPhysicalRedisKey
} from '@fastgpt/dal/redis/runtime';
import { RATE_LIMIT_KEY_PREFIX } from '@fastgpt/service/common/rateLimit/core';
import {
assertMemberRateLimit,
MemberRateLimitPolicy
} from '@fastgpt/service/common/rateLimit/interface/member';
import { beforeEach, describe, expect, it } from 'vitest';
const getMemberRateLimitKey = (policy: string, memberId: string) =>
toPhysicalRedisKey(
createRedisLogicalKey({
namespace: RATE_LIMIT_KEY_PREFIX,
segments: ['member', policy, 'member', memberId]
})
);
const getRedisConnection = () => getRedisRuntime().getCommandConnection();
describe('assertMemberRateLimit', () => {
const memberId = 'member-rate-limit';
beforeEach(async () => {
await getRedisConnection().del(
getMemberRateLimitKey(MemberRateLimitPolicy.Transcriptions, memberId),
getMemberRateLimitKey(MemberRateLimitPolicy.Transcriptions, 'another-member'),
getMemberRateLimitKey(MemberRateLimitPolicy.RedeemCoupon, memberId),
getMemberRateLimitKey(MemberRateLimitPolicy.CheckPayResult, memberId),
getMemberRateLimitKey(MemberRateLimitPolicy.ExportDataset, memberId)
);
});
it('applies the configured one-request-per-second policy', async () => {
const params = { policy: MemberRateLimitPolicy.Transcriptions, memberId } as const;
await expect(assertMemberRateLimit(params)).resolves.toBeUndefined();
await expect(assertMemberRateLimit(params)).rejects.toBeTruthy();
const ttl = await getRedisConnection().ttl(
getMemberRateLimitKey(MemberRateLimitPolicy.Transcriptions, memberId)
);
expect(ttl).toBeGreaterThanOrEqual(0);
expect(ttl).toBeLessThanOrEqual(1);
});
it('keeps members and policies independent', async () => {
await assertMemberRateLimit({ policy: MemberRateLimitPolicy.Transcriptions, memberId });
await expect(
assertMemberRateLimit({
policy: MemberRateLimitPolicy.Transcriptions,
memberId: 'another-member'
})
).resolves.toBeUndefined();
await expect(
assertMemberRateLimit({ policy: MemberRateLimitPolicy.RedeemCoupon, memberId })
).resolves.toBeUndefined();
});
it('applies the configured 60-request-per-minute policy', async () => {
const params = { policy: MemberRateLimitPolicy.CheckPayResult, memberId } as const;
for (let index = 0; index < 60; index++) {
await assertMemberRateLimit(params);
}
await expect(assertMemberRateLimit(params)).rejects.toBeTruthy();
});
it('applies a one-minute window to export policies', async () => {
const params = { policy: MemberRateLimitPolicy.ExportDataset, memberId } as const;
await expect(assertMemberRateLimit(params)).resolves.toBeUndefined();
await expect(assertMemberRateLimit(params)).rejects.toBeTruthy();
const ttl = await getRedisConnection().ttl(
getMemberRateLimitKey(MemberRateLimitPolicy.ExportDataset, memberId)
);
expect(ttl).toBeGreaterThan(0);
expect(ttl).toBeLessThanOrEqual(60);
});
});
import {
createRedisLogicalKey,
getRedisRuntime,
toPhysicalRedisKey
} from '@fastgpt/dal/redis/runtime';
import { RATE_LIMIT_KEY_PREFIX } from '@fastgpt/service/common/rateLimit/core';
import { assertOutLinkRateLimit } from '@fastgpt/service/common/rateLimit/interface/outLink';
import { beforeEach, describe, expect, it } from 'vitest';
const getOutLinkRateLimitKey = (outLinkId: string, uid: string) =>
toPhysicalRedisKey(
createRedisLogicalKey({
namespace: RATE_LIMIT_KEY_PREFIX,
segments: ['out-link', 'request', 'out-link', outLinkId, 'uid', uid]
})
);
const getRedisConnection = () => getRedisRuntime().getCommandConnection();
describe('assertOutLinkRateLimit', () => {
const outLinkId = 'out-link-rate-limit';
const uid = 'visitor-rate-limit';
beforeEach(async () => {
await getRedisConnection().del(
getOutLinkRateLimitKey(outLinkId, uid),
getOutLinkRateLimitKey(outLinkId, 'another-visitor'),
getOutLinkRateLimitKey('another-out-link', uid)
);
});
it('limits requests by the outLinkId and uid combination', async () => {
const params = { outLinkId, uid, limit: 1 };
await expect(assertOutLinkRateLimit(params)).resolves.toBeUndefined();
await expect(assertOutLinkRateLimit(params)).rejects.toThrow('每分钟仅能请求 1 次~');
});
it('keeps different visitors and out links independent', async () => {
await assertOutLinkRateLimit({ outLinkId, uid, limit: 1 });
await expect(
assertOutLinkRateLimit({ outLinkId, uid: 'another-visitor', limit: 1 })
).resolves.toBeUndefined();
await expect(
assertOutLinkRateLimit({ outLinkId: 'another-out-link', uid, limit: 1 })
).resolves.toBeUndefined();
});
});
......@@ -4,6 +4,8 @@ import { ApiRequestInputParseError } from '../../../common/zod/requestParseError
import { UserError } from '@fastgpt/global/common/error/utils';
import { ERROR_ENUM, ERROR_RESPONSE } from '@fastgpt/global/common/error/errorCode';
import { CommonErrEnum } from '@fastgpt/global/common/error/code/common';
import { UserErrEnum } from '@fastgpt/global/common/error/code/user';
import { SandboxErrEnum } from '@fastgpt/global/common/error/code/sandbox';
vi.unmock('@fastgpt/service/common/response');
......@@ -21,7 +23,7 @@ vi.mock('@fastgpt/service/common/logger', () => ({
}
}));
const { getSseErrorResponse, processError } = await import('../../../common/response');
const { getSseErrorResponse, jsonRes, processError } = await import('../../../common/response');
function buildZodError() {
try {
......@@ -97,6 +99,48 @@ describe('processError HTTP status mapping', () => {
});
});
describe('jsonRes HTTP status mapping', () => {
const createResponse = () =>
({
status: vi.fn().mockReturnThis(),
json: vi.fn()
}) as unknown as Parameters<typeof jsonRes>[0];
it.each([
[UserErrEnum.invalidVerificationCode, 400],
[UserErrEnum.sendVerificationCodeTooFrequently, 429],
[UserErrEnum.verifyCodeTooFrequently, 429],
[SandboxErrEnum.agentSandboxInitializing, 409]
] as const)('uses the configured HTTP status for %s', (errorKey, httpStatus) => {
const res = createResponse();
jsonRes(res, { code: 500, error: new UserError(errorKey) });
expect(res.status).toHaveBeenCalledWith(httpStatus);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({
code: ERROR_RESPONSE[errorKey].code,
statusText: errorKey,
message: ERROR_RESPONSE[errorKey].message,
errorType: 'UserError'
})
);
});
it.each(['httpStatus', 'statusCode'] as const)(
'does not trust a third-party %s field',
(statusField) => {
const res = createResponse();
const error = Object.assign(new Error('Upstream failure'), { [statusField]: 404 });
jsonRes(res, { code: 500, error });
expect(res.status).toHaveBeenCalledWith(500);
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ code: 500 }));
}
);
});
describe('getSseErrorResponse logging', () => {
beforeEach(() => {
logger.info.mockClear();
......
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { getRedisRuntime } from '@fastgpt/dal/redis/runtime';
import { RedisInvalidArgumentError } from '@fastgpt/dal/redis';
import {
checkFixedWindowQpmLimit,
createFixedWindowQpmLimitChecker
} from '@fastgpt/service/common/system/frequencyLimit/redisFixedWindow';
describe('checkFixedWindowQpmLimit', () => {
beforeEach(async () => {
await getRedisRuntime().getCommandConnection().flushdb();
});
it('同一个 key 在固定窗口内超过限制后返回 false', async () => {
await expect(
checkFixedWindowQpmLimit({ key: 'enterprise-auth:start:team:t1', limit: 3 })
).resolves.toBe(true);
await expect(
checkFixedWindowQpmLimit({ key: 'enterprise-auth:start:team:t1', limit: 3 })
).resolves.toBe(true);
await expect(
checkFixedWindowQpmLimit({ key: 'enterprise-auth:start:team:t1', limit: 3 })
).resolves.toBe(true);
await expect(
checkFixedWindowQpmLimit({ key: 'enterprise-auth:start:team:t1', limit: 3 })
).resolves.toBe(false);
});
it('不同 key 独立计数', async () => {
await expect(
checkFixedWindowQpmLimit({ key: 'enterprise-auth:start:team:t1', limit: 1 })
).resolves.toBe(true);
await expect(
checkFixedWindowQpmLimit({ key: 'enterprise-auth:start:team:t1', limit: 1 })
).resolves.toBe(false);
await expect(
checkFixedWindowQpmLimit({ key: 'enterprise-auth:start:team:t2', limit: 1 })
).resolves.toBe(true);
});
it('Redis execution failure is mapped to fail-closed', async () => {
const consume = vi.fn().mockRejectedValue(new Error('redis down'));
const check = createFixedWindowQpmLimitChecker({ cache: { consume } });
await expect(check({ key: 'frequency:test:team-1', limit: 1 })).resolves.toBe(false);
});
it('invalid arguments are not hidden as a rate-limit denial', async () => {
const error = new RedisInvalidArgumentError({
operation: 'fixedWindow.consume',
message: 'limit must be a positive safe integer'
});
const consume = vi.fn().mockRejectedValue(error);
const check = createFixedWindowQpmLimitChecker({ cache: { consume } });
await expect(check({ key: 'frequency:test:team-1', limit: 0 })).rejects.toBe(error);
});
});
......@@ -425,6 +425,28 @@ describe('sandbox archive lifecycle', () => {
});
});
it('leaves provisioning recovery to the runtime client', async () => {
mocks.findSandboxInstanceBySandboxId.mockResolvedValue(
createResource('provisioning', {
operation: {
id: 'failed-provision',
type: 'provision',
phase: 'claimed',
previousStatus: 'stopped',
startedAt: new Date(),
heartbeatAt: new Date(),
failedAt: new Date(),
error: 'Failed to create sandbox'
}
})
);
await expect(restoreSandbox()).resolves.toBeUndefined();
expect(mocks.withSandboxLifecycleLease).not.toHaveBeenCalled();
expect(mocks.connectToSandbox).not.toHaveBeenCalled();
});
it('installs the workspace before publishing restoring -> running', async () => {
const archived = createResource('archived');
mocks.findSandboxInstanceBySandboxId.mockResolvedValue(archived);
......
......@@ -460,6 +460,17 @@ describe('sandbox runtime client lifecycle', () => {
await expect(getSandboxClient(query)).rejects.toThrow('Sandbox is initializing');
});
it('maps active provisioning to initializing', async () => {
const provisioning = createInstance('provisioning', 'active-provision');
mocks.touchRunningSandboxInstance.mockResolvedValue(null);
mocks.findSandboxInstanceBySource.mockResolvedValue(provisioning);
await expect(getSandboxClient(query)).rejects.toThrow('Sandbox is initializing');
expect(mocks.createAgentSandboxInitializingError).toHaveBeenCalledTimes(1);
expect(mocks.claimSandboxOperation).not.toHaveBeenCalled();
});
it('publishes a stale providerEnsured phase without reconnecting the provider', async () => {
const provisioning = createInstance('provisioning', 'old-provision');
provisioning.operation.phase = 'providerEnsured';
......@@ -521,6 +532,27 @@ describe('sandbox runtime client lifecycle', () => {
);
});
it('retries failed OpenSandbox provisioning with its persisted Legacy claimName', async () => {
const failedProvisioning = createInstance('provisioning', 'failed-provision', 'opensandbox');
failedProvisioning.operation.error = 'Failed to create sandbox';
const retriedProvisioning = createInstance('provisioning', 'retried-provision', 'opensandbox');
mocks.touchRunningSandboxInstance.mockResolvedValue(null);
mocks.findSandboxInstanceBySource.mockResolvedValue(failedProvisioning);
mocks.claimSandboxOperation.mockResolvedValueOnce(retriedProvisioning);
mocks.advanceSandboxOperation.mockResolvedValueOnce(retriedProvisioning);
await getSandboxClient(query, { providerName: 'opensandbox' });
expect(mocks.createSessionVolumeClaimName).not.toHaveBeenCalled();
expect(mocks.getSessionVolumeConfig).toHaveBeenCalledWith('fastgpt-session-sandbox-1-current');
expect(mocks.claimSandboxOperation).toHaveBeenCalledWith(
expect.objectContaining({ status: 'provisioning', type: 'provision' })
);
expect(mocks.completeSandboxOperation).toHaveBeenCalledWith(
expect.objectContaining({ operationId: 'retried-provision', status: 'running' })
);
});
it('guards the source before migration, restore and provider construction', async () => {
mocks.assertSandboxSourceActive.mockRejectedValueOnce(new Error('source deleted'));
......
......@@ -5,6 +5,11 @@ import { sandboxLsTool } from '@fastgpt/service/core/ai/sandbox/application/tool
const createSandboxInstance = () =>
({
ensureAvailable: vi.fn(async () => undefined),
resolveRuntimePath: vi.fn((path?: string) => {
if (!path || path === '.') return '/workspace/sessions/chat';
if (path.startsWith('/')) return path;
return `/workspace/sessions/chat/${path}`;
}),
provider: {
listDirectory: vi.fn(async () => [
{ name: 'src', isDirectory: true },
......@@ -29,6 +34,9 @@ describe('sandboxLsTool', () => {
expect(result.response).toBe('.env\nREADME.md\nsrc/');
expect(sandbox.ensureAvailable).toHaveBeenCalledTimes(1);
expect(sandbox.resolveRuntimePath).toHaveBeenCalledWith('/workspace', {
allowAbsolutePath: true
});
expect(sandbox.provider.listDirectory).toHaveBeenCalledWith('/workspace');
});
......@@ -46,7 +54,10 @@ describe('sandboxLsTool', () => {
params: {}
})
).resolves.toEqual({ response: '(empty directory)' });
expect(sandbox.provider.listDirectory).toHaveBeenCalledWith('.');
expect(sandbox.resolveRuntimePath).toHaveBeenCalledWith(undefined, {
allowAbsolutePath: true
});
expect(sandbox.provider.listDirectory).toHaveBeenCalledWith('/workspace/sessions/chat');
});
it('reports when the entry limit is reached', async () => {
......
......@@ -3,8 +3,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
const originalEnv = {
AGENT_SANDBOX_PROVIDER: process.env.AGENT_SANDBOX_PROVIDER,
AGENT_SANDBOX_OPENSANDBOX_IMAGE: process.env.AGENT_SANDBOX_OPENSANDBOX_IMAGE,
AGENT_SANDBOX_OPENSANDBOX_IMAGE_REPO: process.env.AGENT_SANDBOX_OPENSANDBOX_IMAGE_REPO,
AGENT_SANDBOX_OPENSANDBOX_IMAGE_TAG: process.env.AGENT_SANDBOX_OPENSANDBOX_IMAGE_TAG,
AGENT_SANDBOX_SEALOS_WORK_DIRECTORY: process.env.AGENT_SANDBOX_SEALOS_WORK_DIRECTORY,
AGENT_SANDBOX_SEALOS_IMAGE: process.env.AGENT_SANDBOX_SEALOS_IMAGE,
AGENT_SANDBOX_STORAGE_SIZE_GI: process.env.AGENT_SANDBOX_STORAGE_SIZE_GI
......@@ -20,14 +18,6 @@ describe('sandbox runtime profile', () => {
vi.stubEnv('AGENT_SANDBOX_PROVIDER', originalEnv.AGENT_SANDBOX_PROVIDER);
vi.stubEnv('AGENT_SANDBOX_OPENSANDBOX_IMAGE', originalEnv.AGENT_SANDBOX_OPENSANDBOX_IMAGE);
vi.stubEnv(
'AGENT_SANDBOX_OPENSANDBOX_IMAGE_REPO',
originalEnv.AGENT_SANDBOX_OPENSANDBOX_IMAGE_REPO
);
vi.stubEnv(
'AGENT_SANDBOX_OPENSANDBOX_IMAGE_TAG',
originalEnv.AGENT_SANDBOX_OPENSANDBOX_IMAGE_TAG
);
vi.stubEnv(
'AGENT_SANDBOX_SEALOS_WORK_DIRECTORY',
originalEnv.AGENT_SANDBOX_SEALOS_WORK_DIRECTORY
);
......@@ -38,8 +28,6 @@ describe('sandbox runtime profile', () => {
it('uses fixed /workspace as opensandbox work directory', async () => {
vi.stubEnv('AGENT_SANDBOX_PROVIDER', '');
vi.stubEnv('AGENT_SANDBOX_OPENSANDBOX_IMAGE', 'registry.local:5000/runtime-image:stable');
vi.stubEnv('AGENT_SANDBOX_OPENSANDBOX_IMAGE_REPO', 'legacy/image');
vi.stubEnv('AGENT_SANDBOX_OPENSANDBOX_IMAGE_TAG', 'legacy-tag');
const { getSandboxRuntimeProfile } = await loadSandboxRuntimeProfileModule();
const runtimeProfile = getSandboxRuntimeProfile('opensandbox');
......@@ -56,34 +44,6 @@ describe('sandbox runtime profile', () => {
expect(runtimeProfile.skillsRootPath).toBe('/workspace/skills');
});
it('falls back to the legacy opensandbox repo and tag variables', async () => {
vi.stubEnv('AGENT_SANDBOX_PROVIDER', '');
vi.stubEnv('AGENT_SANDBOX_OPENSANDBOX_IMAGE', '');
vi.stubEnv('AGENT_SANDBOX_OPENSANDBOX_IMAGE_REPO', 'legacy/runtime-image');
vi.stubEnv('AGENT_SANDBOX_OPENSANDBOX_IMAGE_TAG', 'legacy-stable');
const { getSandboxRuntimeProfile } = await loadSandboxRuntimeProfileModule();
expect(getSandboxRuntimeProfile('opensandbox').defaultImage).toEqual({
repository: 'legacy/runtime-image',
tag: 'legacy-stable'
});
});
it('defaults the legacy opensandbox image tag to latest', async () => {
vi.stubEnv('AGENT_SANDBOX_PROVIDER', '');
vi.stubEnv('AGENT_SANDBOX_OPENSANDBOX_IMAGE', '');
vi.stubEnv('AGENT_SANDBOX_OPENSANDBOX_IMAGE_REPO', 'legacy/runtime-image');
vi.stubEnv('AGENT_SANDBOX_OPENSANDBOX_IMAGE_TAG', '');
const { getSandboxRuntimeProfile } = await loadSandboxRuntimeProfileModule();
expect(getSandboxRuntimeProfile('opensandbox').defaultImage).toEqual({
repository: 'legacy/runtime-image',
tag: 'latest'
});
});
it('preserves an explicit opensandbox ready timeout', async () => {
vi.stubEnv('AGENT_SANDBOX_PROVIDER', '');
vi.stubEnv('AGENT_SANDBOX_OPENSANDBOX_IMAGE', 'runtime/fastgpt:stable');
......
......@@ -201,7 +201,7 @@ describe('buildAgentLoopCoreUserReminderInput', () => {
expect(result).toContain('<id>dataset_1</id>');
expect(result).toContain('## 背景信息');
expect(result).toContain('当前时间: 2026-05-14 10:00:00 Thursday');
expect(result).toContain('当前 sandbox 工作目录: /workspace');
expect(result).toContain('当前沙盒的工作目录: /workspace');
expect(result).toContain('帮我总结');
});
......@@ -241,7 +241,7 @@ describe('buildAgentLoopCoreUserReminderInput', () => {
query: '',
currentWorkingDirectory: '/workspace'
})
).toContain(`当前 sandbox 工作目录: /workspace`);
).toContain(`当前沙盒的工作目录: /workspace`);
expect(
buildAgentLoopCoreUserReminderInput({
query: '',
......@@ -289,7 +289,7 @@ describe('buildAgentLoopCoreUserReminderInput', () => {
]
});
expect(result).toContain('以下技能为特定任务提供专门的操作说明:');
expect(result).toContain('当用户任务与某个技能的描述匹配时');
expect(result).toContain('先使用 sandbox_read_file 读取完整的技能文件');
expect(result).toContain('<available_skills>');
expect(result).toContain('</available_skills>');
......@@ -374,7 +374,7 @@ describe('useUserContext', () => {
);
expect(historyText).toContain('<url>https://files.example.com/old.pdf</url>');
expect(historyText).not.toContain('当前 sandbox 工作目录');
expect(historyText).not.toContain('当前沙盒的工作目录');
expect(historyText).not.toContain('当前时间');
expect(currentFiles).toEqual([
{
......@@ -384,7 +384,7 @@ describe('useUserContext', () => {
}
]);
expect(currentText).toContain('## 背景信息');
expect(currentText).toContain('当前 sandbox 工作目录: /workspace');
expect(currentText).toContain('当前沙盒的工作目录: /workspace');
expect(currentText).toContain('<url>https://files.example.com/current.pdf</url>');
expect(currentText).toContain('<url>https://files.example.com/current.png</url>');
expect(currentText).not.toContain('<id>current_chat_item-');
......
......@@ -508,7 +508,7 @@ describe('dispatchRunAgent user context', () => {
expect(loopInput.systemPrompt).toContain('</sandbox_capability>');
expect(loopInput.systemPrompt).not.toContain('pwd: /workspace');
expect(getMessageTextForTest(loopInput.messages.at(-1)?.content)).toContain(
'当前 sandbox 工作目录: /workspace'
'当前沙盒的工作目录: /workspace'
);
const loopRuntime = runAgentLoopMock.mock.calls[0][0].runtime;
expect(runAgentLoopMock.mock.calls[0][0].provider).toBe('fastAgent');
......@@ -633,7 +633,7 @@ describe('dispatchRunAgent user context', () => {
const loopInput = runAgentLoopMock.mock.calls[0][0].input;
expect(getMessageTextForTest(loopInput.messages.at(-1)?.content)).not.toContain(
'当前 sandbox 工作目录'
'当前沙盒的工作目录'
);
});
......
......@@ -200,7 +200,7 @@ describe('agentLoopCore reminder helpers', () => {
expect(result.indexOf('## 文件')).toBeLessThan(result.indexOf('## 知识库'));
expect(result.indexOf('## 知识库')).toBeLessThan(result.indexOf('## 背景信息'));
expect(result).toContain('<description>产品 &lt;FAQ&gt; &amp; 售后说明</description>');
expect(result).toContain('当前 sandbox 工作目录: /workspace');
expect(result).toContain('当前沙盒的工作目录: /workspace');
expect(result).toContain('帮我总结');
});
});
......@@ -17,7 +17,6 @@ const originalEnv = {
AES256_SECRET_KEY: process.env.AES256_SECRET_KEY,
INVOKE_TOKEN_SECRET: process.env.INVOKE_TOKEN_SECRET,
SOMARK_API_KEY: process.env.SOMARK_API_KEY,
HOME_CHAT_CUSTOM_PDF_PARSE: process.env.HOME_CHAT_CUSTOM_PDF_PARSE,
PRO_URL: process.env.PRO_URL,
PRO_TOKEN: process.env.PRO_TOKEN,
VITEST: process.env.VITEST,
......@@ -29,8 +28,6 @@ const originalEnv = {
AGENT_SANDBOX_OPENSANDBOX_BASEURL: process.env.AGENT_SANDBOX_OPENSANDBOX_BASEURL,
AGENT_SANDBOX_OPENSANDBOX_API_KEY: process.env.AGENT_SANDBOX_OPENSANDBOX_API_KEY,
AGENT_SANDBOX_OPENSANDBOX_IMAGE: process.env.AGENT_SANDBOX_OPENSANDBOX_IMAGE,
AGENT_SANDBOX_OPENSANDBOX_IMAGE_REPO: process.env.AGENT_SANDBOX_OPENSANDBOX_IMAGE_REPO,
AGENT_SANDBOX_OPENSANDBOX_IMAGE_TAG: process.env.AGENT_SANDBOX_OPENSANDBOX_IMAGE_TAG,
AGENT_SANDBOX_OPENSANDBOX_VOLUME_NAME_PREFIX:
process.env.AGENT_SANDBOX_OPENSANDBOX_VOLUME_NAME_PREFIX
};
......@@ -60,7 +57,6 @@ describe('serviceEnv', () => {
vi.stubEnv('AES256_SECRET_KEY', originalEnv.AES256_SECRET_KEY);
vi.stubEnv('INVOKE_TOKEN_SECRET', originalEnv.INVOKE_TOKEN_SECRET);
vi.stubEnv('SOMARK_API_KEY', originalEnv.SOMARK_API_KEY);
vi.stubEnv('HOME_CHAT_CUSTOM_PDF_PARSE', originalEnv.HOME_CHAT_CUSTOM_PDF_PARSE);
vi.stubEnv('PRO_URL', originalEnv.PRO_URL);
vi.stubEnv('PRO_TOKEN', originalEnv.PRO_TOKEN);
vi.stubEnv('VITEST', originalEnv.VITEST);
......@@ -73,14 +69,6 @@ describe('serviceEnv', () => {
vi.stubEnv('AGENT_SANDBOX_OPENSANDBOX_API_KEY', originalEnv.AGENT_SANDBOX_OPENSANDBOX_API_KEY);
vi.stubEnv('AGENT_SANDBOX_OPENSANDBOX_IMAGE', originalEnv.AGENT_SANDBOX_OPENSANDBOX_IMAGE);
vi.stubEnv(
'AGENT_SANDBOX_OPENSANDBOX_IMAGE_REPO',
originalEnv.AGENT_SANDBOX_OPENSANDBOX_IMAGE_REPO
);
vi.stubEnv(
'AGENT_SANDBOX_OPENSANDBOX_IMAGE_TAG',
originalEnv.AGENT_SANDBOX_OPENSANDBOX_IMAGE_TAG
);
vi.stubEnv(
'AGENT_SANDBOX_OPENSANDBOX_VOLUME_NAME_PREFIX',
originalEnv.AGENT_SANDBOX_OPENSANDBOX_VOLUME_NAME_PREFIX
);
......@@ -115,20 +103,6 @@ describe('serviceEnv', () => {
});
});
it('disables home chat custom PDF parsing by default and supports enabling it', async () => {
vi.stubEnv('FILE_TOKEN_KEY', 'filetokenkey');
vi.stubEnv('AES256_SECRET_KEY', 'fastgptsecret');
vi.stubEnv('INVOKE_TOKEN_SECRET', validInvokeTokenSecret);
vi.stubEnv('HOME_CHAT_CUSTOM_PDF_PARSE', undefined);
const defaultEnv = await importServiceEnv();
expect(defaultEnv.serviceEnv.HOME_CHAT_CUSTOM_PDF_PARSE).toBe(false);
vi.stubEnv('HOME_CHAT_CUSTOM_PDF_PARSE', 'true');
const enabledEnv = await importServiceEnv();
expect(enabledEnv.serviceEnv.HOME_CHAT_CUSTOM_PDF_PARSE).toBe(true);
});
it('validates SYSTEM_MAX_STRING_LENGTH_M during service env init', async () => {
vi.stubEnv('FILE_TOKEN_KEY', 'filetokenkey');
vi.stubEnv('AES256_SECRET_KEY', 'fastgptsecret');
......@@ -380,19 +354,22 @@ describe('serviceEnv', () => {
await expect(importServiceEnv()).resolves.toBeDefined();
});
it('保留 OpenSandbox 旧镜像环境变量供升级兼容', async () => {
it('启用 opensandbox 后未配置新运行镜像会阻止启动', async () => {
vi.stubEnv('FILE_TOKEN_KEY', 'filetokenkey');
vi.stubEnv('AES256_SECRET_KEY', 'fastgptsecret');
vi.stubEnv('INVOKE_TOKEN_SECRET', validInvokeTokenSecret);
vi.stubEnv('AGENT_SANDBOX_PROVIDER', '');
vi.stubEnv('VITEST', 'true');
vi.stubEnv('NODE_ENV', 'development');
vi.stubEnv('AGENT_SANDBOX_PROVIDER', 'opensandbox');
vi.stubEnv('AGENT_SANDBOX_OPENSANDBOX_BASEURL', 'http://mock-opensandbox.local');
vi.stubEnv('AGENT_SANDBOX_OPENSANDBOX_API_KEY', 'mock-opensandbox-api-key');
vi.stubEnv('AGENT_SANDBOX_OPENSANDBOX_IMAGE_REPO', 'legacy/runtime-image');
vi.stubEnv('AGENT_SANDBOX_OPENSANDBOX_IMAGE_TAG', 'legacy-stable');
vi.stubEnv('AGENT_SANDBOX_OPENSANDBOX_VOLUME_MANAGER_URL', 'http://mock-volume-manager.local');
vi.stubEnv('AGENT_SANDBOX_OPENSANDBOX_VOLUME_MANAGER_TOKEN', 'mock-volume-manager-token');
await expect(importServiceEnv()).resolves.toMatchObject({
serviceEnv: {
AGENT_SANDBOX_OPENSANDBOX_IMAGE_REPO: 'legacy/runtime-image',
AGENT_SANDBOX_OPENSANDBOX_IMAGE_TAG: 'legacy-stable'
}
});
await expect(importServiceEnv()).rejects.toThrow(
'AGENT_SANDBOX_OPENSANDBOX_IMAGE are required when AGENT_SANDBOX_PROVIDER is opensandbox'
);
});
});
......@@ -128,17 +128,18 @@ describe('env util', () => {
]);
});
it('accepts the legacy opensandbox image repository as the image fallback', () => {
it('requires the new opensandbox image even when legacy image variables are set', () => {
expect(
getAgentSandboxMissingRequiredEnvKeys({
AGENT_SANDBOX_PROVIDER: 'opensandbox',
AGENT_SANDBOX_OPENSANDBOX_BASEURL: 'http://opensandbox.local',
AGENT_SANDBOX_OPENSANDBOX_API_KEY: 'opensandbox-key',
AGENT_SANDBOX_OPENSANDBOX_IMAGE_REPO: 'legacy/runtime',
AGENT_SANDBOX_OPENSANDBOX_IMAGE_TAG: 'legacy-stable',
AGENT_SANDBOX_OPENSANDBOX_VOLUME_MANAGER_URL: 'http://volume-manager.local',
AGENT_SANDBOX_OPENSANDBOX_VOLUME_MANAGER_TOKEN: 'volume-token'
} as NodeJS.ProcessEnv)
).toEqual([]);
).toEqual(['AGENT_SANDBOX_OPENSANDBOX_IMAGE']);
});
it('does not require sandbox env for an unsupported provider', () => {
......
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { ERROR_ENUM } from '@fastgpt/global/common/error/errorCode';
import { MongoOpenApi } from '@fastgpt/service/support/openapi/schema';
import { Types } from 'mongoose';
import { AuthUserTypeEnum } from '@fastgpt/global/support/permission/constant';
const { mockAuthOpenApiHandler } = vi.hoisted(() => ({
mockAuthOpenApiHandler: vi.fn()
}));
import { authOpenApiKey, resolveOpenApiCredential } from '@fastgpt/service/support/openapi/auth';
const { parseHeaderCert } = await vi.importActual<
......@@ -33,17 +29,22 @@ const legacyAppApiKey = {
name: 'legacy app key'
};
const originalFeConfigs = global.feConfigs;
describe('openapi auth', () => {
let updateApiKeyUsedTimeSpy: ReturnType<typeof vi.spyOn>;
beforeEach(async () => {
vi.clearAllMocks();
updateApiKeyUsedTimeSpy = vi.spyOn(MongoOpenApi, 'findByIdAndUpdate');
global.authOpenApiHandler = mockAuthOpenApiHandler;
mockAuthOpenApiHandler.mockResolvedValue(undefined);
global.feConfigs = { ...global.feConfigs, isPlus: true } as any;
await MongoOpenApi.deleteMany({});
});
afterAll(() => {
global.feConfigs = originalFeConfigs;
});
it('解析 APIKey 兼容凭证时只把 ObjectId 后缀识别为 appId', () => {
expect(resolveOpenApiCredential(`fastgpt-team-${parsedAppId}`)).toEqual({
apikey: 'fastgpt-team',
......@@ -72,16 +73,12 @@ describe('openapi auth', () => {
authProxy: false,
sourceName: 'team key'
});
expect(mockAuthOpenApiHandler).toHaveBeenCalledTimes(1);
const [{ openApi: authedOpenApi }] = mockAuthOpenApiHandler.mock.calls[0];
expect(String(authedOpenApi._id)).toBe(String(openApi._id));
expect(authedOpenApi.apiKey).toBe('fastgpt-team');
expect(updateApiKeyUsedTimeSpy).toHaveBeenCalledTimes(1);
expect(String(updateApiKeyUsedTimeSpy.mock.calls[0][0])).toBe(String(openApi._id));
});
it('旧应用 APIKey 按系统 key 鉴权并返回 legacyAppId', async () => {
const openApi = await MongoOpenApi.create(legacyAppApiKey);
await MongoOpenApi.create(legacyAppApiKey);
const result = await authOpenApiKey({
apikey: 'fastgpt-app'
......@@ -96,10 +93,6 @@ describe('openapi auth', () => {
authProxy: false,
sourceName: 'legacy app key'
});
expect(mockAuthOpenApiHandler).toHaveBeenCalledTimes(1);
const [{ openApi: authedOpenApi }] = mockAuthOpenApiHandler.mock.calls[0];
expect(String(authedOpenApi._id)).toBe(String(openApi._id));
expect(authedOpenApi.appId).toBe(appId);
});
it('Bearer apiKey-appId 用真实 key 查库并返回 parsedAppId', async () => {
......@@ -123,7 +116,6 @@ describe('openapi auth', () => {
apikey: 'fastgpt-team',
authType: AuthUserTypeEnum.apikey
});
expect(mockAuthOpenApiHandler).toHaveBeenCalledTimes(1);
});
it('Bearer apiKey-appId 仍把限额和 lastUsedTime 更新到真实 key', async () => {
......@@ -133,9 +125,6 @@ describe('openapi auth', () => {
apikey: `fastgpt-team-${parsedAppId}`
});
const [{ openApi: authedOpenApi }] = mockAuthOpenApiHandler.mock.calls[0];
expect(String(authedOpenApi._id)).toBe(String(openApi._id));
expect(authedOpenApi.apiKey).toBe('fastgpt-team');
expect(String(updateApiKeyUsedTimeSpy.mock.calls[0][0])).toBe(String(openApi._id));
});
......@@ -149,7 +138,6 @@ describe('openapi auth', () => {
})
).rejects.toBe(ERROR_ENUM.unAuthApiKey);
expect(mockAuthOpenApiHandler).not.toHaveBeenCalled();
expect(updateApiKeyUsedTimeSpy).not.toHaveBeenCalled();
});
......@@ -167,10 +155,60 @@ describe('openapi auth', () => {
})
).rejects.toBe(ERROR_ENUM.unAuthApiKey);
expect(mockAuthOpenApiHandler).not.toHaveBeenCalled();
expect(updateApiKeyUsedTimeSpy).not.toHaveBeenCalled();
});
it('商业版拒绝已过期的 API Key', async () => {
await MongoOpenApi.create({
...teamApiKey,
apiKey: 'fastgpt-expired',
limit: {
expiredTime: new Date(Date.now() - 1000),
maxUsagePoints: -1
}
});
await expect(authOpenApiKey({ apikey: 'fastgpt-expired' })).rejects.toMatchObject({
name: 'UserError',
message: expect.stringContaining('is expired')
});
expect(updateApiKeyUsedTimeSpy).not.toHaveBeenCalled();
});
it('商业版拒绝超过用量额度的 API Key', async () => {
await MongoOpenApi.create({
...teamApiKey,
apiKey: 'fastgpt-over-usage',
usagePoints: 2,
limit: {
maxUsagePoints: 1
}
});
await expect(authOpenApiKey({ apikey: 'fastgpt-over-usage' })).rejects.toMatchObject({
name: 'UserError',
message: expect.stringContaining('is over usage')
});
expect(updateApiKeyUsedTimeSpy).not.toHaveBeenCalled();
});
it('社区版保持历史行为,不执行商业版 API Key 限额校验', async () => {
global.feConfigs = { ...global.feConfigs, isPlus: false } as any;
await MongoOpenApi.create({
...teamApiKey,
apiKey: 'fastgpt-community-expired',
limit: {
expiredTime: new Date(Date.now() - 1000),
maxUsagePoints: -1
}
});
await expect(authOpenApiKey({ apikey: 'fastgpt-community-expired' })).resolves.toMatchObject({
apikey: 'fastgpt-community-expired'
});
expect(updateApiKeyUsedTimeSpy).toHaveBeenCalledTimes(1);
});
it('返回 APIKey 是否开启 authProxy', async () => {
await MongoOpenApi.create({
...teamApiKey,
......
import { PasswordVerificationService } from '@fastgpt/service/support/user/account/verification/password/service';
import type {
PasswordVerificationDependencies,
PasswordVerificationUser
} from '@fastgpt/service/support/user/account/verification/password/type';
import { MongoUser } from '@fastgpt/service/support/user/schema';
import { MongoTmpData } from '@fastgpt/service/support/tmpData/schema';
import {
getDataId,
VerificationMaterialError
} from '@fastgpt/service/support/tmpData/verification';
import type { ClientSession } from '@fastgpt/service/common/mongo';
import { UserErrEnum } from '@fastgpt/global/common/error/code/user';
import { describe, expect, it, vi } from 'vitest';
const createDependencies = (
overrides: Partial<PasswordVerificationDependencies> = {}
): PasswordVerificationDependencies => ({
generateCode: vi.fn(() => 'ABC123'),
assertCreateFrequency: vi.fn(async () => undefined),
assertConsumeFrequency: vi.fn(async () => undefined),
savePreLoginCode: vi.fn(async () => undefined),
findUserByCredentials: vi.fn(async () => null),
consumeInTransaction: vi.fn(async (_params, handler) =>
handler({
material: { preLoginCode: 'ABC123' },
session: undefined as unknown as ClientSession
})
),
...overrides
});
describe('PasswordVerificationService.issuePreLoginCode', () => {
it('creates the same six-character, thirty-second pre-login material', async () => {
const dependencies = createDependencies();
const service = new PasswordVerificationService(dependencies);
await expect(
service.issuePreLoginCode({ username: 'test@example.com', purpose: 'login' })
).resolves.toEqual({ code: 'ABC123' });
expect(dependencies.generateCode).toHaveBeenCalledWith(6);
expect(dependencies.savePreLoginCode).toHaveBeenCalledWith({
username: 'test@example.com',
code: 'ABC123',
purpose: 'login',
ttlPreset: 'short'
});
expect(dependencies.assertCreateFrequency).toHaveBeenCalledWith({
account: 'test@example.com',
scene: 'login'
});
expect(dependencies.assertConsumeFrequency).not.toHaveBeenCalled();
});
it('does not generate or replace pre-login material after the account limit rejects', async () => {
const dependencies = createDependencies({
assertCreateFrequency: vi.fn(async () => Promise.reject(new Error('rate limited')))
});
const service = new PasswordVerificationService(dependencies);
await expect(
service.issuePreLoginCode({ username: 'test@example.com', purpose: 'login' })
).rejects.toThrow('rate limited');
expect(dependencies.generateCode).not.toHaveBeenCalled();
expect(dependencies.savePreLoginCode).not.toHaveBeenCalled();
});
});
describe('PasswordVerificationService default adapters', () => {
it('stores and consumes password material after the business callback succeeds', async () => {
const username = 'default-adapters@example.com';
const password = 'hashed-password';
const beforeIssue = Date.now();
const service = new PasswordVerificationService();
const { code } = await service.issuePreLoginCode({ username, purpose: 'login' });
const afterIssue = Date.now();
const authMaterial = await MongoTmpData.findOne({
dataId: getDataId({ scene: 'login', type: 'password', key: username })
}).lean();
expect(code).toMatch(/^[a-z][a-zA-Z0-9]{5}$/);
expect(authMaterial).toMatchObject({
dataId: getDataId({ scene: 'login', type: 'password', key: username }),
data: { preLoginCode: code }
});
expect(authMaterial?.expireAt.getTime()).toBeGreaterThanOrEqual(beforeIssue + 30_000);
expect(authMaterial?.expireAt.getTime()).toBeLessThanOrEqual(afterIssue + 30_000);
const storedUser = await MongoUser.create({ username, password });
const result = await service.withVerifiedCredentials(
{ username, password, code, purpose: 'login' },
async ({ user }) => user
);
await expect(
MongoTmpData.findOne({
dataId: getDataId({ scene: 'login', type: 'password', key: username })
})
).resolves.toBeNull();
expect(String(result._id)).toBe(String(storedUser._id));
});
});
describe('PasswordVerificationService.withVerifiedCredentials', () => {
it('checks the account limit once before reading the login material', async () => {
const calls: string[] = [];
const dependencies = createDependencies({
assertConsumeFrequency: vi.fn(async () => {
calls.push('limit');
}),
consumeInTransaction: vi.fn(async (_params, handler) => {
calls.push('consume');
return handler({
material: { preLoginCode: 'ABC123' },
session: undefined as unknown as ClientSession
});
}),
findUserByCredentials: vi.fn(async () => {
calls.push('password');
return { _id: 'user-id' } as unknown as PasswordVerificationUser;
})
});
const service = new PasswordVerificationService(dependencies);
await service.withVerifiedCredentials(
{
username: 'test@example.com',
password: 'hashed-password',
code: 'ABC123',
purpose: 'login'
},
async () => 'completed'
);
expect(calls).toEqual(['limit', 'consume', 'password']);
expect(dependencies.assertConsumeFrequency).toHaveBeenCalledTimes(1);
expect(dependencies.assertConsumeFrequency).toHaveBeenCalledWith({
account: 'test@example.com',
scene: 'login'
});
});
it('does not read or consume login material after the account limit rejects', async () => {
const dependencies = createDependencies({
assertConsumeFrequency: vi.fn(async () => Promise.reject(new Error('rate limited')))
});
const service = new PasswordVerificationService(dependencies);
await expect(
service.withVerifiedCredentials(
{
username: 'test@example.com',
password: 'hashed-password',
code: 'ABC123',
purpose: 'login'
},
async () => 'unreachable'
)
).rejects.toThrow('rate limited');
expect(dependencies.consumeInTransaction).not.toHaveBeenCalled();
expect(dependencies.findUserByCredentials).not.toHaveBeenCalled();
});
it('passes the matched user and transaction session to the business callback', async () => {
const user = { _id: 'user-id' } as unknown as PasswordVerificationUser;
const session = {} as ClientSession;
const consumeInTransaction = vi.fn(async (_params, handler) =>
handler({ material: { preLoginCode: 'ABC123' }, session })
);
const dependencies = createDependencies({
findUserByCredentials: vi.fn(async (params) => {
expect(params.session).toBe(session);
return user;
}),
consumeInTransaction
});
const service = new PasswordVerificationService(dependencies);
const handler = vi.fn(async ({ user: matchedUser, session: callbackSession }) => {
expect(matchedUser).toBe(user);
expect(callbackSession).toBe(session);
return 'completed';
});
await expect(
service.withVerifiedCredentials(
{
username: 'test@example.com',
password: 'hashed-password',
code: 'ABC123',
purpose: 'login'
},
handler
)
).resolves.toBe('completed');
expect(consumeInTransaction).toHaveBeenCalledWith(
{
scene: 'login',
type: 'password',
key: 'test@example.com',
match: { preLoginCode: 'ABC123' }
},
expect.any(Function)
);
expect(handler).toHaveBeenCalledTimes(1);
});
it('maps missing verification material to the original invalid-code error', async () => {
const dependencies = createDependencies({
consumeInTransaction: vi.fn(async () => Promise.reject(new VerificationMaterialError()))
});
const service = new PasswordVerificationService(dependencies);
await expect(
service.withVerifiedCredentials(
{
username: 'test@example.com',
password: 'hashed-password',
code: 'invalid',
purpose: 'login'
},
async () => 'unreachable'
)
).rejects.toMatchObject({ message: UserErrEnum.invalidVerificationCode });
expect(dependencies.findUserByCredentials).not.toHaveBeenCalled();
});
});
This diff is collapsed. Click to expand it.
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment