# 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 | 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`