先说个场景:用户还在页面上操作,接口突然开始连续报 401,没过几秒就被踢回登录页,用户刚填了一半的表单全没了。这种体验,真实项目里只要遇到一次就很难忘。只要项目存在登录态、Token 会过期、用户会在多个接口间频繁操作,双 Token 机制基本就是绕不开的方案。而 alova 作为一个轻量级请求层,把认证逻辑最需要的“请求前拦截、响应后统一处理、失败重放”这几件事设计得很顺手,这也是我最近几个项目都选择用 alova 来搭建认证架构的原因。
这篇文章不打算堆概念,我会按照“为什么拆成双 Token → 落地前要定哪些决策 → 用 alova 具体怎么写 → 上线后踩过哪些坑”这一条线,把搭建过程完整复现一遍。适合已经会发请求、但还没系统整理过认证逻辑的同学。
1. 为什么双 Token 是现代认证架构的及格线
1.1 单 Token 模式的三个死穴
只用一种 Token 的项目,通常会陷入两难:Token 有效期设短了,用户每半小时就掉线一次,体验非常差;有效期设长了,比如一个月,Token 一旦被前端日志、代理服务器、第三方脚本捞走,攻击者能在一个月内随意访问用户数据。这两个方向其实都在赌,赌用户不烦,或者赌 Token 不会泄漏。真实场景里,这两个赌注都很难成立。
关键是,单 Token 没有一个明确的“止损点”。Token 没到期时,后端即使发现异常行为,也没办法主动让这个凭证失效,只能等它自然过期。而 Token 一旦泄漏,它就是一整包炸药,中间没有任何缓冲地带。所以单 Token 表面简单,实际上把安全和体验这对矛盾全丢给了有效期这一个旋钮,怎么拧都不对。
1.2 双 Token 的核心逻辑:门禁卡和物业卡
双 Token 机制把凭证拆成了两种不同生命周期的票据:
- Access Token:短期有效,通常 15 分钟到 2 小时,负责业务接口的身份认证。
- Refresh Token:长期有效,通常 7 到 30 天,只有刷新 Token 这个单一用途。
你可以把它理解成门禁卡和物业卡的关系。门禁卡(Access Token)进出频繁,但权限有限、定期失效,掉了再去物业补办;物业卡(Refresh Token)权限高,不轻易拿出来,只在门禁卡失效时才用。双 Token 的好处就是:业务接口每次用的都是短命凭证,泄漏了影响面很小;Refresh Token 虽然命长,但它只在安全通道里使用,不随业务请求满天飞。
刷新流程是:业务请求带着 Access Token 出去,后端返回 401,前端收到 401 后,拿 Refresh Token 去刷新接口换新的 Access Token,然后把刚才失败的请求重新发一遍。对用户来说,整个过程是无感的,不掉线、不重新登录,这就是双 Token 机制最大的价值。
1.3 选 alova 而不是 axios 的理由
axios 当然也能实现这套机制,网上也有大量方案。但我在实际对比后发现,alova 的拦截器设计更适合认证这种横切关注点。它的 beforeRequest 和 responded 不是简单的回调,而是请求生命周期内置的钩子,天然支持“在请求出去前统一改配置、在响应回来后统一处理错误,并且能重新发送这个请求”。这一点对口了才能把认证逻辑收敛在一个文件里。
另一个原因是 alova 本身比较轻,支持 Vue、React、Svelte 等框架的请求策略,比如分页、防抖、缓存。它不逼你用某个 UI 框架,也不绑定全局状态管理器。对于只需要“请求层 + 认证逻辑”的项目,alova 不会被一堆重抽象占满体积,代码可读性也更好。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 动手前先定四件事,否则后面全是坑
2.1 Access Token 存放位置:localStorage、内存还是 Cookie
很多教程直接告诉你把 Token 放 localStorage,简单省事,但也把安全风险留给了后续的 XSS 漏洞。只要页面里任何一处脚本能执行,localStorage 里的 Access Token 和 Refresh Token 都会被读走。Refresh Token 一旦泄漏,等于是把长期门禁卡交给了别人。
我自己的实践是分情况考虑:
| 存储方案 | 优点 | 风险 | 适用场景 |
|---|---|---|---|
| localStorage | 刷新页面不丢,跨标签页可用,实现简单 | XSS 可读走,Token 长期暴露在 JS 环境 | 内部系统、对安全要求不极高的业务 |
| 内存变量 | XSS 无法直接读,刷新即失效,风险窗口小 | 页面刷新就丢,需要配合 refresh token 重新换取 | 对安全敏感、交互密集的 SPA |
| Cookie(HttpOnly) | JS 读不到,XSS 拿不走,CSRF 通过 SameSite 缓解 | 需要处理跨域、CSRF、Cookie 生命周期 | 服务端渲染、BFF 架构、强安全场景 |
如果你和我一样主要做纯前端项目,一个折中且常用的方案是:Access Token 放内存,Refresh Token 放 HttpOnly Cookie。刷新页面后,Access Token 没了,但 Refresh Token 还在,静默刷新一次就能恢复登录态。这样即使页面被注入脚本,攻击者也拿不到可用的业务凭证。当然,这要求后端配合设置 Cookie,前后端要统一约定。如果暂时没法改造后端,最务实的方案是 localStorage,但你要清楚它的边界,并且加密传输、定期轮换 Refresh Token。
2.2 接口豁免清单:这些请求不该走认证拦截
双 Token 机制里最容易忽略的是“豁免清单”。不是所有接口都需要认证,也不是所有 401 都要触发刷新。登录接口、刷新接口、退出登录、注册、密码找回、图片验证码、第三方登录回调、埋点上报这些接口,通常都不带 Access Token,也不需要参与 401 刷新逻辑。
我的做法是在项目里维护一份明确的白名单:
js复制export const AUTH_EXEMPT_PATHS = [
'/auth/login',
'/auth/refresh',
'/auth/logout',
'/auth/register',
'/auth/captcha',
'/sentry/event'
];
在 beforeRequest 里判断当前请求的 URL 是否命中白名单,命中就直接跳过 Token 注入;在 responded 里同样判断,命中白名单的请求即使返回 401 也不触发刷新。这里有个细节:刷新接口本身必须用单独的请求实例,或者至少要跳过全局的 401 处理,否则就会出现“刷新接口返回 401 → 触发刷新 → 刷新又失败 → 再触发刷新”的死循环。
2.3 刷新失败后,全局跳转的时机怎么定
刷新失败不一定立刻跳登录页。比如 Refresh Token 只是网络抖动导致失败,立即踢人就很粗暴。我会先给刷新接口设定一个较短的超时和重试机制,比如超时 8 秒,连续失败 2 次才判定为“登录失效”。如果后端明确返回 401 或错误码为 TOKEN_REFRESH_INVALID,那就是 Refresh Token 彻底失效,这时候才清理本地登录态、跳转登录页,并带上一个 redirect 参数,让用户登录后能回到原页面。
这个“先重试、后放弃”的策略很重要。很多项目只做了单次刷新,网络一旦抖动就大量误踢用户,尤其是弱网环境,用户会觉得这个站特别容易掉线。
2.4 安全边界:Refresh Token 轮换和重放检测
双 Token 不是双保险,它的安全性建立在 Refresh Token 不泄漏、不重用的前提下。刷新成功后,后端应当返回一个新的 Refresh Token,并且让旧的 Refresh Token 立即失效,这叫 Refresh Token 轮换(Rotation)。如果攻击者拿到了一个旧 Refresh Token,就算偷到也无处可用。后端还可以检测到“同一个 Refresh Token 被多次使用”时主动撤销该用户的所有会话,这是比较实用的重放防护策略。
前端能做的配合是:刷新成功后将新的 Refresh Token 更新到存储区域并清掉旧值;本地发现 Refresh Token 被篡改或解析异常时,主动调用退出登录接口重置状态。这些细节看似简单,但能挡住不少实际攻击场景。
3. 基于 alova 的完整搭建过程
3.1 初始化 alova 实例与全局钩子
我用 alova 2.x 的 API 为例,3.x 核心思想一致,个别方法名以官方文档为准。先创建一个统一的请求实例,把认证逻辑收敛到这个实例的钩子里。
js复制import { createAlova } from 'alova';
import GlobalFetch from 'alova/GlobalFetch';
export const authState = {
accessToken: '',
refreshToken: ''
};
export const request = createAlova({
baseURL: '/api',
timeout: 10000,
requestAdapter: GlobalFetch(),
beforeRequest(method) {
const url = method.url;
const isExempt = AUTH_EXEMPT_PATHS.some((path) => url.includes(path));
if (isExempt) return;
if (authState.accessToken) {
method.config.headers = {
...method.config.headers,
Authorization: `Bearer ${authState.accessToken}`
};
}
},
responded: {
onSuccess: async (response) => {
if (!response.ok) {
const error = await response.json().catch(() => ({}));
throw new Error(error.message || `HTTP ${response.status}`);
}
return response.json();
},
onError: async (err, method) => {
const status = err?.response?.status ?? err?.status;
if (status === 401 && !isExemptUrl(method.url)) {
return handleRefreshAndRetry(method);
}
throw err;
}
}
});
注意,这里的 onSuccess 里我判断了 response.ok。alova 的 responded.onSuccess 拿到的 response 是 GlobalFetch 返回的 Response 对象,不代表业务成功。如果后端返回 { code: 500, message: 'x' } 但 HTTP 状态是 200,那属于业务错误,应该在业务层统一处理,不要把 HTTP 200 当成无条件成功。养成这个习惯,很多排查成本都能省下来。
3.2 登录接口与 Token 写入逻辑
登录接口本身走豁免路径,不需要认证信息。登录成功后,把后端返回的 Access Token 和 Refresh Token 落到存储区,同时写入内存状态。
js复制export async function login(username, password) {
const res = await request.post('/auth/login', { username, password });
const { accessToken, refreshToken, expiresIn } = res;
authState.accessToken = accessToken;
authState.refreshToken = refreshToken;
localStorage.setItem(
'auth.tokens',
JSON.stringify({ accessToken, refreshToken, updatedAt: Date.now() })
);
// 如果有需要,这里可以启动一个定时器,提前 30 秒刷新 Token
schedulePreemptiveRefresh(expiresIn);
return res;
}
登录返回的 expiresIn 不要浪费。很多项目只做“请求 401 后再刷新”,这是被动刷新;我更倾向于在 Access Token 快过期前主动刷新一次,避免用户操作最频繁的时间点突然遇到一次 401。这个主动刷新策略由前端 Timer 掌控,在过期前 30 秒触发刷新接口。如果用户长时间挂机,Timer 可能会被浏览器节流,那也不要紧,被动刷新仍然兜底。
3.3 请求拦截:自动携带 Access Token
在 beforeRequest 钩子里统一加 Authorization 头,这是最简单的一步。但有个细节容易被忽略:如果使用 method.config.headers 时直接赋值而不是先展开旧配置,可能会覆盖掉你在业务代码里单独设置的 headers。正确做法是展开旧配置再追加,而不是整体赋值。
js复制method.config.headers = {
...method.config.headers,
Authorization: `Bearer ${authState.accessToken}`
};
另外,如果刷新 Token 请求本身也通过这个实例发送,它也会被加上 Authorization 头。这是一个隐蔽的坑:有些后端刷新接口不接受旧 Access Token,甚至只接受 Refresh Token,此时加了 Authorization 头反而会校验失败。所以我建议刷新接口使用独立的请求实例,这样天然绕过全局拦截器。
3.4 响应拦截:401 自动刷新与请求重放
这是整套机制的核心。当业务请求返回 401 时,前端需要判断这个请求是不是已经重试过,如果没有就触发刷新,然后重新发送原请求。这里我写一个独立的重放逻辑:
js复制let refreshPromise = null;
export function refreshTokens(refreshToken) {
// 刷新接口使用独立实例,不走全局认证拦截
if (!refreshPromise) {
refreshPromise = refreshApi
.post('/auth/refresh', { refreshToken }, { skipAuthCheck: true })
.then((res) => {
const nextAccessToken = res.accessToken;
const nextRefreshToken = res.refreshToken || refreshToken;
authState.accessToken = nextAccessToken;
authState.refreshToken = nextRefreshToken;
localStorage.setItem(
'auth.tokens',
JSON.stringify({
accessToken: nextAccessToken,
refreshToken: nextRefreshToken,
updatedAt: Date.now()
})
);
return nextAccessToken;
})
.finally(() => {
refreshPromise = null;
});
}
return refreshPromise;
}
async function handleRefreshAndRetry(method, error) {
const retried = method.config._retryCount || 0;
if (retried >= 1) {
logoutAndRedirect();
throw error;
}
method.config._retryCount = retried + 1;
if (!authState.refreshToken) {
logoutAndRedirect();
throw error;
}
try {
await refreshTokens(authState.refreshToken);
} catch (refreshError) {
logoutAndRedirect();
throw refreshError;
}
method.config.headers = {
...method.config.headers,
Authorization: `Bearer ${authState.accessToken}`
};
return method.send();
}
关键点在于 method.send() 会重新执行这个请求,并且走一遍 alova 内部的请求生命周期。由于这个时候我们已经把 _retryCount 置成 1,如果重放后仍然 401,说明 Access Token 刷新了也没用,极有可能是 Refresh Token 也失效了,此时不能再无限重试,直接清理状态并跳登录页。
3.5 并发刷新控制:单例刷新与请求排队
如果页面上有 5 个接口同时返回 401,而你每个都去发起刷新,积分接口会被打爆。正确做法是把刷新动作收敛成一个单例:无论多少个请求触发了 401,都共用同一个 refreshPromise。这也是上面代码中 refreshPromise 变量的意义:第一个请求创建刷新 Promise,后续请求直接复用,刷新完成后一起重放。
但光有单例还不够。当多个请求都等着刷新完成后重放时,如果每个 request 都在 .then 里执行自己的 method.send(),它们会是并发的,这没有问题。需要注意的是,不要让一个请求的重放失败去影响另一个请求。我的策略是每个重放请求独立 Promise,失败只影响那个请求,如果失败是由刷新失败导致的,再统一跳登录。
我在真实项目里还见过另一个并发问题:一个页面同时发 10 个请求,9 个正常,1 个 401,但这个 401 触发刷新时,另外 9 个正常请求已经返回并读取了旧 Token。这会导致状态不一致。比如用户信息已经返回,但 accessToken 已经刷新,之后用旧 Token 发下一次请求又 401。解决思路是:在刷新过程中,将其他请求也挂起,或者在下一次请求时再加一个“当前 Access Token 版本号”的判断。简单做法是给 Token 加一个 updateAt 时间戳,业务层在拿到响应后可以感知到 Token 已被刷新,从而决定是否要用最新 Token 重新请求。这个场景出现频率不算高,但一旦出现就是排查 2 小时的级别。
3.6 主动登出与全局状态清理
登出不能只清本地 token,还应该调用后端退出接口,让服务端把这个 Refresh Token 加入黑名单。如果后端实现了轮换机制,退出接口会同时撤销整个会话链,这样即使攻击者偷到了旧 Refresh Token,也不能在退出后继续使用。
js复制export async function logout() {
try {
await request.post('/auth/logout', {}, { skipAuthCheck: true });
} finally {
clearAuthState();
}
}
function clearAuthState() {
authState.accessToken = '';
authState.refreshToken = '';
localStorage.removeItem('auth.tokens');
window.location.href = `/login?redirect=${encodeURIComponent(window.location.pathname)}`;
}
这里有个小细节:登出接口即使失败也应该继续清本地状态,不然用户会被卡在“本地还有 token,但后端已失效”的尴尬状态。
4. 常见问题与排查技巧实录
4.1 无限重试循环
最常见的问题是“401 → 刷新 → 重放 → 又 401 → 再刷新”陷入死循环。原因通常是两类:一是刷新接口没有豁免,刷新请求自己也带上了无效 Authorization 头,导致刷新接口永远失败;二是重放后仍然使用旧 Token,因为刷新请求是异步的,重放前没有等待刷新完成就直接发请求。我自己的排查路径是:先在 Network 面板看刷新接口是否只发了一次,如果刷新接口发了很多次,基本可以断定是豁免没做好;如果刷新接口只发一次但业务请求无限循环,那就要检查 _retryCount 标记有没有存住。
4.2 多接口同时报 401 时刷了 N 次
这是并发控制缺失的典型症状。打开 Network 面板,看到 /auth/refresh 同时出现了好几条,就是没有单例化。修复方式就是上面写的 refreshPromise 单例,另外还要注意不要在一个 Promise 完成后立刻把 refreshPromise 置空,否则下一个请求会重新触发刷新。正确做法是在 Promise 的 finally 里置空。
4.3 localStorage 里 Token 过期了但内存里还是旧值
页面刷新后,内存里的 authState 被重置为空,但 localStorage 里还留着上次的 Token。此时如果不做初始化,就会出现“请求没有带 Token → 401 → 刷新 → 刷新用的 refreshToken 是空 → 跳登录”的问题。解决方式是应用启动时先从 localStorage 恢复状态:
js复制const cached = localStorage.getItem('auth.tokens');
if (cached) {
const tokens = JSON.parse(cached);
authState.accessToken = tokens.accessToken;
authState.refreshToken = tokens.refreshToken;
}
4.4 多标签页 Token 不同步
用户打开两个标签页,A 标签刷新了 Token,B 标签的 authState 还是旧的。之后 B 标签发请求就会用旧 Token,导致反复 401。我用的方案是监听 storage 事件,当 auth.tokens 变化时同步内存状态:
js复制window.addEventListener('storage', (event) => {
if (event.key === 'auth.tokens' && event.newValue) {
const tokens = JSON.parse(event.newValue);
authState.accessToken = tokens.accessToken;
authState.refreshToken = tokens.refreshToken;
}
});
如果项目用 BroadcastChannel,也可以实时广播 Token 变化事件。总之不要让两个标签页各自持有独立的 Token 状态,这会造成很多玄学 bug。
4.5 刷新接口把全局错误提示弹出来了
刷新失败时,如果使用全局错误提示插件,用户会看到错误的报错弹窗,然后再被踢下线。这个体验太糟糕了。我的实践是:刷新接口的失败不弹业务错误提示,只做静默处理;只有确认 Refresh Token 失效、需要强制登录时,才弹一个“登录已过期,请重新登录”的提示。为此我通常把刷新接口的错误单独定义成 RefreshTokenError,在全局错误处理里对它做特殊分支。
4.6 常见问题速查表
| 现象 | 原因 | 处理方法 |
|---|---|---|
| 401 后无限循环刷新 | 刷新接口未豁免 / 重放标记未生效 | 独立刷新实例,设置 _retryCount |
| 刷新接口同时请求多次 | 并发请求各自触发刷新 | 使用单例 refreshPromise |
| 页面刷新后立即跳登录 | 内存状态未从 storage 恢复 | 启动时初始化 authState |
| 一个标签页刷新另一个失效 | 多标签页状态未同步 | 监听 storage 事件 |
| 刷新失败后重复弹错误 | 全局错误提示未区分刷新接口 | 对刷新错误静默处理 |
| 重放请求仍携带旧 Token | 重放前未等待刷新完成 | 先 await 刷新 Promise 再 send |
| 401 重放后业务数据重复提交 | 用户同时点了多次提交 | 业务层加 loading 防抖 |
5. 后续扩展建议
双 Token 机制搭完之后,如果时间允许,可以继续做三件优化。一是为 Access Token 增加主动过期前的静默刷新调度,避免业务高峰期被动刷新;二是将 Refresh Token 放入 HttpOnly Cookie,并启用 Refresh Token 轮换,增强防盗能力;三是在认证失败时增加用户操作日志上报,便于定位是哪个接口触发了刷新、刷新失败的原因是什么,以及重放是否成功。这些扩展都属于“在双 Token 骨架上长肉”,不会改变核心流程。
我个人的体会是:双 Token 机制本身并不复杂,复杂度全部来自并发、重试、同步这些边界情况。把认证逻辑收敛在请求层的全局钩子里,比散落在每个业务页面里好维护得多。alova 在这件事上给了一个很干净的入口,但真正决定这套架构稳不稳定的,还是你对刷新并发、存储安全、异常降级这些细节的处理。把这些想清楚,无论之后换哪个请求库,你都能快速复现一套可靠的认证链路。
