9.2 KiB
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 被动刷新 + 请求队列(推荐,首期)
流程:
- 业务请求返回
code === 401或 HTTP 401(且非 refresh/login 接口)。 - 若本地有
refreshToken,进入 单飞(single-flight) 刷新:仅一次POST /web/auth/refresh-token。 - 刷新成功:更新 store 中
accessToken/refreshToken,重放 失败请求队列。 - 刷新失败或无 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. 测试要点
- 将
ACCESS_TOKEN_EXPIRE_MINUTES=1,登录后等待 2 分钟,点击列表 → 应自动刷新并成功加载,不跳登录。 - 将 refresh 吊销(登出后再用旧 refresh 调接口)→ 应跳登录。
- 并发 10 个 API 同时 401 → 仅 1 次 refresh 请求(Network 面板验证)。
- 加密开启环境下 refresh 成功。
- 修复后
fetchRefreshTokenbody 与后端契约一致(集成测试或手动 Swagger)。
安全说明
- Refresh Token 存 localStorage:与现状一致,不新增攻击面。
- 单飞刷新避免 refresh 风暴。
- Rotate refresh:被盗 refresh 使用后旧 token 失效,符合安全实践。
- 不在日志中打印完整 token。
风险与缓解
| 风险 | 缓解 |
|---|---|
| 刷新死循环 | _skipTokenRefresh + 重试次数上限 1 |
| 多 Tab refresh 竞争 | 首期接受;二期 BroadcastChannel |
| 加密 userId 缺失 | refreshToken 解 sub |
| 与现有 401 防抖冲突 | 刷新失败才 showError + logOut;刷新成功不弹窗 |
验收标准
- Access Token 过期、Refresh 有效时,连续操作 不会 因单次 401 踢出登录。
- Refresh 过期或会话被吊销时,一次 明确提示后进入登录页。
fetchRefreshToken与后端 body 契约一致,Swagger/前端联调通过。- 不改变未登录、错误密码等既有登录流程。
参考文件
backend/app/core/auth.py— token 创建、会话、refresh 校验backend/app/api/public.py—web_login、refresh_tokenweb-admin/src/utils/http/index.ts— 拦截器与 401 处理web-admin/src/api/auth.ts—fetchRefreshToken(待修复)web-admin/src/store/modules/user.ts—setToken/logOut