Files
meida/docs/开发任务包/T4-刷新令牌与会话.md
T
xsl e511b6a66f 收尾两批并行开发的文档回写与任务包
- Sprint 执行计划 v1.1:§1.1 补第一批交付实据;T9 SPIKE 结论回写 Sprint 1 表
  (T-P0-16 通过 2.8ms;T-P0-12 有条件;T-P0-10 原型 6/6 + 真实照片重验前置);
  Sprint 2 表标注 T-P2-01~06 交付;新增 §9 两批交付记录
- 开发任务拆解 v1.5:交付回写 + 新风险登记(RTMPose 域差异、BiRefNet 异步化)
- 新增 开发任务包/:三批多 agent 并行的协作规程与 14 份任务契约
- 新增 前置开通清单-云资源与资质.md
- 清理已用毕的评审报告(文档评审 2026-08-13、项目整体评审 2026-08-14),
  README 中相应引用改为留痕表述
2026-08-14 21:07:20 +08:00

7.5 KiB
Raw Blame History

T4 · 刷新令牌与服务端可撤销会话

对应任务:T-P1-13(双令牌会话)| 需求:FR-ACC-05、架构 §6.2/§9.1 | 分支:task/t4-refresh-session | worktree:/home/xsl/meida_backend-t4 先读《README.md(协作规程)》,遵守全部纪律。

1. 目标

在现有 15 分钟 access JWT 之外,补齐 refresh token:opaque 令牌 + id_session 服务端可撤销 + 轮换(rotation)。端点:POST /auth/refresh、POST /auth/logout;dev-token 扩展为同时签发 refresh,使整条链路可测。

明确不做(写进代码注释,防止后人误判):华为账号登录(T-P1-12,等资质)、手机验证码(T-P1-12b,等短信通道)、游客/合并/注销、access token 的黑名单式即时吊销(JWT 无状态,V1 接受"access 最长 15 分钟残留";「登出后旧令牌立即失效」的验收对象是 refresh 令牌与会话,见下)。

2. 已就绪的契约(协调提交已提供)

表(迁移 0004 已建,ORM 已加,禁改 models.py/migrations)

id_session(架构 §6.2 原文列:session_id, user_id, refresh_token_hash, expire_at, revoked;协调提交落地为):

  • ORM 类名为 UserSession(刻意不用 Session,避免与 SQLAlchemy 的 AsyncSession 混淆):
  • session_id bigint PK(雪花)、user_id bigint NOT NULL、refresh_token_hash varchar(64) UNIQUE(sha256 hex)、expire_at timestamptz NOT NULL、revoked boolean NOT NULL default false、revoked_at timestamptz NULL、created_at timestamptz NOT NULL default now();
  • 索引:(user_id)。

配置(config.py 已加字段)

refresh_token_ttl_seconds: int = 2592000   # env: REFRESH_TOKEN_TTL_SECONDS,30 天

错误码(errors.py 已加)

  • REFRESH_TOKEN_INVALID = 40103:401「登录状态无效」,hint「请重新登录」;
  • REFRESH_TOKEN_EXPIRED = 40104:401「登录状态已过期」,hint「请重新登录」;
  • SESSION_REVOKED = 40105:401「该会话已登出」,hint「请重新登录」。

现有代码事实(先读再写)

  • packages/shared/security.py:create_access_token/decode_access_token(JWT HS256,claims sub/iat/exp/iss/typ)——不改此文件,refresh 不是 JWT。
  • packages/identity/service.py:IdentityService(session)、get_or_create_by_external_identity、get_user(注销/禁用会抛 UNAUTHENTICATED)、record_consent;DEV_PROVIDER = "dev"。
  • packages/identity/repo.py:get_user/find_identity/add_user/add_identity/add_consent/get_consent 模式(照抄风格补 session 函数)。
  • apps/api/routers/auth.py:dev-token 端点(DEV_AUTH_ENABLED && is_local 双开关)。
  • apps/api/deps.py:IdentityServiceDep、SettingsDep 已有(deps.py 禁改,够用)。

3. 实现要点

允许改动:packages/identity/tokens.py(新建)、packages/identity/repo.py、packages/identity/service.py、packages/identity/schemas.py(新建)、apps/api/routers/auth.py、测试文件。

  1. packages/identity/tokens.py(纯逻辑,无 IO,重点单测):
    def generate_refresh_token() -> str          # secrets.token_urlsafe(48)
    def hash_refresh_token(token: str) -> str    # hashlib.sha256(token.encode()).hexdigest()
    
    决策注释:为什么 opaque 而不是 JWT——refresh 的核心价值就是"服务端可单条撤销"(FR-ACC-05),无状态 JWT 做不到撤销;哈希落库而非明文,泄漏库不等于泄漏令牌。
  2. repo:find_session_by_hash(session, token_hash)、add_session(session, Session(...))、revoke_session(session, session_id)、revoke_all_sessions(session, user_id)(最后一个 V1 先备着,logout-all 属 FR 后续)。
  3. service(IdentityService 上追加方法):
    • async def issue_session(user_id) -> SessionIssued(dataclass:refresh_token: str、expires_at: datetime、session_id: int):先 await self.get_user(user_id)(防已注销用户续签),生成/哈希/落库/flush;
    • async def rotate(refresh_token: str) -> SessionIssued:查哈希 → 无 → REFRESH_TOKEN_INVALID;revoked → SESSION_REVOKED;expire_at < now → REFRESH_TOKEN_EXPIRED(并把该行 revoked=true 顺手落库);通过 → 旧行 revoked=true + 新行创建,同一事务(轮换 = 旧 refresh 一次性);返回新 token;
    • async def revoke(refresh_token: str) -> bool:查哈希 → 无/已撤销返回 False;否则 revoked=true。重复使用已撤销的 refresh(重放检测):V1 只报 SESSION_REVOKED 并 logger.warning("refresh_reuse_detected", user_id=...),不做家族连坐(注释写明这是 V2 硬化项)。
  4. packages/identity/schemas.py(新建;apps/api/schemas.py 禁改):
    • RefreshRequest(refresh_token: str)、LogoutRequest(refresh_token: str);
    • TokenPairResponse(access_token, refresh_token, expires_in, refresh_expires_in, user_id: str)。
  5. apps/api/routers/auth.py:
    • POST /refresh:identity.rotate(...) → create_access_token(...) → ok(TokenPairResponse(...));
    • POST /logout:user_id: CurrentUserId + identity.revoke(...) → ok({"revoked": True/False})(False 不报错——幂等登出);
    • dev-token 端点扩展:同时 issue_session,响应改用 TokenPairResponse(保留原字段名 access_token/expires_in/user_id 不变,新增 refresh_token/refresh_expires_in——不破坏 smoke.sh 与现有测试对旧字段的读取;若现有测试断言整个 dict 形状,允许增量更新断言但不得删除旧字段)。
    • 注意:登出语义 = 撤销会话(refresh 失效)。access JWT 在 TTL 内仍有效的残留窗口,写进 auth.py 模块 docstring。

4. 测试要求

  • 单测 tests/unit/test_refresh_tokens.py:token 长度/字符域、哈希稳定性、哈希不可逆(≠原文)。
  • 集成测试 tests/integration/test_auth_refresh.py(新文件,允许跑,自建 app fixture 参考 test_vertical_slice.py 的 FakeProvider 段,但 auth 用例不需要 LLM):
    1. dev-token → 拿 refresh_token;POST /v1/auth/refresh → 200,新 access 可调 GET /v1/quota;
    2. 旧 refresh 二次使用 → 40105;
    3. POST /v1/auth/logout → 再 refresh → 40105;
    4. 伪造 refresh → 40103;
    5. 过期路径:构造 REFRESH_TOKEN_TTL_SECONDS=0(monkeypatch.setenv + get_settings.cache_clear())→ refresh → 40104;结束恢复 cache。

5. 禁改清单(通用禁改之外)

packages/shared/security.py、packages/llm/**、packages/billing/**、packages/analytics/**、packages/storage/**、apps/api/routers/{agent,quota,tasks,events,uploads}.py、apps/api/deps.py、tests/integration/test_vertical_slice.py。

6. 验收标准(FR-ACC-05)

  1. access/refresh 双令牌齐备:access 15 分钟 JWT(现状)+ refresh 30 天 opaque 可撤销;
  2. refresh 服务端可单条撤销:logout 后该 refresh 立即失效;
  3. 轮换生效:每次 refresh 换新 token,旧 token 一次性;
  4. dev-token 链路向后兼容(旧响应字段不变);
  5. 全部检查绿(mopy 范围同 T2 列表)。

7. 提交信息

feat(identity): refresh token rotation with server-side revocable sessions

id_session 落地:opaque refresh + sha256 哈希存储 + 一次性轮换 +
logout 撤销 + 重放检测告警(FR-ACC-05);dev-token 兼容性扩展。

偏差说明:<如有写在这里,无则写"无">