尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

基于alova的前端双Token认证架构设计与单飞刷新实践

基于alova的前端双Token认证架构设计与单飞刷新实践 做前端的这些年凡是要对接后台的系统几乎都绕不开认证这件事。我大概从两年前开始重度使用 alova 做项目的请求层当时最头疼的不是它的请求策略怎么用而是如何把双 Token 机制这套认证架构真正落到实处。前后踩了不少坑也重构过两版今天把最终沉淀下来的方案完整拆一遍。无论你是刚接手带用户体系的项目还是想把松松垮垮的登录逻辑收拾利索这篇文章应该都能给你一些具体参考。1. 单一 Token 的困境为什么必须拆成双 Token1.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 框架依赖的请求模型同时又内置了useRequest、useWatcher、useFetcher这类和视图层打交道的钩子以及全局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 登录链路一次登录拿到两张凭证用户提交账号密码服务端校验通过后返回两个 tokenaccessToken和refreshToken。前端把这两个凭证分别存储然后跳转到首页。这个链路里有一个新手特别容易忽略的点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: Promisestring | null null; let loginRedirected false; function refreshAccessToken(): Promisestring { 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(); }登录成功后前端拿到accessToken和refreshToken走存储层的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: Promisestring | null null; function refreshAccessToken(): Promisestring { 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 标签页先刷新了 tokenrefresh token 被服务端轮换掉B 标签页在 A 刷新之前发出的业务请求在这时才返回 401B 用旧的 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 的方案能帮你少走点弯路。
返回列表