Refresh Token 实战指南:解决前端 token 过期自动续签难题

Mc.玉娟 安全 阅读 2,543
赞 19 收藏
二维码
手机扫码查看
反馈

Refresh Token自动续期,结果登录态莫名其妙丢了

今天上线前测登录流程,发现用户明明没点退出、也没关页面,过15分钟再操作一下接口,直接401跳回登录页——但控制台里明明刚刷新过token,Network里也能看到refresh请求返回了新access_token和新refresh_token,状态码200,响应体也正常。我盯着DevTools看了三分钟,喝了一口已经凉透的咖啡,心想:这玩意儿它不讲武德。

Refresh Token 实战指南:解决前端 token 过期自动续签难题

先说结论,核心就三件事

不是后端没返回新refresh_token,也不是前端没存,是我在调用refresh接口时,把旧的refresh_token塞进了请求头Authorization,而服务端校验逻辑写死了只认Bearer开头的token(也就是access_token),导致refresh请求本身被中间件拦下来返回401,但我在前端居然还把它当成功处理了……

这里我踩了个坑:以为只要fetch返回200就是成功,压根没看response.statusText或业务code字段。后端返回的是{"code":401,"msg":"Invalid token type"},但我只判断了res.ok,然后直接解构data,拿到空对象,再存进localStorage——于是新token全丢了,下次请求继续用过期的access_token,自然401滚雪球。

折腾了半天发现,问题根本不在续期逻辑,而在“怎么发起续期请求”

一开始我以为是并发问题:比如两个tab同时到期,都去refresh,后发的那个把前一个覆盖了,导致其中一个tab拿着过期的access_token还在用。试了加锁、加时间戳防重、甚至本地存version做乐观锁……全没用。后来加了一堆console.log,发现关键日志根本没打出来——refresh函数压根没执行完就结束了?

查了一下,原来我用了async/await包装refresh,但在拦截器里直接return了Promise.resolve(),没等refresh完成就放行了原请求,结果原请求带着过期token发出去,401,触发二次refresh,无限套娃……

后来试了下发现,axios的request interceptor里不能直接return一个还没resolve的Promise,得用await或者.then()链式接管。我之前写的伪代码长这样:

// ❌ 错误示范:没等refresh完成就放行
instance.interceptors.request.use(config => {
  if (isAccessTokenExpired()) {
    refreshToken().then(newToken => {
      config.headers.Authorization = Bearer ${newToken};
      return config; // 这里return没用!config已经发出去了
    });
  }
  return config;
});

这代码看着像那么回事,实际就是个摆设。config早就被发出去了,你后面改header纯属自嗨。

核心代码就这几行(亲测有效,已上线三天没再丢登录态)

我把refresh逻辑单独抽成一个带锁的函数,所有需要续期的地方统一走这个入口,而且强制串行。关键点有三个:

  • 用一个Promise变量缓存当前正在进行的refresh请求,避免并发
  • 每次refresh成功后,原子化更新localStorage里的access_token和refresh_token
  • 在请求拦截器里,对401响应统一捕获,触发refresh,然后重放原请求(注意:是重放,不是retry)

下面是最终落地的refresh逻辑(基于axios):

let refreshPromise = null;

async function refreshToken() {
  if (refreshPromise) {
    return refreshPromise;
  }

  const storedRefreshToken = localStorage.getItem('refresh_token');
  if (!storedRefreshToken) {
    throw new Error('No refresh token found');
  }

  refreshPromise = fetch('https://jztheme.com/api/auth/refresh', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      // 注意!这里不用Authorization,而是用专门的refresh_token字段
      'X-Refresh-Token': storedRefreshToken,
    },
  })
    .then(async (res) => {
      if (!res.ok) {
        const errData = await res.json();
        throw new Error(errData.msg || Refresh failed: ${res.status});
      }
      return res.json();
    })
    .then((data) => {
      if (!data.access_token || !data.refresh_token) {
        throw new Error('Missing tokens in refresh response');
      }
      // 原子更新
      localStorage.setItem('access_token', data.access_token);
      localStorage.setItem('refresh_token', data.refresh_token);
      return data.access_token;
    })
    .finally(() => {
      refreshPromise = null;
    });

  return refreshPromise;
}

然后是axios拦截器部分:

instance.interceptors.response.use(
  (response) => response,
  async (error) => {
    const originalRequest = error.config;

    if (error.response?.status === 401 && !originalRequest._retry) {
      originalRequest._retry = true;

      try {
        const newAccessToken = await refreshToken();
        originalRequest.headers.Authorization = Bearer ${newAccessToken};
        return instance(originalRequest); // 重放原请求
      } catch (refreshError) {
        // 刷新失败,清空登录态
        localStorage.removeItem('access_token');
        localStorage.removeItem('refresh_token');
        window.location.href = '/login';
        return Promise.reject(refreshError);
      }
    }

    return Promise.reject(error);
  }
);

这里注意两点:

  • originalRequest._retry = true 是防死循环的关键,不然401重放后还是401,一直递归下去
  • refresh接口我让后端开了个专用header X-Refresh-Token,彻底和access_token的Bearer机制解耦,再也不怕类型校验翻车

踩坑提醒:这三点一定注意

第一,别信res.ok。后端只要返回HTTP 200,哪怕body里写着{“code”:500},res.ok也是true。一定要读body,校验业务code字段。

第二,localStorage更新必须原子。我之前是先存access_token,再存refresh_token,中间如果页面崩溃,就会出现access_token新、refresh_token旧的情况,下次refresh又会失败。现在改成一次set两个,靠JS单线程特性保证顺序,够用了。

第三,不要在拦截器里手动拼接headers。axios的config.headers是引用对象,你直接改它没问题,但别搞错作用域——我最初把originalRequest.headers.Authorization写成config.headers.Authorization,结果在response拦截器里改的是另一个config副本,白忙活。

最后说个小遗憾

目前这套方案在Safari里偶尔会遇到refresh请求被静默取消(network面板显示cancelled),查了下是iOS Safari对background tab的fetch有节流策略。暂时没完美解法,我的妥协是:加个3秒超时重试,最多试两次,再失败就跳登录页。不影响主流程,只是小概率多点一次登录——反正用户也不在乎多输一次密码,只要别动不动就登出就行。

以上是我踩坑后的总结,希望对你有帮助。如果你有更好的方案,比如用service worker接管refresh请求、或者用IndexedDB替代localStorage做token持久化,欢迎评论区交流。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论