别再让用户重新登录了!手把手教你用JWT实现accessToken无感刷新(附完整前后端代码)

告别登录中断:基于JWT的双令牌无感刷新实战全解析

你是否经历过这样的场景?正在一个应用里专注地处理工作,填写了半天的表单,点击提交按钮的瞬间,页面却突然跳转到了登录界面——仅仅因为身份令牌悄无声息地过期了。这种体验不仅打断了工作流,更可能让用户产生挫败感,甚至直接放弃使用。在现代Web应用中,用户对流畅性的要求越来越高,任何不必要的交互中断都可能成为流失的导火索。

今天,我们就来深入探讨一个能彻底解决这个痛点的技术方案:基于JWT的双令牌无感刷新机制。这不是简单的理论概述,而是一套从原理到实践、从前端到后端的完整解决方案。我们将避开那些教科书式的泛泛而谈,直接切入核心,分享我在多个中大型项目中落地这一方案时积累的真实经验、踩过的坑以及最终的优化策略。无论你是正在构建一个需要高安全性的企业级应用,还是一个追求极致用户体验的C端产品,这套方案都能为你提供坚实的技术支撑。

1. 理解JWT与双令牌机制的核心理念

在深入代码之前,我们必须先厘清几个关键概念。JWT(JSON Web Token)早已不是新鲜事物,它由三部分组成:头部(Header)、载荷(Payload)和签名(Signature)。其魅力在于自包含无状态——服务端无需在内存或数据库中保存会话信息,仅凭令牌本身的签名即可验证其有效性。这为分布式系统的身份验证带来了极大的便利。

然而,JWT的“无状态”特性也带来了一个经典难题:如何安全地处理令牌过期?如果将访问令牌(accessToken)的有效期设置得过长,比如几天甚至几周,无疑会增大令牌泄露后被滥用的安全风险。反之,如果设置得很短,比如15分钟,又会频繁地将已认证的用户“踢出”系统,体验极差。

双令牌机制(accessToken + refreshToken)正是为了解决这一矛盾而生的。其设计哲学可以概括为:

  • 职责分离accessToken 生命周期短,专注于授权访问API资源。refreshToken 生命周期长,但权限单一,仅用于获取新的 accessToken
  • 风险控制:即使 accessToken 被截获,由于其有效期很短,攻击窗口有限。而 refreshToken 通常被安全地存储(如HttpOnly Cookie),且可被服务端主动废止(加入黑名单),提供了额外的安全层。
  • 体验优化:用户在一个较长的会话周期内(由 refreshToken 有效期决定),无需反复输入密码,系统在后台静默完成令牌的更新。

这里有一个常见的误解需要澄清:refreshToken 不是用来直接访问业务接口的。它的唯一使命就是在 accessToken 过期时,向特定的令牌刷新端点换取一个新的 accessToken。理解这一点,是设计安全刷新流程的基础。

注意:虽然JWT本身是无状态的,但为了实现安全的令牌刷新和废止能力,我们通常需要将 refreshToken 或其映射关系在服务端进行存储(如Redis),这引入了一定的“状态”,但这是安全与功能之间的必要权衡。

2. 前端实现:构建智能的请求拦截与重试层

前端是实现“无感”体验的关键战场。目标很明确:当 accessToken 过期导致请求失败时,自动发起刷新请求,获取新令牌后重试原请求,整个过程对用户透明。

2.1 请求拦截器的核心逻辑

我们以最流行的Axios库为例,构建一个健壮的HTTP客户端。核心在于响应拦截器中对特定状态码(如 401 Unauthorized)的处理。

// axiosInstance.js
import axios from 'axios';

// 创建axios实例
const service = axios.create({
  baseURL: process.env.VUE_APP_BASE_API,
  timeout: 15000
});

// 是否正在刷新的标志,防止并发请求触发多次刷新
let isRefreshing = false;
// 存储等待重试的请求队列
let requestsQueue = [];

// 请求拦截器:为每个请求注入accessToken
service.interceptors.request.use(
  config => {
    const token = localStorage.getItem('accessToken');
    if (token) {
      config.headers['Authorization'] = `Bearer ${token}`;
    }
    return config;
  },
  error => {
    return Promise.reject(error);
  }
);

// 响应拦截器:处理令牌过期
service.interceptors.response.use(
  response => {
    return response.data;
  },
  async error => {
    const originalRequest = error.config;
    const { response } = error;

    // 判断是否为accessToken过期导致的401错误,且不是刷新令牌的请求本身
    if (response.status === 401 &&
        response.data?.code === 'TOKEN_EXPIRED' &&
        !originalRequest._retry &&
        originalRequest.url !== '/auth/refresh') {

      originalRequest._retry = true; // 标记此请求已进入重试流程

      // 如果正在刷新,则将当前请求加入队列等待
      if (isRefreshing) {
        return new Promise((resolve, reject) => {
          requestsQueue.push({ resolve, reject, config: originalRequest });
        });
      }

      isRefreshing = true;

      try {
        // 调用刷新令牌接口
        const newToken = await refreshAccessToken();
        // 刷新成功后,更新本地存储和axios默认请求头
        localStorage.setItem('accessToken', newToken);
        service.defaults.headers.common['Authorization'] = `Bearer ${newToken}`;
        // 重试原始的请求
        originalRequest.headers['Authorization'] = `Bearer ${newToken}`;
        const retryResponse = await service(originalRequest);
        // 处理队列中等待的其他请求
        processRequestsQueue(null, newToken);
        return retryResponse;
      } catch (refreshError) {
        // 刷新失败(如refreshToken也过期),清空用户状态,跳转登录
        processRequestsQueue(refreshError, null);
        clearUserSession();
        window.location.href = '/login';
        return Promise.reject(refreshError);
      } finally {
        isRefreshing = false;
      }
    
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值