Files
group_fqcd_jr/docs/26-JWT密钥管理与轮换.md
T
qyqy fb7d2f7b6d merge: 跟进架构师最新 qyqy_develop(38 提交)
- model_gateway 冲突取并集:保留本线对 intent_classification→chat 的修正与空集回退,
  并入架构师补充的 5 个风控 task_type;未登记 task_type 的告警留痕一并保留
- docs/25 撞号(本人 JWT 文档 vs 架构师风控评审报告)→ 本人让号到 docs/26,同步 docs/19 引用
- 架构师恢复的 5 份编号文档(04/06/10/13/99)保留其版本(那 5 份已无引用,仅编号占位)
- bootstrap/model_gateway/docs/05/test_risk_agent_contract 自动合并成功

测试:1013 passed / 1 failed(既有空集缺陷)
真机:知识问答 succeeded 且声明仅 1 条;画像问答 succeeded;三端点 403/201/200/404 全绿
配置:release 216 仍为生效版本,工具白名单未被顶掉
2026-09-11 15:40:32 +08:00

115 lines
5.5 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# JWT 密钥管理与轮换
> 适用对象:底座维护者、接入的业务 Agent 开发者。
> 相关配置项:`JWT_PRIVATE_KEY_PATH`、`JWT_PUBLIC_KEY_PATH`(`.env`)。
## 1. 现在有几把密钥
| 文件 | 用途 | 状态 |
| --- | --- | --- |
| `config/jwt/dev/jwt-private.pem` / `jwt-public.pem` | **开发专用**(服务端验签,脚本签发) | **正在使用** |
| ~~`config/jwt/jwt-private.pem` / `jwt-public.pem`~~ | 旧密钥(曾是默认) | **已于 2026-09-10 删除**(删除后全量门禁复跑通过,确认无残留引用) |
| 生产密钥 | 上线时在生产机上生成 | 尚不存在(本就不该在开发机生成) |
开发密钥的公钥指纹可以用生成脚本查看;核对"服务端加载的是不是同一把公钥"时用它比对,
比对人肉看到的文件内容可靠。
## 2. 密钥不进版本库(这是设计,不是遗漏)
`config/jwt/` 已在 `.gitignore` 里,所以**克隆仓库后是没有密钥的**,服务会直接启动失败:
```
RuntimeError: JWT public key cannot be read: .../config/jwt/dev/jwt-public.pem
```
看到这个报错不要去改 `.gitignore`,按下面第 3 节生成即可。
**关键分工(很容易搞反)**:
- **服务端只需要公钥** —— 验签用。`app/core/security.py` 只读 `JWT_PUBLIC_KEY_PATH`,
`JWT_PRIVATE_KEY_PATH` 在 `app/` 里没有任何代码读取。
- **私钥只有签发方需要** —— 也就是调用方(本地脚本、将来的登录接口、你们自己的前端后端)。
因此生产部署时,服务进程所在机器**可以只挂公钥**,私钥留在签发方那边,泄露面更小。
## 3. 生成密钥
```powershell
# 开发环境(默认输出到 config/jwt/dev,已被 .gitignore 忽略)
D:\conda\envs\jr_py313\python.exe tools\generate_jwt_keys.py --out-dir config/jwt/dev
```
脚本会打印公钥指纹,并给出要写进 `.env` 的两行配置。已存在密钥时它**默认拒绝覆盖**,
避免误操作把正在用的密钥冲掉(要换就得显式加 `--force`,见第 5 节)。
克隆仓库后的完整起步动作:
```powershell
D:\conda\envs\jr_py313\python.exe tools\generate_jwt_keys.py --out-dir config/jwt/dev
copy .env.example .env
# 检查 .env 里这两行是否指向刚生成的密钥:
# JWT_PRIVATE_KEY_PATH=config/jwt/dev/jwt-private.pem
# JWT_PUBLIC_KEY_PATH=config/jwt/dev/jwt-public.pem
```
## 4. 最容易踩的坑:几个人连同一个服务,密钥必须一致
- **自己起服务、自己调** → 自己生成一套密钥即可,互不干扰。
- **连别人已经起好的服务** → **必须用那个服务正在用的私钥**,自己新生成一套会一直拿到
`401 AUTHENTICATION_REQUIRED`,而且错误信息不会告诉你"是密钥对不上"。
判断依据很简单:**服务端用自己的公钥验签**。所以"我服务用哪把公钥"决定"谁能签出有效令牌"。
自签令牌的参考实现:`tools/acceptance_check.py` 里的 `token()`(RS256 + 本地私钥)。
## 5. 轮换密钥
```powershell
D:\conda\envs\jr_py313\python.exe tools\generate_jwt_keys.py --out-dir config/jwt/dev --force
```
**影响**:所有已签发的令牌**立即失效**(正在跑的脚本、前端缓存的令牌、别人手里的令牌都会变 401)。
所以在低峰期做,并提前通知正在联调的人。开发阶段无所谓,生产环境要当变更来管。
轮换后只需要重启服务(让它重新加载公钥),不需要改任何代码 —— 密钥路径是配置项,
三个工具脚本(`acceptance_check`、`demo_agent_e2e`、`smoke_check`)也都从配置读路径。
## 6. 生产环境
```powershell
# 在**生产机**上生成,不要把开发密钥拷过去
python tools/generate_jwt_keys.py --out-dir /etc/jr-agent/keys
```
然后用环境变量覆盖(不要改代码里的默认值):
```ini
JWT_PRIVATE_KEY_PATH=/etc/jr-agent/keys/jwt-private.pem
JWT_PUBLIC_KEY_PATH=/etc/jr-agent/keys/jwt-public.pem
JWT_ISSUER=<生产签发方标识>
JWT_AUDIENCE=<生产受众>
```
注意 `JWT_ISSUER` / `JWT_AUDIENCE` 也应当与开发环境不同:`jwt.decode` 会校验这两个声明,
生产沿用 `jr-local` 就等于允许开发环境签发的令牌在生产上生效。**这条比换密钥更容易被忽略。**
## 7. 安全红线(脚本只能提醒,执行靠人)
1. 私钥**永不进仓库**;换目录存放时确认新目录同样被 `.gitignore` 覆盖。
2. 私钥**永不贴进聊天记录、工单、截图、CI 日志**。要分发就走内部共享盘或当面拷。
3. 开发 / 生产**各一套**,互不复用;生产密钥在生产机生成,不做跨机拷贝。
4. 有人员离开或密钥可能外泄时,按第 5 节轮换,并同步轮换 `JWT_ISSUER`(如有区分)。
5. 服务端**不需要**私钥就不要放 —— 生产机上只挂公钥即可。
## 8. 与"暂无登录接口"的关系
当前底座**没有**"账号密码换令牌"的登录接口,令牌由外部按 RS256 自签。这是已知缺口,
记为待办(见 `docs/19` 未解决项)。
补充这个接口时,改动集中在签发侧:新增一个 `POST /auth/login`(校验密码 → 用私钥签令牌),
验签侧(`JwtAuthenticator`)与身份解析侧(`IdentityService`)**都不需要改**。
前提是一条接入红线:**业务代码只能从 `RequestContext` 取身份**,不许自己解析 JWT、
不许硬编码 `user_id`、不许自己查 `sys_user` 判断角色。这条守住了,后补登录接口就是增量;
破了,就得回头翻所有业务代码。