前端登录不掉线!Vue + Node.js 双 Token 无感刷新方案
前言
大家好~ 我是一诺,最近在用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 {// 生成双Tokenasync 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 };}// 刷新Tokenasync 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 {// 刷新Tokenawait 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 {// 刷新Tokenawait 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;})();// 缓存Promisecommit('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 Cookieif (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']);// 更新Cookieres.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);// 清除Cookieif (refreshToken) {res.clearCookie('refreshToken');}return { message: '登出成功' };}
}
2. Token服务
@Injectable()
export class TokenService {async generateAuthTokens(userId, username, rememberMe, userAgent, ipAddress) {// 生成Access Tokenconst payload = { username, sub: userId };const accessToken = this.jwtService.sign(payload);let refreshToken = null;if (rememberMe) {// 生成Refresh TokenrefreshToken = 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 Tokenconst 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 {// 尝试刷新Tokenawait 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 有效期,而内容管理系统则可以相对宽松一些。
我是一诺,希望这篇文章对你有帮助~
参考资料:
- RFC 6749: OAuth 2.0 Authorization Framework
- RFC 7519: JSON Web Token (JWT)