前端登录不掉线!Vue + Node.js 双 Token 无感刷新方案_node无感刷新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)才是真正的安全凭证。
流程图如下:
#mermaid-svg-2L9MnMowaAA4rAWG {font-family:\"trebuchet ms\",verdana,arial,sans-serif;font-size:16px;fill:#333;}#mermaid-svg-2L9MnMowaAA4rAWG .error-icon{fill:#552222;}#mermaid-svg-2L9MnMowaAA4rAWG .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-2L9MnMowaAA4rAWG .edge-thickness-normal{stroke-width:2px;}#mermaid-svg-2L9MnMowaAA4rAWG .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-2L9MnMowaAA4rAWG .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-2L9MnMowaAA4rAWG .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-2L9MnMowaAA4rAWG .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-2L9MnMowaAA4rAWG .marker{fill:#333333;stroke:#333333;}#mermaid-svg-2L9MnMowaAA4rAWG .marker.cross{stroke:#333333;}#mermaid-svg-2L9MnMowaAA4rAWG svg{font-family:\"trebuchet ms\",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-2L9MnMowaAA4rAWG .label{font-family:\"trebuchet ms\",verdana,arial,sans-serif;color:#333;}#mermaid-svg-2L9MnMowaAA4rAWG .cluster-label text{fill:#333;}#mermaid-svg-2L9MnMowaAA4rAWG .cluster-label span{color:#333;}#mermaid-svg-2L9MnMowaAA4rAWG .label text,#mermaid-svg-2L9MnMowaAA4rAWG span{fill:#333;color:#333;}#mermaid-svg-2L9MnMowaAA4rAWG .node rect,#mermaid-svg-2L9MnMowaAA4rAWG .node circle,#mermaid-svg-2L9MnMowaAA4rAWG .node ellipse,#mermaid-svg-2L9MnMowaAA4rAWG .node polygon,#mermaid-svg-2L9MnMowaAA4rAWG .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-2L9MnMowaAA4rAWG .node .label{text-align:center;}#mermaid-svg-2L9MnMowaAA4rAWG .node.clickable{cursor:pointer;}#mermaid-svg-2L9MnMowaAA4rAWG .arrowheadPath{fill:#333333;}#mermaid-svg-2L9MnMowaAA4rAWG .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-2L9MnMowaAA4rAWG .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-2L9MnMowaAA4rAWG .edgeLabel{background-color:#e8e8e8;text-align:center;}#mermaid-svg-2L9MnMowaAA4rAWG .edgeLabel rect{opacity:0.5;background-color:#e8e8e8;fill:#e8e8e8;}#mermaid-svg-2L9MnMowaAA4rAWG .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-2L9MnMowaAA4rAWG .cluster text{fill:#333;}#mermaid-svg-2L9MnMowaAA4rAWG .cluster span{color:#333;}#mermaid-svg-2L9MnMowaAA4rAWG div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\"trebuchet ms\",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-2L9MnMowaAA4rAWG :root{--mermaid-font-family:\"trebuchet ms\",verdana,arial,sans-serif;} 是 否 用户登录 勾选记住登录? 生成双Token 只生成Access Token Access Token (5分钟)
Refresh Token (30天) Access Token (5分钟) 保存到前端存储 正常使用系统
技术架构
流程图如下:
#mermaid-svg-2dqBuw7ffF5ZYLjP {font-family:\"trebuchet ms\",verdana,arial,sans-serif;font-size:16px;fill:#333;}#mermaid-svg-2dqBuw7ffF5ZYLjP .error-icon{fill:#552222;}#mermaid-svg-2dqBuw7ffF5ZYLjP .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-2dqBuw7ffF5ZYLjP .edge-thickness-normal{stroke-width:2px;}#mermaid-svg-2dqBuw7ffF5ZYLjP .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-2dqBuw7ffF5ZYLjP .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-2dqBuw7ffF5ZYLjP .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-2dqBuw7ffF5ZYLjP .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-2dqBuw7ffF5ZYLjP .marker{fill:#333333;stroke:#333333;}#mermaid-svg-2dqBuw7ffF5ZYLjP .marker.cross{stroke:#333333;}#mermaid-svg-2dqBuw7ffF5ZYLjP svg{font-family:\"trebuchet ms\",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-2dqBuw7ffF5ZYLjP .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-2dqBuw7ffF5ZYLjP text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-2dqBuw7ffF5ZYLjP .actor-line{stroke:grey;}#mermaid-svg-2dqBuw7ffF5ZYLjP .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-2dqBuw7ffF5ZYLjP .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-2dqBuw7ffF5ZYLjP #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-2dqBuw7ffF5ZYLjP .sequenceNumber{fill:white;}#mermaid-svg-2dqBuw7ffF5ZYLjP #sequencenumber{fill:#333;}#mermaid-svg-2dqBuw7ffF5ZYLjP #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-2dqBuw7ffF5ZYLjP .messageText{fill:#333;stroke:#333;}#mermaid-svg-2dqBuw7ffF5ZYLjP .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-2dqBuw7ffF5ZYLjP .labelText,#mermaid-svg-2dqBuw7ffF5ZYLjP .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-2dqBuw7ffF5ZYLjP .loopText,#mermaid-svg-2dqBuw7ffF5ZYLjP .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-2dqBuw7ffF5ZYLjP .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-2dqBuw7ffF5ZYLjP .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-2dqBuw7ffF5ZYLjP .noteText,#mermaid-svg-2dqBuw7ffF5ZYLjP .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-2dqBuw7ffF5ZYLjP .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-2dqBuw7ffF5ZYLjP .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-2dqBuw7ffF5ZYLjP .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-2dqBuw7ffF5ZYLjP .actorPopupMenu{position:absolute;}#mermaid-svg-2dqBuw7ffF5ZYLjP .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-2dqBuw7ffF5ZYLjP .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-2dqBuw7ffF5ZYLjP .actor-man circle,#mermaid-svg-2dqBuw7ffF5ZYLjP line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-2dqBuw7ffF5ZYLjP :root{--mermaid-font-family:\"trebuchet ms\",verdana,arial,sans-serif;} 客户端 认证服务 数据库 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.tsexport 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 相关事务。
流程图如下
#mermaid-svg-BxiXLvPP0EfeCI6h {font-family:\"trebuchet ms\",verdana,arial,sans-serif;font-size:16px;fill:#333;}#mermaid-svg-BxiXLvPP0EfeCI6h .error-icon{fill:#552222;}#mermaid-svg-BxiXLvPP0EfeCI6h .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-BxiXLvPP0EfeCI6h .edge-thickness-normal{stroke-width:2px;}#mermaid-svg-BxiXLvPP0EfeCI6h .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-BxiXLvPP0EfeCI6h .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-BxiXLvPP0EfeCI6h .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-BxiXLvPP0EfeCI6h .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-BxiXLvPP0EfeCI6h .marker{fill:#333333;stroke:#333333;}#mermaid-svg-BxiXLvPP0EfeCI6h .marker.cross{stroke:#333333;}#mermaid-svg-BxiXLvPP0EfeCI6h svg{font-family:\"trebuchet ms\",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-BxiXLvPP0EfeCI6h .label{font-family:\"trebuchet ms\",verdana,arial,sans-serif;color:#333;}#mermaid-svg-BxiXLvPP0EfeCI6h .cluster-label text{fill:#333;}#mermaid-svg-BxiXLvPP0EfeCI6h .cluster-label span{color:#333;}#mermaid-svg-BxiXLvPP0EfeCI6h .label text,#mermaid-svg-BxiXLvPP0EfeCI6h span{fill:#333;color:#333;}#mermaid-svg-BxiXLvPP0EfeCI6h .node rect,#mermaid-svg-BxiXLvPP0EfeCI6h .node circle,#mermaid-svg-BxiXLvPP0EfeCI6h .node ellipse,#mermaid-svg-BxiXLvPP0EfeCI6h .node polygon,#mermaid-svg-BxiXLvPP0EfeCI6h .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-BxiXLvPP0EfeCI6h .node .label{text-align:center;}#mermaid-svg-BxiXLvPP0EfeCI6h .node.clickable{cursor:pointer;}#mermaid-svg-BxiXLvPP0EfeCI6h .arrowheadPath{fill:#333333;}#mermaid-svg-BxiXLvPP0EfeCI6h .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-BxiXLvPP0EfeCI6h .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-BxiXLvPP0EfeCI6h .edgeLabel{background-color:#e8e8e8;text-align:center;}#mermaid-svg-BxiXLvPP0EfeCI6h .edgeLabel rect{opacity:0.5;background-color:#e8e8e8;fill:#e8e8e8;}#mermaid-svg-BxiXLvPP0EfeCI6h .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-BxiXLvPP0EfeCI6h .cluster text{fill:#333;}#mermaid-svg-BxiXLvPP0EfeCI6h .cluster span{color:#333;}#mermaid-svg-BxiXLvPP0EfeCI6h div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\"trebuchet ms\",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-BxiXLvPP0EfeCI6h :root{--mermaid-font-family:\"trebuchet ms\",verdana,arial,sans-serif;} 否 是 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 { // 刷新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 次刷新请求(浪费资源)
- 可能产生竞态条件
- 用户体验差
解决方案是使用请求队列:
如图所示:
#mermaid-svg-nixbn7AAS1wmdNHC {font-family:\"trebuchet ms\",verdana,arial,sans-serif;font-size:16px;fill:#333;}#mermaid-svg-nixbn7AAS1wmdNHC .error-icon{fill:#552222;}#mermaid-svg-nixbn7AAS1wmdNHC .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-nixbn7AAS1wmdNHC .edge-thickness-normal{stroke-width:2px;}#mermaid-svg-nixbn7AAS1wmdNHC .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-nixbn7AAS1wmdNHC .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-nixbn7AAS1wmdNHC .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-nixbn7AAS1wmdNHC .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-nixbn7AAS1wmdNHC .marker{fill:#333333;stroke:#333333;}#mermaid-svg-nixbn7AAS1wmdNHC .marker.cross{stroke:#333333;}#mermaid-svg-nixbn7AAS1wmdNHC svg{font-family:\"trebuchet ms\",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-nixbn7AAS1wmdNHC .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-nixbn7AAS1wmdNHC text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-nixbn7AAS1wmdNHC .actor-line{stroke:grey;}#mermaid-svg-nixbn7AAS1wmdNHC .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-nixbn7AAS1wmdNHC .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-nixbn7AAS1wmdNHC #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-nixbn7AAS1wmdNHC .sequenceNumber{fill:white;}#mermaid-svg-nixbn7AAS1wmdNHC #sequencenumber{fill:#333;}#mermaid-svg-nixbn7AAS1wmdNHC #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-nixbn7AAS1wmdNHC .messageText{fill:#333;stroke:#333;}#mermaid-svg-nixbn7AAS1wmdNHC .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-nixbn7AAS1wmdNHC .labelText,#mermaid-svg-nixbn7AAS1wmdNHC .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-nixbn7AAS1wmdNHC .loopText,#mermaid-svg-nixbn7AAS1wmdNHC .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-nixbn7AAS1wmdNHC .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-nixbn7AAS1wmdNHC .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-nixbn7AAS1wmdNHC .noteText,#mermaid-svg-nixbn7AAS1wmdNHC .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-nixbn7AAS1wmdNHC .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-nixbn7AAS1wmdNHC .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-nixbn7AAS1wmdNHC .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-nixbn7AAS1wmdNHC .actorPopupMenu{position:absolute;}#mermaid-svg-nixbn7AAS1wmdNHC .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-nixbn7AAS1wmdNHC .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-nixbn7AAS1wmdNHC .actor-man circle,#mermaid-svg-nixbn7AAS1wmdNHC line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-nixbn7AAS1wmdNHC :root{--mermaid-font-family:\"trebuchet ms\",verdana,arial,sans-serif;} 请求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 { // 刷新Token await refreshToken(); // 处理队列中的请求 processQueue(null, newToken); } catch (error) { // 处理失败的请求 processQueue(error, null); } finally { isRefreshing = false; }}
2. 防重复刷新问题
在 Vuex 中也需要防止重复刷新:
// Vuex Storeconst 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 黑名单:
流程图如下:
#mermaid-svg-TQXfwQNOJqPWTq0O {font-family:\"trebuchet ms\",verdana,arial,sans-serif;font-size:16px;fill:#333;}#mermaid-svg-TQXfwQNOJqPWTq0O .error-icon{fill:#552222;}#mermaid-svg-TQXfwQNOJqPWTq0O .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-TQXfwQNOJqPWTq0O .edge-thickness-normal{stroke-width:2px;}#mermaid-svg-TQXfwQNOJqPWTq0O .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-TQXfwQNOJqPWTq0O .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-TQXfwQNOJqPWTq0O .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-TQXfwQNOJqPWTq0O .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-TQXfwQNOJqPWTq0O .marker{fill:#333333;stroke:#333333;}#mermaid-svg-TQXfwQNOJqPWTq0O .marker.cross{stroke:#333333;}#mermaid-svg-TQXfwQNOJqPWTq0O svg{font-family:\"trebuchet ms\",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-TQXfwQNOJqPWTq0O .label{font-family:\"trebuchet ms\",verdana,arial,sans-serif;color:#333;}#mermaid-svg-TQXfwQNOJqPWTq0O .cluster-label text{fill:#333;}#mermaid-svg-TQXfwQNOJqPWTq0O .cluster-label span{color:#333;}#mermaid-svg-TQXfwQNOJqPWTq0O .label text,#mermaid-svg-TQXfwQNOJqPWTq0O span{fill:#333;color:#333;}#mermaid-svg-TQXfwQNOJqPWTq0O .node rect,#mermaid-svg-TQXfwQNOJqPWTq0O .node circle,#mermaid-svg-TQXfwQNOJqPWTq0O .node ellipse,#mermaid-svg-TQXfwQNOJqPWTq0O .node polygon,#mermaid-svg-TQXfwQNOJqPWTq0O .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-TQXfwQNOJqPWTq0O .node .label{text-align:center;}#mermaid-svg-TQXfwQNOJqPWTq0O .node.clickable{cursor:pointer;}#mermaid-svg-TQXfwQNOJqPWTq0O .arrowheadPath{fill:#333333;}#mermaid-svg-TQXfwQNOJqPWTq0O .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-TQXfwQNOJqPWTq0O .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-TQXfwQNOJqPWTq0O .edgeLabel{background-color:#e8e8e8;text-align:center;}#mermaid-svg-TQXfwQNOJqPWTq0O .edgeLabel rect{opacity:0.5;background-color:#e8e8e8;fill:#e8e8e8;}#mermaid-svg-TQXfwQNOJqPWTq0O .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-TQXfwQNOJqPWTq0O .cluster text{fill:#333;}#mermaid-svg-TQXfwQNOJqPWTq0O .cluster span{color:#333;}#mermaid-svg-TQXfwQNOJqPWTq0O div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\"trebuchet ms\",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-TQXfwQNOJqPWTq0O :root{--mermaid-font-family:\"trebuchet ms\",verdana,arial,sans-serif;} 是 否 用户登出 后端处理 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 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 Tokenconst 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)


