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

9.2 KiB
Raw Blame History

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. 刷新请求约定

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