- 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 中相应引用改为留痕表述
7.5 KiB
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_idbigint PK(雪花)、user_idbigint NOT NULL、refresh_token_hashvarchar(64) UNIQUE(sha256 hex)、expire_attimestamptz NOT NULL、revokedboolean NOT NULL default false、revoked_attimestamptz NULL、created_attimestamptz 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,claimssub/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、测试文件。
packages/identity/tokens.py(纯逻辑,无 IO,重点单测):决策注释:为什么 opaque 而不是 JWT——refresh 的核心价值就是"服务端可单条撤销"(FR-ACC-05),无状态 JWT 做不到撤销;哈希落库而非明文,泄漏库不等于泄漏令牌。def generate_refresh_token() -> str # secrets.token_urlsafe(48) def hash_refresh_token(token: str) -> str # hashlib.sha256(token.encode()).hexdigest()- 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 后续)。 - 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 硬化项)。
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)。
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):- dev-token → 拿
refresh_token;POST /v1/auth/refresh→ 200,新 access 可调GET /v1/quota; - 旧 refresh 二次使用 → 40105;
POST /v1/auth/logout→ 再 refresh → 40105;- 伪造 refresh → 40103;
- 过期路径:构造
REFRESH_TOKEN_TTL_SECONDS=0(monkeypatch.setenv +get_settings.cache_clear())→ refresh → 40104;结束恢复 cache。
- dev-token → 拿
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)
- access/refresh 双令牌齐备:access 15 分钟 JWT(现状)+ refresh 30 天 opaque 可撤销;
- refresh 服务端可单条撤销:logout 后该 refresh 立即失效;
- 轮换生效:每次 refresh 换新 token,旧 token 一次性;
- dev-token 链路向后兼容(旧响应字段不变);
- 全部检查绿(mopy 范围同 T2 列表)。
7. 提交信息
feat(identity): refresh token rotation with server-side revocable sessions
id_session 落地:opaque refresh + sha256 哈希存储 + 一次性轮换 +
logout 撤销 + 重放检测告警(FR-ACC-05);dev-token 兼容性扩展。
偏差说明:<如有写在这里,无则写"无">