基于alova的前端双Token认证架构设计与单飞刷新实践
2026/9/7 18:24:30 网站建设 项目流程

做前端的这些年,凡是要对接后台的系统,几乎都绕不开认证这件事。我大概从两年前开始重度使用 alova 做项目的请求层,当时最头疼的不是它的请求策略怎么用,而是如何把双 Token 机制这套认证架构真正落到实处。前后踩了不少坑,也重构过两版,今天把最终沉淀下来的方案完整拆一遍。无论你是刚接手带用户体系的项目,还是想把松松垮垮的登录逻辑收拾利索,这篇文章应该都能给你一些具体参考。

1. 单一 Token 的困境:为什么必须拆成双 Token

1.1 单 token 方案的两种极端

很多团队一开始做的都是"一个 token 走天下":登录成功,服务端给一个 token,前端存进 localStorage,后续每个请求带着走。问题全出在过期时间的设置上。

如果你把过期时间设成 10 分钟,用户在一个复杂的配置表单上多停留了一会儿,提交时就直接 401。运气好,后端返回 401 的消息够清楚,前端弹出"登录已过期,请重新登录";运气差,用户填的几十个字段全丢,这种体验放到 C 端产品里基本等于劝退。可你要是把过期时间拉到 7 天甚至 30 天,安全团队又该找你谈话了——一个长时间不过期的 token,一旦被中间人截获或者塞进日志,等于把账号永久敞开给别人用。

这就是单一 token 的死结:安全和体验是两个反方向,而一个 token 只能选一边。

1.2 为什么"定时器刷新"靠不住

也有人尝试折中方案,token 有效期设短一点,然后用setInterval在过期前自动刷新。我在前司就见过这种写法,看起来聪明,实际是纸糊的。

定时器本质上依赖"页面一直在运行"这个前提。用户把笔记本合上、浏览器切到后台、电脑休眠,定时器全部停摆。等用户回来,token 已经过期了,而页面里的定时器可能根本没触发下一次刷新,或者被浏览器的节流机制严重延迟。到头来用户还是会在某个毫无预兆的瞬间被弹回登录页。

更关键的是,定时器刷新是"盲刷",你没有感知服务端态的能力。万一服务端已经把这个用户的会话踢掉了,你再怎么定时刷新也只是白费力气,还会多制造一堆无意义请求。

1.3 双 Token 机制到底在解决什么问题

双 Token 机制把"凭证"拆成了两个层级:

方案有效周期职责风险特征
Access Token短(15 分钟 ~ 2 小时)访问业务接口的通行证泄露后短时间失效,破坏半径可控
Refresh Token长(7 天 ~ 30 天)用来换取新 Access Token 的长期凭证泄露后影响大,但可被服务端撤销/轮换

它的逻辑其实很像现实里的"身份证 + 护照":身份证短小便捷,日常随身带,丢了补办一周就能解决;护照长期有效,只在特定场景掏出来,而且补办流程严格。前端正常情况下只拿 Access Token 去请求接口,一旦收到 401,才把 Refresh Token 请出来换新的 Access Token。

这套模型的意义在于:它在安全性和体验之间给了你一个动态调节的旋钮。想要更安全,把 Access Token 的周期压到 15 分钟;想要体验好,把 Refresh Token 的周期拉长到 30 天。两者不再互相掣肘。

2. alova 认证层的全局设计:拦截器与 Method 生命周期

2.1 为什么选 alova 而不是继续用 axios

先解释一下我为什么在认证层选择 alova。当时对比过 axios、react-query、SWR,最终落到 alova 上,核心原因是它把"请求策略"这个概念做得足够轻巧。

axios 很强,但它本质上是"给你一个发请求的能力",各种拦截器、配置项都是你手动拼接的。react-query 和 SWR 在缓存和状态同步上做得深,但它们的重点是服务端状态管理,我为了做认证还得再叠一层封装。alova 介于两者之间,它提供了一个不自带 UI 框架依赖的请求模型,同时又内置了useRequestuseWatcheruseFetcher这类和视图层打交道的钩子,以及全局beforeRequest/responded拦截器。

对我这种"只想把认证层收拢成一个模块、业务代码不感知"的需求来说,alova 刚好够用,又不至于把我绑架到它整个生态里。

2.2 全局拦截器和 Method 实例是两个关键挂载点

alova 里每个请求都会生成一个 Method 实例,这个实例描述了一次请求的完整配置,包括 url、params、headers、请求体。它最方便的一点是:即使在响应阶段,你依然握着同一个 Method 对象,可以就地修改它的配置然后重新send()

这个特征和我们需要的认证能力是完美匹配的:

  • beforeRequest:在请求发出之前,给 Method 实例挂上Authorization头,这一步解决的是"正常请求怎么带 token"。
  • responded.onError:在响应返回错误时,统一判断是不是 401,如果是就刷新 token,刷新成功后调用同样的 Method 重新发送,这一步解决的是"token 过期了怎么办"。

你可能会问,这不就是 axios 拦截器也能做的事情吗?对,基本逻辑一样,但 alova 的 Method.send() 重放语义更干净,配合上钩子机制,重放后上层组件不用额外感知请求被中断过一次,状态也能保持一致。这一点在后面的代码里你能直接体会到。

2.3 公开实例与认证实例:比白名单更省心的做法

很多人在一个 alova 实例里做 token 加白名单,比如判断 url 是不是/login/refresh,是就跳过加 token 逻辑。这套写法能用,但很啰嗦,而且在多个团队合作时容易漏加白名单。

我更推荐直接建两个实例:

  • openAlova:负责登录、刷新、注册、验证码这类公开接口,没有 token 逻辑。
  • authAlova:负责业务接口,统一加 token,统一处理 401。

两个实例baseURL一样,存放的代码也挨在一起,设计意图一目了然。业务代码全程只用authAlova,公开接口只在登录和刷新这两个场景出现,白名单这种东西根本不需要存在。

3. 双 Token 核心交互流程:四条链路拆解

3.1 登录链路:一次登录拿到两张凭证

用户提交账号密码,服务端校验通过后返回两个 token:accessTokenrefreshToken。前端把这两个凭证分别存储,然后跳转到首页。

这个链路里有一个新手特别容易忽略的点:refreshToken的存储位置尽量不要和accessToken放在同一个 key 下,更不要都叫token挂在同一个对象里,这样一旦未来你调整存储方案,比如把 refresh 挪到 HttpOnly Cookie,前端改造范围会很小。

3.2 正常请求链路:beforeRequest 的单一职责

用户进入一个页面,同时发出若干业务请求。beforeRequest钩子会在每个请求发出前检查本地有没有accessToken,有就带上Authorization: Bearer <token>头。服务端验证 token 有效,正常返回数据,整个链路结束。

这段逻辑本身非常简单,但它是后面所有 401 分支的地基。如果这一步做不干净,比如有些请求漏带头、有些请求带了已经过期的 token,那后面的刷新逻辑会进入反复 401 的死循环。

3.3 401 刷新链路:整个设计的核心

业务请求发出后,服务端返回 401。此时前端不能直接弹登录页,先要判断这个请求是不是"因为 access token 过期才被拒"。

如果是,前端用本地的 refresh token 去请求/auth/refresh,换一个新的 access token 回来,然后更新本地存储,再把这个失败的请求带着新 token 重新发送一遍。整个过程中,用户的视角是"没有任何异常发生"。

这里最容易出错的是并发场景。一个页面很可能同时发出 5 个请求,5 个都因为同一个过期 token 返回 401。如果每个请求都独立去刷新,那 /auth/refresh 会被调用 5 次,且很可能被服务端的刷新 token 轮换机制打回。这个问题我在第 5 部分专门展开。

3.4 刷新失败链路:确认会话真的结束了

刷新接口一旦也返回 401,说明 refresh token 本身已经过期、被撤销,或者被服务端判定为非法。这时候前端的正确动作是:清掉本地存储的两个 token,把用户引导到登录页,并且保证整个页面只跳转一次,不要弹出一堆"请重新登录"的提示。

还有一个容易被忽视的情况是:刷新失败时那些正在等待重放的业务请求怎么办。我的做法是全部让它进入错误流,由业务代码统一处理,而不是继续重放。因为这个时刻会话已经终结,继续请求也只是炮灰。

4. 代码落地:在 alova 中封装可复用的认证中间层

4.1 存储层:先定义好 token 的存取方式

我先建一个authStorage.ts,集中管理两个 token 的读写。这里使用 localStorage,后面第 6 部分我会单独说存储方案的选择。

const ACCESS_TOKEN_KEY = 'access_token'; const REFRESH_TOKEN_KEY = 'refresh_token'; export function getAccessToken(): string | null { return localStorage.getItem(ACCESS_TOKEN_KEY); } export function getRefreshToken(): string | null { return localStorage.getItem(REFRESH_TOKEN_KEY); } export function saveTokens(accessToken: string, refreshToken: string) { localStorage.setItem(ACCESS_TOKEN_KEY, accessToken); localStorage.setItem(REFRESH_TOKEN_KEY, refreshToken); } export function clearTokens() { localStorage.removeItem(ACCESS_TOKEN_KEY); localStorage.removeItem(REFRESH_TOKEN_KEY); }

这层没什么花活,但把所有 localStorage 操作收敛到同一处,后续如果要换成 sessionStorage 或者内存存储,只改这一个文件就行。

4.2 认证实例:把刷新函数和拦截器放在同一模块

为了避免循环依赖,我把刷新函数和 alova 实例放在同一个模块authClient.ts里。注意 refresh 接口走的是openAlova,不能用带认证逻辑的authAlova去刷新,否则它自己也会触发认证处理,形成死循环。

import { createAlova } from 'alova'; const BASE_URL = '/api'; // 公开实例:仅在登录、刷新这类不携带 token 的场景使用 export const openAlova = createAlova({ baseURL: BASE_URL, timeout: 15000, responded: { onSuccess: async (response) => { return response.json(); }, }, }); let refreshPromise: Promise<string> | null = null; let loginRedirected = false; function refreshAccessToken(): Promise<string> { if (!refreshPromise) { const refreshToken = getRefreshToken(); if (!refreshToken) { refreshPromise = Promise.reject(new Error('no refresh token')); } else { const refreshMethod = openAlova.post<{ accessToken: string; refreshToken?: string; }>('/auth/refresh', { refreshToken }); refreshPromise = refreshMethod.send().then((data) => { saveTokens(data.accessToken, data.refreshToken || refreshToken); return data.accessToken; }).finally(() => { refreshPromise = null; }); } } return refreshPromise; } function forceLogout() { if (loginRedirected) return; loginRedirected = true; clearTokens(); // 这里调用你的路由跳转逻辑 window.location.href = '/login'; } export const authAlova = createAlova({ baseURL: BASE_URL, timeout: 15000, beforeRequest(method) { const token = getAccessToken(); if (token) { method.config.headers = { ...method.config.headers, Authorization: `Bearer ${token}`, }; } }, responded: { onSuccess: async (response) => { // 保证非 2xx 响应也会进入 onError if (response.status >= 400) { const error = new Error(`HTTP ${response.status}`); (error as any).status = response.status; throw error; } return response.json(); }, onError: async (err, method) => { const status = (err as any)?.status ?? (err as any)?.error?.status; if (status === 401 && !(method as any)._retry) { try { const newToken = await refreshAccessToken(); (method as any)._retry = true; method.config.headers = { ...method.config.headers, Authorization: `Bearer ${newToken}`, }; return method.send(); } catch (refreshError) { forceLogout(); throw refreshError; } } throw err; }, }, });

这段代码里有几个细节值得单独解释:

  • (method as any)._retry是一个防重入标记。没有它,重放的请求如果再次 401,就会再次触发刷新,可能陷入死循环。有了这个标记,一个请求最多只被刷新重试一次。
  • 我在onSuccess里手动抛出了HTTP 400+的错误,因为不同版本的 alova 对非 2xx 响应的处理不完全一致。主动把状态码塞进 error,后面onError里判断err.status就非常稳定。
  • refreshPromise这个变量是第 5 部分的主角,前面先把代码放着,等会儿细说。

4.3 对外暴露的认证 API

调用方需要的不是直接操作authAlova实例,而是一组语义清晰的认证 API。我一般在authClient.ts里继续导出这三个函数:

export function login(username: string, password: string) { const loginMethod = openAlova.post<{ accessToken: string; refreshToken: string; }>('/auth/login', { username, password }); return loginMethod.send().then((data) => { saveTokens(data.accessToken, data.refreshToken); return data; }); } export function logout() { clearTokens(); window.location.href = '/login'; } export function getToken() { return getAccessToken(); }

登录成功后,前端拿到accessTokenrefreshToken,走存储层的saveTokens落盘。logout负责清空凭证,至于要不要调用服务端销毁 refresh token,取决于你的后端设计,但前端至少要把本地凭证清干净。

5. 最难搞的部分:并发 401 与 Token 单飞刷新

5.1 问题还原:五个请求同时 401 会发生什么

用户打开一个列表页,同一时间发出 5 个业务请求。由于 localStorage 里的 access token 已经过期,服务器的响应是 5 个 401。

如果没有并发保护,5 个onError会各自执行一次refreshAccessToken(),也就是会有 5 个/auth/refresh请求同时打向服务端。

如果服务端实现的是 refresh token 轮换机制——即每次刷新成功后旧 refresh token 立即失效——那么第一个刷新请求成功,后面 4 个会拿到"refresh token 已失效"之类的错误。更麻烦的是,某些后端还会把这种情况判定为"疑似 token 被滥用",直接撤销整个会话,给用户的体验就是无端被踢下线。

5.2 单飞模式:把多个刷新请求收拢成一个

问题出在refreshAccessToken没有共享同一个 Promise。我在第 4 部分的代码里其实已经写好了解决骨架,核心就是这个判断:

let refreshPromise: Promise<string> | null = null; function refreshAccessToken(): Promise<string> { if (!refreshPromise) { refreshPromise = doRefresh().finally(() => { refreshPromise = null; }); } return refreshPromise; }

这段代码的思路叫"单飞模式",也叫 single-flight,逻辑很朴素:第一个进来的请求创建刷新 Promise 并缓存到模块变量refreshPromise;后面 4 个请求进来,发现refreshPromise已经存在,就不再重新创建,直接await这同一个 Promise。

这样无论同时有多少个业务请求触发 401,/auth/refresh最终只会被调用一次。刷新完成后,所有等待的请求拿到同一个新 token,各自更新 header 并重放原请求。

finally里把refreshPromise置空同样重要。如果不置空,第二次 token 过期时refreshPromise还残留着上一次的引用,刷新功能就彻底废了。

5.3 刷新失败级联处理与写请求幂等

刷新失败的场景我补一个处理细节:当refreshAccessToken抛错时,所有等待它的人都会进入 catch 分支。此时forceLogout会被多次触发,所以我在authClient.ts里用loginRedirected标记做了拦截,保证只有第一次调用真正执行跳转,后续调用直接 return。

至于重放请求的安全问题,很多人担心 POST 请求在 401 后重放会重复提交。一般来讲,服务端返回 401 说明请求没有被真正执行,所以重放是安全的。但如果你们的接口链路复杂,存在"校验已通过、业务执行时返回 401"这种极端情况,建议给关键写接口增加幂等键。这块属于后端配合的范畴,前端能做的就是在请求头里透传一个客户端生成的Idempotency-Key,服务端用这个 key 去重。

6. 双 Token 方案的边界条件与实战踩坑

6.1 Refresh Token 自身过期:明确一个"最终兜底"

双 Token 机制也有最大的一个盲区,就是 refresh token 也过期了。通常 refresh token 的有效期是 7 到 30 天,一旦过期,用户必然需要重新登录。这不能算 bug,而是设计的一部分。

但要注意的是前后端对这个状态的定义要对齐。我在项目里和后端约定刷新接口遇到 refresh token 过期时返回 401,而不是返回 200 然后塞一个"刷新失败"的错误码。这样前端只需判断 status 即可,逻辑简单清晰。

6.2 多标签页的刷新问题:一个藏得比较深的坑

单飞模式只能在当前标签页内生效。用户开了两个标签页,A 标签页先刷新了 token,refresh token 被服务端轮换掉;B 标签页在 A 刷新之前发出的业务请求在这时才返回 401,B 用旧的 refresh token 去刷新,结果被拒绝。

常见的解法是让 B 标签页监听 localStorage 的storage事件,一旦发现 token 被更新,就基于新的accessToken重新发送请求。但这里的实现会显著增加复杂度,而且和"重放"逻辑容易打架。

我的建议是:如果你不需要支持多标签页同时操作,可以在beforeRequest开头做一次互斥检查,或者干脆和后端约定 refresh token 可以在一小段时间窗口内重复使用。如果你的产品必须支持多标签页,那存储层应该考虑把 access token 放在内存而不是 localStorage,结合单标签页架构来规避这个坑。

6.3 存储位置的选择:不只是 localStorage 这么简单

我用 localStorage 演示是因为它实现最简单,但它有一个现实问题:XSS 一旦发生,攻击者可以轻易读取 token。所以存储方案需要结合你的项目安全等级来选。

存储方式优点缺点适用场景
localStorage简单、跨页面共享XSS 可读,刷新后仍在内部管理系统、安全性要求不高的项目
sessionStorage简单、窗口隔离多标签页不共享,刷新仍可读单窗口应用
内存变量XSS 读取不到,最安全刷新页面丢失,需要额外恢复机制对安全要求极高的 C 端产品
HttpOnly CookieJS 完全读不到,防 XSS需要后端支持,可能引入 CSRF 风险银行类、支付类应用

如果你选了"内存存 access token + refresh token 存 HttpOnly Cookie"的组合,刷新页面的恢复流程要单独设计:页面加载时拿着 Cookie 里的 refresh token 去换一个新的 access token,加载期间所有业务请求排队等待。

6.4 403 与 401 的分工:千万别混在一起刷新

401 表示"没有有效凭证",403 表示"有凭证但权限不够"。很多前端同学在拦截器里看到error.status >= 400就一股脑走刷新逻辑,这是不对的。

403 转刷 token 没有任何意义,因为用户凭证是有效的,只是服务端判定他没有权限访问这个资源。遇到 403 应该直接抛错给业务层,提示"无权操作"。如果你在 401 分支里把 403 也包进去,会导致权限判断完全失效,甚至因为刷新 token 成功而把原始 403 请求重放一次,然后再次 403,白白增加请求。

6.5 定时器静默续期:可以作为优化,不能作为兜底

前面我说定时器续期不可靠,但在双 Token 机制搭好之后,它反而可以做一层优化。

思路是在 access token 生命周期过了 1/3 时,主动提前刷新一次,这样绝大多数用户根本不会遇到业务请求大面积 401 的情况。同时保留 401 时的单飞刷新作为兜底。两者的关系是:定时器减少 401 出现的概率,401 分支保证即使定时器失效系统也不会崩。

具体实现时,可以在登录后启动一个定时器,delay = tokenExpiresIn / 3,到点调用refreshAccessToken()。别在页面 onblur 或者特定事件里做太多功夫,定时器每次触发前检查文档可见性document.visibilityState,页面不可见时跳过本次刷新,等可见再补一次即可。

6.6 刷新失败后,排队请求的体面退场

我在第 5 部分提到所有等待重放的请求在刷新失败时都会进入 catch 分支。这时候除了跳登录页,还应该考虑给这些请求一个统一的错误信号,让页面内的 loading 状态结束、按钮恢复、toast 提示"登录状态已过期,请重新登录"。

具体实现时,forceLogout跳转前可以向外抛一个自定义事件,页面组件监听这个事件统一收敛 UI 状态。很多项目卡在这一步,导致用户被踢出时页面上还留着转圈的 loading,观感很差。这个小细节会直接影响用户对产品稳定性的判断。


我后来把这套认证层抽成了一个独立的.ts文件,塞进公司内部前端基建里,新项目直接复制过去改改接口路径就能用。过程中最值钱的体会不是代码本身,而是想清楚"刷新不刷新""哪些请求该走哪条分支""失败之后怎么办"这几道判断题。毕竟认证架构这种东西,平时不吭声,一旦出问题,全网的用户都会同时盯着你。希望这套基于 alova 的方案能帮你少走点弯路。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询