198 lines
9.2 KiB
Markdown
198 lines
9.2 KiB
Markdown
# 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`
|