- 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 中相应引用改为留痕表述
97 lines
7.5 KiB
Markdown
97 lines
7.5 KiB
Markdown
# 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 兼容性扩展。
|
||
|
||
偏差说明:<如有写在这里,无则写"无">
|
||
```
|