用户在使用 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 无效)。
web-admin;小程序端无同类封装,不在本期。流程:
code === 401 或 HTTP 401(且非 refresh/login 接口)。refreshToken,进入 单飞(single-flight) 刷新:仅一次 POST /web/auth/refresh-token。accessToken / refreshToken,重放 失败请求队列。handleUnauthorizedError → 登出。优点: 改动集中在 web-admin/src/utils/http/;与现有后端 rotate refresh 模型一致;YAGNI。
缺点: 仅在「第一次 401 之后」才续期;若 Access 仅 30 分钟,第 30 分钟后第一个请求会多一次 RTT。
适用: 当前仓库已具备 refresh API,缺口在前端。
在方案 A 基础上,解析 Access JWT 的 exp,在到期前 5 分钟(可配置)主动调用 refresh。
优点: 减少用户感知到的首包 401;长时间表单填写更稳。
缺点: 多标签页需 storage 事件或 BroadcastChannel 同步 token,否则一 tab 刷新后另一 tab 仍用旧 refresh 导致吊销冲突;实现复杂度明显上升。
建议: 作为 二期,在 A 稳定后再加。
在 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 / 长表单场景)
| 单元 | 职责 |
|---|---|
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,复用即可 |
POST /api/web/auth/refresh-token
Content-Type: application/json
{ "refreshToken": "<当前 refreshToken>" }
_skipTokenRefresh: true 标记,避免 refresh 失败再次触发刷新死循环。/web/auth/refresh-token、/web/auth/login、/web/auth/logout。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,需改为先尝试刷新)。
生产环境 VITE_API_ENCRYPT_ENABLED=true 时,refresh 仍走 /web/ 加密规则:
_skipTokenRefresh)。若 refresh 时 access 已过期导致加密 userId 解析失败,需约定:
sub 作为 userId 参与 deriveKey(与 parseUserIdFromToken 扩展一致),或/web/auth/refresh-token 加入 VITE_API_ENCRYPT_EXCLUDE_PATHS(需安全评审)。推荐: 刷新请求从 refreshToken 解 sub 作为 userId 后备,与登录后逻辑一致。
后端 refresh 为 rotate:旧 refresh 一次性失效。
首期策略(简单、可文档说明):
BroadcastChannel 广播新 token(留给方案 B)。| 配置项 | 建议 |
|---|---|
ACCESS_TOKEN_EXPIRE_MINUTES |
生产建议 120~480(2~8 小时),与「工作日操作」匹配;开发可 30 分钟便于测刷新。 |
REDIS_HOST |
启用会话校验时,明确 7 天无 refresh 会掉线;与 Access 过期独立。 |
登录返回 expiresIn |
当前写死 7200,与 JWT 不一致;可选后续改为真实秒数(非本期必须)。 |
| 场景 | 行为 |
|---|---|
| refresh 200,新 token | 更新 store,重试原请求,用户无感 |
| refresh 401 / 网络失败 | logOut() + 提示后端 msg 或默认「请重新登录」 |
| 重试仍 401 | 不再二次刷新,直接登出(防死循环) |
| 用户主动登出 | 不调 refresh |
ACCESS_TOKEN_EXPIRE_MINUTES=1,登录后等待 2 分钟,点击列表 → 应自动刷新并成功加载,不跳登录。fetchRefreshToken body 与后端契约一致(集成测试或手动 Swagger)。| 风险 | 缓解 |
|---|---|
| 刷新死循环 | _skipTokenRefresh + 重试次数上限 1 |
| 多 Tab refresh 竞争 | 首期接受;二期 BroadcastChannel |
| 加密 userId 缺失 | refreshToken 解 sub |
| 与现有 401 防抖冲突 | 刷新失败才 showError + logOut;刷新成功不弹窗 |
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