前言
大家好~ 我是一诺,最近在用Vue+Nest.js 开发个人项目,遇到了一个经典问题:JWT Token 的过期处理。
传统的做法是,Token 一过期就让用户重新登录。但这样用户体验很差,想象一下你正在写一篇长文章,突然系统提示"登录过期,请重新登录",之前的内容可能就丢失了。
有没有更好的解决方案呢?答案是有的,就是 Token 自动刷新机制。
今天咱们一起讨论下 在 Vue.js + NestJS 项目中实现一套完整的 Token 自动刷新方案。
核心思路
传统的单 Token 方案有一个根本性问题:安全性和用户体验无法兼得。
- Token 过期时间短 → 安全性高,但用户体验差
- Token 过期时间长 → 用户体验好,但安全风险大
解决方案是引入双 Token 机制:
- Access Token(访问令牌):过期时间短(5分钟),用于日常 API 调用
- Refresh Token(刷新令牌):过期时间长(30天),用于获取新的 Access Token
这就像银行卡和密码的关系:银行卡(Access Token)丢了影响有限,密码(Refresh Token)才是真正的安全凭证。
流程图如下:
技术架构
流程图如下:
后端设计
后端采用 NestJS 框架,主要包含以下组件:
1. Token 配置
// config/app.config.ts
export const appConfig = {
auth: {
token: {
defaultExpiration: 300, // 5分钟
refreshExpiration: 30, // 30天
}
}
}
为什么选择 5 分钟?这是一个经验值:
- 足够用户完成大部分操作
- 即使被窃取,危害也相对有限
- 不会频繁触发刷新,影响性能
2. 数据库设计
需要一张 tokens
表来存储 Refresh Token:
CREATE TABLE tokens (
_id ObjectId PRIMARY KEY,
userId ObjectId REFERENCES users(_id),
refreshToken String UNIQUE,
userAgent String,
ipAddress String,
isValid Boolean DEFAULT true,
expiresAt Date,
createdAt Date,
updatedAt Date
)
为什么要存储到数据库?因为需要支持服务端主动撤销,比如用户登出、修改密码时。
3. Token 服务
TokenService 是核心组件,负责:
class TokenService {
// 生成双Token
async generateAuthTokens(userId, username, rememberMe) {
const accessToken = this.jwtService.sign(payload);
if (rememberMe) {
const refreshToken = this.generateRefreshToken();
// 保存到数据库
await this.tokenModel.create({
userId, refreshToken, expiresAt: new Date(Date.now() + 30天)
});
return { accessToken, refreshToken };
}
return { accessToken };
}
// 刷新Token
async refreshToken(refreshToken) {
// 1. 验证refreshToken是否存在且有效
const tokenDoc = await this.tokenModel.findOne({
refreshToken, isValid: true, expiresAt: { $gt: new Date() }
});
// 2. 生成新的双Token
// 3. 更新数据库记录
}
}
前端设计
前端的核心是请求拦截器,它像一个智能秘书,自动处理所有的 Token 相关事务。
流程图如下
1. 存储策略
前端需要在多个地方存储 Token:
// localStorage - 页面刷新时恢复
localStorage.setItem('token', accessToken);
// Vuex Store - 运行时状态管理
store.commit('SET_USER_INFO', {
token: accessToken,
refreshToken: refreshToken,
tokenExpiresAt: Date.now() + 300 * 1000
});
// HTTP-only Cookie - 防XSS攻击(后端设置)
res.cookie('refreshToken', refreshToken, { httpOnly: true });
为什么要多重存储?各有各的用途:
- localStorage:持久化,页面刷新不丢失
- Vuex:运行时快速访问
- Cookie:安全性最高,JS 无法读取
2. 请求拦截器
这是整个方案的核心,负责在每个请求中自动添加 Token:
// 请求拦截器
service.interceptors.request.use(async (config) => {
const token = localStorage.getItem('token');
if (token) {
config.headers['Authorization'] = `Bearer ${token}`;
}
return config;
});
3. 响应拦截器
当收到 401 错误时,自动尝试刷新 Token:
// 响应拦截器
service.interceptors.response.use(
response => response,
async (error) => {
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;
try {
// 刷新Token
await store.dispatch('user/refreshToken');
// 重试原请求
return service(originalRequest);
} catch (refreshError) {
// 刷新失败,跳转登录页
router.push('/login');
}
}
return Promise.reject(error);
}
);
关键问题解决
1. 并发请求问题
设想这样一个场景:用户打开了一个页面,这个页面同时发起了 10 个 API 请求,而此时 Token 刚好过期。
如果不做特殊处理,这 10 个请求都会收到 401 错误,然后都去尝试刷新 Token。这就会导致:
- 发起 10 次刷新请求(浪费资源)
- 可能产生竞态条件
- 用户体验差
解决方案是使用请求队列:
如图所示:
let isRefreshing = false;
let failedQueue = [];
// 处理401错误
if (error.response?.status === 401) {
if (isRefreshing) {
// 正在刷新中,加入队列等待
return new Promise((resolve, reject) => {
failedQueue.push({ resolve, reject });
});
}
isRefreshing = true;
try {
// 刷新Token
await refreshToken();
// 处理队列中的请求
processQueue(null, newToken);
} catch (error) {
// 处理失败的请求
processQueue(error, null);
} finally {
isRefreshing = false;
}
}
2. 防重复刷新问题
在 Vuex 中也需要防止重复刷新:
// Vuex Store
const state = {
refreshPromise: null // 缓存刷新Promise
}
const actions = {
async refreshToken({ commit, state }) {
// 如果已经有刷新Promise在进行中,直接返回
if (state.refreshPromise) {
return state.refreshPromise;
}
const refreshPromise = (async () => {
// 执行刷新逻辑
const response = await refreshTokenAPI(refreshToken);
commit('SET_USER_INFO', {
token: response.token,
refreshToken: response.refreshToken,
tokenExpiresAt: Date.now() + response.expiresIn * 1000
});
return response;
})();
// 缓存Promise
commit('SET_REFRESH_PROMISE', refreshPromise);
try {
return await refreshPromise;
} finally {
// 清除缓存
commit('SET_REFRESH_PROMISE', null);
}
}
}
3. Token 黑名单机制
仅有数据库存储还不够,因为 JWT 是无状态的,即使数据库中的 Refresh Token 被标记为无效,已经签发的 Access Token 在过期前仍然有效。
解决方案是引入 Redis 黑名单:
流程图如下:
// 用户登出时
async logout(userId, refreshToken, accessToken) {
// 1. 将Access Token加入Redis黑名单
await this.tokenBlacklistService.addToBlacklist(accessToken, userId);
// 2. 标记Refresh Token为无效
await this.tokenService.invalidateRefreshToken(userId, refreshToken);
}
// JWT守卫中检查黑名单
async canActivate(context) {
const token = this.extractToken(request);
// 检查是否在黑名单中
const isBlacklisted = await this.tokenBlacklistService.isBlacklisted(token);
if (isBlacklisted) {
throw new UnauthorizedException('Token已被撤销');
}
return super.canActivate(context);
}
完整代码实现
后端核心代码
1. 认证控制器
@Controller('v1/auth')
export class AuthController {
@Post('login')
async login(@Body() loginDto: LoginDto, @Res() res: Response) {
const result = await this.authService.login(loginDto, ipAddress, userAgent);
// 设置Refresh Token到HTTP-only Cookie
if (result.refreshToken) {
res.cookie('refreshToken', result.refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
maxAge: 30 * 24 * 60 * 60 * 1000, // 30天
path: '/api/v1/auth/refresh-token',
});
}
return result;
}
@Post('refresh-token')
async refreshToken(@Req() req: Request, @Res() res: Response) {
// 优先从请求体获取,其次从Cookie获取
const refreshToken = req.body.refreshToken || req.cookies?.refreshToken;
const result = await this.tokenService.refreshToken(
refreshToken, req.ip, req.headers['user-agent']
);
// 更新Cookie
res.cookie('refreshToken', result.refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
maxAge: 30 * 24 * 60 * 60 * 1000,
path: '/api/v1/auth/refresh-token',
});
return result;
}
@Post('logout')
@UseGuards(JwtAuthGuard)
async logout(@Req() req: Request, @Res() res: Response) {
const refreshToken = req.cookies?.refreshToken;
const accessToken = this.extractAccessToken(req);
await this.authService.logout(req.user._id, refreshToken, accessToken);
// 清除Cookie
if (refreshToken) {
res.clearCookie('refreshToken');
}
return { message: '登出成功' };
}
}
2. Token服务
@Injectable()
export class TokenService {
async generateAuthTokens(userId, username, rememberMe, userAgent, ipAddress) {
// 生成Access Token
const payload = { username, sub: userId };
const accessToken = this.jwtService.sign(payload);
let refreshToken = null;
if (rememberMe) {
// 生成Refresh Token
refreshToken = this.generateRefreshToken();
const refreshDays = this.appConfigService.auth.token.refreshExpiration;
const expiresAt = new Date(Date.now() + refreshDays * 24 * 60 * 60 * 1000);
// 保存到数据库
await this.tokenModel.create({
userId: new Types.ObjectId(userId),
refreshToken,
userAgent,
ipAddress,
expiresAt,
});
}
return {
token: accessToken,
refreshToken,
expiresIn: this.appConfigService.auth.token.expiresIn,
};
}
async refreshToken(refreshToken, ipAddress, userAgent) {
// 查找并验证Refresh Token
const tokenDoc = await this.tokenModel.findOne({
refreshToken,
isValid: true,
expiresAt: { $gt: new Date() }
});
if (!tokenDoc) {
throw new UnauthorizedException('刷新令牌无效或已过期');
}
// 验证用户状态
const user = await this.userModel.findById(tokenDoc.userId).select('-password');
if (!user || user.status !== 'active') {
await this.tokenModel.updateOne({ _id: tokenDoc._id }, { isValid: false });
throw new UnauthorizedException('用户不存在或已被禁用');
}
// 生成新的Token对
const payload = { username: user.username, sub: user._id };
const accessToken = this.jwtService.sign(payload);
const newRefreshToken = this.generateRefreshToken();
// 更新数据库
const refreshDays = this.appConfigService.auth.token.refreshExpiration;
const expiresAt = new Date(Date.now() + refreshDays * 24 * 60 * 60 * 1000);
await this.tokenModel.updateOne(
{ _id: tokenDoc._id },
{
refreshToken: newRefreshToken,
expiresAt,
userAgent,
ipAddress,
}
);
return {
token: accessToken,
refreshToken: newRefreshToken,
expiresIn: this.appConfigService.auth.token.expiresIn,
};
}
private generateRefreshToken(): string {
return crypto.randomBytes(40).toString('hex');
}
}
前端核心代码
1. Axios拦截器
import axios from 'axios';
import store from '@/store';
import { ElMessage } from 'element-plus';
// 创建axios实例
const service = axios.create({
baseURL: '/api',
timeout: 15000,
});
// 防重复刷新的控制变量
let isRefreshing = false;
let failedQueue = [];
// 处理等待队列
const processQueue = (error, token = null) => {
failedQueue.forEach(({ resolve, reject }) => {
if (error) {
reject(error);
} else {
resolve(token);
}
});
failedQueue = [];
};
// 请求拦截器
service.interceptors.request.use(
async (config) => {
const token = localStorage.getItem('token');
if (token) {
config.headers['Authorization'] = `Bearer ${token}`;
}
return config;
},
(error) => Promise.reject(error)
);
// 响应拦截器
service.interceptors.response.use(
(response) => {
// 处理业务响应格式
const res = response.data;
if (res.code === 200) {
return res.data;
} else {
ElMessage.error(res.message || '请求失败');
return Promise.reject(new Error(res.message || '请求失败'));
}
},
async (error) => {
const originalRequest = error.config;
// 处理401错误
if (error.response?.status === 401 && !originalRequest._retry) {
// 如果正在刷新,将请求加入队列
if (isRefreshing) {
return new Promise((resolve, reject) => {
failedQueue.push({ resolve, reject });
}).then(token => {
if (token) {
originalRequest.headers['Authorization'] = `Bearer ${token}`;
return service(originalRequest);
}
return Promise.reject(error);
});
}
originalRequest._retry = true;
isRefreshing = true;
try {
// 尝试刷新Token
await store.dispatch('user/refreshToken');
const newToken = localStorage.getItem('token');
if (newToken) {
// 刷新成功,处理队列
processQueue(null, newToken);
originalRequest.headers['Authorization'] = `Bearer ${newToken}`;
return service(originalRequest);
} else {
throw new Error('刷新后未获取到新Token');
}
} catch (refreshError) {
// 刷新失败,清除状态并跳转登录
processQueue(refreshError, null);
store.dispatch('user/clearUserInfo');
window.location.href = '/login';
return Promise.reject(error);
} finally {
isRefreshing = false;
}
}
// 其他错误处理
const errorMessage = error.response?.data?.message || '请求失败';
ElMessage.error(errorMessage);
return Promise.reject(error);
}
);
export default service;
2. Vuex用户模块
import { login, logout, refreshToken, getUserInfo } from '@/api/auth';
const state = () => ({
userInfo: JSON.parse(localStorage.getItem('userInfo') || 'null'),
loading: false,
error: null,
refreshPromise: null // 防重复刷新
});
const getters = {
getUserInfo: (state) => state.userInfo,
getToken: (state) => state.userInfo?.token || localStorage.getItem('token'),
isLoggedIn: (state) => !!(state.userInfo?.token || localStorage.getItem('token')),
};
const mutations = {
SET_USER_INFO(state, userInfo) {
state.userInfo = userInfo;
if (userInfo) {
localStorage.setItem('userInfo', JSON.stringify(userInfo));
} else {
localStorage.removeItem('userInfo');
}
},
SET_REFRESH_PROMISE(state, promise) {
state.refreshPromise = promise;
}
};
const actions = {
async login({ commit }, { usernameOrEmail, password, rememberMe = false }) {
try {
const response = await login({ usernameOrEmail, password, rememberMe });
// 保存Token信息
localStorage.setItem('token', response.token);
commit('SET_USER_INFO', {
token: response.token,
refreshToken: response.refreshToken,
tokenExpiresAt: Date.now() + response.expiresIn * 1000
});
return response;
} catch (error) {
throw error;
}
},
async refreshToken({ commit, state }) {
// 防重复刷新
if (state.refreshPromise) {
return state.refreshPromise;
}
const refreshTokenValue = state.userInfo?.refreshToken;
if (!refreshTokenValue) {
throw new Error('刷新令牌不存在');
}
const refreshPromise = (async () => {
try {
const response = await refreshToken(refreshTokenValue);
commit('SET_USER_INFO', {
token: response.token,
refreshToken: response.refreshToken || refreshTokenValue,
tokenExpiresAt: Date.now() + response.expiresIn * 1000
});
localStorage.setItem('token', response.token);
return response;
} finally {
commit('SET_REFRESH_PROMISE', null);
}
})();
commit('SET_REFRESH_PROMISE', refreshPromise);
return refreshPromise;
},
async logout({ commit }) {
try {
await logout();
commit('SET_USER_INFO', null);
localStorage.removeItem('token');
} catch (error) {
// 即使登出失败也要清除本地状态
commit('SET_USER_INFO', null);
localStorage.removeItem('token');
}
}
};
export default {
namespaced: true,
state,
getters,
mutations,
actions
};
拓展开发
1. 配置参数
根据实际业务场景调整配置:
// 推荐配置
const tokenConfig = {
// Access Token: 5-15分钟
accessTokenExpiration: 300, // 5分钟,平衡安全性和用户体验
// Refresh Token: 7-30天
refreshTokenExpiration: 7, // 7天,根据业务敏感度调整
// 预防性刷新: 提前30秒
refreshBeforeExpire: 30, // 避免用户操作中断
};
2. 错误处理
完善的错误处理能显著提升用户体验:
// 错误码映射
const AUTH_ERROR_CONFIGS = {
TOKEN_EXPIRED: {
title: '登录已过期',
message: '您的登录已过期,请重新登录',
needRedirect: true,
},
TOKEN_REVOKED: {
title: '已在其他设备登录',
message: '您的账号已在其他设备登录,请重新登录',
needRedirect: true,
},
REFRESH_TOKEN_EXPIRED: {
title: '会话已过期',
message: '会话已过期,请重新登录',
needRedirect: true,
},
};
3. 安全考虑
Refresh Token 轮换
每次刷新时生成新的 Refresh Token,避免长期使用同一个 Token:
// 刷新时更新Refresh Token
const newRefreshToken = this.generateRefreshToken();
await this.tokenModel.updateOne(
{ _id: tokenDoc._id },
{ refreshToken: newRefreshToken }
);
设备指纹
记录设备信息,检测异常登录:
// 保存设备信息
await this.tokenModel.create({
userId,
refreshToken,
userAgent: req.headers['user-agent'],
ipAddress: req.ip,
fingerprint: this.generateFingerprint(req)
});
性能优化
1. Redis 缓存
对于高频访问的用户信息,可以使用 Redis 缓存:
// 缓存用户信息,减少数据库查询
async validateUser(userId) {
// 先查缓存
let user = await this.redis.get(`user:${userId}`);
if (!user) {
// 缓存未命中,查数据库
user = await this.userModel.findById(userId);
// 缓存5分钟
await this.redis.setex(`user:${userId}`, 300, JSON.stringify(user));
}
return JSON.parse(user);
}
2. 批量验证
对于批量请求,可以考虑批量验证 Token:
// 批量验证Token(适用于内部服务调用)
async validateTokensBatch(tokens) {
const pipeline = this.redis.pipeline();
tokens.forEach(token => {
const tokenHash = this.hashToken(token);
pipeline.exists(`bl_token:${tokenHash}`);
});
return await pipeline.exec();
}
最后
JWT Token 自动刷新机制看似复杂,但核心思路很简单:用短期的 Access Token 保证安全性,用长期的 Refresh Token 保证用户体验。
关键要素:
- 双Token设计 - 分离安全性和便利性
- 前端拦截器 - 自动处理Token相关逻辑
- 并发控制 - 避免重复刷新
- 黑名单机制 - 支持主动撤销
- 错误降级 - 刷新失败时优雅处理
具体的token有效期配置需要根据业务场景调整。比如金融系统可能需要更短的 Token 有效期,而内容管理系统则可以相对宽松一些。
我是一诺,希望这篇文章对你有帮助~
参考资料: