From 2c5ef35d188639e10b4ce561ba7623d49db51bcb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8D=BF=E4=BA=91=E7=A7=8B=E6=9C=88?= <15273589815@163.com> Date: Thu, 10 Sep 2026 18:18:00 +0800 Subject: [PATCH] =?UTF-8?q?chore:=20=E5=88=A0=E9=99=A4=E9=80=80=E5=BD=B9?= =?UTF-8?q?=E7=9A=84=E6=97=A7=20JWT=20=E5=AF=86=E9=92=A5=EF=BC=8C=E5=90=8C?= =?UTF-8?q?=E6=AD=A5=E8=BF=87=E6=97=B6=E5=BC=95=E7=94=A8=E5=B9=B6=E8=AE=B0?= =?UTF-8?q?=E5=BD=95=E7=99=BB=E5=BD=95=E6=8E=A5=E5=8F=A3=E5=86=B3=E7=AD=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 删除 config/jwt/jwt-private.pem 与 jwt-public.pem(旧密钥,配置已不再指向)。 删除后复跑全量门禁以确认没有残留依赖:ruff 通过、unit+contract 447 passed、 acceptance_check --production 7 PASS、demo_agent_e2e 9/9 PASS。 2. 修掉 4 处引用旧密钥路径的地方(它们会在删除密钥后直接失败或误导接入方): - tests/unit/core/test_security.py 的三处硬编码路径改为单一常量 DEV_KEY_DIR, 否则删除旧密钥后该测试会因读不到文件而失败; - tools/demo_agent_e2e.py 的 docstring 前置条件; - docs/09 的配置示例;docs/19 的手工自签说明(改为指向配置项与 tools/generate_jwt_keys.py)。 3. docs/21 记录旧密钥已删除,并注明删除后已复跑验证无残留引用。 4. docs/19 未解决项新增第 5 条:无登录接口属于**有意识的推迟**(等业务 Agent 开发阶段 结束后再补),写明补的时候只需动签发侧、验签侧与身份解析侧都不需要改, 并附上当前私钥边界的实测结论(无法伪造不存在的用户、无法使用已禁用账号)。 --- docs/09-底座使用文档.md | 2 +- docs/19-业务Agent接入实操(示例验证版).md | 13 +++++++++++-- docs/21-JWT密钥管理与轮换.md | 2 +- tests/unit/core/test_security.py | 10 +++++++--- tools/demo_agent_e2e.py | 3 ++- 5 files changed, 22 insertions(+), 8 deletions(-) diff --git a/docs/09-底座使用文档.md b/docs/09-底座使用文档.md index 36dbcc3..e3cc148 100644 --- a/docs/09-底座使用文档.md +++ b/docs/09-底座使用文档.md @@ -33,7 +33,7 @@ Copy-Item .env.example .env ```dotenv JWT_ISSUER=jr-local JWT_AUDIENCE=jr-agent-platform -JWT_PUBLIC_KEY_PATH=config/jwt/jwt-public.pem +JWT_PUBLIC_KEY_PATH=config/jwt/dev/jwt-public.pem MYSQL_DSN=mysql+asyncmy://用户名:密码@127.0.0.1:3306/jr REDIS_URL=redis://127.0.0.1:6379/0 MILVUS_URI=http://127.0.0.1:19530 diff --git a/docs/19-业务Agent接入实操(示例验证版).md b/docs/19-业务Agent接入实操(示例验证版).md index 6af59e4..907bb0c 100644 --- a/docs/19-业务Agent接入实操(示例验证版).md +++ b/docs/19-业务Agent接入实操(示例验证版).md @@ -141,8 +141,9 @@ D:\conda\envs\jr_py313\python.exe tools\demo_agent_e2e.py D:\conda\envs\jr_py313\python.exe C:\Users\...\e2e_run_check.py --label 手工验证 ``` -单条链路的手工命令(客户身份 9001,JWT 用 `config/jwt/jwt-private.pem` 按 RS256 自签, -参考 `tools/acceptance_check.py::token()`): +单条链路的手工命令(客户身份 9001,JWT 用配置项 `JWT_PRIVATE_KEY_PATH` 指向的私钥按 RS256 自签, +参考 `tools/acceptance_check.py::token()`)。还没有密钥就先跑一次 +`python tools/generate_jwt_keys.py --out-dir config/jwt/dev`(详见 `docs/21-JWT密钥管理与轮换.md`): ```text POST /api/v1/agent-runs @@ -218,3 +219,11 @@ D:\conda\envs\jr_py313\python.exe tools\demo_agent_e2e.py (`reviews`/`activations`/`archivals`)且运行期读取生效(见 3.1)。 4. **`docs/05` §9.5 未收录 `agent-intent-configs` 的 `GET` 详情路径**:该接口实际存在(用于获取 `If-Match` 所需的 ETag),但权威接口文档未列出,属文档待补项。 +5. **没有登录接口(已决定推迟,不是遗漏)**:当前没有"账号密码换令牌"的接口,令牌由外部按 + RS256 用私钥自签(见 `docs/21-JWT密钥管理与轮换.md`)。**决定:等业务 Agent 开发阶段 + 结束后再补**。补的时候只需动签发侧(新增 `POST /auth/login`:校验密码 → 用私钥签令牌), + 验签侧 `JwtAuthenticator` 与身份解析侧 `IdentityService` 都不需要改。 + 代价与约束:开发阶段凡拿到私钥者都能以 `9001/9002/9003` 身份调用(实测**无法**伪造不存在的 + 用户、**无法**使用已禁用账号,边界是"签名有效 + 用户存在且启用"),因此私钥按 `docs/21` + 第 7 节的红线管理;同时业务代码必须始终只从 `RequestContext` 取身份,否则后补登录接口会 + 从"增量"变成"翻遍所有业务代码"。 diff --git a/docs/21-JWT密钥管理与轮换.md b/docs/21-JWT密钥管理与轮换.md index 14af812..ca9eb12 100644 --- a/docs/21-JWT密钥管理与轮换.md +++ b/docs/21-JWT密钥管理与轮换.md @@ -8,7 +8,7 @@ | 文件 | 用途 | 状态 | | --- | --- | --- | | `config/jwt/dev/jwt-private.pem` / `jwt-public.pem` | **开发专用**(服务端验签,脚本签发) | **正在使用** | -| `config/jwt/jwt-private.pem` / `jwt-public.pem` | 旧密钥(曾是默认) | **已退役**,配置不再指向它,用它签的令牌会被拒绝,可删 | +| ~~`config/jwt/jwt-private.pem` / `jwt-public.pem`~~ | 旧密钥(曾是默认) | **已于 2026-09-10 删除**(删除后全量门禁复跑通过,确认无残留引用) | | 生产密钥 | 上线时在生产机上生成 | 尚不存在(本就不该在开发机生成) | 开发密钥的公钥指纹可以用生成脚本查看;核对"服务端加载的是不是同一把公钥"时用它比对, diff --git a/tests/unit/core/test_security.py b/tests/unit/core/test_security.py index 6bdf3e8..817457d 100644 --- a/tests/unit/core/test_security.py +++ b/tests/unit/core/test_security.py @@ -8,6 +8,10 @@ from app.core.config import Settings from app.core.errors import UnauthorizedAgentError from app.core.security import JwtAuthenticator +# 开发专用密钥目录(tools/generate_jwt_keys.py 生成)。本测试需要真实密钥完成签发与验签, +# 所以路径只在这里定义一次:换密钥目录时改这一处,避免多处硬编码各自漂移。 +DEV_KEY_DIR = Path("config/jwt/dev") + def _settings() -> Settings: return Settings( @@ -18,13 +22,13 @@ def _settings() -> Settings: redis_url="redis://localhost", milvus_uri="http://localhost:19530", neo4j_uri="bolt://localhost:7687", - jwt_public_key_path="config/jwt/jwt-public.pem", + jwt_public_key_path=str(DEV_KEY_DIR / "jwt-public.pem"), ) def _token(subject: str = "1") -> str: now = datetime.now(UTC) - private_key = Path("config/jwt/jwt-private.pem").read_text(encoding="utf-8") + private_key = (DEV_KEY_DIR / "jwt-private.pem").read_text(encoding="utf-8") return jwt.encode( {"sub": subject, "iss": "jr-auth", "aud": "jr-agent-platform", "iat": now, "nbf": now, "exp": now + timedelta(minutes=5), "jti": "jti-1"}, @@ -65,7 +69,7 @@ def test_signed_invalid_subject_returns_401_before_identity_query(monkeypatch): def test_authenticate_rejects_expired_token() -> None: with pytest.raises(UnauthorizedAgentError): now = datetime.now(UTC) - private_key = Path("config/jwt/jwt-private.pem").read_text(encoding="utf-8") + private_key = (DEV_KEY_DIR / "jwt-private.pem").read_text(encoding="utf-8") token = jwt.encode( {"sub": "user-1", "iss": "jr-auth", "aud": "jr-agent-platform", "nbf": now - timedelta(minutes=2), "exp": now - timedelta(minutes=1), "jti": "jti-2"}, diff --git a/tools/demo_agent_e2e.py b/tools/demo_agent_e2e.py index 8ff990b..33c95f8 100644 --- a/tools/demo_agent_e2e.py +++ b/tools/demo_agent_e2e.py @@ -9,7 +9,8 @@ python tools/demo_agent_e2e.py # 全流程并清理 run 数据 python tools/demo_agent_e2e.py --keep-run # 保留 run/审计数据便于排查 -前置:数据库与配置中心已就绪;`config/jwt/jwt-private.pem` 存在; +前置:数据库与配置中心已就绪;JWT 私钥存在(配置项 `JWT_PRIVATE_KEY_PATH`,默认 +`config/jwt/dev/jwt-private.pem`,没有就先跑 `tools/generate_jwt_keys.py`); **运行前请停掉常驻 Worker**,否则它会抢走本次 run 并使用同一队列的执行路径。 复核方式:配置发布保留"提交审核 → 审核 → 激活"状态机,但**不再要求审核人不是创建人**,