首页 / 资讯中心 / 文章详情

前端Token无感刷新:基于axios拦截器与双token模型解决登录过期问题

前端Token无感刷新:基于axios拦截器与双token模型解决登录过期问题 ★ FEATURED ARTICLE
先问个问题你自己负责的前端项目有没有被用户吐槽过“用着用着就被踢下线”我最近重构了一个后台管理系统的登录态逻辑起因就是接到了这样的反馈用户编辑一条很长的资料离开座位回了个消息回来点保存页面直接跳回登录页刚填的内容全没了。产品经理找到我说这体验再不改用户真的要骂人了。后来我把这套逻辑改成了token 无感刷新上线两周多相关的“登录过期”反馈基本归零。这篇文章就把完整的思路、代码和踩坑过程整理出来给同样被这个问题困扰的前端朋友参考。用到的技术栈是 axios Vue/React 皆可核心是拦截器、双 token 和请求队列这三件事。看完之后即使后端只提供了最简单的登录/刷新接口你也能在用户完全无感知的情况下把过期 token 换掉让会话在后台悄悄续命。1. token过期不等于只能把用户踢下线1.1 token为什么要过期安全本来就是一道防线先回到最基础的问题token 为什么要有有效期后端给 token 设置过期时间本质上是控制风险窗口。如果 token 永久有效一旦被窃取或泄露攻击者就能一直用这个凭证访问你的接口。加了有效期之后就算 token 被偷也最多只能用到过期那一刻损失可控。常见的 access_token 有效期从 15 分钟到 2 小时不等。具体多长取决于你的业务对安全性和体验的平衡度。有效期越短攻击窗口越小但用户被踢下线的概率越高有效期越长体验越丝滑但安全隐患也越大。这里有个关键认知token 过期只是“凭证失效”不是“会话结束”。用户登录一次后端完全可以在很长时间内记住这个会话只是需要前端在凭证失效后自动换一张新的。如果你把“过期”直接等同于“重新登录”那就等于把一次本可自动完成的续期动作变成了让用户手动重复劳动力。1.2 强制重登的真实代价内容丢失和信任崩塌我在项目里统计过用户因为登录过期被踢出最典型的三个场景在后台管理系统里编辑一长段产品详情中间离开了一会儿回来点保存被弹回登录页内容全丢。打开某个数据报表页放着不动切回来刷新数据时所有接口集体 401页面瞬间变成一堆报错。多标签页场景下一个页面还在正常使用另一个闲置页面随便点个按钮也被跳到登录页。这些场景的共同点是用户本身是正常登录状态只是凭证断了。强制重登除了让用户烦躁还会带来一个很隐蔽的后果——用户会下意识觉得“这系统不稳定”。尤其是 B 端产品一旦用户对稳定性失去信任往后很多功能做得再好也会被打折扣。从开发侧看强制重登也不省事。你得处理路由守卫、记住当前页、登录后回跳、用户表单草稿恢复……光是把“跳走再跳回来不丢状态”这件事做好就已经要写不少代码了。与其花精力处理这些善后问题不如直接把过期这一步变成无感续期。1.3 无感刷新到底“无感”在哪里无感刷新做的事情概括起来就一句话access_token 过期后前端自动用 refresh_token 换一个新的 access_token然后把刚才失败的请求重放一遍。整个过程用户无感知没有跳转、没有弹窗、没有“请重新登录”的提示。用户看到的页面正常加载数据正常返回仿佛 token 从来没过期过。这套机制适合几乎所有需要登录态的 Web 应用尤其适合后台管理系统、SaaS 平台、运营中台这类用户会长时间停留在页面、操作链条又很长的产品。它不依赖后端大改只需要后端提供一个刷新接口配合几个字段约定前端就能把体验兜住。2. 双token模型与刷新时机先想清楚再动手2.1 双token模型为什么 access_token 要“短命”无感刷新的基础是双 token 模型。access_token业务请求里携带的凭证有效期短比如 15 到 30 分钟。前端每次请求都把它放在 Authorization header 里。refresh_token专门用来换新 access_token 的凭证有效期长比如 7 天、15 天甚至 30 天。它不参与业务请求只在刷新接口里出现。核心逻辑是用一个短期凭证保证业务安全性用一个长期凭证保证用户不必频繁重新登录。短期凭证泄露了风险窗口小长期凭证使用范围窄只在特定接口出现泄露面也小。这个设计可以类比成“身份证 护照”的关系。身份证access_token日常用的多有效期有限护照refresh_token不常用但能在身份证过期后帮你办一张新的。护照本身也要妥善保管因为你不会天天拿出来刷。实际项目中很多后端第一版只提供了单个 token没有 refresh_token。如果遇到这种情况可以让后端加一个刷新接口或者用现有接口的 response header 里返回的新 token 做续期。但最标准、最省心的还是双 token 模型。2.2 刷新时机的三种方案对比token 会过期但“什么时候发刷新请求”是个需要决策的问题。我见过三种主流做法方案触发时机优点缺点适用场景定时刷新根据 token 的 expires_in 提前 N 秒主动刷新请求基本不会遇到 401体验最顺滑实现复杂依赖后端返回准确的有效期且请求发出瞬间仍可能过期对零 401 有执念、请求频率极低的场景失败后刷新收到 401 后再刷新 token、重放原请求实现简单逻辑集中在拦截器绝大多数项目都能用多一次网络往返并发请求时需要处理竞态最通用的方案混合策略定时校验为主401 兜底刷新为辅把 401 出现概率降到最低兼顾两者代码复杂度最高两套逻辑都要维护大型 B 端系统、金融类高要求场景2.3 我的选择默认走“失败后刷新”如果项目没有特别严苛的“零 401”要求我推荐先实现“失败后刷新”。理由有三个第一逻辑集中。所有刷新动作都收敛在响应拦截器里不会散落在业务代码各处。第二不依赖额外字段。定时刷新要依赖后端返回 expires_in而且后端时钟和前端时钟未必完全一致提前量不好把握。失败后刷新只要拿到 401 就能触发和后端约定最小。第三容错性强。就算定时刷新没赶上401 兜底仍然会生效不会因为“算错时间”就翻车。这就像手机设了低电量提醒但就算你忘了充电手机自动关机后再插上充电器仍然能开机。低电量提醒是体验优化自动关机后的重启是兜底方案。两者不冲突但兜底方案一定要先有。3. 拦截器加请求队列一套能直接落地的无感刷新实现3.1 整体思路把逻辑收敛到 axios 封装层确定方案后实现的核心位置就是 axios 实例的拦截器。我要达成的目标有三个请求发出前统一在 header 里带上 access_token。响应回来是 401 时自动用 refresh_token 换新 access_token。刷新期间如果有其他请求也失效不能各自刷新要排队等待刷新完成后统一重放。先准备一个 token 工具模块负责读写 access_token 和 refresh_token。为了示例简单我用 localStorage 存储实际项目如何更安全放在第 5 节讲。// auth.js const ACCESS_TOKEN_KEY access_token const REFRESH_TOKEN_KEY refresh_token export function getAccessToken() { return localStorage.getItem(ACCESS_TOKEN_KEY) } export function getRefreshToken() { return localStorage.getItem(REFRESH_TOKEN_KEY) } export function setToken({ access_token, refresh_token }) { localStorage.setItem(ACCESS_TOKEN_KEY, access_token) if (refresh_token) { localStorage.setItem(REFRESH_TOKEN_KEY, refresh_token) } } export function clearToken() { localStorage.removeItem(ACCESS_TOKEN_KEY) localStorage.removeItem(REFRESH_TOKEN_KEY) }3.2 请求拦截器让每个请求都带上身份请求拦截器的逻辑很简单如果本地有 access_token就把它加到 Authorization header里。// request.js import axios from axios import { getAccessToken } from ./auth const service axios.create({ baseURL: /api, timeout: 15000 }) service.interceptors.request.use(config { const token getAccessToken() if (token) { config.headers.Authorization Bearer ${token} } return config })这块没有太多坑但有两点要留意要让所有请求都走这个实例业务代码里不要自己 new axios否则拦截器不生效。如果项目里有些接口不需要登录态比如登录接口本身可以在 config 里加一个自定义标记比如skipAuth: true在拦截器里判断后跳过。3.3 响应拦截器401触发的刷新动作和请求队列响应拦截器是整个方案的核心。我先把完整代码贴出来再逐步拆解。// request.js 追加 let isRefreshing false let waitingQueue [] async function refreshTokenAction() { const refresh_token getRefreshToken() // 这里用裸 axios 发起刷新请求避开当前实例的拦截器 const res await axios.post(/auth/refresh, { refresh_token }) const { access_token, refresh_token: newRefreshToken } res.data setToken({ access_token, refresh_token: newRefreshToken }) return access_token } function handleQueue(error, token null) { waitingQueue.forEach(promise { if (error) { promise.reject(error) } else { promise.resolve(token) } }) waitingQueue [] } service.interceptors.response.use( response response, async error { const { response, config } error // 只处理 401并且排除刷新接口本身 if (response?.status 401 !config._retry config.url ! /auth/refresh) { if (!isRefreshing) { isRefreshing true try { const newToken await refreshTokenAction() handleQueue(null, newToken) config._retry true config.headers.Authorization Bearer ${newToken} return service(config) } catch (refreshError) { handleQueue(refreshError) clearToken() window.location.href /login return Promise.reject(refreshError) } finally { isRefreshing false } } // 刷新进行中将请求加入队列 return new Promise((resolve, reject) { waitingQueue.push({ resolve: token { config._retry true config.headers.Authorization Bearer ${token} resolve(service(config)) }, reject }) }) } return Promise.reject(error) } )这段代码里有几个关键设计单独拿出来说。第一个关键点刷新接口要绕过拦截器。我在refreshTokenAction里用的是裸 axios而不是service。原因很直接如果刷新接口返回 401用同一个实例去请求会再次触发响应拦截器里的 401 逻辑形成死循环。裸 axios 没有拦截器刷新失败就直接抛错能干净地走到 catch 分支。第二个关键点_retry标记防止同一个请求无限重放。每次重放前给 config 打上_retry标记下一次如果这个请求又返回 401说明新的 token 也无效就直接 reject不再进入刷新流程。否则可能出现一个请求反复刷新、反复重放最后变成死循环请求。第三个关键点请求队列解决并发竞态。页面同时发出多个请求时如果都因 token 过期返回 401第一个请求会进入刷新分支其余请求则看到isRefreshing true进入排队分支。等刷新完成后通过handleQueue(null, newToken)统一放行排队的请求拿到新 token 后各自重放。这个设计避免了同一时间多个请求重复刷新 token 的问题。3.4 重放原请求为什么用 service(config) 而不是手动发一次代码里重放请求用的是service(config)而不是手动组装一个新请求。这么做的原因是service(config)会再次走请求拦截器统一加 header、统一走超时逻辑和业务代码的行为完全一致。需要注意一个细节因为请求拦截器是在发出请求时读取getAccessToken()如果我在刷新完成后只是把新 token 写进 localStorage其实不重设 headers 也能生效。但我在代码里仍然显式更新了config.headers.Authorization目的是让_retry和 header 状态清晰可见避免依赖隐式读取导致排查困难。我见过有的实现会在刷新后直接return Promise.reject(error)让业务层再手动重新调用接口。这种做法也不是不行但会把“请求重放”的责任分散到每个业务调用方容易出现漏掉某个请求不重放的问题。集中在拦截器里重放是维护成本最低的方式。4. 并发竞态、重复刷新、多标签页失同步这几个坑值得单独说4.1 并发请求同时打401没有队列就会各自刷新这是无感刷新实现里最容易踩的坑。场景是这样的用户进入某个页面页面同时发出三个请求。此时 access_token 正好过期三个请求全部返回 401。如果你的响应拦截器里没有isRefreshing和队列的约束三个请求会各自触发一次刷新动作。后果有两个一是刷新接口被并发调用三次。虽然一般不会报错但这是无意义的请求浪费而且有些后端会对刷新接口做频率限制三次并发可能直接触发限流。二是 refresh_token 可能被轮换后失效。如果后端采用了刷新即轮换的策略每次刷新都会生成新的 refresh_token旧的作废三个并发请求每个都带着旧 refresh_token 去刷新后两个请求会因为旧 refresh_token 已失效而失败导致本该成功的刷新操作被自己人打断了。有了isRefreshing标志位之后第一个请求进入刷新逻辑后面两个请求进队列等待刷新完成后统一重放整个过程只会发生一次刷新请求。4.2 刷新接口本身失败要清登录态但不能死循环另一个高频坑是access_token 和 refresh_token 同时过期了刷新接口返回的也是 401。如果没有排除条件代码会在“刷新失败 - 401 - 触发刷新”之间陷入死循环。我在代码里做了两重防护判断config.url ! /auth/refresh让刷新接口自身的 401 不进入刷新流程。刷新失败后直接clearToken()并跳转登录页彻底终止后续的刷新逻辑。还有一个细节跳转登录页之前应该记录当前页面的路由登录成功后再回跳。这个逻辑可以用 sessionStorage 存一个redirect字段不复杂但能明显提升用户体验。否则用户每被要求重登一次都得自己重新导航回刚才的页面。4.3 多标签页失同步一个页面刷新另一个页面却不知道多标签页场景是不少人忽略的问题。用户开着两个标签页访问同一个系统两个页面共用同一个 localStorage 里的 token。当 token 过期时如果两个页面同时发起刷新请求就会出现和并发竞态类似的问题一个页面刷新成功把 refresh_token 轮换掉了另一个页面的刷新请求带着旧 refresh_token失败直接被踢到登录页。更常见的版本是一个页面成功刷新了 token另一个页面还在用旧的 access_token后续请求一直 401也没有触发刷新机制因为已经刷新过一次了isRefreshing等状态可能不一致用户就会看到一个页面正常、另一个页面持续报错。解决思路是利用浏览器的storage事件window.addEventListener(storage, event { if (event.key access_token event.newValue) { // 当前页面感知到另一个标签页更新了 token // 可以更新内存里的 token 变量让后续请求带上新 token } })注意一个细节storage事件只在其他标签页触发触发更新的那个页面本身不会收到这个事件。所以在刷新成功的那个页面里你自己处理完 token 更新就够了不需要依赖 storage 事件自触发。另外如果项目对多标签页同步要求很高可以考虑用 BroadcastChannel 做标签页间通信在刷新成功后主动广播新 token。这个方案比 storage 事件更主动、更可靠但实现成本也略高一点。一般项目用 storage 事件足够。4.4 重放请求的隐含前提接口要具备幂等性拦截器重放请求这个设计隐含了一个前提同一个请求发两次结果应该是一致的。对于 GET 请求重放基本不会有问题。但写操作不一定。比如提交订单、创建任务这类接口如果第一次请求已经到达后端、后端已经处理完了只是响应在回来路上被 401 拦截你重放一次就可能产生两条记录。在实际 Web 项目里401 通常是后端中间件在进入业务逻辑前拦截的所以这个风险不算大。但如果是网关层或者安全性配置不全的后端存在响应和业务执行顺序不一致的情况。稳妥的做法是在和后端确认接口幂等性之前无感刷新优先应用在读请求较多的页面写请求如果失败且被 401 拦截宁可让用户重新操作一次也不要贸然重放。这个取舍在项目里要提前和产品对齐不要等上线后出了重复订单再来补救。5. 安全边界与后端约定无感刷新不只是前端的事5.1 refresh_token 放哪里localStorage、内存还是 httpOnly Cookie很多前端实现把 token 直接存 localStorage图省事。但这里有一个安全权衡localStorage 的优点是持久化刷新页面不丢失缺点是 JS 可以读取。一旦页面被注入恶意脚本XSS攻击者可以直接把 refresh_token 偷走。而 refresh_token 的有效期通常是 7 天甚至更长被偷之后攻击者可以长期冒充用户。比 localStorage 更稳妥的是httpOnly Cookie。Cookie 带httpOnly属性后前端 JS 读不到只有浏览器会在请求时自动携带XSS 拿不到这个凭证。但这要求后端的刷新接口能接受 Cookie 认证且要处理好 CORS 和 SameSite 属性前后端的配合成本会高一些。还有一个折中方案access_token 放内存变量刷新页面后丢失就重新登录refresh_token 放 httpOnly Cookie。这个方案的体验和安全性比较均衡缺点是刷新页面后要重新走一次刷新流程才能拿到新的 access_token对前端代码的封装要求更高。就个人经验而言如果团队没有专职安全人员先把 access_token 和 refresh_token 都放 localStorage、同时做好基础 XSS 防护比如内容安全策略、不随意引入第三方脚本是投入产出比最高的起步方案。等项目安全要求上来了再逐步把 refresh_token 迁移到 httpOnly Cookie。5.2 刷新失败后的登出路径不要静默踢人刷新失败的处理除了跳登录页还要注意用户感知。我在项目里的做法是跳转前弹一个提示文案是“登录状态已过期请重新登录”然后才跳转。这看起来是个小细节但能避免一个很常见的问题——用户什么都没干页面突然跳到登录页他第一反应是“系统出 bug 了”。如果刷新失败是网络原因而不是 token 真的过期可以考虑做个简单重试。但注意不要过度设计连续两次刷新失败直接走登出流程不要无限重试把请求卡死。还有一点登出时要调用后端的登出接口通知后端把这个会话从有效会话列表里移除。否则 refresh_token 即使没被主动清掉后端仍然认为它有效后续可能被重新利用。5.3 和后端对齐的三件事接口约定决定稳定性无感刷新稳定不稳定一半取决于前端实现另一半取决于和后端的约定。我每次和新后端团队合作时会先对齐这三件事第一刷新接口的入参和出参。刷新接口接收 refresh_token返回新的 access_token。我建议同时返回新的 refresh_token因为如果后端采用轮换策略前端不更新存储下次刷新就会因为旧 refresh_token 失效而失败。第二refresh_token 的轮换策略。轮换策略有两种固定不变和每次刷新后更换。固定不变实现简单但如果 refresh_token 被偷攻击者可以长期使用每次刷新后更换更安全但要求前端每次刷新后同步更新存储。如果后端暂时没做轮换至少要确保 refresh_token 有合理的有效期不能无限期有效。第三401 是否会因为 token 之外的原因返回。有些后端会在权限不足、账号被禁用等情况下也返回 401。这种情况下前端把 401 一律当作“token 过期”去刷新不仅刷新不会成功还可能掩盖真实的权限问题。建议和后端约定清楚token 过期和其他认证失败尽量用不同的状态码区分或者响应体里带一个错误码字段前端只在特定错误码下走刷新流程。5.4 加上一个“登录态过期”的全局提示最后补充一个实用建议在拦截器里加一个全局提示开关只在刷新失败时触发。项目的 UI 组件库通常都有轻提示组件比如 message 或 toast。刷新失败后弹一条提示然后才跳登录页。这个细节能让无感刷新机制的容错体验完整正常情况下用户无感知异常情况下用户能明确知道发生了什么而不是处于“为什么页面突然变了”的困惑里。用户不知道你做了什么但你得在兜底时给他一个交代。最后几句实际体会无感刷新这种功能表面上只是拦截器里几十行代码真正难的是把它放在真实环境里跑。我后来每次上线前都会测一个固定场景登录后开两个标签页放其中一个十五分钟不动另一个一直在操作再回来看闲置页面会不会被打回登录页。这个场景能同时验证刷新失败、并发请求、多标签页同步三个问题。另外如果后端同学对“refresh_token 要不要轮换”不确定建议他们做 rotate每次刷新都换一个新的 refresh_token旧的作废。虽然对前端来说只是多存一个字段但安全性会明显上一个台阶。做无感刷新的那次实践让我一个很重要的认知前端想做无感刷新一半的功夫其实要花在和后端的约定上。接口字段、错误码、轮换策略这三件事对齐了后面的实现反而顺手很多。
阅读完成 · 觉得有帮助?
咨询建站