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

97 lines
7.5 KiB
Markdown
Raw 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.
# 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` 已加字段)
```python
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,重点单测):
```python
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 兼容性扩展。
偏差说明:<如有写在这里,无则写"无">
```