Files
shanxia2.0/docs/superpowers/specs/2026-05-20-web-token-auto-refresh-design.md
T
2026-05-20 14:32:07 +08:00

198 lines
9.2 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.
# Web 管理端 Token 自动续期设计文档
## 背景
用户在使用 `web-admin` 时,即使持续操作界面,仍可能突然收到「Token无效或已过期」并被踢回登录页。
### 现状(代码事实)
| 层级 | 行为 |
|------|------|
| **Access Token** | JWT,`exp` 由 `ACCESS_TOKEN_EXPIRE_MINUTES` 控制。`config.py` 默认 **30×24 分钟(30 天)**;`backend/README` / `.env.example` 推荐 **30 分钟**。 |
| **Refresh Token** | JWT,**7 天**;Redis 存 `auth:refresh:{jti}`,**一次性**:刷新成功后旧 `jti` 吊销。 |
| **Redis 会话** | 配置 `REDIS_HOST` 后启用;`auth:sessions:{sid}` TTL **7 天**;仅在调用 `refresh-token` 时 `extend_session`,**普通 API 请求不续期**。 |
| **前端** | 登录保存 `accessToken` + `refreshToken`(Pinia + localStorage);`fetchRefreshToken` 已定义但 **全项目未调用**;`utils/http/index.ts` 遇 **401 直接 `logOut()`**,无静默刷新。 |
| **已知缺陷** | `fetchRefreshToken` 使用 `params: { refreshToken }`,而后端 `refresh_token(body: dict)` 读取 **`body.refreshToken`**,当前实现可能刷新失败。 |
后端错误文案:`Token无效或已过期`(`app/core/auth.py` → `is_session_valid` 失败或 JWT 无效)。
### 目标
- 用户在 **Access Token 过期但 Refresh Token 仍有效** 时,业务请求应 **自动续期并重试**,无感知或仅极短延迟。
- Refresh Token 也失效时,再提示并跳转登录(保留现有 401 防抖)。
- **不扩大范围**:首期仅 `web-admin`;小程序端无同类封装,不在本期。
### 非目标
- 改造 OAuth2 标准全家桶、单点登录、多设备互踢策略产品化。
- 修改 API 加密算法或全局中间件行为(除 refresh 请求路径约定外)。
---
## 方案对比(2~3 种)
### 方案 A:401 被动刷新 + 请求队列(推荐,首期)
**流程:**
1. 业务请求返回 `code === 401` 或 HTTP 401(且非 refresh/login 接口)。
2. 若本地有 `refreshToken`,进入 **单飞(single-flight)** 刷新:仅一次 `POST /web/auth/refresh-token`。
3. 刷新成功:更新 store 中 `accessToken` / `refreshToken`,**重放** 失败请求队列。
4. 刷新失败或无 refreshToken:走现有 `handleUnauthorizedError` → 登出。
**优点:** 改动集中在 `web-admin/src/utils/http/`;与现有后端 rotate refresh 模型一致;YAGNI。
**缺点:** 仅在「第一次 401 之后」才续期;若 Access 仅 30 分钟,第 30 分钟后第一个请求会多一次 RTT。
**适用:** 当前仓库已具备 refresh API,缺口在前端。
### 方案 B:主动定时刷新 + 401 兜底
在方案 A 基础上,解析 Access JWT 的 `exp`,在到期前 **5 分钟**(可配置)主动调用 refresh。
**优点:** 减少用户感知到的首包 401;长时间表单填写更稳。
**缺点:** 多标签页需 `storage` 事件或 `BroadcastChannel` 同步 token,否则一 tab 刷新后另一 tab 仍用旧 refresh 导致吊销冲突;实现复杂度明显上升。
**建议:** 作为 **二期**,在 A 稳定后再加。
### 方案 C:后端「滑动会话」——每次请求延长 Redis TTL
在 `get_current_user_by_token` 成功校验后调用 `extend_session(session_id)`。
**优点:** 7 天内「有操作就不掉会话」;减轻「JWT 仍有效但 Redis 会话已过期」问题。
**缺点:** **不解决 Access JWT `exp` 到期**(若配置 30 分钟仍会 401);Redis 写放大;与「仅 refresh 续会话」的现有语义不一致。
**建议:** 可选 **后端小改**,与方案 A **组合**使用,不作为唯一手段。
---
## 推荐组合
**首期:方案 A(必须) + 修复 `fetchRefreshToken` 传参(必须) + 配置对齐说明(文档)**
**可选同期:方案 C(若产品要求「7 天内有操作永不过期」)**
**二期:方案 B(多 Tab / 长表单场景)**
---
## 详细设计(方案 A)
### 1. 模块划分
| 单元 | 职责 |
|------|------|
| `web-admin/src/utils/http/token-refresh.ts`(新建) | 单飞刷新、`refreshAccessToken()`、判断是否跳过刷新的 URL |
| `web-admin/src/utils/http/index.ts`(修改) | 响应拦截器接入刷新队列;刷新专用请求不走业务重试 |
| `web-admin/src/api/auth.ts`(修改) | `fetchRefreshToken` 改为 **JSON body** `{ refreshToken }`;可选 `showErrorMessage: false` |
| `web-admin/src/store/modules/user.ts` | 已有 `setToken`,复用即可 |
### 2. 刷新请求约定
```http
POST /api/web/auth/refresh-token
Content-Type: application/json
{ "refreshToken": "<当前 refreshToken>" }
```
- 使用 **独立 axios 实例** 或带 `_skipTokenRefresh: true` 标记,避免 refresh 失败再次触发刷新死循环。
- refresh 接口 **不携带** 已过期的 accessToken 亦可(后端只读 body);为与加密中间件一致,仍建议带当前 accessToken(若解密需要 userId)。
- 路径加入「跳过自动刷新」白名单:`/web/auth/refresh-token`、`/web/auth/login`、`/web/auth/logout`。
### 3. 响应拦截逻辑(伪代码)
```
onResponseError(error):
if not isUnauthorized(error): reject
if config._skipTokenRefresh or isAuthExcludedUrl(url): handleUnauthorizedError(); return
if !refreshToken: handleUnauthorizedError(); return
return enqueueRequest(() => singleFlightRefresh().then(() => retry(originalConfig)))
```
**单飞:** 模块级 `refreshPromise: Promise<void> | null`;并发 401 共用一个 refresh。
**业务 `code === 401`:** 与 HTTP 401 同样处理(当前拦截器已对 `resData.code === unauthorized` 调 `handleUnauthorizedError`,需改为先尝试刷新)。
### 4. 与 API 加密的交互
生产环境 `VITE_API_ENCRYPT_ENABLED=true` 时,refresh 仍走 `/web/` 加密规则:
- refresh 请求体小,可继续走现有 request 拦截器加密。
- 刷新专用调用须 **避免** 进入「刷新失败 → 再刷新」循环(`_skipTokenRefresh`)。
若 refresh 时 access 已过期导致加密 `userId` 解析失败,需约定:
- 从 **refreshToken JWT** 解析 `sub` 作为 `userId` 参与 `deriveKey`(与 `parseUserIdFromToken` 扩展一致),或
- 将 `/web/auth/refresh-token` 加入 `VITE_API_ENCRYPT_EXCLUDE_PATHS`(需安全评审)。
**推荐:** 刷新请求从 refreshToken 解 `sub` 作为 userId 后备,与登录后逻辑一致。
### 5. 多标签页(首期策略)
后端 refresh 为 **rotate**:旧 refresh 一次性失效。
首期策略(简单、可文档说明):
- 仅 **当前标签** 在 401 时刷新;其他标签仍持旧 refresh,下一次 401 会失败并登出,用户重新登录即可。
- 不在首期实现 `BroadcastChannel` 广播新 token(留给方案 B)。
### 6. 配置与运维建议(文档化,非必须改代码)
| 配置项 | 建议 |
|--------|------|
| `ACCESS_TOKEN_EXPIRE_MINUTES` | 生产建议 **120~480**(2~8 小时),与「工作日操作」匹配;开发可 30 分钟便于测刷新。 |
| `REDIS_HOST` | 启用会话校验时,明确 **7 天无 refresh 会掉线**;与 Access 过期独立。 |
| 登录返回 `expiresIn` | 当前写死 `7200`,与 JWT 不一致;可选后续改为真实秒数(非本期必须)。 |
### 7. 错误处理
| 场景 | 行为 |
|------|------|
| refresh 200,新 token | 更新 store,重试原请求,用户无感 |
| refresh 401 / 网络失败 | `logOut()` + 提示后端 `msg` 或默认「请重新登录」 |
| 重试仍 401 | 不再二次刷新,直接登出(防死循环) |
| 用户主动登出 | 不调 refresh |
### 8. 测试要点
1. 将 `ACCESS_TOKEN_EXPIRE_MINUTES=1`,登录后等待 2 分钟,点击列表 → 应自动刷新并成功加载,不跳登录。
2. 将 refresh 吊销(登出后再用旧 refresh 调接口)→ 应跳登录。
3. 并发 10 个 API 同时 401 → 仅 1 次 refresh 请求(Network 面板验证)。
4. 加密开启环境下 refresh 成功。
5. 修复后 `fetchRefreshToken` body 与后端契约一致(集成测试或手动 Swagger)。
---
## 安全说明
- Refresh Token 存 localStorage:与现状一致,不新增攻击面。
- 单飞刷新避免 refresh 风暴。
- Rotate refresh:被盗 refresh 使用后旧 token 失效,符合安全实践。
- 不在日志中打印完整 token。
---
## 风险与缓解
| 风险 | 缓解 |
|------|------|
| 刷新死循环 | `_skipTokenRefresh` + 重试次数上限 1 |
| 多 Tab refresh 竞争 | 首期接受;二期 BroadcastChannel |
| 加密 userId 缺失 | refreshToken 解 `sub` |
| 与现有 401 防抖冲突 | 刷新失败才 `showError` + `logOut`;刷新成功不弹窗 |
---
## 验收标准
1. Access Token 过期、Refresh 有效时,连续操作 **不会** 因单次 401 踢出登录。
2. Refresh 过期或会话被吊销时,**一次** 明确提示后进入登录页。
3. `fetchRefreshToken` 与后端 body 契约一致,Swagger/前端联调通过。
4. 不改变未登录、错误密码等既有登录流程。
---
## 参考文件
- `backend/app/core/auth.py` — token 创建、会话、refresh 校验
- `backend/app/api/public.py` — `web_login`、`refresh_token`
- `web-admin/src/utils/http/index.ts` — 拦截器与 401 处理
- `web-admin/src/api/auth.ts` — `fetchRefreshToken`(待修复)
- `web-admin/src/store/modules/user.ts` — `setToken` / `logOut`