Files
group_fqcd_jr/tools/smoke_check.py
lzf_0626 c32d3dbd06 chore: 开发专用 JWT 密钥、密钥生成脚本与轮换文档
背景:此前全环境共用一把 JWT 密钥(config/jwt/jwt-private.pem)。它相当于
"能冒充 9001/9002/9003 的万能钥匙"(实测边界:签名有效 + 用户存在且启用才通过,
伪造新用户与使用禁用账号都会被拒)。为避免同一把密钥将来又变成生产密钥,
本次引入开发专用密钥,并把签发侧收敛到配置。

改动:
1. 新增 tools/generate_jwt_keys.py:可复现地生成 RS256 密钥对(PKCS#8 / SPKI),
   打印公钥 SHA-256 指纹便于核对服务端加载的是否同一把;密钥已存在时默认拒绝
   覆盖,避免误操作导致所有已签发令牌立即失效。
2. 生成开发专用密钥到 config/jwt/dev/(该目录整体已被 .gitignore 忽略,不入库)。
3. 三个工具脚本不再硬编码私钥路径,改为读配置:acceptance_check 与 demo_agent_e2e
   走 get_settings().jwt_private_key_path,smoke_check 因刻意不依赖 app 包而读
   JWT_PRIVATE_KEY_PATH 环境变量。今后轮换密钥只需改 .env 一处。
4. .env、.env.example 与 Settings 默认值统一指向 config/jwt/dev/。
5. 新增 docs/21-JWT密钥管理与轮换.md:密钥分工(服务端只读公钥,
   JWT_PRIVATE_KEY_PATH 在 app/ 中无任何读取点,故生产机可只挂公钥)、
   克隆后必须自行生成、多人共用一个服务时必须共用同一把私钥、
   轮换的影响面与生产部署要点、安全红线。
6. 记录一处易被忽略的问题:生产环境的 JWT_ISSUER / JWT_AUDIENCE 也应与开发不同,
   否则开发环境签发的令牌在生产上依然有效——这比换密钥更容易漏。

说明:本次提交不含任何密钥文件(.env 与 config/jwt/ 均在 .gitignore 中)。
旧密钥 config/jwt/jwt-private.pem 已退役但保留未删,配置不再引用它,
用它签发的令牌会被拒绝。

验证:ruff 通过、mypy 103 文件无错、unit+contract 447 passed、integration 29 passed、
acceptance_check --production 7 PASS、demo_agent_e2e 9/9 PASS——均使用新密钥完成
签发与验签。
2026-09-10 18:14:03 +08:00

157 lines
5.6 KiB
Python
Raw Permalink 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.
"""底座接口冒烟测试:认证、越权、幂等、SSE 传输。
用法:先启动 `python -m uvicorn app.main:app --port 8099`,再运行本脚本。
"""
from __future__ import annotations
import datetime as dt
import os
import uuid
from pathlib import Path
import httpx
import jwt
BASE_URL = "http://127.0.0.1:8099"
# 本脚本专门对"已经跑起来的服务"做冒烟,因此刻意不依赖 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]] = []
def record(name: str, expected: str, actual: str) -> None:
verdict = "PASS" if expected == actual else "FAIL"
_results.append((verdict, name, f"期望 {expected} / 实际 {actual}"))
def token(sub: str) -> str:
now = dt.datetime.now(dt.UTC)
return jwt.encode(
{
"sub": sub,
"iss": "jr-local",
"aud": "jr-agent-platform",
"exp": now + dt.timedelta(minutes=30),
"nbf": now - dt.timedelta(seconds=5),
"jti": str(uuid.uuid4()),
},
PRIVATE_KEY,
algorithm="RS256",
)
def headers(sub: str) -> dict[str, str]:
return {"Authorization": f"Bearer {token(sub)}"}
def payload(
agent_type: str,
key: str | None = None,
message: str = "冒烟测试消息",
session_id: str = "smoke-session",
) -> dict:
return {
"agent_type": agent_type,
"message": message,
"session_id": session_id,
"idempotency_key": key or uuid.uuid4().hex,
}
def main() -> None:
# trust_env=False:绕开 Windows 注册表代理设置,直连本地服务
with httpx.Client(base_url=BASE_URL, timeout=10, trust_env=False) as client:
r = client.post("/api/v1/agent-runs", json=payload("customer_service"))
record("无 token 调用", "401", str(r.status_code))
r = client.post(
"/api/v1/agent-runs",
json=payload("customer_service"),
headers={"Authorization": "Bearer not-a-jwt"},
)
record("无效 token", "401", str(r.status_code))
r = client.post(
"/api/v1/agent-runs",
json=payload("customer_service"),
headers=headers("9001"),
)
record("合法用户创建 customer_service run", "202", str(r.status_code))
# 文档 §3.3/§6.2:受理响应是 {data, meta} 信封。
run_id = r.json().get("data", {}).get("run_id") if r.status_code == 202 else None
r = client.post("/api/v1/agent-runs", json=payload("risk"), headers=headers("9001"))
record("普通客户调用 risk Agent(越权)", "403", str(r.status_code))
if r.status_code == 202:
risk_run_id = r.json().get("data", {}).get("run_id")
detail = client.get(f"/api/v1/agent-runs/{risk_run_id}", headers=headers("9001"))
record(
"越权 run 是否真的落库",
"未落库",
f"已落库 agent_type={detail.json().get('data', {}).get('agent_type')}"
if detail.status_code == 200
else f"GET {detail.status_code}",
)
r = client.post(
"/api/v1/agent-runs", json=payload("no_such_agent"), headers=headers("9001")
)
record("未注册 agent_type", "400", str(r.status_code))
r = client.post(
"/api/v1/agent-runs", json=payload("customer_service"), headers=headers("not-a-number")
)
record("sub 非数字(应 4xx 而非 500)", "400", str(r.status_code))
if run_id:
r = client.get(f"/api/v1/agent-runs/{run_id}", headers=headers("9002"))
record("用户 2 读取用户 1 的 run", "404", str(r.status_code))
sse_events: list[str] = []
try:
with client.stream(
"GET",
f"/api/v1/agent-runs/{run_id}/events",
headers=headers("9001"),
timeout=8,
) as stream:
content_type = stream.headers.get("content-type", "").split(";")[0]
record("SSE Content-Type", "text/event-stream", content_type)
for line in stream.iter_lines():
if line.startswith("event:"):
sse_events.append(line.split(":", 1)[1].strip())
if len(sse_events) >= 2:
break
except httpx.TimeoutException:
pass
record("SSE 首个事件", "start", sse_events[0] if sse_events else "8 秒内无事件")
key = uuid.uuid4().hex
idem_session = f"smoke-idem-{key[:12]}"
client.post(
"/api/v1/agent-runs",
json=payload("customer_service", key, session_id=idem_session),
headers=headers("9001"),
)
r = client.post(
"/api/v1/agent-runs",
json=payload("customer_service", key, message="不同内容", session_id=idem_session),
headers=headers("9001"),
)
record("同幂等键不同请求体", "409", str(r.status_code))
width = max(len(name) for _, name, _ in _results)
print()
for verdict, name, detail in _results:
print(f"[{verdict}] {name.ljust(width)} {detail}")
failed = sum(1 for verdict, _, _ in _results if verdict == "FAIL")
print(f"\n合计 {len(_results)} 项,失败 {failed} 项")
if __name__ == "__main__":
main()