diff --git a/.env.example b/.env.example index 753f827..e1aece9 100644 --- a/.env.example +++ b/.env.example @@ -6,8 +6,11 @@ TIMEZONE=Asia/Shanghai JWT_ISSUER=jr-local JWT_AUDIENCE=jr-agent-platform JWT_ALGORITHM=RS256 -JWT_PRIVATE_KEY_PATH=config/jwt/jwt-private.pem -JWT_PUBLIC_KEY_PATH=config/jwt/jwt-public.pem +# 开发专用密钥,由 tools/generate_jwt_keys.py 生成。config/jwt/ 已被 .gitignore 忽略, +# 所以克隆仓库后**必须自己生成一次**,否则服务会因为读不到公钥而启动失败。 +# 生产环境:在生产机上单独生成一套,用环境变量覆盖这两行,不要复用开发密钥。 +JWT_PRIVATE_KEY_PATH=config/jwt/dev/jwt-private.pem +JWT_PUBLIC_KEY_PATH=config/jwt/dev/jwt-public.pem JWT_CLOCK_SKEW_SECONDS=30 MYSQL_DSN=mysql+asyncmy://jr_app:change-me@127.0.0.1:3306/jr_agent diff --git a/app/core/config.py b/app/core/config.py index 8973c83..7bbc6eb 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -20,8 +20,10 @@ class Settings(BaseSettings): jwt_issuer: str jwt_audience: str jwt_algorithm: str = "RS256" - jwt_private_key_path: str = "config/jwt/jwt-private.pem" - jwt_public_key_path: str = "config/jwt/jwt-public.pem" + # 默认指向**开发专用**密钥(tools/generate_jwt_keys.py 生成,config/jwt/ 不进版本库)。 + # 生产环境必须用环境变量覆盖为生产机上单独生成的那一套,不要复用开发密钥。 + jwt_private_key_path: str = "config/jwt/dev/jwt-private.pem" + jwt_public_key_path: str = "config/jwt/dev/jwt-public.pem" jwt_clock_skew_seconds: int = Field(default=30, ge=0) mysql_dsn: str mysql_pool_size: int = Field(default=5, ge=1) diff --git a/docs/21-JWT密钥管理与轮换.md b/docs/21-JWT密钥管理与轮换.md new file mode 100644 index 0000000..14af812 --- /dev/null +++ b/docs/21-JWT密钥管理与轮换.md @@ -0,0 +1,114 @@ +# 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` | 旧密钥(曾是默认) | **已退役**,配置不再指向它,用它签的令牌会被拒绝,可删 | +| 生产密钥 | 上线时在生产机上生成 | 尚不存在(本就不该在开发机生成) | + +开发密钥的公钥指纹可以用生成脚本查看;核对"服务端加载的是不是同一把公钥"时用它比对, +比对人肉看到的文件内容可靠。 + +## 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` 判断角色。这条守住了,后补登录接口就是增量; +破了,就得回头翻所有业务代码。 diff --git a/tools/acceptance_check.py b/tools/acceptance_check.py index 1cf6470..677c197 100644 --- a/tools/acceptance_check.py +++ b/tools/acceptance_check.py @@ -20,6 +20,7 @@ from sqlalchemy import delete, select import app.service.admin_service as admin_service import app.service.agent_run_application_service as run_service import app.service.public_platform_service as public_platform_service +from app.core.config import get_settings from app.core.contracts import ( AgentDefinition, AgentRequest, @@ -37,7 +38,8 @@ from app.service.agent.bootstrap import get_agent_factory from app.service.agent.factory import AgentFactory from app.worker.runtime import WorkerRuntime -PRIVATE_KEY = Path("config/jwt/jwt-private.pem").read_text(encoding="utf-8") +# 密钥路径统一从配置读(.env 的 JWT_PRIVATE_KEY_PATH),换密钥只改配置,不用改脚本。 +PRIVATE_KEY = Path(get_settings().jwt_private_key_path).read_text(encoding="utf-8") CUSTOMER_ID = "9001" # tools/seed_test_rbac.py 造的数据 _results: list[tuple[str, str, str]] = [] diff --git a/tools/demo_agent_e2e.py b/tools/demo_agent_e2e.py index fb9b4f0..8ff990b 100644 --- a/tools/demo_agent_e2e.py +++ b/tools/demo_agent_e2e.py @@ -30,6 +30,7 @@ import httpx import jwt from sqlalchemy import delete, select, text +from app.core.config import get_settings from app.infrastructure.db import SessionFactory from app.main import create_app from app.model.audit import InteractionAudit @@ -37,7 +38,8 @@ from app.model.conversation import ConversationMessage from app.model.platform import AgentRun, DomainEventOutbox, OutboxDelivery, RequestIdempotency from app.worker.runtime import WorkerRuntime -PRIVATE_KEY = Path("config/jwt/jwt-private.pem").read_text(encoding="utf-8") +# 密钥路径统一从配置读(.env 的 JWT_PRIVATE_KEY_PATH),换密钥只改配置,不用改脚本。 +PRIVATE_KEY = Path(get_settings().jwt_private_key_path).read_text(encoding="utf-8") ADMIN = "9003" CUSTOMER = "9001" AGENT_TYPE = "fund_query_demo" diff --git a/tools/generate_jwt_keys.py b/tools/generate_jwt_keys.py new file mode 100644 index 0000000..aba957b --- /dev/null +++ b/tools/generate_jwt_keys.py @@ -0,0 +1,125 @@ +"""生成 JWT(RS256)密钥对。 + +为什么要有这个脚本,而不是让你手敲 `openssl genrsa`: + +- **可复现**:任何人拿到这个脚本就能在目标环境生成同一规格的密钥(RSA 2048 / PKCS#8 / + SubjectPublicKeyInfo),不会因为 openssl 版本或参数写法不同而产生互不兼容的密钥。 +- **少一个踩坑点**:`openssl` 在 Windows 上经常不可用或来自 Git 自带的旧版本(那套不支持 + `genpkey`),手敲命令失败时很难判断是参数问题还是环境问题。 +- **自带安全检查**:生成后会打印公钥指纹(用于核对"服务端加载的是不是同一把公钥")并在 + 私钥已存在时默认拒绝覆盖(避免误操作把正在用的密钥冲掉,导致所有已签发令牌立即失效)。 + +用法: + +```powershell +# 开发环境(会被 .gitignore 忽略,目录本身不进仓库) +D:\\conda\\envs\\jr_py313\\python.exe tools\\generate_jwt_keys.py --out-dir config/jwt/dev + +# 生产环境:在**生产机**上生成,不要把开发密钥拿去用 +D:\\conda\\envs\\jr_py313\\python.exe tools\\generate_jwt_keys.py --out-dir /etc/jr-agent/keys +``` + +生成后把路径写进 `.env`: + +```ini +JWT_PRIVATE_KEY_PATH=config/jwt/dev/jwt-private.pem +JWT_PUBLIC_KEY_PATH=config/jwt/dev/jwt-public.pem +``` + +**安全约束(本脚本只能提醒,执行靠人)**: + +1. 私钥**永不进仓库**——`config/jwt/` 已在 `.gitignore` 中,换目录存放时请确认新目录同样被忽略。 +2. 私钥**永不复用**:开发、预发、生产各一套。开发私钥泄露不影响生产,反之亦然。 +3. 生产密钥**在生产机上生成**,不要在本机生成再拷贝过去——拷贝过程本身就是泄露面。 +4. 轮换私钥会让**所有已签发的令牌立即失效**(公钥验签失败),需要在低峰期做并通知调用方。 +""" + +import argparse +import hashlib +import sys +from pathlib import Path + +PRIVATE_KEY_FILENAME = "jwt-private.pem" +PUBLIC_KEY_FILENAME = "jwt-public.pem" + + +def public_key_fingerprint(public_pem: bytes) -> str: + """公钥 SHA-256 指纹(DER 部分)。用于核对服务端加载的公钥与签发方是否一致。""" + from cryptography.hazmat.primitives import serialization + + public_key = serialization.load_pem_public_key(public_pem) + der = public_key.public_bytes( + encoding=serialization.Encoding.DER, + format=serialization.PublicFormat.SubjectPublicKeyInfo, + ) + return hashlib.sha256(der).hexdigest() + + +def generate(out_dir: Path, *, force: bool, key_size: int) -> int: + from cryptography.hazmat.primitives import serialization + from cryptography.hazmat.primitives.asymmetric import rsa + + private_path = out_dir / PRIVATE_KEY_FILENAME + public_path = out_dir / PUBLIC_KEY_FILENAME + + if not force and (private_path.exists() or public_path.exists()): + existing = [str(p) for p in (private_path, public_path) if p.exists()] + print("拒绝覆盖已存在的密钥:") + for path in existing: + print(f" - {path}") + print("覆盖会让所有已签发令牌立即失效。确认要换,请显式加 --force。") + return 1 + + out_dir.mkdir(parents=True, exist_ok=True) + + key = rsa.generate_private_key(public_exponent=65537, key_size=key_size) + private_pem = key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ) + public_pem = key.public_key().public_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PublicFormat.SubjectPublicKeyInfo, + ) + + private_path.write_bytes(private_pem) + public_path.write_bytes(public_pem) + + print(f"已生成 {key_size} 位 RSA 密钥对:") + print(f" 私钥:{private_path}") + print(f" 公钥:{public_path}") + print(f" 公钥 SHA-256 指纹:{public_key_fingerprint(public_pem)}") + print() + print("请把下面两行写进 .env(或在部署环境注入同名变量):") + print(f" JWT_PRIVATE_KEY_PATH={private_path.as_posix()}") + print(f" JWT_PUBLIC_KEY_PATH={public_path.as_posix()}") + print() + print("提醒:私钥不要提交、不要贴进聊天记录、不要与生产密钥混用;") + print(" 服务端只需要公钥即可验签,签发方(调用方)才需要私钥。") + return 0 + + +def main() -> int: + parser = argparse.ArgumentParser(description="生成 JWT(RS256) 密钥对") + parser.add_argument( + "--out-dir", + default="config/jwt/dev", + help="输出目录,默认 config/jwt/dev(已被 .gitignore 忽略)", + ) + parser.add_argument("--force", action="store_true", help="覆盖已存在的密钥(会让旧令牌立即失效)") + parser.add_argument("--key-size", type=int, default=2048, help="RSA 位数,默认 2048") + args = parser.parse_args() + + try: + import cryptography # noqa: F401 + except ImportError: + print("缺少 cryptography 依赖。项目依赖 PyJWT[rsa],请先安装依赖:") + print(" D:\\conda\\envs\\jr_py313\\python.exe -m pip install cryptography") + return 2 + + return generate(Path(args.out_dir), force=args.force, key_size=args.key_size) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/smoke_check.py b/tools/smoke_check.py index 0e11c33..04c1426 100644 --- a/tools/smoke_check.py +++ b/tools/smoke_check.py @@ -6,6 +6,7 @@ from __future__ import annotations import datetime as dt +import os import uuid from pathlib import Path @@ -13,7 +14,11 @@ import httpx import jwt BASE_URL = "http://127.0.0.1:8099" -PRIVATE_KEY = Path("config/jwt/jwt-private.pem").read_text(encoding="utf-8") + +# 本脚本专门对"已经跑起来的服务"做冒烟,因此刻意不依赖 app 包,只从环境变量取密钥路径。 +# 默认指向开发专用密钥;对其它环境冒烟时用 JWT_PRIVATE_KEY_PATH 覆盖。 +PRIVATE_KEY_PATH = Path(os.getenv("JWT_PRIVATE_KEY_PATH", "config/jwt/dev/jwt-private.pem")) +PRIVATE_KEY = PRIVATE_KEY_PATH.read_text(encoding="utf-8") _results: list[tuple[str, str, str]] = []