Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
58e51e96a9 | ||
|
|
aa12a3c71d | ||
|
|
ae0393da57 | ||
|
|
8c872ac15d | ||
|
|
1721bc15cb | ||
|
|
8af60565a1 | ||
|
|
ad70cbec79 | ||
|
|
12f69504e4 | ||
|
|
237a1f1c26 | ||
|
|
8d0eba4957 | ||
|
|
62b69657a6 | ||
|
|
965b314751 | ||
|
|
b1ad0f727a | ||
|
|
80b0035ba6 | ||
|
|
517b6532f6 |
@@ -25,3 +25,4 @@ front/dist/
|
||||
|
||||
# 本地环境配置(不入库)
|
||||
.env
|
||||
.kimi-code/mcp.json
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"xiaoqu": {
|
||||
"url": "http://192.168.4.236:8088/mcp",
|
||||
"bearerTokenEnvVar": "XQ_API_KEY"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -55,7 +55,7 @@ xiaoqu-agent-service/
|
||||
- `/mcp` 全路径要求 `Authorization: Bearer <ak>`,缺失/无效/禁用/过期 → 401 JSON。
|
||||
- 判定链(设计文档 §3.2):ak → SHA-256 hex → `agent_api_key` 匹配 → 解析出人(Redis db5 `entity:User:{id}` 热缓存优先,fastjson camelCase JSON 用 Jackson 解析;读不到回源 m0 `user` 表)→ AuthContext 存 ThreadLocal → 工具调用前 scope 校验 → 审计落库。
|
||||
- **ThreadLocal 实测结论**:`spring.ai.mcp.server.type=SYNC`(application.yml 已显式设置)时,Spring AI 1.1.8 的工具回调在 servlet 请求线程(`nio-8088-exec-*`)上同步执行,ThreadLocal 的 AuthContext 与 RequestContextHolder 均有效(已用真实 curl 验证:agent.list.query 按人过滤、audit_log 正确落 user_id/token_id/client_ip)。AkAuthFilter 在请求结束 finally 清理。
|
||||
- **已知限制**:`tools/list` 暂不按 key 过滤工具列表,所有工具可见;`tools/call` 时的 scope 强制校验是底线。
|
||||
- **tools/list 按作用域过滤**(2026-09-04 起):MCP SDK 的工具清单在启动时静态注册,无法按请求过滤,故 `ToolsListScopeFilter` 在 servlet 层拦截 POST /mcp 的 tools/list 响应并重写 `result.tools`(只保留该 ak 作用域并集内、且启用状态的工具)。`tools/call` 路径不变:越权调用仍返回可读拒绝信息并落 denied 审计。
|
||||
- client_type 识别:`X-Client-Type` 头优先,否则 User-Agent 含 kimi→mcp-kimi / claude→mcp-claude / curl→cli,其余 unknown。
|
||||
|
||||
## 行级数据权限引擎(permission/PermissionService)
|
||||
@@ -130,11 +130,13 @@ cp deploy/.env.example deploy/.env.dev # 填写 dev 凭据(已 gitignore,
|
||||
|
||||
## 测试 ak(仅 dev)
|
||||
|
||||
仓库不提供固定 ak。每位开发者在本地生成随机 ak,只把 SHA-256 写入 `agent_api_key`,再用 `token_scope` 关联需要的作用域;明文只保存在本地环境变量中,不提交版本库。仓库历史中曾出现的 dev ak 必须视为已泄露并在数据库中禁用。
|
||||
仓库不提供固定 ak。每位开发者在本地生成随机 ak,只把 SHA-256 写入 `agent_api_key`,再用 `token_scope` 关联需要的作用域;明文只保存在本机客户端配置(如 `.kimi-code/mcp.json`,已 gitignore)中,不提交版本库。仓库历史中曾出现的 dev ak 必须视为已泄露并在数据库中禁用。
|
||||
|
||||
手工插入的 ak 没有 `key_cipher` 密文:走 `/mcp` 正常(只校验哈希),但被 `ensure` 命中时会触发自动轮换(旧把禁用、签发新把)——这是预期行为。
|
||||
|
||||
```bash
|
||||
export XQ_API_KEY="xqak_$(openssl rand -hex 24)"
|
||||
printf %s "$XQ_API_KEY" | shasum -a 256
|
||||
AK="xqak_$(openssl rand -hex 24)" # 生成随机 ak,明文只贴进本机客户端配置
|
||||
printf %s "$AK" | shasum -a 256 # 输出的哈希写入 agent_api_key.key_hash
|
||||
```
|
||||
|
||||
## curl 验证(Streamable HTTP 握手)
|
||||
@@ -173,29 +175,51 @@ agent.list.query 抽测:channel_no='D' + province='北京市' → 总部 11 /
|
||||
|
||||
dev 服务地址示例:`http://192.168.4.236:8088/mcp`(本机内网 IP;本机自测可用 localhost)。生产必须 HTTPS(Nginx 终结 TLS)。
|
||||
|
||||
**Kimi Code** — 项目级 `.kimi-code/mcp.json`:
|
||||
**Kimi Code** — 项目级 `.kimi-code/mcp.json`(ak 明文直写;该文件已加入 `.gitignore`,不会提交版本库):
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"xiaoqu": {
|
||||
"url": "http://192.168.4.236:8088/mcp",
|
||||
"bearerTokenEnvVar": "XQ_API_KEY"
|
||||
"headers": {
|
||||
"Authorization": "Bearer <测试ak>"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
shell 里 `export XQ_API_KEY=<测试ak>` 后启动 Kimi Code 即可自发现工具。
|
||||
写入 ak 后 `/new` 开个新会话即可自发现工具(MCP 配置只在会话启动时加载)。
|
||||
|
||||
**Claude Code**:
|
||||
|
||||
```bash
|
||||
export XQ_API_KEY=<测试ak>
|
||||
claude mcp add --transport http xiaoqu http://192.168.4.236:8088/mcp \
|
||||
--header "Authorization: Bearer $XQ_API_KEY"
|
||||
--header "Authorization: Bearer <测试ak>"
|
||||
```
|
||||
|
||||
## Open API(服务间接口,/api/open/**)
|
||||
|
||||
供其他系统在服务端到服务端场景下获取用户 Token。鉴权为静态服务间密钥(header `X-Open-Key`,dev 明文配置在 `application-dev.yml` 的 `app.open.api-key`;配置为空 = 接口关闭)。
|
||||
|
||||
**`POST /api/open/token/ensure`** —— 按 手机号/邮箱 + 作用域编码 确保 Token 存在(get-or-create):
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8088/api/open/token/ensure \
|
||||
-H 'Content-Type: application/json' \
|
||||
-H 'X-Open-Key: <服务间密钥>' \
|
||||
-d '{"mobile":"13800001111","scopeCode":"operation"}'
|
||||
```
|
||||
|
||||
- 入参:`mobile`、`email` 至少一个(手机号优先);`scopeCode` 必填。人员只认企业账号(enterprise_type 0/1),查不到 → 404。
|
||||
- 已存在有效 Token → `{"created":false,"token":"xqak_...(解密既有密文得到的明文)",...}`(**支持二次获取**:服务端保存 SHA-256 哈希 + AES-GCM 密文,密钥由 `app.token-cipher-key` 派生)。
|
||||
- 已存在但无密文(历史数据)→ 自动轮换:禁用旧把、签发新把,按 `created=true` 返回新明文。
|
||||
- 不存在 → 自动创建永久 Token → `{"created":true,"token":"xqak_...(明文)",...}`。
|
||||
- 调用写 `audit_log`(`open.token.ensure` / client_type=open-api,不记录明文)。
|
||||
|
||||
**签发规则(2026-09-04 起)**:一作用域一 Token。后台签发时选 N 个作用域会生成 N 把 token,每把独立绑定单作用域;编辑存量 token 时作用域为单选。
|
||||
|
||||
## codegen(run-once)
|
||||
|
||||
表白名单:m0 `user, agent, authority, properyt, agent_manage_district_auth`;m2 `warehouse_agent_auth`;svc `agent_api_key, agent_scope, tool, scope_tool, token_scope, audit_log, admin_user`(注:物业表真实表名为老系统遗留拼写 `properyt`)。生成物落在 `src/main/java` 对应分包并提交进仓库。
|
||||
|
||||
+34
-15
@@ -8,7 +8,7 @@
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
现有 SaaS(xiaoqu-franchisee)是面向 PC 后台人工操作的系统,接口鉴权(uuid + 短信登录态 + 签名)不适合机器调用。本方案新建一套**独立系统**,将仓库、客户等业务数据以 **MCP 服务**形式暴露给智能体,同时提供 CLI 兜底与 Web 管理后台。
|
||||
现有 SaaS(xiaoqu-franchisee)是面向 PC 后台人工操作的系统,接口鉴权(uuid + 短信登录态 + 签名)不适合机器调用。本方案新建一套**独立系统**,将仓库、客户等业务数据以 **MCP 服务**形式暴露给 xiaoqu-ai Android/iOS/Web、xiaoqu-operations-ai 及外部 MCP 客户端,同时提供 CLI 兜底与 Web 管理后台。
|
||||
|
||||
设计约束(讨论中明确):
|
||||
|
||||
@@ -20,11 +20,11 @@
|
||||
## 2. 总体架构
|
||||
|
||||
```
|
||||
Kimi Code ──┐
|
||||
├─ MCP (Streamable HTTP, Bearer ak) ─┐
|
||||
Claude ─────┤ │
|
||||
│ ▼
|
||||
xq CLI ─────┘ (Go 单二进制, 同为 MCP 客户端) ┌─────────────────────────┐
|
||||
Kimi/Claude/xq CLI ── MCP (Bearer ak) ──────────┐
|
||||
xiaoqu-ai Android/iOS/Web ─► Gateway/Agent ─────┤
|
||||
运营助手 Android/iOS ─────► ops-assistant-service┤
|
||||
▼
|
||||
┌─────────────────────────┐
|
||||
│ xiaoqu-agent-service │
|
||||
│ (单 jar, Spring Boot 3) │
|
||||
│ │
|
||||
@@ -44,7 +44,7 @@ xq CLI ─────┘ (Go 单二进制, 同为 MCP 客户端) ┌──
|
||||
└──────────┘ └────────────┘
|
||||
```
|
||||
|
||||
关键分层:**业务工具只写一遍**,MCP 端点(Kimi/Claude/CLI 同为 MCP 客户端)、后台页面共享同一套鉴权、权限引擎与数据访问层。
|
||||
关键分层:**业务工具只写一遍**。Kimi/Claude/CLI 和两个产品的服务端 Agent 是 MCP Client;Android/iOS/Web 业务客户端只访问本产品 Gateway/Agent API,不直接持有服务间密钥或 MCP Token。所有 MCP 调用共享同一套鉴权、权限引擎与数据访问层。
|
||||
|
||||
## 3. 权限模型(三层)
|
||||
|
||||
@@ -69,6 +69,8 @@ xq CLI ─────┘ (Go 单二进制, 同为 MCP 客户端) ┌──
|
||||
4. 执行查询,按"人"的数据权限规则加行过滤
|
||||
5. 审计落库(人、token、工具、入参摘要、结果、耗时)
|
||||
|
||||
注:`tools/list` 按作用域过滤(2026-09-04 决策,避免"看得到却调不了"的二义性):MCP SDK 工具清单在启动时静态注册,无法按请求过滤,故由 `ToolsListScopeFilter` 在 servlet 层重写 tools/list 响应。第 3 步的强制点仍是 `tools/call`——绕过 list 直接调未授权工具,得到的仍是可读的拒绝信息 + denied 审计。
|
||||
|
||||
### 3.3 行级数据权限移植规范(照搬现有系统)
|
||||
|
||||
来源:`WrapperUtils.getByIsAgent` 系列 + `authority.isagent`。规则:
|
||||
@@ -105,25 +107,27 @@ xq CLI ─────┘ (Go 单二进制, 同为 MCP 客户端) ┌──
|
||||
### 5.1 MCP(主形态)
|
||||
|
||||
- Streamable HTTP 传输,端点 `https://mcp.<域名>/mcp`。
|
||||
- 客户端配置(Kimi Code `~/.kimi-code/mcp.json`):
|
||||
- 客户端配置(Kimi Code `~/.kimi-code/mcp.json` 或项目级 `.kimi-code/mcp.json`,ak 明文直写;项目级文件已加入 `.gitignore`):
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"xiaoqu": {
|
||||
"url": "https://mcp.<域名>/mcp",
|
||||
"bearerTokenEnvVar": "XQ_API_KEY"
|
||||
"headers": {
|
||||
"Authorization": "Bearer <ak>"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- Claude Code:`claude mcp add --transport http xiaoqu https://mcp.<域名>/mcp --header "Authorization: Bearer <ak>"`。
|
||||
- ak 明文只出现在客户端环境变量/配置与签发时刻;服务端只存 SHA-256 哈希。
|
||||
- ak 明文只出现在服务端签发/ensure 响应和运行时;服务端保存 SHA-256 哈希及 AES-GCM 加密密文(二次获取用),含 ak 的客户端配置文件不得提交版本库。
|
||||
|
||||
### 5.2 Web 管理后台(四个页面)
|
||||
|
||||
- **用户与 Token 管理**:人员选自现有 `user` 表,**只列企业账号**(`enterprise_type` 0=企业内部 / 1=代理,历史 NULL 保留),物业账号(=2)不进列表——Token 是给能查代理商/客户数据的人用的;每人名下签发/禁用 token,展示 key 前缀、作用域、最后使用时间;选中人员展示其**数据权限归属**(权限组/isagent、所属代理、企业类型、可见范围说明,只读,便于排查"查不到数据")。
|
||||
- **用户与 Token 管理**:人员选自现有 `user` 表,**只列企业账号**(`enterprise_type` 0=企业内部 / 1=代理,历史 NULL 保留),物业账号(=2)不进列表——Token 是给能查代理商/客户数据的人用的;每人名下签发/禁用 token,展示 key 前缀、作用域、最后使用时间;选中人员展示其**数据权限归属**(权限组/isagent、所属代理、企业类型、可见范围说明,只读,便于排查"查不到数据")。**一作用域一 Token**:签发时选 N 个作用域生成 N 把 token(每把独立绑定单作用域,独立吊销/审计,明文逐把展示一次);编辑存量 token 时作用域收敛为单选。
|
||||
- **工具管理**:工具列表来自服务端注册自动同步(工具名/分类/读写类型页面只读);展示每个工具被哪些作用域引用;「停用」为全局紧急开关。分类(category)在代码注册时由开发者声明(工具的业务域归属是客观属性,不允许页面上修改,避免与代码漂移)。
|
||||
- **作用域管理**:作用域 CRUD + 勾选工具(工具按 category 分组展示)。
|
||||
- **审计日志**:按人/token/工具/时间筛选。
|
||||
@@ -140,9 +144,22 @@ xq CLI ─────┘ (Go 单二进制, 同为 MCP 客户端) ┌──
|
||||
- **命令规范**:从工具名派生,不手工维护——`warehouse.stock.query` → `xq warehouse stock query --warehouse-id 7`;参数与 help 从 `tools/list` 的 schema 生成。
|
||||
- **输出约定**:stdout 恒为 JSON(`--pretty` 给人看);错误统一 `{"error":{code,message}}` 且 exit code 非 0;`--schema` 吐入参 JSON Schema 供 agent 自发现。
|
||||
- **探测命令**:`xq whoami`(ak 归属的人 + 作用域,兼作连通性自检)、`xq tools`(列出本 ak 可用工具)。
|
||||
- **配置**:`~/.xq/config.yaml` 存 base-url 与多环境 profile(`--env dev|test|prd`);**ak 不落配置文件**,走 `XQ_API_KEY` 环境变量,与 Kimi 的 `bearerTokenEnvVar` 约定一致。
|
||||
- **配置**:`~/.xq/config.yaml` 存 base-url、多环境 profile(`--env dev|test|prd`)与 ak 明文(本机文件,权限 600),与 mcp.json 明文直写同一风格。
|
||||
- **服务端配套改动**(仅两点):审计表加 `client_type`(mcp-kimi / mcp-claude / cli,从请求头识别);错误消息面向"人和 agent 都可读"(如权限拒绝时提示应由管理员在哪个作用域开通)。
|
||||
|
||||
### 5.4 Open API(服务间接口)
|
||||
|
||||
供其他业务系统/智能体平台在服务端到服务端场景下获取用户 Token,路径 `/api/open/**`,与 ak 体系、后台 session 体系完全分离。
|
||||
|
||||
- **鉴权**:静态服务间密钥,header `X-Open-Key`,配置项 `app.open.api-key`(dev 明文写 `application-dev.yml`;配置为空 = 接口关闭)。常量时间比较防时序侧信道。
|
||||
- **`POST /api/open/token/ensure`**(get-or-create):
|
||||
- 入参:`mobile`、`email`(至少一个,手机号优先)、`scopeCode`(作用域编码,必填)。
|
||||
- 人员只认企业账号(`enterprise_type` 0/1,NULL 保留,与后台用户列表同口径);查不到 → 404。
|
||||
- 该人员在该作用域下**已存在**有效 Token(正常且未过期)→ `created=false`,**解密 `key_cipher` 回传明文**(二次获取,2026-09-07 决策:服务端保存 SHA-256 哈希 + AES-GCM 加密密文,密钥由配置 `app.token-cipher-key` 派生)。
|
||||
- 已存在但**无密文**(V3 之前的历史数据)→ 自动轮换:禁用旧把、签发新把,按 `created=true` 返回新明文;解密失败 → 500,不回退。
|
||||
- **不存在** → 自动创建永久 Token(一作用域一 Token,memo 标记 `open-api 自动签发`),`created=true` 返回明文。
|
||||
- 全程写 `audit_log`(`tool_name=open.token.ensure`,`client_type=open-api`,参数摘要脱敏,不记录明文)。
|
||||
|
||||
## 6. 技术栈与工程结构
|
||||
|
||||
- **JDK 17 + Spring Boot 3.x**(Spring AI MCP starter 的硬性要求)
|
||||
@@ -170,7 +187,7 @@ xiaoqu-agent-service/
|
||||
|
||||
| 表 | 关键字段 | 说明 |
|
||||
|---|---|---|
|
||||
| `agent_api_key` | user_id、key_prefix、key_hash(SHA-256)、status、expire_time(可空=永久)、last_used_time | token;user_id 逻辑关联 m0.user |
|
||||
| `agent_api_key` | user_id、key_prefix、key_hash(SHA-256)、key_cipher(AES-GCM 密文)、status、expire_time(可空=永久)、last_used_time | token;user_id 逻辑关联 m0.user |
|
||||
| `scope` | name、code、memo、status | 作用域 |
|
||||
| `scope_tool` | scope_id、tool_id | 作用域-工具 |
|
||||
| `token_scope` | token_id、scope_id | token-作用域 |
|
||||
@@ -183,7 +200,7 @@ xiaoqu-agent-service/
|
||||
## 8. 安全基线
|
||||
|
||||
- dev/内网测试阶段允许裸跑 HTTP(客户端 URL 用 `http://`);**对外/生产必须 HTTPS**(Nginx 终结 TLS,ak 在 HTTP 下明文可被抓包)
|
||||
- ak 只存哈希;审计不记录 ak;限流按 token 维度(防 agent 死循环打爆库)
|
||||
- ak 存 SHA-256 哈希 + AES-GCM 加密密文(不存明文;密钥独立配置 `app.token-cipher-key`);审计不记录 ak;限流按 token 维度(防 agent 死循环打爆库)
|
||||
- 数据访问全部只读账号;scope 最小化发放,新工具默认不可用
|
||||
- 可选:Nginx IP 白名单(客户端出口固定时)
|
||||
|
||||
@@ -223,6 +240,9 @@ xiaoqu-agent-service/
|
||||
- CLI 复用 `/mcp` 端点做 MCP 客户端,不开第二套服务 → 权限逻辑只有一份;Go 单二进制分发,与服务端 Java 栈解耦
|
||||
- 不依赖老 jar,Entity 自行生成 → 与老系统零代码依赖
|
||||
- 前后端一体打包单 jar → 可逆决定,将来可拆
|
||||
- 一作用域一 Token(2026-09-04)→ 收回某能力不需整把吊销;数据模型不变(token_scope 每把只写一行)
|
||||
- ensure 支持二次获取明文(2026-09-07,推翻此前"明文不可回吐"红线):服务端存 SHA-256 哈希 + AES-GCM 密文(`app.token-cipher-key` 派生密钥);已存在 Token 解密回传明文,历史无密文 Token 自动轮换 → 调用方丢了明文无需人工重新签发;代价是拿到 `X-Open-Key` 者可取回任意用户 ak 明文,密钥必须仅限受信任后端保管
|
||||
- Open API 鉴权用配置文件静态密钥(dev)→ 服务间调用方少,落库+轮换留给生产阶段
|
||||
- 列表类接口一律服务端分页,pageSize/rows 上限 100(超出截断为 100)→ 防止一次性拉全表,MCP 工具与后台 API 同一尺度
|
||||
|
||||
## 11. 开发环境(dev)
|
||||
@@ -247,4 +267,3 @@ xiaoqu-agent-service/
|
||||
- 合并回 main 后用 `xiaoqu-agent-service/deploy/deploy.sh restart` 完成构建+重启,接口实测通过再 `git push`。
|
||||
- 后台列表页统一服务端分页(page 从 1 开始,pageSize 默认 20、上限 100);MCP 工具 `rows` 同样上限 100,超出截断并在 `notice` 字段提示。
|
||||
- **新增/修改 MCP 工具必须同步更新 `doc/agent-mcp-tools-reference.md`**(入参、出参、权限语义、示例),工具对外契约以该文档为准。
|
||||
|
||||
+2
-2
@@ -8,8 +8,8 @@
|
||||
| 项 | 说明 |
|
||||
|---|---|
|
||||
| 端点 | `POST http://<host>:8088/mcp`(MCP Streamable HTTP) |
|
||||
| 认证 | Header `Authorization: Bearer xqak_xxx`,ak 在管理后台「用户与 Token」签发,服务端只存 SHA-256 |
|
||||
| 工具可见性 | 按 ak 绑定的作用域过滤,`tools/list` 只返回该 ak 可用的工具 |
|
||||
| 认证 | Header `Authorization: Bearer xqak_xxx`,Token 由管理后台或受信任 Open API 获取;服务端保存 SHA-256 及加密密文 |
|
||||
| 工具可见性 | `tools/list` 按 ak 作用域过滤,只返回该 ak 作用域并集内且启用状态的工具(2026-09-04 起,服务端 servlet 层重写响应实现);`tools/call` 另有强制校验兜底,越权返回可读拒绝信息并落审计(denied) |
|
||||
| 行级数据权限 | 跟 ak 所属人走,照搬老系统 `getByIsAgent` 语义(见设计文档 §3.3),与作用域无关 |
|
||||
| 分页 | 列表类工具统一 `page`(从 1 开始,默认 1)、`rows`(默认 20,**上限 100**,超出截断为 100 并在返回 `notice` 字段提示) |
|
||||
| 分页出参 | 统一含 `total`(总数)、`page`、`rows`、`records`(本页记录)、`notice`(仅截断时出现) |
|
||||
@@ -0,0 +1,153 @@
|
||||
# xiaoqu-ai 接入 xiaoqu-mcp 详细设计
|
||||
|
||||
## 1. 目标与边界
|
||||
|
||||
本文是 [`xiaoqu 多产品 Agent 统一接入 xiaoqu-mcp 总体设计`](./xiaoqu-mcp-multi-product-agent-design.md) 的 xiaoqu-ai 落地设计。
|
||||
|
||||
xiaoqu-ai 的 iOS、Android、Web 共用一套服务端 MCP 接入,不在三个客户端分别实现 MCP Client:
|
||||
|
||||
```text
|
||||
iOS / Android / Web
|
||||
│ 产品登录 Token
|
||||
▼
|
||||
xiaoqu-ai Gateway / Agent Runtime
|
||||
│ 用户 MCP Token(仅服务端持有)
|
||||
▼
|
||||
xiaoqu-agent-service /mcp
|
||||
```
|
||||
|
||||
项目目录:
|
||||
|
||||
- iOS、Web、Gateway、Agent Service:`/Users/tonylai/xiaoquai/xiaoqu-ai`
|
||||
- Android:`/Users/tonylai/jiaxk/xiaoqu-ai-android`
|
||||
|
||||
## 2. 当前代码依据
|
||||
|
||||
| 端/服务 | 当前入口 | 结论 |
|
||||
|---|---|---|
|
||||
| iOS 销售助手 | `ios/Sources/XiaoquCRM/Features/SalesAgent/SalesAgentClient.swift` | 继续调用 `/api/v1/sales-agent/*` REST/SSE |
|
||||
| iOS 管理助手 | `ios/Sources/XiaoquCRM/Features/AI/Networking/ManagerAgentClient.swift` | 继续调用服务端 Agent,不新增 MCP Token |
|
||||
| Android | `app/src/main/java/com/xiaoquai/crm/data/remote/WebSocketManager.kt`、`ApiService.kt` | 继续走 Gateway WebSocket/REST,`TokenManager` 只保存产品 Token |
|
||||
| Web | `web/src/hooks/use-agent-chat.ts` | 继续调用 `/api/v1/agents/{id}/invoke` |
|
||||
| Gateway | `gateway/internal/agent/tools.go`、`gateway/internal/handler/runtime_tool_invoke_handlers.go` | 已有统一 ToolExecutor、数据范围校验和运行时工具入口 |
|
||||
| Agent Service | `agent-service/agent_service/tools/runtime_gateway_tools.py` | 已使用当前用户 JWT调用 Gateway runtime catalog/tool invoke |
|
||||
|
||||
iOS 的 `ClawBridge.swift` 是独立端侧 Agent 模式,不属于本次服务端 xiaoqu-mcp 接入,不修改其 Keychain、MCP 配置或 Token。
|
||||
|
||||
## 3. 统一接入点
|
||||
|
||||
MCP 适配器放在 Gateway 的工具执行层,三端最终复用同一实现:
|
||||
|
||||
```text
|
||||
Web / Android Agent 请求 ──► Gateway AgentRuntime ─┐
|
||||
├─► Gateway ToolExecutor
|
||||
iOS Sales Agent ──► agent-service │ │
|
||||
└─ runtime_gateway_tools ───────┘ ▼
|
||||
xiaoqu-mcp
|
||||
```
|
||||
|
||||
这样保留现有客户端和 Agent 编排,只把新的远程工具接入集中在 Gateway。Agent Service 继续转发已验证的用户 JWT,不自行持有第二份 xiaoqu-mcp Token 缓存。
|
||||
|
||||
## 4. Gateway 变更
|
||||
|
||||
### 4.1 配置
|
||||
|
||||
在 Gateway 启动配置中增加:
|
||||
|
||||
| 变量 | 默认值 | 说明 |
|
||||
|---|---|---|
|
||||
| `XQ_MCP_ENABLED` | `false` | 是否启用 xiaoqu-mcp 工具 |
|
||||
| `XQ_MCP_URL` | unset | xiaoqu-mcp 内网地址 |
|
||||
| `XQ_MCP_OPEN_KEY` | unset | Token 初始化凭证 |
|
||||
| `XQ_MCP_SCOPE_CODE` | unset | xiaoqu-ai 固定 scope;启用前必须在管理后台确认 |
|
||||
| `XQ_MCP_TIMEOUT_SECONDS` | `10` | MCP 请求超时 |
|
||||
|
||||
启用时 URL、Open Key 和 scope 必填。Open Key 只来自部署 Secret,不写入 YAML、客户端配置或仓库。
|
||||
|
||||
### 4.2 最小模块
|
||||
|
||||
新增 `gateway/internal/connector/xiaoqumcp/`:
|
||||
|
||||
- `token_provider.go`
|
||||
- 输入 Gateway 已验证的 `tenantID`、`userID`。
|
||||
- 从用户资料存储取得可信 mobile/email 并调用 `ensure`。
|
||||
- 按租户、用户和 scope 缓存 Token;401 时只刷新当前用户一次。
|
||||
- `client.go`
|
||||
- 管理 MCP JSON-RPC、Session ID、Bearer Header、超时和错误映射。
|
||||
- 按租户、用户和 scope 隔离 Session。
|
||||
|
||||
使用 Go 标准 HTTP Client 和项目现有错误/日志设施,不增加 MCP 框架依赖。
|
||||
|
||||
### 4.3 ToolExecutor 接入
|
||||
|
||||
在 `gateway/internal/agent/tools.go` 注册稳定的本地工具名,由 Handler 调用 `xiaoqumcp.Client`。不得把远端 `tools/list` 原样注册给模型。
|
||||
|
||||
首期只接只读工具。候选映射以 xiaoqu-mcp 当前工具为基础,但必须先为 xiaoqu-ai 创建并审核独立 scope:
|
||||
|
||||
| Gateway 本地工具 | MCP 工具 | 状态 |
|
||||
|---|---|---|
|
||||
| `xiaoqu.agent_count` | `agent.count` | scope 绑定后启用 |
|
||||
| `xiaoqu.agent_list` | `agent.list.query` | scope 绑定后启用 |
|
||||
| `xiaoqu.property_list` | `property.list.query` | scope 绑定后启用 |
|
||||
|
||||
每个工具继续使用现有 `RequiredDataScope`、Agent Capability 绑定和参数 schema。MCP 返回结果先做字段白名单投影,再进入模型上下文、卡片渲染和 `chat_tool_logs`。
|
||||
|
||||
## 5. 用户与权限流程
|
||||
|
||||
```text
|
||||
产品登录 Token
|
||||
→ Gateway middleware 得到 userID / tenantID / role
|
||||
→ Agent Capability + RequiredDataScope 校验
|
||||
→ 可信用户资料解析 mobile/email
|
||||
→ token_provider.ensure(scopeCode)
|
||||
→ mcp_client.tools/call
|
||||
→ xiaoqu-mcp 再校验 Token scope 与数据权限
|
||||
```
|
||||
|
||||
`agent.InvokeRequest` 已携带 `UserID`、`TenantID`、`UserJWT` 和 `UserRole`。MCP Handler 必须使用这些可信字段,不接受模型参数覆盖身份或 scope。
|
||||
|
||||
Agent Service 的 `runtime_gateway_tools.py` 继续转发入站用户 JWT;无 JWT 时保持 fail-closed,不允许自签或使用公共用户身份。
|
||||
|
||||
## 6. 客户端影响
|
||||
|
||||
| 客户端 | 变更 |
|
||||
|---|---|
|
||||
| iOS | 无 MCP 网络层变更;继续消费现有 REST/SSE、确认卡和工具结果 |
|
||||
| Android | 无 MCP 网络层变更;Gateway Token 仍由现有 `TokenManager` 管理 |
|
||||
| Web | 无 MCP 网络层变更;继续调用 Agent invoke API 并渲染返回结果 |
|
||||
|
||||
如果新工具需要专用展示,只扩展现有服务端响应投影和客户端卡片 schema;不得因此向客户端下发 MCP Token、Session ID 或原始 Header。
|
||||
|
||||
## 7. 错误与生命周期
|
||||
|
||||
- 用户切换或产品会话失效时,当前用户的 MCP Token 缓存和 Session 一并失效。
|
||||
- MCP 401:清理当前用户缓存,重新 `ensure` 并重连一次。
|
||||
- MCP 403:返回权限不足,不刷新 Token。
|
||||
- MCP 超时或不可用:返回现有稳定工具错误,不回退公共 Token 或数据库直连。
|
||||
- 只读请求最多重试一次;写工具不在首期范围。
|
||||
- 日志和 `chat_tool_logs` 只记录工具名、状态、耗时与脱敏错误,不记录 Token、Header、mobile/email 或未投影原始结果。
|
||||
|
||||
## 8. 开发顺序
|
||||
|
||||
1. 在目标环境创建或确认 xiaoqu-ai 独立 scope,并绑定首期只读工具。
|
||||
2. 增加 Gateway 配置与启动校验。
|
||||
3. 实现 `token_provider.go` 和 `client.go`,覆盖 Token、Session、超时和错误映射测试。
|
||||
4. 在 ToolExecutor 注册固定工具并复用现有 DataScope 校验。
|
||||
5. 验证 Agent Service runtime gateway 路径仍只转发用户 JWT。
|
||||
6. 分别用 iOS、Android、Web 完成同用户、跨用户和跨租户联调。
|
||||
|
||||
## 9. 项目验收
|
||||
|
||||
- 同一用户在 iOS、Android、Web 调用同一工具时,结果契约和数据权限一致。
|
||||
- 三端请求、存储、日志中均无 MCP Token、Session ID 或 `X-Open-Key`。
|
||||
- Web/Android Gateway Agent 与 iOS Sales Agent 最终调用同一个 Gateway MCP Handler。
|
||||
- 用户 A/B、不同租户和 scope 的 Token 缓存与 MCP Session 完全隔离。
|
||||
- 无本地 Capability、无 RequiredDataScope 或无 MCP scope 时均不能调用。
|
||||
- MCP 401 只刷新当前用户;MCP 不可用不影响未启用该 Capability 的原有 Agent 功能。
|
||||
|
||||
## 10. 当前不做
|
||||
|
||||
- 不为 iOS、Android、Web 分别实现 MCP Client。
|
||||
- 不改造 iOS ClawBridge。
|
||||
- 不把远端全部工具动态暴露给 Agent。
|
||||
- 不在首期接入写工具或副作用自动重试。
|
||||
@@ -0,0 +1,137 @@
|
||||
# xiaoqu 多产品 Agent 统一接入 xiaoqu-mcp 总体设计
|
||||
|
||||
## 1. 设计结论
|
||||
|
||||
xiaoqu-ai 与 xiaoqu-operations-ai 共用 xiaoqu-mcp 的服务能力、Token 原语、工具协议、权限模型和审计规则,但保留各自的 Agent Runtime:
|
||||
|
||||
```text
|
||||
xiaoqu-ai iOS / Android / Web
|
||||
│ 产品登录 Token
|
||||
▼
|
||||
xiaoqu-ai Gateway / Agent Runtime ───┐
|
||||
│ 用户 MCP Token(仅服务端持有)
|
||||
xiaoqu-operations-ai iOS / Android │
|
||||
│ 产品登录 Token │
|
||||
▼ │
|
||||
ops-assistant-service ───────────────┤
|
||||
▼
|
||||
xiaoqu-agent-service /mcp
|
||||
Token + Scope + Tool
|
||||
行级权限 + Audit
|
||||
```
|
||||
|
||||
共同的是后端协议,不是 Swift、Kotlin 或 Web UI 代码。任何客户端都不实现本次 xiaoqu-mcp Client,不保存用户 MCP Token,也不接触 `X-Open-Key`。
|
||||
|
||||
## 2. 范围与文档分工
|
||||
|
||||
| 层级 | 项目 | 项目目录 | 文档 |
|
||||
|---|---|---|---|
|
||||
| 总体 | xiaoqu-mcp | 当前仓库 `xiaoqu-agent-service` | 本文 |
|
||||
| 详细设计 | xiaoqu-ai iOS、Android、Web、服务端 Agent | `/Users/tonylai/xiaoquai/xiaoqu-ai`、`/Users/tonylai/jiaxk/xiaoqu-ai-android` | [`xiaoqu-ai-mcp-integration-design.md`](./xiaoqu-ai-mcp-integration-design.md) |
|
||||
| 详细设计 | xiaoqu-operations-ai iOS、Android、服务端 Agent | `/Users/tonylai/jiaxk/xiaoqu-operations-ai` | [`xiaoqu-operations-ai-mcp-integration-design.md`](./xiaoqu-operations-ai-mcp-integration-design.md) |
|
||||
|
||||
本文只定义两个产品必须共同遵守的架构和安全契约。项目代码落点、配置、Capability 映射和项目级验收只写在对应详细设计中。
|
||||
|
||||
## 3. 统一职责
|
||||
|
||||
| 层 | 职责 |
|
||||
|---|---|
|
||||
| iOS / Android / Web | 登录、会话 UI、流式展示和确认交互;只携带产品登录凭证 |
|
||||
| 产品 Gateway | 验证登录态,建立可信用户、租户和数据范围上下文 |
|
||||
| 产品 Agent Runtime | 编排 Agent,维护本地 Capability allowlist,获取用户 MCP Token 并调用 MCP |
|
||||
| xiaoqu-agent-service | 校验 MCP Token,按 scope 过滤工具,执行用户/租户/行级权限并记录审计 |
|
||||
|
||||
两个产品可以暴露不同的 Capability 集合,但都必须通过同一套 MCP 鉴权和工具契约执行。运营项目不得直接调用 xiaoqu-ai Agent,也不得复制或直连 xiaoqu-mcp 数据库。
|
||||
|
||||
## 4. 认证与 Token 契约
|
||||
|
||||
### 4.1 三类凭证
|
||||
|
||||
| 凭证 | 使用链路 | 用途 |
|
||||
|---|---|---|
|
||||
| 产品登录 Token | 客户端 → 产品 Gateway | 证明当前产品用户身份 |
|
||||
| `X-Open-Key` | 产品 Agent Runtime → `/api/open/token/ensure` | 服务间获取指定用户的 MCP Token |
|
||||
| 用户 MCP Token `xqak_*` | 产品 Agent Runtime → `/mcp` | 绑定 MCP 用户、scope、权限和审计 |
|
||||
|
||||
`X-Open-Key` 和用户 MCP Token 只能存在于受信任服务端,不得进入客户端、prompt、模型输出、工具参数、普通日志或 Git。
|
||||
|
||||
### 4.2 用户映射
|
||||
|
||||
1. Gateway 验证产品登录 Token。
|
||||
2. Agent Runtime 从可信上下文取得产品用户、租户和数据范围。
|
||||
3. Agent Runtime 从受信任用户资料源取得手机号或邮箱。
|
||||
4. Agent Runtime 使用服务端固定的 `scopeCode` 调用 `POST /api/open/token/ensure`。
|
||||
5. xiaoqu-agent-service 返回该 MCP 用户对应的 Token。
|
||||
6. Agent Runtime 使用该 Token 调用 `/mcp`。
|
||||
|
||||
客户端、Planner 和模型不得提供或覆盖 `user_id`、手机号、邮箱、租户、数据范围或 `scopeCode`。每个产品启用前必须核对管理后台中的真实 scope 和工具绑定。
|
||||
|
||||
### 4.3 `POST /api/open/token/ensure`
|
||||
|
||||
认证:`X-Open-Key: <server-only-key>`。
|
||||
|
||||
```json
|
||||
{
|
||||
"mobile": "<trusted-user-mobile>",
|
||||
"email": "<trusted-user-email>",
|
||||
"scopeCode": "<server-configured-scope>"
|
||||
}
|
||||
```
|
||||
|
||||
- `mobile`、`email` 至少一个,手机号优先。
|
||||
- 已有有效 Token:解密 `key_cipher` 并返回同一明文。
|
||||
- 历史无密文 Token:禁用旧 Token 并自动签发新 Token。
|
||||
- 不存在有效 Token:创建长期 Token;回收通过禁用实现。
|
||||
- 密文解密失败必须报错,不得回退哈希值或公共 Token。
|
||||
|
||||
错误语义:`400` 参数或 scope 错误,`401` Open Key 无效,`403` Open API 未启用,`404` 用户不存在或不属于允许的企业账号。
|
||||
|
||||
## 5. MCP 协议与权限
|
||||
|
||||
调用入口:`POST /mcp`。
|
||||
|
||||
```http
|
||||
Authorization: Bearer <user-mcp-token>
|
||||
Content-Type: application/json
|
||||
Accept: application/json, text/event-stream
|
||||
```
|
||||
|
||||
调用顺序:`initialize` → `notifications/initialized` → `tools/list` → `tools/call`。初始化响应包含 Session ID 时,后续请求携带 `Mcp-Session-Id`。
|
||||
|
||||
最终可执行能力为:
|
||||
|
||||
```text
|
||||
产品 Agent capability allowlist
|
||||
∩ MCP Token scope
|
||||
∩ 当前用户权限
|
||||
∩ 当前租户/数据范围
|
||||
```
|
||||
|
||||
`tools/list` 必须按 Token scope 过滤,`tools/call` 必须再次强制校验。不得仅依赖提示词、本地隐藏菜单或 Planner 输出做授权。
|
||||
|
||||
## 6. 生命周期与错误处理
|
||||
|
||||
- 缓存键至少包含产品、租户、产品用户 ID 和 scope。
|
||||
- 登录用户变化或退出登录时,旧用户的 Token 缓存和 MCP Session 必须失效。
|
||||
- MCP 返回 401 时,只清理当前用户缓存并重新 `ensure` 一次。
|
||||
- 只读调用可在重新连接后重试一次;有副作用调用不得自动重放。
|
||||
- MCP 不可用时返回稳定的工具不可用错误,不得回退公共 Token 或数据库直连。
|
||||
- 日志只记录用户 ID、scope、工具名、状态和耗时,不记录凭证明文或手机号/邮箱明文。
|
||||
|
||||
## 7. 联调顺序
|
||||
|
||||
| # | 工作 | 完成标准 |
|
||||
|---|---|---|
|
||||
| 1 | 确认两个产品的用户映射与实际 scope | scope 已存在并绑定所需工具,不依赖客户端身份字段 |
|
||||
| 2 | 完成 xiaoqu-ai 服务端接入 | iOS、Android、Web 经同一服务端适配器调用 |
|
||||
| 3 | 完成 xiaoqu-operations-ai 服务端接入 | 运营 iOS、Android 经 ops-assistant-service 调用 |
|
||||
| 4 | 跨用户、跨租户和跨产品回归 | 无 Token 泄露、串号、越权或副作用自动重放 |
|
||||
|
||||
## 8. 总体验收与回退
|
||||
|
||||
- 所有客户端请求只出现产品登录 Token,不出现用户 MCP Token 或 `X-Open-Key`。
|
||||
- 用户 A/B、不同租户、不同 scope 的 Token 缓存和 MCP Session 完全隔离。
|
||||
- 绕过 `tools/list` 直接调用 `tools/call` 仍会被服务端拒绝并审计。
|
||||
- 两个产品的本地 allowlist 可以不同,但同名 MCP 工具的请求和响应契约一致。
|
||||
- 任一产品回退时,只关闭该 Agent Runtime 的 MCP Capability 开关;客户端继续使用原有 Agent 功能。
|
||||
- 禁止回退为客户端共享固定 Token、公共服务端 Token 或数据库直连。
|
||||
@@ -0,0 +1,112 @@
|
||||
# xiaoqu-operations-ai 接入 xiaoqu-mcp 详细设计
|
||||
|
||||
## 1. 目标与边界
|
||||
|
||||
本文是 [`xiaoqu 多产品 Agent 统一接入 xiaoqu-mcp 总体设计`](./xiaoqu-mcp-multi-product-agent-design.md) 的运营项目落地设计。
|
||||
|
||||
`ops-assistant-service` 是运营项目唯一 MCP Client,同时服务运营 iOS 和 Android,并保留现有编排:
|
||||
|
||||
```text
|
||||
运营 iOS / Android
|
||||
│ 产品登录 Token
|
||||
▼
|
||||
Router → Planner → Executor → Answer
|
||||
│
|
||||
▼
|
||||
xiaoqu-mcp
|
||||
```
|
||||
|
||||
客户端不新增 MCP 代码。项目目录:`/Users/tonylai/jiaxk/xiaoqu-operations-ai`。
|
||||
|
||||
## 2. 当前代码依据
|
||||
|
||||
- `services/ops-assistant-service` 是 FastAPI + Python 3.12 服务。
|
||||
- 当前依赖中没有 MCP SDK,也没有 `tools/list` / `tools/call` 客户端。
|
||||
- `src/ops_assistant_service/capability_registry.py` 维护静态 `CAPABILITY_REGISTRY`。
|
||||
- `src/ops_assistant_service/executor.py` 当前执行 `customer_issues.*`、`smartclean.query` 等本地 Capability。
|
||||
- 用户身份来自 Gateway JWT,并通过 `ops_identity_mappings` 解析租户、用户和 `scope_tags`。
|
||||
- 最小接入点是 Executor 的工具适配层,不新增第二套编排器。
|
||||
|
||||
## 3. 配置设计
|
||||
|
||||
在 `src/ops_assistant_service/config.py` 的 `Settings` 中增加以下 `OPS_ASSISTANT_` 配置:
|
||||
|
||||
| 变量 | 默认值 | 说明 |
|
||||
|---|---|---|
|
||||
| `XQ_MCP_ENABLED` | `false` | 是否启用 MCP Capability |
|
||||
| `XQ_MCP_URL` | unset | xiaoqu-mcp 内网地址 |
|
||||
| `XQ_MCP_OPEN_KEY` | unset | Token 初始化凭证,类型使用 `SecretStr` |
|
||||
| `XQ_MCP_SCOPE_CODE` | `operation` | 服务端固定 scope,禁止由 Planner 或客户端选择 |
|
||||
| `XQ_MCP_TIMEOUT_SECONDS` | `10` | MCP 请求超时 |
|
||||
|
||||
启用时必须同时提供 URL、Open Key 和 scope。`operation` 已存在于 dev 种子,其他环境启用前仍需核对实际 scope 和工具绑定;生产配置不得写入仓库。
|
||||
|
||||
## 4. 最小模块
|
||||
|
||||
只新增两个模块:
|
||||
|
||||
- `src/ops_assistant_service/mcp_client.py`
|
||||
- 使用现有 `httpx` 完成 JSON-RPC、Session ID、Bearer Header、超时和错误映射。
|
||||
- 按租户、`user_id` 和 scope 隔离 MCP Session。
|
||||
- `src/ops_assistant_service/mcp_token_provider.py`
|
||||
- 接收已验证的 `SharedIdentity`。
|
||||
- 从受信任用户资料源取得 mobile/email 并调用 `ensure`。
|
||||
- 按租户、`user_id` 和 scope 缓存 Token;401 时只刷新当前用户一次。
|
||||
- 不接受客户端或 Planner 传入的身份字段作为覆盖参数。
|
||||
|
||||
不引入通用 MCP 框架、不新增 Agent,也不直连 xiaoqu-mcp 数据库。
|
||||
|
||||
## 5. Capability 映射
|
||||
|
||||
在 `capability_registry.py` 注册固定的本地 Capability,不把远端 `tools/list` 原样暴露给 Planner:
|
||||
|
||||
| 本地 Capability | MCP 工具 | 首期权限 |
|
||||
|---|---|---|
|
||||
| `xiaoqu.agent_count` | `agent.count` | read |
|
||||
| `xiaoqu.agent_list` | `agent.list.query` | read |
|
||||
| `xiaoqu.property_list` | `property.list.query` | read |
|
||||
|
||||
Planner 只能选择注册表中的名称和 schema。Executor 在调用前执行本地 Capability、用户 `scope_tags`、租户/DataScope 校验;xiaoqu-mcp 再执行 Token scope 和服务端数据权限校验。
|
||||
|
||||
远端结果经过字段白名单投影后再进入 Answer。Token、Authorization Header 和未授权字段不得进入 execution trace。
|
||||
|
||||
## 6. Executor 接入
|
||||
|
||||
在 `executor.py` 增加 MCP 只读分支:
|
||||
|
||||
```text
|
||||
validate_tool_input
|
||||
→ 校验本地 Capability 和参数白名单
|
||||
→ 读取 SharedIdentity / DataScope
|
||||
→ mcp_token_provider 获取当前用户 Token
|
||||
→ mcp_client 调用 tools/call
|
||||
→ 结果字段投影
|
||||
→ 现有 execution_trace / Answer
|
||||
```
|
||||
|
||||
现有 `customer_issues.*` 和 `smartclean.query` 保持不变。MCP 不可用时返回稳定的 `TOOL_UNAVAILABLE`,不得回退公共 Token 或数据库直连。
|
||||
|
||||
## 7. 开发顺序
|
||||
|
||||
1. 核对目标环境中的 `operation` scope、工具绑定和用户资料映射。
|
||||
2. 增加 Settings 配置及启用校验。
|
||||
3. 实现 `mcp_token_provider.py` 与 `mcp_client.py`,覆盖 JSON-RPC 和 Session 单元测试。
|
||||
4. 注册 3 个只读 Capability 并接入 Executor。
|
||||
5. 增加用户隔离、scope 拒绝、401 重连、Token 脱敏和 MCP 不可用测试。
|
||||
6. 在 development/test 环境联调,通过后再进入 staging。
|
||||
|
||||
## 8. 项目验收
|
||||
|
||||
- 运营 iOS、Android 继续只调用 `ops-assistant-service`,客户端无 MCP Token。
|
||||
- 用户 A/B、不同租户和 scope 的 Token 与 MCP Session 不互用。
|
||||
- Planner 不能选择未注册的远程工具,Executor 不能绕过 `scope_tags` 和 DataScope。
|
||||
- `tools/list`、`tools/call` 使用同一用户 Session 和 Bearer Token。
|
||||
- MCP 401 只刷新当前用户;只读请求最多重试一次。
|
||||
- 测试、日志、trace、配置和 Git 历史中均无凭证明文。
|
||||
|
||||
## 9. 当前不做
|
||||
|
||||
- 不把 MCP Client 放进运营 iOS 或 Android。
|
||||
- 不动态注册全部远程工具。
|
||||
- 不在首期引入写工具;写工具需另行设计审批、幂等和补偿协议。
|
||||
- 不复制 xiaoqu-mcp 数据库,不调用 xiaoqu-ai Agent 代查数据。
|
||||
@@ -26,8 +26,8 @@ const audits = ref([])
|
||||
const toolFilter = reactive({ category: '', kind: '', keyword: '' })
|
||||
const auditFilter = reactive({ person: '', tokenPrefix: '', tool: '', result: '', client: '', from: '', to: '' })
|
||||
const tokenDialog = ref(false)
|
||||
const tokenForm = reactive({ id: null, memo: '', scopeIds: [], expireTime: null })
|
||||
const issuedPlaintext = ref('')
|
||||
const tokenForm = reactive({ id: null, memo: '', scopeIds: [], scopeId: null, expireTime: null })
|
||||
const issuedTokens = ref([])
|
||||
const scopeDialog = ref(false)
|
||||
const scopeForm = reactive({ id: null, name: '', code: '', memo: '' })
|
||||
const scopeToolsDialog = ref(false)
|
||||
@@ -106,24 +106,26 @@ async function selectUser(user) {
|
||||
|
||||
function openToken(token = null) {
|
||||
Object.assign(tokenForm, token ? {
|
||||
id: token.id, memo: token.memo || '', scopeIds: [...token.scopeIds], expireTime: token.expireTime || null,
|
||||
} : { id: null, memo: '', scopeIds: [], expireTime: null })
|
||||
id: token.id, memo: token.memo || '', scopeIds: [], scopeId: token.scopeIds[0] ?? null,
|
||||
expireTime: token.expireTime || null,
|
||||
} : { id: null, memo: '', scopeIds: [], scopeId: null, expireTime: null })
|
||||
tokenDialog.value = true
|
||||
}
|
||||
|
||||
async function saveToken() {
|
||||
if (!tokenForm.scopeIds.length) return ElMessage.warning('至少选择一个作用域')
|
||||
const scopeIds = tokenForm.id ? (tokenForm.scopeId ? [tokenForm.scopeId] : []) : tokenForm.scopeIds
|
||||
if (!scopeIds.length) return ElMessage.warning('至少选择一个作用域')
|
||||
await run(async () => {
|
||||
const body = JSON.stringify({ memo: tokenForm.memo, scopeIds: tokenForm.scopeIds, expireTime: tokenForm.expireTime })
|
||||
const body = JSON.stringify({ memo: tokenForm.memo, scopeIds, expireTime: tokenForm.expireTime })
|
||||
if (tokenForm.id) await api(`/tokens/${tokenForm.id}`, { method: 'PUT', body })
|
||||
else {
|
||||
const issued = await api(`/users/${selectedUser.value.id}/tokens`, { method: 'POST', body })
|
||||
issuedPlaintext.value = issued.plaintext
|
||||
// 一作用域一 Token:选 N 个作用域返回 N 把明文
|
||||
issuedTokens.value = await api(`/users/${selectedUser.value.id}/tokens`, { method: 'POST', body })
|
||||
}
|
||||
tokenDialog.value = false
|
||||
tokens.value = await api(`/users/${selectedUser.value.id}/tokens`)
|
||||
await loadUsers()
|
||||
ElMessage.success(tokenForm.id ? 'Token 已更新' : 'Token 已签发')
|
||||
ElMessage.success(tokenForm.id ? 'Token 已更新' : `已签发 ${issuedTokens.value.length} 把 Token`)
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
@@ -206,7 +208,10 @@ async function loadAudit() { audits.value = await api(`/audit${qs(auditFilter)}`
|
||||
function date(value) { return value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '—' }
|
||||
function expiry(value) { return value ? date(value) : '永久' }
|
||||
function enterprise(value) { return ({ 1: '代理', 2: '物业' })[value] || `类型 ${value ?? '未配置'}` }
|
||||
async function copyToken() { await navigator.clipboard.writeText(issuedPlaintext.value); ElMessage.success('已复制') }
|
||||
async function copyText(value) { await navigator.clipboard.writeText(value); ElMessage.success('已复制') }
|
||||
async function copyAllTokens() {
|
||||
await copyText(issuedTokens.value.map(item => `${item.token.scopeNames.join('/')}: ${item.plaintext}`).join('\n'))
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
@@ -388,7 +393,15 @@ onMounted(async () => {
|
||||
<el-form label-width="88px">
|
||||
<el-form-item label="所属人员"><strong>{{ selectedUser?.name }}</strong></el-form-item>
|
||||
<el-form-item label="用途备注"><el-input v-model="tokenForm.memo" maxlength="255" placeholder="如:Kimi 桌面用" aria-label="Token 用途备注" data-testid="admin-token-memo" /></el-form-item>
|
||||
<el-form-item label="作用域"><el-checkbox-group v-model="tokenForm.scopeIds" data-testid="admin-token-scopes"><el-checkbox v-for="scope in scopes.filter(item => item.status === 1)" :key="scope.id" :value="scope.id">{{ scope.name }}</el-checkbox></el-checkbox-group></el-form-item>
|
||||
<el-form-item label="作用域">
|
||||
<el-radio-group v-if="tokenForm.id" v-model="tokenForm.scopeId" data-testid="admin-token-scopes">
|
||||
<el-radio v-for="scope in scopes.filter(item => item.status === 1)" :key="scope.id" :value="scope.id">{{ scope.name }}</el-radio>
|
||||
</el-radio-group>
|
||||
<el-checkbox-group v-else v-model="tokenForm.scopeIds" data-testid="admin-token-scopes">
|
||||
<el-checkbox v-for="scope in scopes.filter(item => item.status === 1)" :key="scope.id" :value="scope.id">{{ scope.name }}</el-checkbox>
|
||||
</el-checkbox-group>
|
||||
<div class="hint" style="margin:4px 0 0">{{ tokenForm.id ? '一把 Token 只绑一个作用域' : '一作用域一 Token:选几个作用域就签发几把' }}</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="过期时间"><el-date-picker v-model="tokenForm.expireTime" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" placeholder="留空为永久" aria-label="Token 过期时间" data-testid="admin-token-expiry" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer><el-button data-testid="admin-token-cancel" @click="tokenDialog = false">取消</el-button><el-button type="primary" data-testid="admin-token-save" @click="saveToken">确认</el-button></template>
|
||||
@@ -410,9 +423,13 @@ onMounted(async () => {
|
||||
<template #footer><el-button data-testid="admin-scope-tools-cancel" @click="scopeToolsDialog = false">取消</el-button><el-button type="primary" data-testid="admin-scope-tools-save" @click="saveScopeTools">保存配置</el-button></template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog :model-value="!!issuedPlaintext" title="Token 签发成功" width="560px" :close-on-click-modal="false" @close="issuedPlaintext = ''">
|
||||
<el-alert title="明文只展示这一次,请立即复制并妥善保存。" type="warning" :closable="false" show-icon />
|
||||
<code class="token-secret" data-testid="admin-issued-token">{{ issuedPlaintext }}</code>
|
||||
<template #footer><el-button type="primary" data-testid="admin-copy-token" @click="copyToken">复制 Token</el-button><el-button data-testid="admin-close-token" @click="issuedPlaintext = ''">我已保存</el-button></template>
|
||||
<el-dialog :model-value="!!issuedTokens.length" title="Token 签发成功" width="600px" :close-on-click-modal="false" @close="issuedTokens = []">
|
||||
<el-alert title="每个作用域一把 Token,明文只展示这一次,请立即复制并妥善保存。" type="warning" :closable="false" show-icon />
|
||||
<div v-for="item in issuedTokens" :key="item.token.id" class="issued-row">
|
||||
<el-tag size="small" effect="plain">{{ item.token.scopeNames.join('/') }}</el-tag>
|
||||
<code class="token-secret" :data-testid="`admin-issued-token-${item.token.id}`">{{ item.plaintext }}</code>
|
||||
<el-button link type="primary" :data-testid="`admin-copy-token-${item.token.id}`" @click="copyText(item.plaintext)">复制</el-button>
|
||||
</div>
|
||||
<template #footer><el-button type="primary" data-testid="admin-copy-all-tokens" @click="copyAllTokens">全部复制</el-button><el-button data-testid="admin-close-token" @click="issuedTokens = []">我已保存</el-button></template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
@@ -60,4 +60,6 @@ code { font-family: Menlo, Consolas, monospace; font-size: 12px; }
|
||||
.tool-group .el-checkbox__label { display: flex; flex-direction: column; white-space: normal; }
|
||||
.tool-group small { margin-top: 3px; color: #909399; }
|
||||
.token-secret { display: block; margin-top: 14px; padding: 14px; word-break: break-all; border-radius: 4px; background: #f4f4f5; font-size: 13px; }
|
||||
.issued-row { display: flex; align-items: center; gap: 10px; margin-top: 12px; }
|
||||
.issued-row .token-secret { flex: 1; margin-top: 0; padding: 9px 12px; }
|
||||
@media (max-width: 1200px) { .token-layout { grid-template-columns: 300px minmax(640px, 1fr); } }
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
-- 回滚 V3:删除 key_cipher 列。回滚后 ensure 对存量 Token 无法二次获取明文(回到只发新把模式)。
|
||||
ALTER TABLE agent_api_key
|
||||
DROP COLUMN key_cipher;
|
||||
@@ -130,8 +130,8 @@ public class AdminConsoleController {
|
||||
}
|
||||
|
||||
@PostMapping("/users/{id}/tokens")
|
||||
public AdminTokenService.IssuedToken issue(@PathVariable Long id, @RequestBody TokenRequest body,
|
||||
HttpSession session) {
|
||||
public List<AdminTokenService.IssuedToken> issue(@PathVariable Long id, @RequestBody TokenRequest body,
|
||||
HttpSession session) {
|
||||
if (body == null) bad("请求内容不能为空");
|
||||
return tokenService.issue(id, body.memo(), body.scopeIds(), body.expireTime(), adminId(session));
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import me.iiv.agent.mapper.svc.AgentApiKeyMapper;
|
||||
import me.iiv.agent.mapper.svc.AgentScopeMapper;
|
||||
import me.iiv.agent.mapper.svc.TokenScopeMapper;
|
||||
import me.iiv.agent.security.AuthService;
|
||||
import me.iiv.agent.security.TokenCipher;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
@@ -25,37 +26,70 @@ public class AdminTokenService {
|
||||
private final AgentApiKeyMapper tokenMapper;
|
||||
private final TokenScopeMapper relationMapper;
|
||||
private final AgentScopeMapper scopeMapper;
|
||||
private final TokenCipher tokenCipher;
|
||||
|
||||
public AdminTokenService(UserMapper userMapper, AgentApiKeyMapper tokenMapper,
|
||||
TokenScopeMapper relationMapper, AgentScopeMapper scopeMapper) {
|
||||
TokenScopeMapper relationMapper, AgentScopeMapper scopeMapper,
|
||||
TokenCipher tokenCipher) {
|
||||
this.userMapper = userMapper;
|
||||
this.tokenMapper = tokenMapper;
|
||||
this.relationMapper = relationMapper;
|
||||
this.scopeMapper = scopeMapper;
|
||||
this.tokenCipher = tokenCipher;
|
||||
}
|
||||
|
||||
/**
|
||||
* 签发 Token:一作用域一 Token。选 N 个作用域生成 N 把,每把独立绑定单个作用域,
|
||||
* 便于独立吊销、独立审计,明文逐把返回;服务端同时保存 SHA-256 哈希和 AES-GCM 密文(供二次获取)。
|
||||
*/
|
||||
@Transactional(transactionManager = "svcTransactionManager")
|
||||
public IssuedToken issue(Long userId, String memo, List<Long> scopeIds, Date expireTime, Long adminId) {
|
||||
public List<IssuedToken> issue(Long userId, String memo, List<Long> scopeIds, Date expireTime, Long adminId) {
|
||||
User user = userMapper.selectById(userId);
|
||||
if (user == null || Integer.valueOf(1).equals(user.getSoftDelete())) bad("人员不存在");
|
||||
validateMemo(memo);
|
||||
validateScopes(scopeIds);
|
||||
return scopeIds.stream().distinct()
|
||||
.map(scopeId -> issueSingle(userId, memo, scopeId, expireTime, adminId))
|
||||
.toList();
|
||||
}
|
||||
|
||||
/** 签发一把绑定单个作用域的 Token;人员/作用域合法性由调用方保证。 */
|
||||
public IssuedToken issueSingle(Long userId, String memo, Long scopeId, Date expireTime, Long creatorId) {
|
||||
String plaintext = "xqak_" + AdminAuthController.randomHex(24);
|
||||
AgentApiKey token = new AgentApiKey();
|
||||
token.setUserId(userId);
|
||||
token.setKeyPrefix(plaintext.substring(0, 13));
|
||||
token.setKeyHash(AuthService.sha256Hex(plaintext));
|
||||
token.setKeyCipher(tokenCipher.encrypt(plaintext));
|
||||
token.setMemo(trim(memo, 255));
|
||||
token.setStatus((byte) 1);
|
||||
token.setExpireTime(expireTime);
|
||||
token.setCreateId(adminId);
|
||||
token.setModifyId(adminId);
|
||||
token.setCreateId(creatorId);
|
||||
token.setModifyId(creatorId);
|
||||
token.setSoftDelete((byte) 0);
|
||||
tokenMapper.insert(token);
|
||||
replaceScopes(token.getId(), scopeIds, adminId);
|
||||
replaceScopes(token.getId(), List.of(scopeId), creatorId);
|
||||
return new IssuedToken(plaintext, view(token));
|
||||
}
|
||||
|
||||
/** 查找某人员在某作用域下的有效 Token(正常状态且未过期),取最新一把;无则 null。 */
|
||||
public AgentApiKey findActiveToken(Long userId, Long scopeId) {
|
||||
List<Long> tokenIds = relationMapper.selectList(new LambdaQueryWrapper<TokenScope>()
|
||||
.eq(TokenScope::getScopeId, scopeId).eq(TokenScope::getSoftDelete, 0))
|
||||
.stream().map(TokenScope::getTokenId).toList();
|
||||
if (tokenIds.isEmpty()) return null;
|
||||
return tokenMapper.selectList(new LambdaQueryWrapper<AgentApiKey>()
|
||||
.in(AgentApiKey::getId, tokenIds)
|
||||
.eq(AgentApiKey::getUserId, userId)
|
||||
.eq(AgentApiKey::getStatus, 1)
|
||||
.eq(AgentApiKey::getSoftDelete, 0)
|
||||
.and(w -> w.isNull(AgentApiKey::getExpireTime)
|
||||
.or().gt(AgentApiKey::getExpireTime, new Date()))
|
||||
.orderByDesc(AgentApiKey::getCreateTime)
|
||||
.last("LIMIT 1"))
|
||||
.stream().findFirst().orElse(null);
|
||||
}
|
||||
|
||||
@Transactional(transactionManager = "svcTransactionManager")
|
||||
public void update(Long tokenId, String memo, List<Long> scopeIds, Date expireTime, Long adminId) {
|
||||
AgentApiKey current = activeToken(tokenId);
|
||||
|
||||
@@ -66,7 +66,7 @@ public class AuditService {
|
||||
}
|
||||
|
||||
/**
|
||||
* client_type:X-Client-Type 头优先;否则 User-Agent 含 kimi→mcp-kimi,
|
||||
* client_type:/api/open/** → open-api;X-Client-Type 头其次;否则 User-Agent 含 kimi→mcp-kimi,
|
||||
* 含 claude→mcp-claude,含 curl→cli,其余 unknown。
|
||||
*/
|
||||
private static String clientType() {
|
||||
@@ -74,6 +74,9 @@ public class AuditService {
|
||||
if (request == null) {
|
||||
return "unknown";
|
||||
}
|
||||
if (request.getRequestURI() != null && request.getRequestURI().startsWith("/api/open/")) {
|
||||
return "open-api";
|
||||
}
|
||||
String explicit = request.getHeader("X-Client-Type");
|
||||
if (explicit != null && !explicit.isBlank()) {
|
||||
return explicit;
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package me.iiv.agent.common;
|
||||
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 统一错误响应:业务校验抛出的 ResponseStatusException 一律返回 {"message": 原因}。
|
||||
* 后台前端与 Open API 调用方(含 agent)都按 message 字段取错误说明;
|
||||
* 未预期的异常不经过这里,避免内部细节外泄。
|
||||
*/
|
||||
@RestControllerAdvice
|
||||
public class GlobalExceptionHandler {
|
||||
|
||||
@ExceptionHandler(ResponseStatusException.class)
|
||||
public ResponseEntity<Map<String, String>> handleStatus(ResponseStatusException error) {
|
||||
String reason = error.getReason();
|
||||
return ResponseEntity.status(error.getStatusCode())
|
||||
.body(Map.of("message", reason == null || reason.isBlank() ? "请求失败" : reason));
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,11 @@ public class AgentApiKey implements Serializable {
|
||||
*/
|
||||
private String keyHash;
|
||||
|
||||
/**
|
||||
* ak 明文 AES-GCM 加密密文(二次获取用),空=历史数据未存
|
||||
*/
|
||||
private String keyCipher;
|
||||
|
||||
/**
|
||||
* 用途备注(如 Kimi 用 / Claude 用)
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
package me.iiv.agent.mcp;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.node.ArrayNode;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* tools/list 响应重写:把 SSE(或纯 JSON)响应里 result.tools 按 allowedToolNames 过滤。
|
||||
* 纯函数,便于单测;逐行处理 SSE 的 data 负载,其余字节原样保留;
|
||||
* 解析失败的行一律原样放行(不把链路搞坏)。
|
||||
*/
|
||||
public final class ToolsListResponseRewriter {
|
||||
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||
|
||||
private ToolsListResponseRewriter() {
|
||||
}
|
||||
|
||||
public static String rewrite(String body, Set<String> allowedToolNames) {
|
||||
if (body == null || body.isBlank()) {
|
||||
return body;
|
||||
}
|
||||
// 逐行处理:SSE 事件结构(id:/event:/空行)原样保留,只动 data: 负载行
|
||||
String[] lines = body.split("\\n", -1);
|
||||
for (int i = 0; i < lines.length; i++) {
|
||||
String line = lines[i];
|
||||
if (line.startsWith("data:")) {
|
||||
lines[i] = rewriteDataLine(line, allowedToolNames);
|
||||
} else if (i == 0 && line.startsWith("{")) {
|
||||
// 非 SSE 的纯 JSON 响应
|
||||
return rewriteDataLine(line, allowedToolNames);
|
||||
}
|
||||
}
|
||||
return String.join("\n", lines);
|
||||
}
|
||||
|
||||
private static String rewriteDataLine(String line, Set<String> allowedToolNames) {
|
||||
boolean sse = line.startsWith("data:");
|
||||
String payload = sse ? line.substring("data:".length()).stripLeading() : line;
|
||||
try {
|
||||
JsonNode node = MAPPER.readTree(payload);
|
||||
if (!filterTools(node, allowedToolNames)) {
|
||||
return line;
|
||||
}
|
||||
return (sse ? "data:" : "") + MAPPER.writeValueAsString(node);
|
||||
} catch (Exception e) {
|
||||
return line;
|
||||
}
|
||||
}
|
||||
|
||||
/** 若节点含 result.tools 数组则按白名单过滤,返回是否命中 tools 负载。 */
|
||||
private static boolean filterTools(JsonNode node, Set<String> allowedToolNames) {
|
||||
if (!(node instanceof ObjectNode object)) {
|
||||
return false;
|
||||
}
|
||||
JsonNode result = object.get("result");
|
||||
if (!(result instanceof ObjectNode resultObject)) {
|
||||
return false;
|
||||
}
|
||||
JsonNode tools = resultObject.get("tools");
|
||||
if (!(tools instanceof ArrayNode toolsArray)) {
|
||||
return false;
|
||||
}
|
||||
ArrayNode filtered = MAPPER.createArrayNode();
|
||||
for (JsonNode tool : toolsArray) {
|
||||
JsonNode name = tool.get("name");
|
||||
if (name != null && allowedToolNames.contains(name.asText())) {
|
||||
filtered.add(tool);
|
||||
}
|
||||
}
|
||||
resultObject.set("tools", filtered);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
package me.iiv.agent.mcp;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.ServletInputStream;
|
||||
import jakarta.servlet.ServletOutputStream;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletRequestWrapper;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import jakarta.servlet.http.HttpServletResponseWrapper;
|
||||
import me.iiv.agent.security.AuthContext;
|
||||
import me.iiv.agent.security.AuthContextHolder;
|
||||
import me.iiv.agent.security.ToolGuard;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStreamReader;
|
||||
import java.io.PrintWriter;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* tools/list 按 token 作用域过滤(2026-09-04 决策:避免"看得到却调不了"的二义性)。
|
||||
* MCP SDK 的工具清单在服务启动时静态注册,无法按请求过滤,故在 servlet 层拦截
|
||||
* POST /mcp 中 method=tools/list 的响应,重写 result.tools 负载。
|
||||
* tools/call 路径不变:越权调用仍走 GuardedToolCallback 的可读拒绝 + denied 审计。
|
||||
* 仅拦截 POST(请求-响应式);GET /mcp 的长连接 SSE 流不缓冲。
|
||||
* 必须运行在 AkAuthFilter(@Order(10))之后,才能拿到 AuthContext。
|
||||
*/
|
||||
@Component
|
||||
@Order(20)
|
||||
public class ToolsListScopeFilter extends OncePerRequestFilter {
|
||||
|
||||
private final ToolGuard toolGuard;
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
public ToolsListScopeFilter(ToolGuard toolGuard) {
|
||||
this.toolGuard = toolGuard;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected boolean shouldNotFilter(HttpServletRequest request) {
|
||||
return !(request.getRequestURI().startsWith("/mcp") && "POST".equalsIgnoreCase(request.getMethod()));
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
|
||||
throws ServletException, IOException {
|
||||
byte[] body = request.getInputStream().readAllBytes();
|
||||
CachedBodyRequest wrappedRequest = new CachedBodyRequest(request, body);
|
||||
if (!isToolsList(body)) {
|
||||
chain.doFilter(wrappedRequest, response);
|
||||
return;
|
||||
}
|
||||
BufferingResponse wrappedResponse = new BufferingResponse(response);
|
||||
chain.doFilter(wrappedRequest, wrappedResponse);
|
||||
|
||||
byte[] result = wrappedResponse.bodyBytes();
|
||||
AuthContext ctx = AuthContextHolder.get();
|
||||
if (ctx != null && wrappedResponse.status() == 200) {
|
||||
Set<String> allowed = toolGuard.allowedToolNames(ctx.getTokenId());
|
||||
result = ToolsListResponseRewriter
|
||||
.rewrite(new String(result, StandardCharsets.UTF_8), allowed)
|
||||
.getBytes(StandardCharsets.UTF_8);
|
||||
}
|
||||
response.setStatus(wrappedResponse.status());
|
||||
if (wrappedResponse.contentType() != null) {
|
||||
response.setContentType(wrappedResponse.contentType());
|
||||
}
|
||||
response.setContentLength(result.length);
|
||||
response.getOutputStream().write(result);
|
||||
response.flushBuffer();
|
||||
}
|
||||
|
||||
private boolean isToolsList(byte[] body) {
|
||||
try {
|
||||
JsonNode node = objectMapper.readTree(body);
|
||||
return "tools/list".equals(node.path("method").asText());
|
||||
} catch (Exception e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 可重复读 Body 的请求包装(过滤器预读后继续传给下游)。 */
|
||||
static final class CachedBodyRequest extends HttpServletRequestWrapper {
|
||||
|
||||
private final byte[] body;
|
||||
|
||||
CachedBodyRequest(HttpServletRequest request, byte[] body) {
|
||||
super(request);
|
||||
this.body = body;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ServletInputStream getInputStream() {
|
||||
ByteArrayInputStream input = new ByteArrayInputStream(body);
|
||||
return new ServletInputStream() {
|
||||
@Override
|
||||
public boolean isFinished() {
|
||||
return input.available() == 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isReady() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setReadListener(jakarta.servlet.ReadListener readListener) {
|
||||
// 同步读取,无需监听器
|
||||
}
|
||||
|
||||
@Override
|
||||
public int read() {
|
||||
return input.read();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int read(byte[] b, int off, int len) {
|
||||
return input.read(b, off, len);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@Override
|
||||
public BufferedReader getReader() {
|
||||
return new BufferedReader(new InputStreamReader(getInputStream(), StandardCharsets.UTF_8));
|
||||
}
|
||||
}
|
||||
|
||||
/** 全量缓冲响应的包装:下游写完前不向真实响应提交任何字节,便于整体重写。 */
|
||||
static final class BufferingResponse extends HttpServletResponseWrapper {
|
||||
|
||||
private final ByteArrayOutputStream buffer = new ByteArrayOutputStream();
|
||||
private int status = HttpServletResponse.SC_OK;
|
||||
private String contentType;
|
||||
|
||||
BufferingResponse(HttpServletResponse response) {
|
||||
super(response);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ServletOutputStream getOutputStream() {
|
||||
return new ServletOutputStream() {
|
||||
@Override
|
||||
public boolean isReady() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setWriteListener(jakarta.servlet.WriteListener writeListener) {
|
||||
// 同步写出,无需监听器
|
||||
}
|
||||
|
||||
@Override
|
||||
public void write(int b) {
|
||||
buffer.write(b);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void write(byte[] b, int off, int len) {
|
||||
buffer.write(b, off, len);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@Override
|
||||
public PrintWriter getWriter() {
|
||||
return new PrintWriter(new java.io.OutputStreamWriter(getOutputStream(), StandardCharsets.UTF_8), true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setStatus(int sc) {
|
||||
this.status = sc;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void sendError(int sc) {
|
||||
this.status = sc;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void sendError(int sc, String msg) {
|
||||
this.status = sc;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setContentType(String type) {
|
||||
this.contentType = type;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getContentType() {
|
||||
return contentType;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setContentLength(int len) {
|
||||
// 长度在重写后由外层统一设置
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setContentLengthLong(long len) {
|
||||
// 长度在重写后由外层统一设置
|
||||
}
|
||||
|
||||
@Override
|
||||
public void flushBuffer() {
|
||||
// 缓冲期间禁止提前提交
|
||||
}
|
||||
|
||||
int status() {
|
||||
return status;
|
||||
}
|
||||
|
||||
String contentType() {
|
||||
return contentType;
|
||||
}
|
||||
|
||||
byte[] bodyBytes() throws IOException {
|
||||
return buffer.toByteArray();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package me.iiv.agent.open;
|
||||
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
|
||||
/**
|
||||
* /api/open 通道服务间鉴权:X-Open-Key 静态密钥(配置 app.open.api-key)。
|
||||
* 与 ak 体系、后台 session 体系完全分离;配置为空 = 接口关闭。
|
||||
*/
|
||||
@Component
|
||||
public class OpenKeyFilter extends OncePerRequestFilter {
|
||||
|
||||
static final String OPEN_KEY_HEADER = "X-Open-Key";
|
||||
|
||||
@Value("${app.open.api-key:}")
|
||||
private String apiKey;
|
||||
|
||||
@Override
|
||||
protected boolean shouldNotFilter(HttpServletRequest request) {
|
||||
return !request.getRequestURI().startsWith("/api/open/");
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
|
||||
FilterChain chain) throws ServletException, IOException {
|
||||
response.setHeader("Cache-Control", "no-store");
|
||||
if (apiKey == null || apiKey.isBlank()) {
|
||||
error(response, 403, "Open API 未启用");
|
||||
return;
|
||||
}
|
||||
String provided = request.getHeader(OPEN_KEY_HEADER);
|
||||
if (provided == null || !MessageDigest.isEqual(
|
||||
apiKey.getBytes(StandardCharsets.UTF_8), provided.getBytes(StandardCharsets.UTF_8))) {
|
||||
error(response, 401, "服务间密钥无效");
|
||||
return;
|
||||
}
|
||||
chain.doFilter(request, response);
|
||||
}
|
||||
|
||||
private static void error(HttpServletResponse response, int status, String message) throws IOException {
|
||||
response.setStatus(status);
|
||||
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
|
||||
response.setContentType("application/json");
|
||||
response.getWriter().write("{\"message\":\"" + message + "\"}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package me.iiv.agent.open;
|
||||
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.Date;
|
||||
|
||||
/**
|
||||
* 对外服务间接口(/api/open/**),X-Open-Key 鉴权,见 OpenKeyFilter。
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/open")
|
||||
public class OpenTokenController {
|
||||
|
||||
private final OpenTokenService openTokenService;
|
||||
|
||||
public OpenTokenController(OpenTokenService openTokenService) {
|
||||
this.openTokenService = openTokenService;
|
||||
}
|
||||
|
||||
/**
|
||||
* 确保某人员在某作用域下有有效 Token:存在则解密回传明文(created=false,二次获取),
|
||||
* 不存在则自动创建永久 Token 并返回明文(created=true);历史无密文 Token 自动轮换后按创建返回。
|
||||
*/
|
||||
@PostMapping("/token/ensure")
|
||||
public EnsureResult ensure(@RequestBody EnsureRequest body) {
|
||||
return openTokenService.ensure(body);
|
||||
}
|
||||
|
||||
public record EnsureRequest(String mobile, String email, String scopeCode) {}
|
||||
public record EnsureResult(boolean created, String token, String keyPrefix, Date expireTime,
|
||||
Long userId, String userName, String scopeCode) {}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package me.iiv.agent.open;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import me.iiv.agent.admin.AdminTokenService;
|
||||
import me.iiv.agent.common.AuditService;
|
||||
import me.iiv.agent.entity.m0.User;
|
||||
import me.iiv.agent.entity.svc.AgentApiKey;
|
||||
import me.iiv.agent.entity.svc.AgentScope;
|
||||
import me.iiv.agent.mapper.m0.UserMapper;
|
||||
import me.iiv.agent.mapper.svc.AgentScopeMapper;
|
||||
import me.iiv.agent.security.AuthContext;
|
||||
import me.iiv.agent.security.TokenCipher;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.util.Date;
|
||||
|
||||
/**
|
||||
* Open API:按 手机号/邮箱 + 作用域编码 确保 Token 存在(get-or-create)。
|
||||
* 存在有效 Token 时解密 key_cipher 回传明文(二次获取,2026-09-07 决策);
|
||||
* 历史数据无密文的 Token 自动轮换(禁用旧把、签发新把);
|
||||
* 不存在则自动创建永久 Token。两条路径均返回明文。
|
||||
*/
|
||||
@Service
|
||||
public class OpenTokenService {
|
||||
|
||||
/** 系统签发(Open API 自动创建)的 createId 约定值 */
|
||||
static final long SYSTEM_CREATOR_ID = 0L;
|
||||
static final String AUDIT_TOOL = "open.token.ensure";
|
||||
|
||||
private final UserMapper userMapper;
|
||||
private final AgentScopeMapper scopeMapper;
|
||||
private final AdminTokenService tokenService;
|
||||
private final AuditService auditService;
|
||||
private final TokenCipher tokenCipher;
|
||||
|
||||
public OpenTokenService(UserMapper userMapper, AgentScopeMapper scopeMapper,
|
||||
AdminTokenService tokenService, AuditService auditService,
|
||||
TokenCipher tokenCipher) {
|
||||
this.userMapper = userMapper;
|
||||
this.scopeMapper = scopeMapper;
|
||||
this.tokenService = tokenService;
|
||||
this.auditService = auditService;
|
||||
this.tokenCipher = tokenCipher;
|
||||
}
|
||||
|
||||
@Transactional(transactionManager = "svcTransactionManager")
|
||||
public OpenTokenController.EnsureResult ensure(OpenTokenController.EnsureRequest body) {
|
||||
long start = System.currentTimeMillis();
|
||||
if (body == null) bad(HttpStatus.BAD_REQUEST, "请求内容不能为空");
|
||||
if (!StringUtils.hasText(body.mobile()) && !StringUtils.hasText(body.email())) {
|
||||
bad(HttpStatus.BAD_REQUEST, "手机号和邮箱至少传一个");
|
||||
}
|
||||
if (!StringUtils.hasText(body.scopeCode())) bad(HttpStatus.BAD_REQUEST, "作用域编码不能为空");
|
||||
|
||||
AgentScope scope = scopeMapper.selectOne(new LambdaQueryWrapper<AgentScope>()
|
||||
.eq(AgentScope::getCode, body.scopeCode().trim()).eq(AgentScope::getSoftDelete, 0));
|
||||
if (scope == null || !Byte.valueOf((byte) 1).equals(scope.getStatus())) {
|
||||
bad(HttpStatus.BAD_REQUEST, "作用域不存在或已停用");
|
||||
}
|
||||
|
||||
User user = findUser(body.mobile(), body.email());
|
||||
if (user == null) {
|
||||
audit(null, null, body, "error", "人员不存在或非企业账号", start);
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "人员不存在或非企业账号");
|
||||
}
|
||||
|
||||
AgentApiKey existing = tokenService.findActiveToken(user.getId(), scope.getId());
|
||||
if (existing != null) {
|
||||
if (StringUtils.hasText(existing.getKeyCipher())) {
|
||||
String plaintext;
|
||||
try {
|
||||
plaintext = tokenCipher.decrypt(existing.getKeyCipher());
|
||||
} catch (IllegalStateException e) {
|
||||
audit(user, existing, body, "error", "Token 密文解密失败", start);
|
||||
throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Token 密文解密失败");
|
||||
}
|
||||
audit(user, existing, body, "success", "已存在有效 Token,解密返回明文", start);
|
||||
return new OpenTokenController.EnsureResult(false, plaintext, existing.getKeyPrefix(),
|
||||
existing.getExpireTime(), user.getId(), user.getName(), scope.getCode());
|
||||
}
|
||||
// 历史数据无密文:禁用旧把并签发新把(轮换),保证总能拿到明文
|
||||
tokenService.status(existing.getId(), (byte) 0, SYSTEM_CREATOR_ID);
|
||||
audit(user, existing, body, "success", "旧 Token 无密文,自动轮换", start);
|
||||
}
|
||||
|
||||
AdminTokenService.IssuedToken issued = tokenService.issueSingle(user.getId(),
|
||||
"open-api 自动签发(" + scope.getCode() + ")", scope.getId(), null, SYSTEM_CREATOR_ID);
|
||||
AgentApiKey created = new AgentApiKey();
|
||||
created.setId(issued.token().id());
|
||||
audit(user, created, body, "success", "自动创建永久 Token", start);
|
||||
return new OpenTokenController.EnsureResult(true, issued.plaintext(), issued.token().keyPrefix(),
|
||||
null, user.getId(), user.getName(), scope.getCode());
|
||||
}
|
||||
|
||||
/** 手机号优先、邮箱兜底;只认企业账号(enterprise_type 0/1,历史 NULL 保留),与后台用户列表同口径。 */
|
||||
private User findUser(String mobile, String email) {
|
||||
LambdaQueryWrapper<User> query = new LambdaQueryWrapper<User>()
|
||||
.eq(User::getSoftDelete, 0)
|
||||
.and(w -> w.ne(User::getEnterpriseType, 2).or().isNull(User::getEnterpriseType));
|
||||
if (StringUtils.hasText(mobile)) {
|
||||
query.eq(User::getMobile, mobile.trim());
|
||||
} else {
|
||||
query.eq(User::getEmail, email.trim());
|
||||
}
|
||||
return userMapper.selectList(query.orderByDesc(User::getId).last("LIMIT 1"))
|
||||
.stream().findFirst().orElse(null);
|
||||
}
|
||||
|
||||
private void audit(User user, AgentApiKey token, OpenTokenController.EnsureRequest body,
|
||||
String status, String message, long start) {
|
||||
String digest = "mobile=" + maskMobile(body.mobile()) + " email=" + maskEmail(body.email())
|
||||
+ " scopeCode=" + body.scopeCode();
|
||||
auditService.record(new AuthContext(token, user), AUDIT_TOOL, digest,
|
||||
status, message, System.currentTimeMillis() - start);
|
||||
}
|
||||
|
||||
private static String maskMobile(String mobile) {
|
||||
if (!StringUtils.hasText(mobile)) return "-";
|
||||
String value = mobile.trim();
|
||||
return value.length() < 7 ? "***" : value.substring(0, 3) + "****" + value.substring(value.length() - 4);
|
||||
}
|
||||
|
||||
private static String maskEmail(String email) {
|
||||
if (!StringUtils.hasText(email)) return "-";
|
||||
String value = email.trim();
|
||||
int at = value.indexOf('@');
|
||||
return at <= 1 ? "***" + value.substring(at) : value.charAt(0) + "***" + value.substring(at);
|
||||
}
|
||||
|
||||
private static void bad(HttpStatus status, String message) {
|
||||
throw new ResponseStatusException(status, message);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
@@ -17,8 +18,10 @@ import java.util.Map;
|
||||
/**
|
||||
* /mcp 通道 ak 鉴权:Authorization: Bearer <ak> → AuthContext(ThreadLocal)。
|
||||
* 无 ak / ak 无效 → 401 JSON。
|
||||
* @Order(10):必须先于 ToolsListScopeFilter(@Order(20))执行,后者依赖 AuthContext。
|
||||
*/
|
||||
@Component
|
||||
@Order(10)
|
||||
public class AkAuthFilter extends OncePerRequestFilter {
|
||||
|
||||
private static final String MCP_PATH = "/mcp";
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
package me.iiv.agent.security;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import javax.crypto.Cipher;
|
||||
import javax.crypto.spec.GCMParameterSpec;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.SecureRandom;
|
||||
import java.util.Base64;
|
||||
|
||||
/**
|
||||
* ak 明文加密器:AES-GCM,密钥由配置 app.token-cipher-key 经 SHA-256 派生(配置任意长度字符串即可)。
|
||||
* 密文格式:Base64( iv(12B) ‖ cipher+tag ),供 Open API 二次获取明文时解密。
|
||||
* 配置为空时加解密直接报错(视为功能未开启,不回退到任何弱行为)。
|
||||
*/
|
||||
@Component
|
||||
public class TokenCipher {
|
||||
|
||||
private static final int IV_LEN = 12;
|
||||
private static final int TAG_BITS = 128;
|
||||
|
||||
private final byte[] key;
|
||||
private final SecureRandom random = new SecureRandom();
|
||||
|
||||
public TokenCipher(@Value("${app.token-cipher-key:}") String cipherKey) {
|
||||
if (cipherKey == null || cipherKey.isBlank()) {
|
||||
this.key = null;
|
||||
} else {
|
||||
try {
|
||||
this.key = MessageDigest.getInstance("SHA-256")
|
||||
.digest(cipherKey.trim().getBytes(StandardCharsets.UTF_8));
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException("SHA-256 不可用", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public String encrypt(String plaintext) {
|
||||
byte[] iv = new byte[IV_LEN];
|
||||
random.nextBytes(iv);
|
||||
try {
|
||||
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
|
||||
cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(requireKey(), "AES"),
|
||||
new GCMParameterSpec(TAG_BITS, iv));
|
||||
byte[] encrypted = cipher.doFinal(plaintext.getBytes(StandardCharsets.UTF_8));
|
||||
byte[] packed = new byte[IV_LEN + encrypted.length];
|
||||
System.arraycopy(iv, 0, packed, 0, IV_LEN);
|
||||
System.arraycopy(encrypted, 0, packed, IV_LEN, encrypted.length);
|
||||
return Base64.getEncoder().encodeToString(packed);
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException("ak 加密失败", e);
|
||||
}
|
||||
}
|
||||
|
||||
public String decrypt(String packedBase64) {
|
||||
try {
|
||||
byte[] packed = Base64.getDecoder().decode(packedBase64);
|
||||
byte[] iv = new byte[IV_LEN];
|
||||
System.arraycopy(packed, 0, iv, 0, IV_LEN);
|
||||
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
|
||||
cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(requireKey(), "AES"),
|
||||
new GCMParameterSpec(TAG_BITS, iv));
|
||||
return new String(cipher.doFinal(packed, IV_LEN, packed.length - IV_LEN), StandardCharsets.UTF_8);
|
||||
} catch (IllegalStateException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException("ak 密文解密失败", e);
|
||||
}
|
||||
}
|
||||
|
||||
private byte[] requireKey() {
|
||||
if (key == null) throw new IllegalStateException("未配置 app.token-cipher-key,ak 加解密不可用");
|
||||
return key;
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
@@ -35,23 +36,28 @@ public class ToolGuard {
|
||||
}
|
||||
|
||||
public boolean isAllowed(Long tokenId, String toolName) {
|
||||
return allowedToolNames(tokenId).contains(toolName);
|
||||
}
|
||||
|
||||
/** token 的作用域并集内、且启用状态的工具名集合(tools/list 过滤用,固定 3 次查询,与工具数无关)。 */
|
||||
public Set<String> allowedToolNames(Long tokenId) {
|
||||
List<Long> scopeIds = enabledScopeIds(tokenId);
|
||||
if (scopeIds.isEmpty()) {
|
||||
return false;
|
||||
return Set.of();
|
||||
}
|
||||
List<ScopeTool> scopeTools = scopeToolMapper.selectList(new LambdaQueryWrapper<ScopeTool>()
|
||||
.eq(ScopeTool::getSoftDelete, 0)
|
||||
.in(ScopeTool::getScopeId, scopeIds));
|
||||
if (scopeTools.isEmpty()) {
|
||||
return false;
|
||||
return Set.of();
|
||||
}
|
||||
List<Long> toolIds = scopeTools.stream().map(ScopeTool::getToolId).distinct().collect(Collectors.toList());
|
||||
Long count = toolMapper.selectCount(new LambdaQueryWrapper<me.iiv.agent.entity.svc.Tool>()
|
||||
.eq(me.iiv.agent.entity.svc.Tool::getSoftDelete, 0)
|
||||
.eq(me.iiv.agent.entity.svc.Tool::getStatus, 1)
|
||||
.eq(me.iiv.agent.entity.svc.Tool::getName, toolName)
|
||||
.in(me.iiv.agent.entity.svc.Tool::getId, toolIds));
|
||||
return count != null && count > 0;
|
||||
return toolMapper.selectList(new LambdaQueryWrapper<me.iiv.agent.entity.svc.Tool>()
|
||||
.eq(me.iiv.agent.entity.svc.Tool::getSoftDelete, 0)
|
||||
.eq(me.iiv.agent.entity.svc.Tool::getStatus, 1)
|
||||
.in(me.iiv.agent.entity.svc.Tool::getId, toolIds)
|
||||
.select(me.iiv.agent.entity.svc.Tool::getName))
|
||||
.stream().map(me.iiv.agent.entity.svc.Tool::getName).collect(Collectors.toSet());
|
||||
}
|
||||
|
||||
/** token 勾选的、且处于启用状态的作用域 id 集合 */
|
||||
|
||||
@@ -2,6 +2,12 @@
|
||||
# 账号密码明文写在本文件(内网 dev 库,与 xiaoqu-franchisee pom.xml dev profile 同一惯例);
|
||||
# 生产/测试环境不得在配置文件中写明文密码,改用环境变量
|
||||
app:
|
||||
open:
|
||||
# Open API(/api/open/**)服务间静态密钥,dev 明文;调用方放 header X-Open-Key
|
||||
api-key: xqok_4b44012f12b9fa54d6ec11690e6da57563a75c891df60961
|
||||
# ak 明文 AES-GCM 加密密钥(任意长度字符串,SHA-256 派生为密钥),供 ensure 二次获取明文;
|
||||
# dev 明文(同库密码惯例),生产必须改用环境变量且与 dev 不同值
|
||||
token-cipher-key: d70da46f5f0db2016d88d5ee4b1d6af35867b7700b97105fec9b3ad567d474a5
|
||||
datasource:
|
||||
m0: # 权限库 xiaoqu_comples_d(user/agent/authority/propery/agent_manage_district_auth)
|
||||
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||
|
||||
@@ -14,6 +14,9 @@ app:
|
||||
account: ${ADMIN_BOOTSTRAP_ACCOUNT:}
|
||||
password: ${ADMIN_BOOTSTRAP_PASSWORD:}
|
||||
name: ${ADMIN_BOOTSTRAP_NAME:系统管理员}
|
||||
open:
|
||||
# /api/open 服务间静态密钥;为空 = Open API 关闭。dev 明文见 application-dev.yml
|
||||
api-key: ${OPEN_API_KEY:}
|
||||
|
||||
spring:
|
||||
application:
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
-- ak 二次获取(2026-09-07):签发时保存 AES-GCM 加密密文,ensure 已存在 Token 时解密回传明文
|
||||
ALTER TABLE agent_api_key
|
||||
ADD COLUMN key_cipher VARCHAR(512) NULL COMMENT 'ak 明文 AES-GCM 加密密文(Base64: iv‖cipher‖tag),空=历史数据未存' AFTER key_hash;
|
||||
@@ -1,2 +1,3 @@
|
||||
f8ec43a4fda81382d5675bc59fe7f4339f2e38a5b4241c260c3569e33b43a26d V1__init.sql
|
||||
0ad892601e891a18712b1b6a613c392c94e63492dc14f4f779375c092a3acab6 V2__p4_admin.sql
|
||||
0b6e407106156911f5fe83a13b342eeec5986e2e1e5b37b6797431a8720e1332 V3__token_cipher.sql
|
||||
|
||||
@@ -3,11 +3,13 @@ package me.iiv.agent.admin;
|
||||
import me.iiv.agent.entity.m0.User;
|
||||
import me.iiv.agent.entity.svc.AgentApiKey;
|
||||
import me.iiv.agent.entity.svc.AgentScope;
|
||||
import me.iiv.agent.entity.svc.TokenScope;
|
||||
import me.iiv.agent.mapper.m0.UserMapper;
|
||||
import me.iiv.agent.mapper.svc.AgentApiKeyMapper;
|
||||
import me.iiv.agent.mapper.svc.AgentScopeMapper;
|
||||
import me.iiv.agent.mapper.svc.TokenScopeMapper;
|
||||
import me.iiv.agent.security.AuthService;
|
||||
import me.iiv.agent.security.TokenCipher;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
@@ -15,12 +17,14 @@ import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@@ -30,13 +34,25 @@ class AdminTokenServiceTest {
|
||||
@Mock AgentApiKeyMapper tokenMapper;
|
||||
@Mock TokenScopeMapper tokenScopeMapper;
|
||||
@Mock AgentScopeMapper scopeMapper;
|
||||
@Mock TokenCipher tokenCipher;
|
||||
|
||||
@Test
|
||||
void issueReturnsPlaintextOnceAndStoresOnlyHash() {
|
||||
private User stubUser() {
|
||||
User user = new User();
|
||||
user.setId(7L);
|
||||
user.setSoftDelete(0);
|
||||
when(userMapper.selectById(7L)).thenReturn(user);
|
||||
return user;
|
||||
}
|
||||
|
||||
private AdminTokenService service() {
|
||||
org.mockito.Mockito.lenient().when(tokenCipher.encrypt(any()))
|
||||
.thenAnswer(invocation -> "cipher:" + invocation.getArgument(0));
|
||||
return new AdminTokenService(userMapper, tokenMapper, tokenScopeMapper, scopeMapper, tokenCipher);
|
||||
}
|
||||
|
||||
@Test
|
||||
void issueReturnsPlaintextAndStoresHashAndCipher() {
|
||||
stubUser();
|
||||
AgentScope scope = new AgentScope();
|
||||
scope.setId(3L);
|
||||
scope.setStatus((byte) 1);
|
||||
@@ -47,28 +63,53 @@ class AdminTokenServiceTest {
|
||||
return 1;
|
||||
});
|
||||
|
||||
AdminTokenService service = new AdminTokenService(
|
||||
userMapper, tokenMapper, tokenScopeMapper, scopeMapper);
|
||||
AdminTokenService.IssuedToken issued = service.issue(7L, "Kimi", List.of(3L), null, 1L);
|
||||
List<AdminTokenService.IssuedToken> issued = service().issue(7L, "Kimi", List.of(3L), null, 1L);
|
||||
|
||||
assertEquals(1, issued.size());
|
||||
ArgumentCaptor<AgentApiKey> captor = ArgumentCaptor.forClass(AgentApiKey.class);
|
||||
org.mockito.Mockito.verify(tokenMapper).insert(captor.capture());
|
||||
AgentApiKey stored = captor.getValue();
|
||||
assertTrue(issued.plaintext().matches("xqak_[0-9a-f]{48}"));
|
||||
assertEquals(AuthService.sha256Hex(issued.plaintext()), stored.getKeyHash());
|
||||
assertNotEquals(issued.plaintext(), stored.getKeyHash());
|
||||
assertEquals(11L, issued.token().id());
|
||||
assertTrue(issued.get(0).plaintext().matches("xqak_[0-9a-f]{48}"));
|
||||
assertEquals(AuthService.sha256Hex(issued.get(0).plaintext()), stored.getKeyHash());
|
||||
assertNotEquals(issued.get(0).plaintext(), stored.getKeyHash());
|
||||
assertEquals("cipher:" + issued.get(0).plaintext(), stored.getKeyCipher());
|
||||
assertEquals(11L, issued.get(0).token().id());
|
||||
}
|
||||
|
||||
@Test
|
||||
void issueWithTwoScopesCreatesTwoTokensEachBoundToOneScope() {
|
||||
stubUser();
|
||||
AgentScope scopeA = new AgentScope();
|
||||
scopeA.setId(3L);
|
||||
scopeA.setStatus((byte) 1);
|
||||
scopeA.setSoftDelete((byte) 0);
|
||||
AgentScope scopeB = new AgentScope();
|
||||
scopeB.setId(4L);
|
||||
scopeB.setStatus((byte) 1);
|
||||
scopeB.setSoftDelete((byte) 0);
|
||||
when(scopeMapper.selectList(any())).thenReturn(List.of(scopeA, scopeB));
|
||||
AtomicLong idSequence = new AtomicLong(10);
|
||||
when(tokenMapper.insert(any(AgentApiKey.class))).thenAnswer(invocation -> {
|
||||
invocation.<AgentApiKey>getArgument(0).setId(idSequence.incrementAndGet());
|
||||
return 1;
|
||||
});
|
||||
|
||||
List<AdminTokenService.IssuedToken> issued = service().issue(7L, "Kimi", List.of(3L, 4L), null, 1L);
|
||||
|
||||
assertEquals(2, issued.size());
|
||||
assertNotEquals(issued.get(0).plaintext(), issued.get(1).plaintext());
|
||||
org.mockito.Mockito.verify(tokenMapper, times(2)).insert(any(AgentApiKey.class));
|
||||
// 每把 Token 只绑定一个作用域
|
||||
ArgumentCaptor<TokenScope> relationCaptor = ArgumentCaptor.forClass(TokenScope.class);
|
||||
org.mockito.Mockito.verify(tokenScopeMapper, times(2)).insert(relationCaptor.capture());
|
||||
List<TokenScope> relations = relationCaptor.getAllValues();
|
||||
assertEquals(List.of(3L, 4L), relations.stream().map(TokenScope::getScopeId).sorted().toList());
|
||||
}
|
||||
|
||||
@Test
|
||||
void issueRejectsDuplicateScopesBeforeWriting() {
|
||||
User user = new User();
|
||||
user.setId(7L);
|
||||
user.setSoftDelete(0);
|
||||
when(userMapper.selectById(7L)).thenReturn(user);
|
||||
AdminTokenService service = new AdminTokenService(userMapper, tokenMapper, tokenScopeMapper, scopeMapper);
|
||||
|
||||
stubUser();
|
||||
assertThrows(RuntimeException.class,
|
||||
() -> service.issue(7L, "Kimi", List.of(3L, 3L), null, 1L));
|
||||
() -> service().issue(7L, "Kimi", List.of(3L, 3L), null, 1L));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ class FlywayMigrationTest {
|
||||
|
||||
flyway.migrate();
|
||||
|
||||
assertEquals("2", flyway.info().current().getVersion().getVersion());
|
||||
assertEquals("3", flyway.info().current().getVersion().getVersion());
|
||||
assertEquals(255, passwordHashLength(dataSource));
|
||||
}
|
||||
|
||||
@@ -28,12 +28,13 @@ class FlywayMigrationTest {
|
||||
JdbcDataSource dataSource = dataSource("legacy");
|
||||
try (Connection connection = dataSource.getConnection(); Statement statement = connection.createStatement()) {
|
||||
statement.execute("CREATE TABLE admin_user (id BIGINT AUTO_INCREMENT PRIMARY KEY, password_hash CHAR(64) NOT NULL)");
|
||||
statement.execute("CREATE TABLE agent_api_key (id BIGINT AUTO_INCREMENT PRIMARY KEY, key_hash CHAR(64) NOT NULL)");
|
||||
}
|
||||
Flyway flyway = flyway(dataSource, true);
|
||||
|
||||
flyway.migrate();
|
||||
|
||||
assertEquals("2", flyway.info().current().getVersion().getVersion());
|
||||
assertEquals("3", flyway.info().current().getVersion().getVersion());
|
||||
assertEquals(255, passwordHashLength(dataSource));
|
||||
}
|
||||
|
||||
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
package me.iiv.agent.mcp;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class ToolsListResponseRewriterTest {
|
||||
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||
|
||||
private static String sseEvent(String id, String json) {
|
||||
return "id:" + id + "\nevent:message\ndata:" + json + "\n\n";
|
||||
}
|
||||
|
||||
private static String toolsJson(String... names) {
|
||||
StringBuilder tools = new StringBuilder();
|
||||
for (String name : names) {
|
||||
if (tools.length() > 0) tools.append(',');
|
||||
tools.append("{\"name\":\"").append(name).append("\",\"description\":\"d\"}");
|
||||
}
|
||||
return "{\"jsonrpc\":\"2.0\",\"id\":2,\"result\":{\"tools\":[" + tools + "]}}";
|
||||
}
|
||||
|
||||
private static List<String> toolNames(String body) throws Exception {
|
||||
String data = null;
|
||||
for (String line : body.split("\\n")) {
|
||||
if (line.startsWith("data:")) {
|
||||
data = line.substring(5);
|
||||
}
|
||||
}
|
||||
JsonNode node = MAPPER.readTree(data == null ? body : data);
|
||||
List<String> names = new ArrayList<>();
|
||||
node.path("result").path("tools").forEach(tool -> names.add(tool.path("name").asText()));
|
||||
return names;
|
||||
}
|
||||
|
||||
@Test
|
||||
void filtersToolsInSseEvent() throws Exception {
|
||||
String body = sseEvent("evt-1", toolsJson("agent.list.query", "property.list.query", "sys.ping"));
|
||||
|
||||
String rewritten = ToolsListResponseRewriter.rewrite(body, Set.of("agent.list.query", "sys.ping"));
|
||||
|
||||
assertEquals(List.of("agent.list.query", "sys.ping"), toolNames(rewritten));
|
||||
assertTrue(rewritten.startsWith("id:evt-1\nevent:message\n"), "SSE 元信息行需保留");
|
||||
assertTrue(rewritten.endsWith("\n\n"), "事件末尾空行需保留");
|
||||
}
|
||||
|
||||
@Test
|
||||
void emptyAllowanceYieldsEmptyToolList() throws Exception {
|
||||
String body = sseEvent("evt-1", toolsJson("agent.list.query"));
|
||||
|
||||
String rewritten = ToolsListResponseRewriter.rewrite(body, Set.of());
|
||||
|
||||
assertEquals(List.of(), toolNames(rewritten));
|
||||
}
|
||||
|
||||
@Test
|
||||
void nonToolsEventsPassThroughUnchanged() {
|
||||
String initEvent = sseEvent("evt-0", "{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{\"protocolVersion\":\"2025-03-26\"}}");
|
||||
String body = initEvent + sseEvent("evt-1", toolsJson("a", "b"));
|
||||
|
||||
String rewritten = ToolsListResponseRewriter.rewrite(body, Set.of("a"));
|
||||
|
||||
assertTrue(rewritten.startsWith(initEvent), "非 tools/list 事件原样保留");
|
||||
}
|
||||
|
||||
@Test
|
||||
void plainJsonResponseIsAlsoFiltered() throws Exception {
|
||||
String body = toolsJson("a", "b");
|
||||
|
||||
String rewritten = ToolsListResponseRewriter.rewrite(body, Set.of("b"));
|
||||
|
||||
assertEquals(List.of("b"), toolNames(rewritten));
|
||||
}
|
||||
|
||||
@Test
|
||||
void unparseableBodyPassesThrough() {
|
||||
String garbage = "not-json-not-sse";
|
||||
assertEquals(garbage, ToolsListResponseRewriter.rewrite(garbage, Set.of("a")));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package me.iiv.agent.open;
|
||||
|
||||
import me.iiv.agent.admin.AdminTokenService;
|
||||
import me.iiv.agent.common.AuditService;
|
||||
import me.iiv.agent.entity.m0.User;
|
||||
import me.iiv.agent.entity.svc.AgentApiKey;
|
||||
import me.iiv.agent.entity.svc.AgentScope;
|
||||
import me.iiv.agent.mapper.m0.UserMapper;
|
||||
import me.iiv.agent.mapper.svc.AgentScopeMapper;
|
||||
import me.iiv.agent.security.TokenCipher;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.ArgumentMatchers.isNull;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class OpenTokenServiceTest {
|
||||
|
||||
@Mock UserMapper userMapper;
|
||||
@Mock AgentScopeMapper scopeMapper;
|
||||
@Mock AdminTokenService tokenService;
|
||||
@Mock AuditService auditService;
|
||||
@Mock TokenCipher tokenCipher;
|
||||
|
||||
OpenTokenService service;
|
||||
AgentScope scope;
|
||||
User user;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
service = new OpenTokenService(userMapper, scopeMapper, tokenService, auditService, tokenCipher);
|
||||
scope = new AgentScope();
|
||||
scope.setId(3L);
|
||||
scope.setCode("operation");
|
||||
scope.setStatus((byte) 1);
|
||||
scope.setSoftDelete((byte) 0);
|
||||
user = new User();
|
||||
user.setId(7L);
|
||||
user.setName("张三");
|
||||
user.setSoftDelete(0);
|
||||
}
|
||||
|
||||
private void stubScopeAndUser() {
|
||||
when(scopeMapper.selectOne(any())).thenReturn(scope);
|
||||
when(userMapper.selectList(any())).thenReturn(List.of(user));
|
||||
}
|
||||
|
||||
@Test
|
||||
void existingTokenWithCipherReturnsDecryptedPlaintext() {
|
||||
stubScopeAndUser();
|
||||
AgentApiKey existing = new AgentApiKey();
|
||||
existing.setId(11L);
|
||||
existing.setKeyPrefix("xqak_abcd1234");
|
||||
existing.setKeyCipher("packed-cipher");
|
||||
when(tokenService.findActiveToken(7L, 3L)).thenReturn(existing);
|
||||
when(tokenCipher.decrypt("packed-cipher")).thenReturn("xqak_abcd1234ffff");
|
||||
|
||||
OpenTokenController.EnsureResult result = service.ensure(
|
||||
new OpenTokenController.EnsureRequest("13800001111", null, "operation"));
|
||||
|
||||
assertFalse(result.created());
|
||||
assertEquals("xqak_abcd1234ffff", result.token());
|
||||
assertEquals("xqak_abcd1234", result.keyPrefix());
|
||||
assertEquals(7L, result.userId());
|
||||
org.mockito.Mockito.verify(tokenService, never())
|
||||
.issueSingle(anyLong(), anyString(), anyLong(), isNull(), anyLong());
|
||||
}
|
||||
|
||||
@Test
|
||||
void existingTokenWithoutCipherRotatesAndReturnsNewPlaintext() {
|
||||
stubScopeAndUser();
|
||||
AgentApiKey existing = new AgentApiKey();
|
||||
existing.setId(11L);
|
||||
existing.setKeyPrefix("xqak_abcd1234");
|
||||
when(tokenService.findActiveToken(7L, 3L)).thenReturn(existing);
|
||||
AdminTokenService.TokenView view = new AdminTokenService.TokenView(
|
||||
12L, "xqak_efgh5678", "open-api 自动签发(operation)", (byte) 1,
|
||||
null, null, List.of(3L), List.of("运营"));
|
||||
when(tokenService.issueSingle(eq(7L), anyString(), eq(3L), isNull(), eq(0L)))
|
||||
.thenReturn(new AdminTokenService.IssuedToken("xqak_" + "b".repeat(48), view));
|
||||
|
||||
OpenTokenController.EnsureResult result = service.ensure(
|
||||
new OpenTokenController.EnsureRequest("13800001111", null, "operation"));
|
||||
|
||||
org.mockito.Mockito.verify(tokenService).status(11L, (byte) 0, 0L);
|
||||
assertTrue(result.created());
|
||||
assertTrue(result.token().matches("xqak_[0-9a-f]{48}"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void decryptFailureReturns500() {
|
||||
stubScopeAndUser();
|
||||
AgentApiKey existing = new AgentApiKey();
|
||||
existing.setId(11L);
|
||||
existing.setKeyPrefix("xqak_abcd1234");
|
||||
existing.setKeyCipher("bad-cipher");
|
||||
when(tokenService.findActiveToken(7L, 3L)).thenReturn(existing);
|
||||
when(tokenCipher.decrypt("bad-cipher")).thenThrow(new IllegalStateException("ak 密文解密失败"));
|
||||
|
||||
ResponseStatusException error = assertThrows(ResponseStatusException.class, () -> service.ensure(
|
||||
new OpenTokenController.EnsureRequest("13800001111", null, "operation")));
|
||||
assertEquals(500, error.getStatusCode().value());
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingTokenCreatesPermanentOneAndReturnsPlaintext() {
|
||||
stubScopeAndUser();
|
||||
when(tokenService.findActiveToken(7L, 3L)).thenReturn(null);
|
||||
AdminTokenService.TokenView view = new AdminTokenService.TokenView(
|
||||
12L, "xqak_efgh5678", "open-api 自动签发(operation)", (byte) 1,
|
||||
null, null, List.of(3L), List.of("运营"));
|
||||
when(tokenService.issueSingle(eq(7L), anyString(), eq(3L), isNull(), eq(0L)))
|
||||
.thenReturn(new AdminTokenService.IssuedToken("xqak_" + "a".repeat(48), view));
|
||||
|
||||
OpenTokenController.EnsureResult result = service.ensure(
|
||||
new OpenTokenController.EnsureRequest(null, "zhangsan@test.com", "operation"));
|
||||
|
||||
assertTrue(result.created());
|
||||
assertTrue(result.token().matches("xqak_[0-9a-f]{48}"));
|
||||
assertNull(result.expireTime());
|
||||
assertEquals("operation", result.scopeCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void unknownUserRejected() {
|
||||
when(scopeMapper.selectOne(any())).thenReturn(scope);
|
||||
when(userMapper.selectList(any())).thenReturn(List.of());
|
||||
|
||||
ResponseStatusException error = assertThrows(ResponseStatusException.class, () -> service.ensure(
|
||||
new OpenTokenController.EnsureRequest("13800001111", null, "operation")));
|
||||
assertEquals(404, error.getStatusCode().value());
|
||||
}
|
||||
|
||||
@Test
|
||||
void disabledScopeRejected() {
|
||||
scope.setStatus((byte) 0);
|
||||
when(scopeMapper.selectOne(any())).thenReturn(scope);
|
||||
|
||||
assertThrows(ResponseStatusException.class, () -> service.ensure(
|
||||
new OpenTokenController.EnsureRequest("13800001111", null, "operation")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void requiresMobileOrEmail() {
|
||||
assertThrows(ResponseStatusException.class, () -> service.ensure(
|
||||
new OpenTokenController.EnsureRequest(null, null, "operation")));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package me.iiv.agent.security;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
class TokenCipherTest {
|
||||
|
||||
private final TokenCipher cipher = new TokenCipher("test-cipher-key");
|
||||
|
||||
@Test
|
||||
void encryptDecryptRoundTrip() {
|
||||
String plaintext = "xqak_25939fab31f812eb4e566197f53f16482d3e1f588b8bcaca";
|
||||
String packed = cipher.encrypt(plaintext);
|
||||
|
||||
assertNotEquals(plaintext, packed);
|
||||
assertEquals(plaintext, cipher.decrypt(packed));
|
||||
}
|
||||
|
||||
@Test
|
||||
void samePlaintextEncryptsDifferentlyEachTime() {
|
||||
String plaintext = "xqak_" + "a".repeat(48);
|
||||
assertNotEquals(cipher.encrypt(plaintext), cipher.encrypt(plaintext));
|
||||
}
|
||||
|
||||
@Test
|
||||
void wrongKeyCannotDecrypt() {
|
||||
String packed = cipher.encrypt("xqak_secret");
|
||||
TokenCipher other = new TokenCipher("another-key");
|
||||
assertThrows(IllegalStateException.class, () -> other.decrypt(packed));
|
||||
}
|
||||
|
||||
@Test
|
||||
void tamperedCipherRejected() {
|
||||
String packed = cipher.encrypt("xqak_secret");
|
||||
byte[] raw = java.util.Base64.getDecoder().decode(packed);
|
||||
raw[raw.length - 1] ^= 0x01;
|
||||
String tampered = java.util.Base64.getEncoder().encodeToString(raw);
|
||||
assertThrows(IllegalStateException.class, () -> cipher.decrypt(tampered));
|
||||
}
|
||||
|
||||
@Test
|
||||
void blankKeyDisablesCipher() {
|
||||
TokenCipher disabled = new TokenCipher(" ");
|
||||
assertThrows(IllegalStateException.class, () -> disabled.encrypt("xqak_x"));
|
||||
assertThrows(IllegalStateException.class, () -> disabled.decrypt("AAAA"));
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,9 @@ import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
@@ -26,6 +28,15 @@ import static org.mockito.Mockito.when;
|
||||
@SuppressWarnings("unchecked")
|
||||
class ToolGuardTest {
|
||||
|
||||
@org.junit.jupiter.api.BeforeAll
|
||||
static void initTableInfo() {
|
||||
// allowedToolNames 的 .select(Tool::getName) 在构建 wrapper 时即解析 lambda 列,需 TableInfo
|
||||
com.baomidou.mybatisplus.core.metadata.TableInfoHelper.initTableInfo(
|
||||
new org.apache.ibatis.builder.MapperBuilderAssistant(
|
||||
new com.baomidou.mybatisplus.core.MybatisConfiguration(), ""),
|
||||
me.iiv.agent.entity.svc.Tool.class);
|
||||
}
|
||||
|
||||
@Mock
|
||||
TokenScopeMapper tokenScopeMapper;
|
||||
@Mock
|
||||
@@ -59,7 +70,7 @@ class ToolGuardTest {
|
||||
when(tokenScopeMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of(tokenScope(3L)));
|
||||
when(agentScopeMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of(scope(3L)));
|
||||
when(scopeToolMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of(scopeTool(9L)));
|
||||
when(toolMapper.selectCount(any(LambdaQueryWrapper.class))).thenReturn(1L);
|
||||
when(toolMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of(tool(9L, "agent.list.query")));
|
||||
ToolGuard guard = guard();
|
||||
|
||||
assertTrue(guard.isAllowed(7L, "agent.list.query"));
|
||||
@@ -70,12 +81,24 @@ class ToolGuardTest {
|
||||
when(tokenScopeMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of(tokenScope(3L)));
|
||||
when(agentScopeMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of(scope(3L)));
|
||||
when(scopeToolMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of(scopeTool(9L)));
|
||||
when(toolMapper.selectCount(any(LambdaQueryWrapper.class))).thenReturn(0L);
|
||||
when(toolMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of());
|
||||
ToolGuard guard = guard();
|
||||
|
||||
assertFalse(guard.isAllowed(7L, "agent.list.query"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void allowedToolNamesReturnsEnabledNamesOnly() {
|
||||
when(tokenScopeMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of(tokenScope(3L)));
|
||||
when(agentScopeMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of(scope(3L)));
|
||||
when(scopeToolMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of(scopeTool(9L)));
|
||||
when(toolMapper.selectList(any(LambdaQueryWrapper.class)))
|
||||
.thenReturn(List.of(tool(9L, "agent.list.query"), tool(10L, "sys.ping")));
|
||||
ToolGuard guard = guard();
|
||||
|
||||
assertEquals(Set.of("agent.list.query", "sys.ping"), guard.allowedToolNames(7L));
|
||||
}
|
||||
|
||||
private ToolGuard guard() {
|
||||
return new ToolGuard(tokenScopeMapper, scopeToolMapper, toolMapper, agentScopeMapper);
|
||||
}
|
||||
@@ -97,4 +120,11 @@ class ToolGuardTest {
|
||||
scopeTool.setToolId(toolId);
|
||||
return scopeTool;
|
||||
}
|
||||
|
||||
private static me.iiv.agent.entity.svc.Tool tool(Long id, String name) {
|
||||
me.iiv.agent.entity.svc.Tool tool = new me.iiv.agent.entity.svc.Tool();
|
||||
tool.setId(id);
|
||||
tool.setName(name);
|
||||
return tool;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,282 +0,0 @@
|
||||
# xiaoqu-ai 每用户 MCP Token 接入开发设计文档
|
||||
|
||||
## 1. 需求概述
|
||||
|
||||
### 1.1 需求信息
|
||||
|
||||
| 项目 | 内容 |
|
||||
|---|---|
|
||||
| 需求标题 | xiaoqu-ai 每用户独立 MCP Token 接入 |
|
||||
| 优先级 | high |
|
||||
| 目标客户端 | xiaoqu-ai iOS ClawMobile |
|
||||
| 目标服务 | xiaoqu-agent-service `/mcp` |
|
||||
| 认证要求 | 每个登录用户使用自己的 MCP Token |
|
||||
| 预估工时 | 12-16h |
|
||||
|
||||
### 1.2 成功标准
|
||||
|
||||
- 用户 A 的 MCP 请求只能携带用户 A 的 Token,不能使用用户 B 或应用公共 Token。
|
||||
- 登录、刷新 Token、切换账号、退出登录后,Claw/MCP 会话不会串用旧用户凭证。
|
||||
- MCP 请求使用 `Authorization: Bearer <token>`,服务端继续执行用户、租户、作用域和审计校验。
|
||||
- Token 不出现在 prompt、工具参数、普通日志、崩溃信息或 Git 文件中。
|
||||
- 用户无 MCP Token、Token 过期、Token 被撤销时,客户端显示可恢复错误,不发送无凭证请求。
|
||||
|
||||
### 1.3 明确不做
|
||||
|
||||
- 不把一个固定 Token 写入 App、公共配置文件或所有用户共用。
|
||||
- 不把数据库中保存的 Token 哈希当作客户端 Token 返回;明文只在签发响应中出现一次。
|
||||
- 不让客户端提交任意 `user_id`、`tenant_id` 来冒充用户身份。
|
||||
|
||||
## 2. 现状分析
|
||||
|
||||
### 2.1 相关现有代码
|
||||
|
||||
| 文件 | 层级 | 当前功能 | 改动类型 |
|
||||
|---|---|---|---|
|
||||
| `xiaoqu-ai/ios/Sources/XiaoquCRM/Core/Dependencies/ClawBridge.swift` | iOS 依赖 | 静态初始化 Claw 配置,并创建 MCP Server 列表 | 修改 |
|
||||
| `xiaoqu-ai/claw-code-parity/rust/crates/claw-mobile/src/ffi.rs` | Rust FFI | 接收 MCP 配置,连接、发现工具、调用工具 | 修改 |
|
||||
| `xiaoqu-ai/claw-code-parity/rust/crates/claw-mobile/src/mcp_http.rs` | Rust HTTP | JSON-RPC HTTP/SSE MCP 客户端 | 修改 |
|
||||
| `xiaoqu-agent-service/src/main/java/me/iiv/agent/security/AkAuthFilter.java` | 服务鉴权 | 仅解析 `Authorization: Bearer <ak>` | 保持兼容 |
|
||||
| `xiaoqu-agent-service/src/main/java/me/iiv/agent/security/AuthService.java` | 服务鉴权 | AK SHA-256 哈希查询、用户加载、过期检查 | 修改/复用 |
|
||||
| `xiaoqu-agent-service/src/main/java/me/iiv/agent/admin/AdminTokenService.java` | 服务 Token | 管理员为指定用户签发 AK,明文只返回一次 | 扩展复用 |
|
||||
|
||||
### 2.2 当前缺口
|
||||
|
||||
1. `ClawBridge.liveValue` 是静态值,Token 只在进程初始化时读取一次。
|
||||
2. 当前 FFI 将 `apiKey` 发送为 `X-API-Key`,与服务端要求的 Bearer 不一致。
|
||||
3. iOS FFI 未把配置文件中的 MCP Server 自动接入 `FfiEngineConfig`。
|
||||
4. 多 MCP Client 共存时,工具调用没有稳定的“工具名 → 所属 Server”映射。
|
||||
5. 当前管理端签发接口需要管理员会话,不适合作为普通用户登录后的自动取 Token 接口。
|
||||
|
||||
## 3. 方案设计
|
||||
|
||||
### 3.1 认证模型
|
||||
|
||||
采用两级凭证:
|
||||
|
||||
1. xiaoqu-ai 登录 `accessToken`:证明当前用户登录身份,只用于向业务后端换取 MCP Token。
|
||||
2. MCP Token(`xqak_*`):绑定 `user_id`,受 MCP scope、租户/行级权限、过期时间和撤销状态约束,只用于 `/mcp`。
|
||||
|
||||
客户端不得把 `accessToken` 和 MCP Token 混为一谈。MCP 服务仍只接受 MCP Token。
|
||||
|
||||
### 3.2 Token 生命周期
|
||||
|
||||
| 场景 | 行为 |
|
||||
|---|---|
|
||||
| 登录成功 | 用当前用户登录态换取 MCP Token,按用户保存 |
|
||||
| MCP Token 过期/401 | 清除当前用户 MCP Token,重新换取一次;同一请求不自动重放工具副作用 |
|
||||
| accessToken 刷新 | 保留 MCP Token;若服务返回 401,再重新换取 |
|
||||
| 切换账号 | 先断开旧 MCP Client,再加载新账号 Token 并建立新 Client |
|
||||
| 退出登录 | 销毁旧 Claw/MCP Client,删除当前账号本地 MCP Token;服务端撤销或等待短期过期 |
|
||||
| 多账号 | Keychain key 必须包含稳定用户 ID,禁止所有账号共用 `mcpToken` |
|
||||
|
||||
建议 MCP Token 默认有效期 15 分钟,并提供换取接口;长期 AK 仅用于受控开发/管理场景。
|
||||
|
||||
## 4. API 契约
|
||||
|
||||
### 4.1 POST `/api/v1/mcp/session-token`
|
||||
|
||||
描述: 使用当前 xiaoqu-ai 登录态,为当前用户签发一个短期 MCP Token。
|
||||
|
||||
认证:
|
||||
|
||||
- `Authorization: Bearer <xiaoqu-access-token>`
|
||||
- 服务端必须从签名 Token 解析用户和租户,忽略请求体中的身份字段。
|
||||
|
||||
请求体:
|
||||
|
||||
- `client`: string (required) — 固定为 `xiaoqu-ai-ios`,最长 64 字符。
|
||||
- `device_id`: string (required) — 设备安装实例 ID,最长 128 字符;不得包含 Token。
|
||||
|
||||
响应 200:
|
||||
|
||||
- `token`: string (required) — MCP 明文 Token,仅本次返回。
|
||||
- `expires_at`: datetime (required) — MCP Token 到期时间。
|
||||
- `user_id`: int (required) — 服务端从登录态解析出的用户 ID。
|
||||
- `tenant_id`: int (required) — 服务端解析出的租户 ID。
|
||||
- `scope_version`: string (required) — 本次绑定权限版本,用于客户端诊断,不作为鉴权依据。
|
||||
|
||||
错误:
|
||||
|
||||
- `401`: 登录态缺失、无效或已过期。
|
||||
- `403`: 用户无权使用 MCP 或租户未开通。
|
||||
- `409`: 用户 Token 配置缺失/作用域未配置。
|
||||
- `429`: 换取频率超限。
|
||||
- `503`: MCP Token 服务暂不可用;客户端不得携带旧 Token 强行重试写操作。
|
||||
|
||||
服务端处理要求:
|
||||
|
||||
- 使用现有 `agent_api_key` 哈希存储和 `token_scope` 绑定逻辑。
|
||||
- 每次签发生成新的随机 Token,明文仅返回一次;不能查询旧 Token 明文。
|
||||
- 默认只绑定当前用户允许的只读作用域;高风险作用域必须单独审批。
|
||||
- 签发、刷新、撤销写入审计日志,记录 `user_id`、`tenant_id`、client、device_id、结果和原因,不记录 Token。
|
||||
|
||||
### 4.2 POST `/mcp`
|
||||
|
||||
描述: MCP Streamable HTTP JSON-RPC 端点,现有接口保持不变。
|
||||
|
||||
请求 Header:
|
||||
|
||||
- `Authorization: Bearer <mcp-token>` (required)
|
||||
- `Content-Type: application/json` (required)
|
||||
- `Accept: application/json, text/event-stream` (required)
|
||||
- `Mcp-Session-Id: <id>` (required after initialize response contains it)
|
||||
|
||||
请求/响应:
|
||||
|
||||
- 保持 MCP `initialize` → `notifications/initialized` → `tools/list` → `tools/call` 顺序。
|
||||
- `tools/call` 必须继续执行 Token 作用域、用户和租户数据范围校验。
|
||||
|
||||
错误:
|
||||
|
||||
- `401`: MCP Token 缺失、无效、撤销或过期。
|
||||
- `200` + MCP `isError=true`: Token 有效但无权调用该工具,客户端展示权限错误,不切换账号重试。
|
||||
|
||||
### 4.3 客户端配置文件
|
||||
|
||||
配置文件只保存非用户敏感配置:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"xiaoqu": {
|
||||
"type": "http",
|
||||
"url": "http://<host>:8088/mcp"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Token 不写入该文件。客户端运行时读取当前用户 Token,并动态加入 `Authorization` Header。
|
||||
|
||||
## 5. 客户端实现设计
|
||||
|
||||
### 5.1 ClawBridge
|
||||
|
||||
- 将 `ClawBridge` 从静态全局凭证改为按当前用户创建的实例。
|
||||
- `connectMcp(userID, mcpToken)` 创建只属于当前用户的 FFI Engine。
|
||||
- `disconnectMcp()` 在切换账号、退出登录和重新换 Token 前调用。
|
||||
- 不在 `liveValue` 捕获 `accessToken` 或 MCP Token;通过依赖注入传入当前账号凭证。
|
||||
|
||||
### 5.2 FFI / HTTP
|
||||
|
||||
- `FfiMcpServerConfig` 增加明确的认证模式,或直接将 `apiKey` 改为 `bearerToken`。
|
||||
- 使用 `Authorization: Bearer <mcpToken>`,禁止发送 `X-API-Key`。
|
||||
- `initialize` 后保存 `Mcp-Session-Id`,发送 `notifications/initialized`,再调用 `tools/list`。
|
||||
- 工具发现时保存 `tool_name → server_name` 映射;调用只发送到所属 Server,不按 Client 顺序盲试。
|
||||
- Token 过期只触发一次重新换取和重新连接;并发请求共享刷新锁,避免同一用户并发签发多个 Token。
|
||||
|
||||
### 5.3 Keychain
|
||||
|
||||
- Key 名:`mcpToken.<user_id>`,值为 MCP Token;使用 ThisDeviceOnly 存储。
|
||||
- `device_id` 单独生成并持久化,不从 Token 派生。
|
||||
- 日志只记录 Token 是否存在、剩余有效期和用户 ID,不记录 Token 内容。
|
||||
|
||||
## 6. 数据模型变更
|
||||
|
||||
### 6.1 现有表复用
|
||||
|
||||
| 表 | 用途 | 变更 |
|
||||
|---|---|---|
|
||||
| `agent_api_key` | MCP Token 哈希、用户绑定、状态、过期时间 | 复用;确认 `user_id/status/expire_time/key_hash` 索引 |
|
||||
| `token_scope` | Token 与作用域绑定 | 复用 |
|
||||
| `audit_log` | MCP 调用和 Token 生命周期审计 | 扩展事件类型/元数据(若当前字段不足) |
|
||||
|
||||
### 6.2 建议新增字段(仅在现有字段不足时)
|
||||
|
||||
| 表 | 字段 | 类型/约束 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `agent_api_key` | `source` | `VARCHAR(32) NOT NULL DEFAULT 'admin'` | `admin` / `user_session`,便于撤销和审计 |
|
||||
| `agent_api_key` | `device_id` | `VARCHAR(128) NULL` | 用户会话 Token 的设备绑定 |
|
||||
|
||||
索引:`(user_id, source, status, expire_time)`。新增字段必须通过新的 Flyway migration,禁止修改已执行 migration。
|
||||
|
||||
## 7. 变更文件清单
|
||||
|
||||
### xiaoqu-agent-service
|
||||
|
||||
| 文件/目录 | 层级 | 类型 | 改动说明 |
|
||||
|---|---|---|---|
|
||||
| `security/` JWT 验证组件 | Security | 新增/修改 | 验证 xiaoqu 登录态并解析 user/tenant,拒绝客户端自报身份 |
|
||||
| `mcp/` Token exchange service | Service | 新增 | 签发短期用户 MCP Token、作用域绑定、限流和审计 |
|
||||
| `handler/` MCP credential controller | Handler | 新增 | 实现 `/api/v1/mcp/session-token` |
|
||||
| `security/AkAuthFilter.java` | Filter | 修改 | 保持 Bearer MCP AK 兼容,补齐撤销/错误审计要求 |
|
||||
| `db/migration/V__user_session_token.sql` | Migration | 条件新增 | 只有字段/索引确有需要时新增 |
|
||||
| `src/test/...` | Test | 新增 | 用户隔离、过期、撤销、越权、限流和不记录明文测试 |
|
||||
|
||||
### xiaoqu-ai
|
||||
|
||||
| 文件/目录 | 层级 | 类型 | 改动说明 |
|
||||
|---|---|---|---|
|
||||
| `ios/.../ClawBridge.swift` | iOS | 修改 | 按登录用户注入 Token,重建/销毁 Claw 实例 |
|
||||
| `claw-mobile/src/ffi.rs` | Rust FFI | 修改 | Bearer Header、会话通知、Server 路由映射 |
|
||||
| `claw-mobile/src/mcp_http.rs` | Rust HTTP | 修改 | 通用 Header、Session ID 和 JSON-RPC 初始化流程 |
|
||||
| `ios/.../KeychainClient.swift` | iOS 存储 | 修改 | 账号隔离的 MCP Token 存取和删除 |
|
||||
| `ios/.../AuthFeature.swift` | 登录生命周期 | 修改 | 登录/刷新/切换/退出触发 MCP 凭证生命周期 |
|
||||
| `ios/Tests/...` | Test | 新增 | 用户切换、401 刷新、Token 不串号测试 |
|
||||
| `claw-code-parity/.../settings*.json` | 配置 | 修改 | 仅保存 URL,不保存用户 Token |
|
||||
|
||||
## 8. 开发任务拆分
|
||||
|
||||
| # | 任务标题 | 工时 | 依赖 | 完成标准 |
|
||||
|---|---|---:|---|---|
|
||||
| 1 | 服务端确认 JWT 与租户映射、Token exchange 契约 | 2h | - | 401/403/409/429 语义和 scope 默认值确定 |
|
||||
| 2 | 服务端实现用户 MCP Token 签发与审计 | 4h | #1 | Token 只返回一次,哈希落库,绑定 user/tenant/scope |
|
||||
| 3 | iOS Keychain 账号隔离和认证生命周期接入 | 3h | #1 | 登录/切换/退出无旧 Token 残留 |
|
||||
| 4 | Rust MCP HTTP Bearer、Session、初始化通知 | 3h | #3 | initialize/list/call 真实握手成功 |
|
||||
| 5 | MCP 工具到 Server 路由映射 | 2h | #4 | 多 Server 下工具调用不发错目标 |
|
||||
| 6 | 集成与安全回归测试 | 2-4h | #2-#5 | 用户隔离、401 刷新、权限拒绝和日志脱敏通过 |
|
||||
|
||||
实施顺序:#1 → #2;#3 与 #4 可并行;完成后 #5 → #6。
|
||||
|
||||
## 9. 测试与验收
|
||||
|
||||
### 9.1 服务端
|
||||
|
||||
- 用户 A Token 调用 `tools/list` / `tools/call` 成功,审计 `user_id=A`。
|
||||
- 用户 B Token 调用时只能看到/调用 B 作用域允许的数据。
|
||||
- A 的 Token 调用不存在或已撤销 Token 返回 401;A 的 Token 调 B 身份参数不得改变审计身份。
|
||||
- A 无某工具 scope 时返回 MCP `isError=true`,不得执行 Handler。
|
||||
- 签发响应后数据库和日志中搜索不到 Token 明文。
|
||||
|
||||
### 9.2 iOS
|
||||
|
||||
- A 登录后抓包确认所有 MCP 请求为 `Authorization: Bearer <A-MCP-Token>`。
|
||||
- 切换 B 后,旧连接关闭,后续请求只携带 B Token。
|
||||
- A Token 401 时只刷新 A 的 Token,不影响 B 或其他账号。
|
||||
- 退出后旧 Claw 不再产生网络请求。
|
||||
- 配置文件、prompt、工具参数和日志不包含 Token。
|
||||
|
||||
### 9.3 联调命令
|
||||
|
||||
```bash
|
||||
# 使用当前用户登录态换 MCP Token(示例,不把 Token 写入仓库)
|
||||
curl -X POST http://<host>/api/v1/mcp/session-token \
|
||||
-H 'Authorization: Bearer <xiaoqu-access-token>' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"client":"xiaoqu-ai-ios","device_id":"<device-id>"}'
|
||||
|
||||
# 使用返回的用户 MCP Token 完成 MCP 握手
|
||||
curl -X POST http://<host>:8088/mcp \
|
||||
-H 'Authorization: Bearer <user-mcp-token>' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-H 'Accept: application/json, text/event-stream' \
|
||||
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"xiaoqu-ai","version":"1.0"}}}'
|
||||
```
|
||||
|
||||
## 10. 技术风险与回退
|
||||
|
||||
| 风险 | 等级 | 应对 |
|
||||
|---|---|---|
|
||||
| accessToken 与 MCP AK 类型混用 | 高 | 强制 Token exchange;服务端只接受明确的 MCP AK |
|
||||
| 静态 Claw 实例持有旧用户 Token | 高 | 用户切换/退出先销毁实例,增加生命周期测试 |
|
||||
| 多 MCP Server 工具路由错误 | 高 | 建立工具归属映射,不采用首个 Client 试调用 |
|
||||
| Token 泄露到日志/崩溃 | 高 | 脱敏中间件、禁止打印 Header、静态扫描和测试 |
|
||||
| Token exchange 不可用 | 中 | MCP 功能降级为不可用并提示重试;不得使用公共 Token 兜底 |
|
||||
| 新字段迁移失败 | 中 | 字段变更独立 Flyway migration;回滚只撤销新增字段/索引 |
|
||||
|
||||
回退策略:保留现有管理员签发 Token 和 `/mcp` 协议不变;先关闭用户自动换取入口,客户端显示 MCP 不可用。禁止回退到全局共享 Token。
|
||||
|
||||
## 11. 设计结论
|
||||
|
||||
公共 MCP 配置文件只负责“连哪个服务”;用户 Token 由登录用户上下文动态决定。实现完成后,xiaoqu-ai 的每个用户拥有独立 MCP 权限边界,服务端仍能按用户、租户、作用域和审计链路追踪调用。
|
||||
Reference in New Issue
Block a user