企业后台的登录态治理:如何处理 Token 刷新风暴与跨标签同步
登录页通常是企业后台最容易完成的一页:提交账号密码,拿到 Token,跳转工作台。
真正棘手的部分发生在登录之后。用户开着多个标签页,某一页的 Token 失效;同一时刻又有十几个请求返回 401;用户在另一页退出登录;或者权限信息还没加载完,路由已经开始跳转。若这些场景分别由业务页面处理,系统往往会出现重复刷新、重复跳转和状态不一致。
这篇文章讨论一种分层的处理方式:登录态的处理贯穿请求、导航与应用生命周期,localStorage 里的字符串只是这条链上的一环。
以下内容来自一个已脱敏的 Vue 3 教务前端实现。它使用 Axios、Pinia、Vue Router 和浏览器原生通信能力,文章关注每一层应解决什么问题,不限定于某个库。
本文是「教务前端架构实践」系列的一篇。系列以同一套已脱敏项目背景,分别讨论双端体验、导航与权限、会话治理和复杂业务交互。
登录态不是一个状态,而是一条协作链路
这个项目将认证相关职责拆成五层。
flowchart LR
A[业务 API 请求] --> B[Axios 请求拦截器<br/>注入 Bearer Token]
B --> C[响应拦截器<br/>识别 HTTP 401/403]
C --> D[TokenManager<br/>刷新锁与等待队列]
D --> E[auth.ts<br/>Token 存取、刷新、跳转]
F[路由守卫] --> E
G[App 生命周期] --> H[主动校验与跨标签同步]
I[Pinia user store] --> E
I --> H
拆开之后,业务模块只管调用领域 API;请求、导航和应用生命周期上的会话问题,各有自己的处理位置。
这些层并非彼此隔绝。它们经由 auth.ts 的 Token 存取和登录跳转串起来,但页面组件不必再知道整条认证链路的细节。
请求层先收敛:每个请求都从同一个地方带上 Token
请求拦截器会在请求发送前读取 access token,并写入 Authorization: Bearer <token>。Token 的读写被收敛在认证辅助模块中,而不是让每个接口或组件直接访问 localStorage。
这个约束不复杂,但后续调整都有地方可落:更换存储方式、修改请求头格式,或补充 Token 失效处理时,都不用回头逐页修改。
响应层也应区分两类错误:业务响应中非成功的业务码可以统一提示消息;HTTP 401/403 则进入未授权处理流程。在这个实现里,登录、移动端登录和密码找回等接口会被排除在刷新流程之外,避免“登录失败也去刷新 Token”的循环。
需要注意一个实现边界:当前刷新逻辑由 HTTP 状态码 401/403 触发,响应体中的业务码 403 并不会触发刷新。后端若混用两种错误表达,前后端需要先约定清楚,否则调用方会看到不同的行为。
并发未授权时,刷新锁比“每个请求自己刷新”更可靠
假设用户停留在一个数据密集的页面,Token 恰好过期。列表请求、统计和通知请求可能同时收到 401。
如果每个响应都立刻调用刷新接口,服务端很快会收到一阵刷新请求。同一个会话开始竞争,后续请求究竟带哪个 Token 也难以判断。
项目中的 TokenManager 用单例维护两项状态:
isFreshToken:当前是否允许发起刷新;cacheRequests:刷新进行期间等待重试的请求回调。
首个收到未授权响应的请求关闭开关并发起刷新;后续请求发现刷新已在进行,便把自身重试逻辑放入队列。刷新成功后,管理器按队列执行已缓存的请求,并重试最初触发刷新的那一个请求。
sequenceDiagram
participant R1 as 请求 1
participant R2 as 请求 2
participant TM as TokenManager
participant Auth as 刷新接口
R1->>TM: 收到 401/403
TM->>TM: 关闭刷新开关
TM->>Auth: 刷新 Token
R2->>TM: 收到 401/403
TM->>TM: 加入等待队列
Auth-->>TM: 返回新 Token
TM->>R2: 重放请求
TM->>R1: 重试原请求
把并发控制收进 TokenManager 这一个可验证的位置后,至少单个浏览器标签页内,不会有一串失效请求同时去刷新 Token。
刷新成功不等于所有风险都消失
请求队列能压住并发刷新,但失败和重放的边界得一开始就说清。
先看失败路径:当前队列只在刷新成功后被执行。刷新失败时,认证辅助函数会清除 Token 并跳转登录页;但等待队列既没有被显式 reject,也没有被清空,因此已入队的请求可能长期处于 pending 状态。首个触发刷新的请求会返回拒绝结果并进入统一登录跳转,但后续排队请求没有机会收到同一个失败结果。
队列应保存 resolve 与 reject,而不只是 resolve 回调。刷新成功后逐个重放并清空队列;刷新失败、超时或用户主动退出时,先复制并清空队列,再用同一个认证错误拒绝所有等待项。这样业务调用方能结束 loading、恢复按钮状态,并按自身需要显示“登录已失效”,而不会把请求永久挂起。核心形态如下。
1 | type QueueItem = { |
第二个边界在重放:它并不天然适用于所有操作。当前实现没有按 HTTP method 或请求配置区分重放资格,POST、PUT、DELETE 和读取请求都会走同一条队列。所以,包括创建、提交和删除在内的非幂等请求若已在服务端产生部分效果,再次发送就可能造成重复操作。
更稳妥的约定是,读取类请求默认允许重放;写请求默认拒绝自动重放,只有调用方显式标记为可重放、且同时携带服务端可识别的幂等键时才放行。例如可给 Axios 配置扩展 __authReplay: 'explicit',并在进入队列前判断:
1 | const method = config.method?.toLowerCase() |
显式标记不等于前端就能“保证幂等”。它只是要求调用方先确认接口有幂等键、去重号或等价的服务端保护;没有这些前提,就让用户重新登录后自己再次提交。
已重放的请求还要留下一次性标记,例如 config.__authRetried = true。如果响应拦截器又收到它的 401/403,直接结束自动恢复、清理会话并拒绝原请求,不再把它送回 TokenManager 开第二轮刷新。isFreshToken 只能保证同一时刻单标签内只有一个刷新动作;它认不出“这是带着新 Token 重试过的请求”,单靠它挡不住服务端持续返回 401 时的刷新循环。
还有一道边界在标签页之外:TokenManager 只在页面运行时内作为单例运行,浏览器各个标签页不会共享它。若多个标签页同时发现 Token 失效,每个标签页仍可能各自发起刷新。是否需要进一步协调,取决于服务端刷新策略、实际并发量和会话风险,不能仅凭“已有刷新锁”就宣称跨标签不会竞争。
路由守卫负责准入,而不是替代后端授权
请求带上 Token 后,用户仍可能通过收藏夹、历史记录或手输 URL 直接进入受保护页面。路由守卫承担的是导航前的准入检查。
在这个实现中,守卫按以下步骤判断页面是否可进入。
- 放行登录、证书查询等白名单路由;
- 检查本地是否存在 Token;
- 首次进入受保护页面时拉取用户资料、角色和权限;
- 用户信息拉取失败时清理状态,并重定向到与当前终端对应的登录页;
- 无 Token 时携带
redirect参数跳转登录,使用户完成登录后能回到原目标页面。
这让“Token 存在”与“当前页面可进入”成为两个不同判断。前者只是一个本地线索,后者还需要用户信息加载成功。
但前端守卫只控制页面可达性,不能作为权限安全边界。接口权限、数据范围和关键操作授权必须由后端执行;前端的角色与权限数据主要服务于导航、交互和减少无效请求。
主动校验补足“没有请求就不知道过期”的空白
仅依赖 API 返回 401 有一个盲点:用户停在一个没有持续请求的页面,服务端会话可能早已失效,前端却还显示为登录状态。
项目在应用根组件中通过 vue-request 每 30 秒调用一次 checkToken。轮询只在非白名单路由启用;接口明确返回 Token 无效,应用重置用户状态并跳转到桌面或移动端登录页。
轮询也有生命周期。当前代码在根组件的 setup 阶段注册 useRequest,用 ready 排除登录页、证书查询等白名单路由;卸载钩子只关闭跨标签通信通道,没有保留轮询控制器来显式取消任务。单页应用里的根组件通常会陪着整个会话存在,所以受保护路由上的轮询也会持续存在,不能指望页面组件销毁时顺便清理它。
这项轮询更合适的做法是把它当成可启停的应用级任务:路由进入白名单就暂停,离开后恢复;应用卸载时调用 useRequest 返回的取消方法;登录失效、主动退出或收到跨标签退出消息时,也先停掉轮询,再清理用户状态和跳转。还要防住一种容易漏掉的情况:路由已经切走,旧请求才返回,结果反过来覆盖了新页面的状态。结果处理因此需要确认当前会话或路由仍然有效。
30 秒不是通用答案,间隔要看 Token 生命周期、用户操作频率及服务端负载。当前实现把轮询请求的 onError 注释掉了,网络异常不会自动退出登录。这能避免一次短暂断网就把用户踢下线,但网络故障和明确的无效会话也就走了不同路径。实际运行时还应规定网络错误后的退避、观测和恢复方式,别让失败请求仍按固定间隔一直打出去。
跨标签退出需要通知,而不是只清空当前页面
用户在一个标签页退出登录后,其他标签页仍可能保留旧的 Pinia 状态和 UI。即使请求最终会收到未授权响应,这段延迟也足以造成困惑。
用户 Store 因此在退出流程的 finally 中先重置本地认证状态,再向一个专用于登出同步的 BroadcastChannel 发送消息。其他标签页的根组件收到消息后,跳转至对应终端的登录页。
不支持 BroadcastChannel 的环境中,代码会写入带时间戳的 localStorage 键;其他标签通过 storage 事件监听并比较时间戳,避免重复处理。这是一个渐进增强策略:优先使用更直接的标签通信能力,再保留浏览器兼容降级通道。
这改善的是一致性体验;严格实时的分布式协调不在它的覆盖范围内。浏览器能力、隐私模式和事件调度都可能带来延迟或降级,所以关键接口仍得由服务端 Token 校验兜底。
高并发下,刷新链路也需要超时、降级和监控
刷新锁能压住单标签内的并发刷新,却不能保证刷新接口始终可用。刷新请求必须设置合理的超时:若网络或认证服务长时间无响应,队列会持续堆积,页面看起来像是“卡住了”。超时后应让本次刷新明确失败,并按统一退出路径拒绝和清理等待队列,而不是让请求无限等待。
连续刷新失败也应有降级边界。例如,在短时间内多次失败后,暂时停止新的刷新尝试,直接结束当前会话并提示用户重新登录。这里的“熔断”只用于避免每一个 401 都继续向已经异常的刷新接口施压,不由前端替服务端承担可用性治理。
这条链路也得看得见。至少记录 401/403 的数量和刷新成功率、耗时,还要记录队列长度、刷新超时以及最终登出次数。指标不会替你修复问题,但能区分是 Token 集中到期、网络波动,还是服务端异常和客户端缺陷;否则刷新风暴往往只能等用户反馈后才知道。
结语:把会话问题拆开,才能分别验证
企业后台的登录态通常需要 Token 注入、并发刷新和路由准入,也要处理空闲会话校验与跨标签退出。这些问题发生在不同时间点,解决的也不是同一件事。
这套实现没有把所有逻辑塞进登录页或业务组件:请求层处理传输和失效响应,TokenManager 处理单标签并发,路由守卫处理页面准入,应用根组件处理主动校验和跨标签消息。职责落在各自的位置,出了问题也更容易判断该从哪里查。
后续可以先补上刷新失败或超时时的统一 reject 与队列清理,再补齐刷新耗时、失败率和队列长度等观测;接着为非幂等请求划清重放边界,并根据真实负载决定是否需要跨标签刷新协调和前端熔断降级。
边界不清的登录态最终会落在“出错后跳登录”。把它们讲清,登录态才有条件成为能继续维护的会话基础设施。
系列文章:大型业务前端里,路由为什么不只是导航|一个仓库,如何同时承载 PC 后台与移动端|从领域组件到考试入口:教务前端中的复杂交互抽象