用户操作被 “401 登录弹窗” 打断?🤔一文讲透 Token 无感刷新

24 阅读2分钟

现代 Web 应用开发中,身份验证是保障系统安全的核心环节,而 Token 认证因灵活性高、无状态等优势被广泛采用。
但 Access Token 过期后突然报错或强制跳转登录,会严重影响体验。
Token 无感刷新 能在用户毫无察觉的情况下完成令牌更新,让操作流程无缝衔接。
本文从前端视角出发,一步步拆解落地方案,后端部分仅做必要说明。

一、先搞懂:Token 无感刷新的核心逻辑

令牌作用有效期权限
Access Token接口通行证短期(1-2 h)降低泄露风险
Refresh Token换票凭证长期(7-30 d)仅用于刷新

4 步无感流程

  1. 请求带 Access Token
  2. 后端返回 401
  3. 前端用 Refresh Token 调 /refresh 换新令牌
  4. 重试原请求,用户 0 感知

二、前端落地:从存储到拦截,全链路实现

1. 令牌存储策略

方案优点缺点
localStorage操作简单、可跨域易受 XSS
httpOnly Cookie防 XSS跨域复杂、需防 CSRF

本文示例用 localStorage,封装如下:

// tokenStorage.js
export const tokenStorage = {
  setTokens: (accessToken, refreshToken) => {
    const expiryTime = Date.now() + 2 * 60 * 60 * 1000; // 2 h
    localStorage.setItem('accessToken', accessToken);
    localStorage.setItem('refreshToken', refreshToken);
    localStorage.setItem('tokenExpiry', expiryTime);
  },
  getAccessToken: () => localStorage.getItem('accessToken'),
  getRefreshToken: () => localStorage.getItem('refreshToken'),
  clearTokens: () => {
    ['accessToken', 'refreshToken', 'tokenExpiry']
      .forEach(k => localStorage.removeItem(k));
  },
  shouldRefreshEarly: () => {
    const t = Number(localStorage.getItem('tokenExpiry'));
    return !isNaN(t) && (t - Date.now() < 10 * 60 * 1000);
  }
};

2. Axios 拦截器——核心战场

// api.js
import axios from 'axios';
import { tokenStorage } from './tokenStorage';

const baseApi = axios.create({
  baseURL: import.meta.env.VITE_API_BASE_URL,
  timeout: 10000,
  headers: { 'Content-Type': 'application/json' }
});

let isRefreshing = false;
let requestQueue = [];

const refreshAccessToken = async () => {
  try {
    const refreshToken = tokenStorage.getRefreshToken();
    if (!refreshToken) throw new Error('No refresh token');

    const { data } = await axios.post(
      `${import.meta.env.VITE_API_BASE_URL}/auth/refresh-token`,
      { refreshToken }
    );
    tokenStorage.setTokens(data.accessToken, data.refreshToken);
    return data.accessToken;
  } catch (e) {
    tokenStorage.clearTokens();
    location.href = '/login?redirect=' + encodeURIComponent(location.pathname);
    throw e;
  }
};

/* 请求拦截:自动加头 + 提前刷新 */
baseApi.interceptors.request.use(async config => {
  if (tokenStorage.shouldRefreshEarly() && !isRefreshing) {
    refreshAccessToken().catch(console.error);
  }
  const token = tokenStorage.getAccessToken();
  if (token && config.headers) config.headers.Authorization = `Bearer ${token}`;
  return config;
});

/* 响应拦截:401 无感重试 */
baseApi.interceptors.response.use(
  res => res,
  async err => {
    const original = err.config;
    if (!err.response || err.response.status !== 401 || original._retry) return Promise.reject(err);
    original._retry = true;

    if (!isRefreshing) {
      isRefreshing = true;
      try {
        const newToken = await refreshAccessToken();
        requestQueue.forEach(cb => cb(newToken));
        requestQueue = [];
        original.headers.Authorization = `Bearer ${newToken}`;
        return baseApi(original);
      } catch (e) {
        return Promise.reject(e);
      } finally {
        isRefreshing = false;
      }
    } else {
      return new Promise(resolve => {
        requestQueue.push(token => {
          original.headers.Authorization = `Bearer ${token}`;
          resolve(baseApi(original));
        });
      });
    }
  }
);

export default baseApi;

3. 体验优化细节

  1. 页面切回提前刷新
document.addEventListener('visibilitychange', () => {
  if (document.visibilityState === 'visible' &&
      tokenStorage.shouldRefreshEarly()) {
    refreshAccessToken().catch(console.error);
  }
});
  1. 刷新接口绕开拦截器
    直接用 axios.post 而不用 baseApi,防止循环加头。

  2. 主动登出清理现场

export const logout = () => {
  tokenStorage.clearTokens();
  isRefreshing = false;
  requestQueue = [];
  location.href = '/login';
};

三、后端配合(3 句话带过)

  1. Access Token 过期返回 401
  2. 提供 /refresh 接口:验 Refresh Token → 返回新双 Token
  3. Refresh Token 存 Redis,支持黑名单 & 滚动刷新

四、常见问题速查

问题答案
刷新接口为何也返回新 Refresh Token?滚动刷新,降低重放风险
localStorage 怕 XSS 怎么办?改 httpOnly Cookie + 内存存 Access Token
开发环境 Token 太短?单独配置长有效期,避免干扰调试

五、结语

“把 401 当换票信号,而不是踢人信号”
30 行拦截器代码,就能让登录态“永不下线”。
根据业务平衡安全与体验,敏感操作再叠加二次校验即可。
落地过程中有坑,欢迎在评论区交流!