Files
group_fqcd_jr/docs/26-JWT密钥管理与轮换.md
T
lzf_0626 36c7a9d8d2 文档:审查报告入库 + 全量校对补注
## 新入库(`docs/演示用/`)

- `代码库全面审查报告-2026-09-14.md`
- `代码修改方案-2026-09-14.md`
- `记忆系统排查报告-2026-09-14.md`
- `记忆系统修复文档-2026-09-14.md`
- `文档一致性审计报告-2026-09-14.md`
- `多Worker接入方案-2026-09-14.md`

## 全量校对(32 个既有文档 + `AGENTS.md`)

跨 39 个文件、**1125 insertions / 148 deletions**。

⚠️ **这批改动同样不是本次会话写的**。我抽样核对过性质:是**实质内容补充**而不是
格式/换行转换。例如 `docs/44-演示流程.md` 新增两条"2026-09-14 补注":

- `启动金融Agent平台.bat` 只在**桌面**上,仓库里只有 `启动平台.bat` 这一份
  (两份由同一个 `tools/make_launcher_bat.py` 产出,改完 `start.ps1` 重跑它一起更新);
- `advisor_t`(9020) 与 `offsite_t`(9006) **不在 `tools/seed_test_rbac.py` 的演示用户里**
  (那里只有 `cust_t`/`risk_t`/`admin_t`/`review_t` 四个),由 `grant_*.py` 系列创建,
  **重跑种子不会重建它们** —— 换机器时这两个账号登录失败,要先查 `sys_user` 有没有这两行,
  而不是查密码。

这两条都是对的地方,与我这一路踩到的现象一致(我确实用到了 `advisor_t`/`offsite_t`)。

**我没有逐字审阅全部 39 个文件**,只抽样确认了改动性质与规模。若其中有需要复核的段落,
请指明文件,我逐处核对。
2026-09-14 20:36:00 +08:00

6.3 KiB
Raw Blame History

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. 生成密钥

# 开发环境(默认输出到 config/jwt/dev,已被 .gitignore 忽略)
D:\conda\envs\jr_py313\python.exe tools\generate_jwt_keys.py --out-dir config/jwt/dev

脚本会打印公钥指纹,并给出要写进 .env 的两行配置。已存在密钥时它默认拒绝覆盖, 避免误操作把正在用的密钥冲掉(要换就得显式加 --force,见第 5 节)。

克隆仓库后的完整起步动作:

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. 轮换密钥

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. 生产环境

# 在**生产机**上生成,不要把开发密钥拷过去
python tools/generate_jwt_keys.py --out-dir /etc/jr-agent/keys

然后用环境变量覆盖(不要改代码里的默认值):

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. 与登录接口的关系

✅ 该缺口已闭环(2026-09-14 更新)。本文原文写「当前底座没有账号密码换令牌的登录接口, 令牌由外部按 RS256 自签」——现在有了:

  • POST /api/v1/auth/tokens(app/api/controllers/auth.py:24,32,接口编号 A034), 校验账号密码后由服务端用私钥签发 RS256 令牌。
  • 因此不需要外部自签令牌了;docs/05-接口文档.md 的 A034 是当前权威说明。
  • 关键点:验签侧(JwtAuthenticator)与身份解析侧(IdentityService)确实没有改 ——下面那段预判是准确的,补登录接口只是签发侧的增量。

下面这段原文保留,作为"当初为什么这样设计"的记录:

当前底座没有"账号密码换令牌"的登录接口,令牌由外部按 RS256 自签。这是已知缺口, 记为待办(见 docs/19 未解决项)。

补充这个接口时,改动集中在签发侧:新增一个 POST /auth/login(校验密码 → 用私钥签令牌), 验签侧(JwtAuthenticator)与身份解析侧(IdentityService)都不需要改。

实现时用的是 POST /api/v1/auth/tokens(不是 /auth/login),语义同上。

前提是一条接入红线:业务代码只能从 RequestContext 取身份,不许自己解析 JWT、 不许硬编码 user_id、不许自己查 sys_user 判断角色。这条守住了,后补登录接口就是增量; 破了,就得回头翻所有业务代码。