# 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. 与登录接口的关系 > ✅ **该缺口已闭环(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` 判断角色。这条守住了,后补登录接口就是增量; 破了,就得回头翻所有业务代码。