告别登录中断:基于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;
}

&spm=1001.2101.3001.5002&articleId=150476113&d=1&t=3&u=190c6c4aa7e34bc189426fe90cb8315e)
753

被折叠的 条评论
为什么被折叠?



