一、前言
在前端开发中,用户身份认证通常通过 Token 来实现。然而,Token 的有效期是有限的,过期后用户需要重新登录,这会影响用户体验。为了解决这个问题,Token 无感刷新 成为了一种常见的优化方案。本文将详细介绍如何实现 Token 无感刷新,并探讨其在高并发场景下的优化策略。
二、Token 基础与有效期痛点
1、Token 工作原理详解
(1)Token身份认证: Token 是一种用于证明用户身份和授权的令牌,通常在用户登录成功后由服务器生成并返回给客户端。客户端在后续的请求中,会将 Token 携带在请求头或请求参数中发送给服务器,服务器通过验证 Token 的有效性来确认用户身份,并决定是否授权用户访问受保护的资源。
(2) 常见的 Token 类型有 JSON Web Token(JWT)和 OAuth 2.0 中的 Access Token 等。
(3) JWT 由三部分组成:Header(头部)、Payload(负载)和 Signature(签名)。Header 包含了令牌的类型和使用的签名算法;Payload 则携带了用户的相关信息,如用户 ID、用户名、权限等;Signature 用于验证 Token 的完整性和真实性。OAuth 2.0 的 Access Token 则是用于授权第三方应用访问资源服务器上的受保护资源。
2、传统有效期设置弊端
| 问题分类 | 具体问题 | 示例场景 |
|---|---|---|
| 用户体验差 | 频繁重新登录:Token 有效期过短(如 15 分钟),用户需要频繁重新登录,影响体验。 | 用户正在填写一个长表单,Token 突然过期,提交失败,数据丢失。 |
| 突然中断:用户在操作过程中 Token 突然过期,导致请求失败,操作中断。 | ||
| 安全性问题 | 长期有效的 Token:Token 有效期过长(如 7 天),一旦泄露,攻击者可长期冒充用户 。 | 用户 Token 易被窃取,攻击者在 7 天内可以随意访问用户数据。 |
| 静态有效期:固定过期时间无法根据用户行为动态调整,增加安全风险。 | ||
| 高并发性能问题 | 集中过期:大量用户的 Token 在同一时间过期,导致服务端瞬间压力激增。 | 系统设置 Token 有效期为 1 小时,大量用户在整点时间 Token 过期,导致服务端刷新接口被挤爆。 |
| 重复刷新:多个并发请求同时检测到 Token 过期,可能触发多次刷新操作,浪费资源。 | ||
| 动态适配 | 固定有效期:无法根据用户行为(如活跃度、风险等级)动态调整 Token 有效期。 | 高风险操作(如支付)需要更短的 Token 有效期,而普通操作(如浏览)可以适当延长。 |
| 一刀切策略:所有用户使用相同的有效期设置,无法满足个性化需求。 |
三、什么是 Token 无感刷新?
Token 无感刷新是指:当用户的 Access Token 过期时,系统能够自动刷新 Token,用户无需感知或手动操作。其核心目标是:
- 提升用户体验:用户无需频繁登录。
- 保障安全性:通过合理的 Token 管理,防止 Token 泄露或滥用。
- 性能优化:无感刷新 token 能按需更新,使服务器可适时释放旧会话资源, 释放服务器性能,减轻服务器压力,提高稳定性。
应用场景
适合那些需要长时间保持用户会话的应用程序:
- Web 应用程序-在单页应用(SPA)中,如使用 Vue.js、React 或 Angular 构建的应用,用户可能会在一个页面上停留很长时间而不进行任何操作,导致 token 过期。
- 移动应用程序-移动应用通常长时间运行于后台,期间用户的 token 可能会过期。如果每次过期都需要用户重新登录,会影响用户体验。
- 多设备同步-当用户在多个设备上登录同一账户时,若其中一个设备上的 token 过期,其他设备也可能会受到影响。
- 实时通信应用(如聊天应用或在线协作工具)需要持续与服务器保持连接,以保证消息的即时传递。
四、Token 无感刷新技术实现
1. 双 Token 机制(Access Token + Refresh Token)
这是最常见的无感刷新方案,通过两个 Token 实现:
- 获得双token:
- 用户登录,服务端返回 Access Token 和 Refresh Token,访问接口时则携带 access_token 访问
- Access Token 访问令牌:短期有效(如 15 分钟),用于业务请求。
- Refresh Token 刷新令牌:长期有效(如 7 天),用于刷新 Access Token。
- 核心价值:通过分离 Token 的生命周期,减少 Access Token 的刷新频率,同时提高安全性。
- Access Token 过期:
- 客户端检测到 Access Token 过期(如接口返回 401 错误)。
- 使用 refresh_token 请求新的 Access Token,客户端更新本地存储。
- 重新请求:
- 使用新获取的access_token重新发起之前的请求
注意点:
- Access Token 存储在客户端(如
localStorage或内存)。 - Refresh Token 存储在安全位置(如 HttpOnly Cookie),可有效防御 XSS攻击(跨站脚本攻击)。
流程图
代码实现
import axios from 'axios'
const service = axios.create({
baseURL: 'https://api.example.com',
timeout: 10000
})
// 请求拦截器:自动添加 Access Token
service.interceptors.request.use(config => {
const accessToken = localStorage.getItem('access_token');
if (accessToken) {
config.headers.Authorization = `Bearer ${accessToken}`;
}
return config;
});
// 响应拦截器:处理 Token 过期
service.interceptors.response.use(
response => response,
async error => {
const originalRequest = error.config;
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;
try {
// 使用 Refresh Token 刷新 access Token
const accessToken = await refreshToken();
localStorage.setItem('access_token', accessToken);
// 更新请求头并重试原始请求
originalRequest.headers.Authorization = `Bearer ${accessToken}`;
return service(originalRequest);
} catch (refreshError) {
// 刷新失败,跳转登录
router.push('/login')
}
}
return Promise.reject(error);
}
);
//刷新token函数
async function refreshToken() {
try {
const response = await service.get('/refresh', {
params: {
token: getRefreshTokenFromCookie(); // 从 HttpOnly Cookie 获取 Refresh Token
},
timeout: 30000, // 单独设置超时时间
});
return response.data.accessToken; // 返回新的 access_token
} catch (error) {
// 清除本地存储的 token
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
throw error; // 抛出错误
}
}
export default service;
2.前端定时刷新
在 Access Token 过期前主动刷新 Token,避免用户请求时突然过期。
-
设置 Token 有效期:
- Access Token 有效期 15 分钟。
- 在 Token 过期前 1 分钟主动刷新。
-
定时刷新:
- 客户端定时检查 Token 的剩余有效期。
- 如果剩余时间小于阈值(如 1 分钟),则主动刷新 Token。
流程图
代码实现
let refreshTimeout;
// 检查并刷新 Token 的函数
function checkAndRefreshToken() {
const accessToken = localStorage.getItem('accessToken');
if (accessToken) {
const expiresIn = getTokenExpiresIn(accessToken); // 获取 Token 剩余时间
if (expiresIn < 60) { // 剩余时间小于 60 秒
refreshAccessToken().then(newAccessToken => {
localStorage.setItem('accessToken', newAccessToken);
// 重新设置定时器
refreshTimeout = setTimeout(checkAndRefreshToken, (getTokenExpiresIn(newAccessToken) - 60) * 1000);
});
} else {
// 设置定时器,在过期前 1 分钟刷新
refreshTimeout = setTimeout(checkAndRefreshToken, (expiresIn - 60) * 1000);
}
}
}
// 初始化时启动检查
checkAndRefreshToken();
3.服务端主动刷新
服务端可以在每次请求时检查 Token 的剩余有效期,并在接近过期时返回新的 Token。
-
服务端检查:
- 每次请求时,服务端轮询Token的剩余有效期。
- 判断,如果剩余时间小于阈值(如 1 分钟),返回新的 Access Token。
-
客户端更新:
- 客户端检测到响应中包含新的 Token,更新本地存储。
流程图
代码实现
// 服务端逻辑
app.use((req, res, next) => {
const accessToken = req.headers.authorization?.split(' ')[1];
if (accessToken) {
const expiresIn = getTokenExpiresIn(accessToken);
if (expiresIn < 60) { // 剩余时间小于 60 秒
const newAccessToken = generateAccessToken(req.user);
res.set('New-Access-Token', newAccessToken);
}
}
next();
});
// 客户端逻辑
service.interceptors.response.use(response => {
const newAccessToken = response.headers['new-access-token'];
if (newAccessToken) {
localStorage.setItem('accessToken', newAccessToken);
}
return response;
});
4.双token+并发请求锁机制
1. 用户登录 & 获取双 Token
-
客户端:发送登录请求(账号密码/验证码等)
-
服务端:
- 验证身份后生成
Access Token(短效)和Refresh Token(长效)。 - Access Token:返回给客户端存储(如
localStorage)。 - Refresh Token:通过
HttpOnly Cookie返回(防 XSS)。
- 验证身份后生成
2. 请求拦截器:设置请求头token
- 配置请求头添加token,判断请求url,设置不同token
// 添加请求拦截器
service.interceptors.request.use(
config => {
if (config.url !== '/login') {
const accessToken = localStorage.getItem('access_token')
config.headers["Authorization"] = `Bearer ${accessToken}`
}
//判断是否是获取新token
if (config.url === '/refresh_token') {
const refreshToken = localStorage.getItem('refresh_token')
config.headers["Authorization"] = `Bearer ${refreshToken}`
}
return config
},
error => {
return Promise.reject(error)
})
3. 响应拦截器:检测 Token 过期(401 错误)处理
-
判断返回 的401,不是重新获取token的401
-
判断锁变量
isRefreshing:标记是否正在刷新 Token,则进行token刷新,处理高并发请求。 -
请求队列
processQueue:存储等待刷新的请求。 -
使用 refreshToken 请求新 accessToken
-
处理队列中的其他请求,以及重新发起失败的请求
-
释放锁
// 添加响应拦截器
service.interceptors.response.use(
response => response, // 成功的响应直接返回
async error => {
const originalRequest = error.config;
//originalRequest._retry 是一个自定义属性,用于标记请求是否已经重试过。
//1、判断是不是token过期
if (error.response.status === 401 && !originalRequest._retry) {
originalRequest._retry = true; // 显式标记请求为重试
//2、并且不是重新获取token的401,则进行token刷新
if (!isRefreshing) {
isRefreshing = true;
// 重新请求access_token
try {
const accessToken = await refreshToken()
// 更新localstorage中的access_token
localStorage.setItem('access_token', accessToken);
//配置请求头
originalRequest.headers['Authorization'] = `Bearer ${accessToken}`;
// 处理队列中的其他请求
processQueue(null, accessToken);
// 重新发起失败的请求
return service(originalRequest)
} catch (err) {
// 处理队列中的请求
processQueue(err, null);
console.log("刷新token失败,跳转登录界面", err)
// 重定向到登录页
router.push('/login')
} finally {
isRefreshing = false; //isRefreshing设置为false
}
}
else {
//如果正在刷新token,则将请求加入队列
return new Promise((resolve, reject) => {
failedQueue.push({ resolve, reject });
})
}
}
// 如果不是401错误,则直接抛出错误
return Promise.reject(error);
}
);
流程图
代码实现
import axios from 'axios'
const service = axios.create({
baseURL: 'https://api.example.com',
timeout: 10000
})
//是否正在刷新token
let isRefreshing = false;
// 定义一个队列,用于存储失败的请求
let failedQueue = [];
// 处理队列中的请求
const processQueue = (error, token = null) => {
failedQueue.forEach(prom => {
if (error) {
prom.reject(error); // 拒绝请求
} else {
prom.resolve(token);// 使用新的 token 重新发起请求
}
});
if (!error) {
failedQueue = []; // 只有在成功刷新 token 时才清空队列
}
};
//刷新token函数
async function refreshToken() {
try {
const response = await service.get('/refresh', {
params: {
token: localStorage.getItem('refresh_token'),
},
timeout: 30000, // 单独设置超时时间
});
return response.data.accessToken; // 返回新的 access_token
} catch (error) {
// 清除本地存储的 token
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
throw error; // 抛出错误
}
}
// 添加请求拦截器
service.interceptors.request.use(
config => {
if (config.url !== '/login') {
const accessToken = localStorage.getItem('access_token')
config.headers["Authorization"] = `Bearer ${accessToken}`
}
//判断是否是获取新token
if (config.url === '/refresh_token') {
const refreshToken = localStorage.getItem('refresh_token')
config.headers["Authorization"] = `Bearer ${refreshToken}`
}
return config
},
error => {
return Promise.reject(error)
}
)
// 添加响应拦截器
service.interceptors.response.use(
response => response, // 成功的响应直接返回
async error => {
const originalRequest = error.config;
//originalRequest._retry 是一个自定义属性,用于标记请求是否已经重试过。
//1、判断是不是token过期
if (error.response.status === 401 && !originalRequest._retry) {
originalRequest._retry = true; // 显式标记请求为重试
//2、并且不是重新获取token的401,则进行token刷新
if (!isRefreshing) {
isRefreshing = true;
// 重新请求access_token
try {
const accessToken = await refreshToken()
// 更新localstorage中的access_token
localStorage.setItem('access_token', accessToken);
//配置请求头
originalRequest.headers['Authorization'] = `Bearer ${accessToken}`;
// 处理队列中的其他请求
processQueue(null, accessToken);
// 重新发起失败的请求
return service(originalRequest)
} catch (err) {
// 处理队列中的请求
processQueue(err, null);
console.log("刷新token失败,跳转登录界面", err)
// 重定向到登录页
router.push('/login')
} finally {
isRefreshing = false; //isRefreshing设置为false
}
}
else {
//如果正在刷新token,则将请求加入队列
return new Promise((resolve, reject) => {
failedQueue.push({ resolve, reject });
})
}
}
// 如果不是401错误,则直接抛出错误
return Promise.reject(error);
}
);
export default service;
总结
Token 无感刷新是一种提升用户体验和保障应用安全的有效技术,有多种技术都能实现,但是各有优缺如下图:
| 方法 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| 双 Token 机制 | 通用场景 | 安全性高,刷新频率低 | 需要服务端支持双 Token |
| 前端定时刷新 | 高活跃用户 | 避免请求时突然过期 | 需要定时器管理 |
| 双token+并发请求锁机制 | 高并发场景 | 解决并发刷新问题 | 实现较复杂 |
| 服务端主动刷新 | 服务端可控性强的场景 | 客户端无需额外逻辑 | 服务端压力较大 |