当前位置: 首页 > news >正文

前端登录不掉线!Vue + Node.js 双 Token 无感刷新方案

前言

大家好~ 我是一诺,最近在用Vue+Nest.js 开发个人项目,遇到了一个经典问题:JWT Token 的过期处理。

传统的做法是,Token 一过期就让用户重新登录。但这样用户体验很差,想象一下你正在写一篇长文章,突然系统提示"登录过期,请重新登录",之前的内容可能就丢失了。

有没有更好的解决方案呢?答案是有的,就是 Token 自动刷新机制

今天咱们一起讨论下 在 Vue.js + NestJS 项目中实现一套完整的 Token 自动刷新方案。

核心思路

传统的单 Token 方案有一个根本性问题:安全性和用户体验无法兼得

  • Token 过期时间短 → 安全性高,但用户体验差
  • Token 过期时间长 → 用户体验好,但安全风险大

解决方案是引入双 Token 机制

  1. Access Token(访问令牌):过期时间短(5分钟),用于日常 API 调用
  2. Refresh Token(刷新令牌):过期时间长(30天),用于获取新的 Access Token

这就像银行卡和密码的关系:银行卡(Access Token)丢了影响有限,密码(Refresh Token)才是真正的安全凭证。

流程图如下:

用户登录
勾选记住登录?
生成双Token
只生成Access Token
Access Token (5分钟)
Refresh Token (30天)
Access Token (5分钟)
保存到前端存储
正常使用系统

技术架构

流程图如下:

客户端 认证服务 数据库 Redis黑名单 登录请求 (rememberMe=true) 验证用户凭证 用户信息 生成 Access Token (5分钟) 生成 Refresh Token (30天) 保存 Refresh Token 返回双Token 5分钟后 Access Token 过期 API请求 (携带过期Token) 401 Unauthorized 刷新请求 (携带Refresh Token) 验证 Refresh Token Token有效 生成新的双Token 更新 Refresh Token 返回新Token 重试API请求 (携带新Token) 正常响应 客户端 认证服务 数据库 Redis黑名单

后端设计

后端采用 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 相关事务。

流程图如下

200 OK
401 Unauthorized
用户发起API请求
请求拦截器
Token存在?
直接发送请求
添加Authorization头
发送到后端
返回状态
正常返回数据
响应拦截器
正在刷新中?
加入等待队列
开始刷新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 次刷新请求(浪费资源)
  • 可能产生竞态条件
  • 用户体验差

解决方案是使用请求队列

如图所示:

请求1 请求2 请求3 拦截器 服务器 同时发起请求,Token已过期 API请求 API请求 API请求 401 错误 401 错误 401 错误 处理401 (第一个) 设置 isRefreshing = true 开始刷新Token 处理401 (第二个) 发现正在刷新,加入队列 处理401 (第三个) 发现正在刷新,加入队列 刷新Token请求 返回新Token 刷新成功,处理队列 重试请求1 重试请求2 重试请求3 请求1 请求2 请求3 拦截器 服务器
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 黑名单
流程图如下:

用户登出
后端处理
Access Token 加入 Redis黑名单
Refresh Token 标记为无效
设置过期时间 = Token剩余时间
数据库 isValid = false
后续API请求
JWT守卫验证
检查JWT签名和过期时间
检查Redis黑名单
在黑名单中?
拒绝请求 401
继续处理
// 用户登出时
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 保证用户体验

关键要素:

  1. 双Token设计 - 分离安全性和便利性
  2. 前端拦截器 - 自动处理Token相关逻辑
  3. 并发控制 - 避免重复刷新
  4. 黑名单机制 - 支持主动撤销
  5. 错误降级 - 刷新失败时优雅处理

具体的token有效期配置需要根据业务场景调整。比如金融系统可能需要更短的 Token 有效期,而内容管理系统则可以相对宽松一些。

我是一诺,希望这篇文章对你有帮助~


参考资料:

  • RFC 6749: OAuth 2.0 Authorization Framework
  • RFC 7519: JSON Web Token (JWT)
http://www.lryc.cn/news/573437.html

相关文章:

  • 爱高集团引领转型浪潮:AI与区块链驱动香港科技资本新机遇
  • [C++] STL数据结构小结
  • Linux——Json
  • 【系统分析师】2017年真题:综合知识-答案及详解
  • JVM(8)——详解分代收集算法
  • 【基础算法】贪心 (一) :简单贪心
  • Python标准库 zlib模块【数据压缩/解压】全面讲解
  • Python元组常用操作方法
  • 什么是跨域问题?后端如何解决跨域问题?
  • MCU量产高效烧录:BootLoader与App合并技巧
  • 【Python】正则表达式中的`^`和`[]`
  • 学c++ cpp 可以投递哪些岗位
  • 从0开始学习计算机视觉--Day02--数据驱动
  • MySQL误删数据急救指南:基于Binlog日志的实战恢复详解
  • Mac Parallels Desktop Kali 2025 代理设置
  • OpenAI与微软的未来合作之路:充满挑战的AI竞赛与共赢
  • YAML 数据格式详解
  • 计算机网络第九章——数据链路层《流量控制和可靠传输》
  • 基于SpringBoot+Uniapp的活动中心预约小程序(协同过滤算法、腾讯地图、二维码识别)
  • Docker镜像制作---指令
  • Qt输入数据验证的方法
  • rent8_wechat-最常用出租屋管理系统-微信小程序
  • 从零开发ComfyUI插件:打造你的AI绘画专属工具
  • 私有规则库:企业合规与安全的终极防线
  • C# 将 Enum枚举转成List,并显示在下拉列表中
  • LINUX621 NFS 同步 ;FTP;samba环境
  • 面试题-ts中的typeof
  • 面试题-把类型为b的值赋给类型为a的变量
  • Laravel 项目中图片上传后无法访问的问题
  • SQL关键字三分钟入门:INSERT INTO —— 插入数据详解