1. 为什么我们需要双Token从登录体验说起不知道你有没有遇到过这种情况正在一个后台管理系统里吭哧吭哧地填报表突然页面弹出一个提示“登录已过期请重新登录”然后你辛辛苦苦填了半天的数据就因为一个登录超时全没了。或者你正在用手机App刷着内容突然被踢回登录页面那种感觉真的非常糟糕。这就是传统的单Token认证机制带来的一个典型痛点——用户体验的中断。在前后端分离的项目里我们通常用JWTJSON Web Token来做用户认证。简单来说用户登录成功后后端生成一个Token字符串返回给前端前端之后每次请求API都把这个Token放在请求头里带过去后端验证Token有效就放行。这个Token就像一张临时的“通行证”。问题在于为了安全这张通行证不能长期有效通常我们设置一个比较短的有效期比如15分钟、30分钟。时间一到通行证就失效了用户就必须重新登录。对于需要长时间操作的系统比如后台管理、在线文档编辑这简直是灾难。于是“双Token无感刷新”的方案就应运而生了。它的核心思想很简单发两张通行证。一张是访问令牌Access Token有效期很短比如15分钟用于日常的API请求认证。另一张是刷新令牌Refresh Token有效期很长比如7天它不直接用于业务请求只干一件事当Access Token过期时用它去后端“悄悄地”换一张新的Access Token回来。整个过程对用户完全透明他感觉不到登录状态的中断这就是“无感刷新”。我最早在项目里用上这个方案是因为产品经理被用户投诉搞烦了跑来问我“能不能让用户一天只登录一次” 我研究了一圈发现双Token是目前平衡安全性和体验的最佳实践之一。它把安全风险控制在了短期的Access Token上即使这个Token泄露了危害时间也很有限而长期的Refresh Token则被安全地存储比如HttpOnly的Cookie里专门用于维持会话。接下来我就手把手带你在一个SpringBoot Vue的项目里从零搭建这套机制。2. 后端核心设计如何生成、校验与刷新令牌2.1 JWT工具类的增强与安全考量原始文章里的JWT工具类是个不错的起点但实际用起来我们得考虑更多。比如密钥secret硬编码在代码里是非常不安全的一旦代码泄露攻击者就能伪造任意Token。我们应该把它放到配置文件里。另外Access Token和Refresh Token的内容也应该有所区分。下面是我优化后的一个JWT工具类我把它放在src/main/java/com/yourproject/util/JwtUtil.javapackage com.yourproject.util; import io.jsonwebtoken.*; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; import javax.annotation.PostConstruct; import java.util.Date; import java.util.HashMap; import java.util.Map; Component public class JwtUtil { // 从application.yml读取例如jwt.secret: your-256-bit-secret-key-here Value(${jwt.secret}) private String secretKey; // Access Token过期时间例如900000 (15分钟) Value(${jwt.access-token-expire}) private long accessTokenExpire; // Refresh Token过期时间例如604800000 (7天) Value(${jwt.refresh-token-expire}) private long refreshTokenExpire; private static String secret; private static long accessExpire; private static long refreshExpire; // PostConstruct 确保在依赖注入完成后将配置文件的值赋给静态变量 PostConstruct public void init() { secret this.secretKey; accessExpire this.accessTokenExpire; refreshExpire this.refreshTokenExpire; } /** * 生成Access Token * param userId 用户ID * param username 用户名 * return Access Token字符串 */ public static String generateAccessToken(String userId, String username) { MapString, Object claims new HashMap(); claims.put(userId, userId); claims.put(username, username); claims.put(type, access); // 明确令牌类型 return Jwts.builder() .setHeaderParam(typ, JWT) .setHeaderParam(alg, HS256) .setClaims(claims) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() accessExpire)) .signWith(SignatureAlgorithm.HS256, secret) .compact(); } /** * 生成Refresh Token * param userId 用户ID * return Refresh Token字符串 */ public static String generateRefreshToken(String userId) { MapString, Object claims new HashMap(); claims.put(userId, userId); claims.put(type, refresh); // 明确令牌类型 return Jwts.builder() .setHeaderParam(typ, JWT) .setHeaderParam(alg, HS256) .setClaims(claims) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() refreshExpire)) .signWith(SignatureAlgorithm.HS256, secret) .compact(); } /** * 解析JWT获取Claims载荷 * param token JWT字符串 * return Claims对象 */ public static Claims parseToken(String token) { try { return Jwts.parser() .setSigningKey(secret) .parseClaimsJws(token) .getBody(); } catch (ExpiredJwtException e) { // 特别注意Token过期异常单独抛出方便上层判断是过期还是非法Token throw e; } catch (JwtException e) { // 其他解析异常如签名错误、格式错误等 throw new RuntimeException(令牌无效, e); } } /** * 验证Token是否过期 (通过解析是否抛出ExpiredJwtException来判断) * 这个方法通常不需要因为parseToken已经包含了验证。 * 这里单独列出来是为了逻辑清晰。 */ public static boolean isTokenExpired(String token) { try { parseToken(token); return false; } catch (ExpiredJwtException e) { return true; } catch (Exception e) { // 非过期异常视为无效令牌 return true; } } /** * 从Claims中获取用户ID */ public static String getUserIdFromClaims(Claims claims) { return claims.get(userId, String.class); } /** * 从Claims中获取令牌类型 */ public static String getTokenTypeFromClaims(Claims claims) { return claims.get(type, String.class); } }关键点解析配置化密钥和过期时间都从application.yml读取不同环境开发、测试、生产可以配置不同的值更安全也更灵活。令牌区分我在Token的载荷claims里加了一个type字段明确区分这是Access Token还是Refresh Token。这在后续的校验逻辑里非常有用可以防止用Refresh Token直接访问业务接口。异常处理在parseToken方法里我特意将ExpiredJwtException过期异常单独捕获并重新抛出。这样在过滤器中我们就能精确地知道Token是“过期了”还是“根本就是假的”从而做出不同的响应过期了可以尝试刷新假令牌直接报错。静态变量初始化因为工具类的方法通常是静态的为了方便我们用PostConstruct将配置文件注入的值赋给静态变量。这是一种常见的处理方式。对应的application.yml配置jwt: secret: your-super-secret-key-change-in-production # 生产环境一定要用强随机字符串 access-token-expire: 900000 # 15分钟单位毫秒 refresh-token-expire: 604800000 # 7天单位毫秒2.2 登录接口签发双Token登录接口是双Token的起点。用户用账号密码登录验证通过后我们同时生成Access Token和Refresh Token返回给前端。这里有个重要的决策点Refresh Token存哪里常见的有两种方式和Access Token一起返回给前端前端自己存比如sessionStorage。这种方式实现简单但Refresh Token暴露在JS可访问的范围有被XSS攻击窃取的风险。通过HttpOnly Cookie返回后端在响应头里设置一个HttpOnly的Cookie来存储Refresh Token。这样JavaScript无法读取这个Cookie能有效防御XSS攻击安全性更高。但需要注意跨域CORS和CSRF防护。为了教程的直观性我们先采用第一种方式一起返回。在实际生产环境中我强烈建议你根据安全评估采用第二种或更安全的方案例如结合Redis黑名单。下面是登录Controller的示例package com.yourproject.controller; import com.yourproject.entity.User; import com.yourproject.service.UserService; import com.yourproject.util.JwtUtil; import com.yourproject.vo.ResponseModel; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.*; import java.util.HashMap; import java.util.Map; RestController RequestMapping(/api/auth) public class AuthController { Autowired private UserService userService; PostMapping(/login) public ResponseModel login(RequestBody User loginUser) { // 1. 验证用户名密码 (这里简化实际应从数据库查询并比对加密密码) User user userService.authenticate(loginUser.getUsername(), loginUser.getPassword()); if (user null) { return new ResponseModel(401, 用户名或密码错误, null); } // 2. 生成双Token String accessToken JwtUtil.generateAccessToken(user.getId(), user.getUsername()); String refreshToken JwtUtil.generateRefreshToken(user.getId()); // 3. 构造返回数据 MapString, String tokenMap new HashMap(); tokenMap.put(accessToken, accessToken); tokenMap.put(refreshToken, refreshToken); // 也可以返回用户基本信息如用户名、头像等 MapString, Object data new HashMap(); data.put(tokens, tokenMap); data.put(userInfo, user.safeInfo()); // 返回脱敏后的用户信息 return new ResponseModel(200, 登录成功, data); } }2.3 核心实现自动刷新的过滤器/拦截器这是后端最核心的部分。它的职责是拦截所有需要认证的请求检查Access Token。如果Token有效就放行如果Token过期但Refresh Token有效就“悄悄地”生成新的双Token返回给前端并让原请求继续执行。原始文章里的过滤器逻辑比较简单它只检查Access Token是否存在和是否过期过期就直接返回401。我们需要改造它加入自动刷新的逻辑。这里我选择使用Spring的OncePerRequestFilter它比WebFilter注解的方式更易于集成到Spring Security如果你后续要引入的话。创建一个TokenRefreshFilter.javapackage com.yourproject.filter; import com.yourproject.util.JwtUtil; import com.yourproject.util.ResponseUtil; import com.yourproject.vo.ResponseModel; import com.fasterxml.jackson.databind.ObjectMapper; import io.jsonwebtoken.Claims; import io.jsonwebtoken.ExpiredJwtException; import org.springframework.util.AntPathMatcher; import org.springframework.util.StringUtils; import org.springframework.web.filter.OncePerRequestFilter; import javax.servlet.FilterChain; import javax.servlet.ServletException; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; import java.util.Arrays; import java.util.List; public class TokenRefreshFilter extends OncePerRequestFilter { // 不需要Token验证的白名单路径 private static final ListString WHITE_LIST Arrays.asList( /api/auth/login, /api/auth/register, /api/public/** ); private final AntPathMatcher pathMatcher new AntPathMatcher(); Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String requestURI request.getRequestURI(); // 1. 检查是否为白名单请求是则直接放行 if (isWhiteList(requestURI)) { filterChain.doFilter(request, response); return; } // 2. 获取Access Token String accessToken request.getHeader(Authorization); if (StringUtils.hasText(accessToken) accessToken.startsWith(Bearer )) { accessToken accessToken.substring(7); // 去掉 Bearer 前缀 } else { // 没有Token返回未授权 ResponseUtil.write(response, new ResponseModel(401, 认证令牌缺失, null)); return; } try { // 3. 验证Access Token Claims claims JwtUtil.parseToken(accessToken); String tokenType JwtUtil.getTokenTypeFromClaims(claims); // 额外检查确保这是Access Token防止有人拿Refresh Token来访问 if (!access.equals(tokenType)) { ResponseUtil.write(response, new ResponseModel(403, 令牌类型错误, null)); return; } // Token验证通过将用户信息放入请求属性方便后续Controller使用 request.setAttribute(userId, JwtUtil.getUserIdFromClaims(claims)); filterChain.doFilter(request, response); } catch (ExpiredJwtException expiredEx) { // 4. Access Token 过期尝试使用Refresh Token刷新 handleTokenRefresh(request, response, filterChain, expiredEx); } catch (Exception e) { // 5. 其他解析错误签名无效、格式错误等 ResponseUtil.write(response, new ResponseModel(403, 令牌无效或已失效, null)); } } private void handleTokenRefresh(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain, ExpiredJwtException expiredEx) throws IOException, ServletException { // 1. 获取Refresh Token (假设前端也通过Authorization头传递格式如 Refresh {token}) String refreshTokenHeader request.getHeader(Refresh-Token); // 或者可以从特定Cookie中获取 if (!StringUtils.hasText(refreshTokenHeader)) { ResponseUtil.write(response, new ResponseModel(401, 访问令牌已过期请重新登录, null)); return; } try { // 2. 验证Refresh Token Claims refreshClaims JwtUtil.parseToken(refreshTokenHeader); String refreshTokenType JwtUtil.getTokenTypeFromClaims(refreshClaims); if (!refresh.equals(refreshTokenType)) { ResponseUtil.write(response, new ResponseModel(403, 刷新令牌类型错误, null)); return; } // 3. 验证Refresh Token中的用户ID是否与过期的Access Token中的用户ID一致防止令牌被挪用 String expiredUserId expiredEx.getClaims().get(userId, String.class); String refreshUserId JwtUtil.getUserIdFromClaims(refreshClaims); if (!expiredUserId.equals(refreshUserId)) { ResponseUtil.write(response, new ResponseModel(403, 令牌不匹配, null)); return; } // 4. 刷新令牌有效生成新的双Token String newAccessToken JwtUtil.generateAccessToken(refreshUserId, expiredEx.getClaims().get(username, String.class)); String newRefreshToken JwtUtil.generateRefreshToken(refreshUserId); // 5. 将新的Access Token放入响应头这是关键 response.setHeader(New-Access-Token, newAccessToken); response.setHeader(New-Refresh-Token, newRefreshToken); // 也可以考虑将新的Refresh Token通过HttpOnly Cookie设置 // 6. 允许原请求继续执行将用户信息放入请求 request.setAttribute(userId, refreshUserId); filterChain.doFilter(request, response); } catch (ExpiredJwtException e) { // Refresh Token 也过期了 ResponseUtil.write(response, new ResponseModel(401, 会话已过期请重新登录, null)); } catch (Exception e) { // Refresh Token 无效 ResponseUtil.write(response, new ResponseModel(403, 刷新令牌无效, null)); } } private boolean isWhiteList(String requestURI) { for (String pattern : WHITE_LIST) { if (pathMatcher.match(pattern, requestURI)) { return true; } } return false; } }这个过滤器的精妙之处在于第5步当它判断出Access Token过期但Refresh Token有效时它并没有直接返回错误而是生成了新的双Token通过响应头New-Access-Token返回给前端然后继续执行用户原本的请求。这样前端在收到这个请求的响应时就能从响应头里拿到新Token并更新本地存储同时用户原本的API调用也成功拿到了数据。整个过程对用户和前端业务逻辑都是无感的。最后别忘了在SpringBoot配置类中注册这个过滤器package com.yourproject.config; import com.yourproject.filter.TokenRefreshFilter; import org.springframework.boot.web.servlet.FilterRegistrationBean; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; Configuration public class FilterConfig { Bean public FilterRegistrationBeanTokenRefreshFilter tokenRefreshFilter() { FilterRegistrationBeanTokenRefreshFilter registrationBean new FilterRegistrationBean(); registrationBean.setFilter(new TokenRefreshFilter()); registrationBean.addUrlPatterns(/api/*); // 拦截所有/api/开头的请求 registrationBean.setOrder(1); // 设置过滤器顺序 return registrationBean; } }2.4 提供一个显式的刷新接口虽然我们的过滤器能自动刷新但为了前端灵活性比如在应用启动时主动检查并刷新Token我们最好也提供一个显式的刷新接口。这个接口专门用于接收一个有效的Refresh Token并返回一套全新的双Token。package com.yourproject.controller; import com.yourproject.util.JwtUtil; import com.yourproject.vo.ResponseModel; import io.jsonwebtoken.Claims; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import java.util.HashMap; import java.util.Map; RestController RequestMapping(/api/auth) public class TokenRefreshController { PostMapping(/refresh) public ResponseModel refreshToken(RequestHeader(Refresh-Token) String refreshToken) { if (refreshToken null || refreshToken.isEmpty()) { return new ResponseModel(400, 刷新令牌不能为空, null); } try { Claims claims JwtUtil.parseToken(refreshToken); if (!refresh.equals(JwtUtil.getTokenTypeFromClaims(claims))) { return new ResponseModel(403, 令牌类型错误, null); } String userId JwtUtil.getUserIdFromClaims(claims); // 这里可以添加额外的安全检查比如检查该Refresh Token是否在黑名单中 // 生成新的双Token String newAccessToken JwtUtil.generateAccessToken(userId, claims.get(username, String.class)); String newRefreshToken JwtUtil.generateRefreshToken(userId); MapString, String tokenMap new HashMap(); tokenMap.put(accessToken, newAccessToken); tokenMap.put(refreshToken, newRefreshToken); return new ResponseModel(200, 令牌刷新成功, tokenMap); } catch (io.jsonwebtoken.ExpiredJwtException e) { return new ResponseModel(401, 刷新令牌已过期请重新登录, null); } catch (Exception e) { return new ResponseModel(403, 刷新令牌无效, null); } } }3. 前端Vue项目让无感刷新丝滑起来后端准备好了前端的工作就是配合这个机制在用户无感知的情况下完成Token的更新。核心在于Axios的请求拦截器和响应拦截器。3.1 项目结构与Axios封装首先我习惯在src/utils/目录下创建一个request.js文件来封装Axios实例这样全局配置管理起来更方便。// src/utils/request.js import axios from axios; import { Message } from element-ui; // 假设使用Element UI的消息提示你可以用任何UI库或console // 创建axios实例 const service axios.create({ baseURL: process.env.VUE_APP_API_BASE_URL || http://localhost:8080/api, // 从环境变量读取 timeout: 15000, // 请求超时时间 }); // 是否正在刷新的标记防止多个请求同时过期导致重复刷新 let isRefreshing false; // 重试队列存储那些因为Token过期而失败的请求 let requestsQueue []; // 请求拦截器 service.interceptors.request.use( (config) { // 从本地存储获取Access Token const accessToken localStorage.getItem(accessToken); // 或用sessionStorage if (accessToken) { // 按照后端过滤器约定的格式添加Token config.headers[Authorization] Bearer ${accessToken}; } // 如果是刷新Token的请求携带Refresh Token if (config.url.includes(/auth/refresh)) { const refreshToken localStorage.getItem(refreshToken); if (refreshToken) { config.headers[Refresh-Token] refreshToken; } } return config; }, (error) { return Promise.reject(error); } ); // 响应拦截器 - 这里是实现无感刷新的核心 service.interceptors.response.use( (response) { // 2xx 范围内的状态码都会触发该函数。 // 检查响应头是否有新的Token返回 const newAccessToken response.headers[new-access-token]; const newRefreshToken response.headers[new-refresh-token]; if (newAccessToken) { localStorage.setItem(accessToken, newAccessToken); console.log(Access Token已自动更新); } if (newRefreshToken) { localStorage.setItem(refreshToken, newRefreshToken); } return response.data; // 直接返回后端响应的数据部分 }, async (error) { // 超出 2xx 范围的状态码都会触发该函数。 const originalRequest error.config; // 判断错误是否因Access Token过期引起 (这里假设后端返回401状态码和特定信息) if (error.response error.response.status 401 error.response.data.msg 访问令牌已过期请重新登录) { // 并且这个请求不是刷新Token的请求本身防止死循环 if (!originalRequest._retry !originalRequest.url.includes(/auth/refresh)) { originalRequest._retry true; // 标记这个请求已经重试过 if (!isRefreshing) { isRefreshing true; // 尝试调用刷新接口获取新Token try { const refreshToken localStorage.getItem(refreshToken); if (!refreshToken) { throw new Error(无刷新令牌); } // 调用显式的刷新接口 const refreshResponse await service.post(/auth/refresh, {}, { headers: { Refresh-Token: refreshToken } }); // 刷新成功更新本地存储 const { accessToken, refreshToken: newRfToken } refreshResponse.data; localStorage.setItem(accessToken, accessToken); localStorage.setItem(refreshToken, newRfToken); console.log(Token刷新成功); // 执行等待队列中的请求 requestsQueue.forEach(cb cb()); requestsQueue []; isRefreshing false; // 用新的Token重试原来的请求 originalRequest.headers[Authorization] Bearer ${accessToken}; return service(originalRequest); } catch (refreshError) { // 刷新Token失败Refresh Token也过期或无效 console.error(自动刷新Token失败:, refreshError); // 清空本地Token跳转到登录页 localStorage.removeItem(accessToken); localStorage.removeItem(refreshToken); // 这里可以触发一个全局事件让Vue Router跳转到登录页 // 例如EventBus.$emit(forceLogout); Message.error(登录已过期请重新登录); // 清空请求队列 requestsQueue []; isRefreshing false; // 跳转到登录页 window.location.href /login; // 或者使用router.push return Promise.reject(refreshError); } } else { // 正在刷新中将当前这个失败的请求加入队列等待刷新完成后重试 return new Promise((resolve) { requestsQueue.push(() { // 等待刷新完成后用新的Token重试原请求 const newAccessToken localStorage.getItem(accessToken); originalRequest.headers[Authorization] Bearer ${newAccessToken}; resolve(service(originalRequest)); }); }); } } } // 其他错误如403禁止访问、404未找到、500服务器错误等 // 可以在这里统一处理错误提示 const errorMsg error.response?.data?.msg || error.message || 请求失败; Message.error(errorMsg); return Promise.reject(error); } ); export default service;这段代码是前端无感刷新的灵魂我解释几个关键设计isRefreshing和requestsQueue这是为了防止并发请求导致的“刷新风暴”。想象一下用户打开了一个页面同时发出了5个API请求此时Access Token刚好过期。如果没有这个锁机制5个请求的响应拦截器都会发现401错误然后各自去调用刷新接口这会浪费资源且可能导致逻辑混乱。我们设置一个标志位isRefreshing当第一个请求触发刷新时将其设为true后续遇到401的请求就不再发起新的刷新请求而是将其配置originalRequest推入一个队列requestsQueue中。等刷新成功后再依次执行队列里的请求。响应成功时的处理在response的成功回调里我们检查响应头中是否有后端过滤器设置的new-access-token。如果有就静默地更新本地存储。这样即使某个请求触发了自动刷新用户也完全感知不到只是这个请求的响应头里多带了新Token而已。响应失败时的处理在error回调里我们精准判断错误是否是“Access Token过期”根据状态码401和特定错误信息。如果是并且不是刷新请求本身就尝试用Refresh Token去获取新Token。刷新成功后更新本地Token并重试retry原先失败的请求最后将成功的结果返回给最初的调用者。对于用户和发起请求的组件来说就像是一次稍微有点慢但成功的请求。刷新失败的处理如果刷新接口也返回错误比如Refresh Token过期说明用户需要重新登录了。这时我们清除本地Token并跳转到登录页面。3.2 在Vue组件中使用封装的请求封装好Axios实例后在Vue组件里使用就非常清爽了。我们不再直接使用axios而是使用自己封装的service。首先在main.js或你存放全局配置的地方引入并挂载到Vue原型上或者用Provide/Inject这里用原型链举例// main.js import Vue from vue; import App from ./App.vue; import router from ./router; import request from ./utils/request; Vue.prototype.$http request; // 挂载到Vue原型组件内通过 this.$http 访问 new Vue({ router, render: h h(App), }).$mount(#app);然后在登录组件中template div classlogin-container el-form submit.native.preventhandleLogin el-form-item label用户名 el-input v-modelform.username/el-input /el-form-item el-form-item label密码 el-input typepassword v-modelform.password/el-input /el-form-item el-button typeprimary native-typesubmit :loadingloading登录/el-button /el-form /div /template script export default { name: Login, data() { return { form: { username: , password: }, loading: false }; }, methods: { async handleLogin() { this.loading true; try { const response await this.$http.post(/auth/login, this.form); // 登录成功存储Token const { accessToken, refreshToken } response.data.tokens; localStorage.setItem(accessToken, accessToken); localStorage.setItem(refreshToken, refreshToken); // 存储用户信息可选 localStorage.setItem(userInfo, JSON.stringify(response.data.userInfo)); // 跳转到首页 this.$router.push(/dashboard); this.$message.success(登录成功); } catch (error) { this.$message.error(error.response?.data?.msg || 登录失败); } finally { this.loading false; } } } }; /script在需要调用API的页面组件中template div el-button clickfetchUserData获取用户数据/el-button div v-ifuserData{{ userData }}/div /div /template script export default { data() { return { userData: null }; }, methods: { async fetchUserData() { try { // 直接调用即可Token的携带、过期刷新都由拦截器自动处理 const data await this.$http.get(/user/profile); this.userData data; this.$message.success(数据获取成功); } catch (error) { // 这里一般捕获的是非Token过期错误比如参数错误、权限不足等 // Token过期错误已经在拦截器里统一处理并跳转登录了 console.error(获取数据失败:, error); } } } }; /script你看在业务组件里我们完全不用关心Token是否过期。就像调用一个永远会成功的API一样所有的认证和刷新逻辑都被拦截器“隐藏”起来了。这就是“无感”的精髓。3.3 处理页面刷新与初始化还有一个常见的场景用户刷新浏览器页面。此时Vue应用重新加载本地存储的Token还在但我们需要在应用初始化时验证一下Token是否有效或者至少验证Refresh Token是否有效以决定用户是保持在登录状态还是跳转到登录页。我们可以在Vue应用的入口处比如App.vue的created钩子或路由守卫里添加一个检查。这里我推荐在全局路由守卫里做// src/router/index.js import Vue from vue; import VueRouter from vue-router; import Login from ../views/Login.vue; import Dashboard from ../views/Dashboard.vue; import request from ../utils/request; Vue.use(VueRouter); const routes [ { path: /login, name: Login, component: Login, meta: { requiresAuth: false } }, { path: /dashboard, name: Dashboard, component: Dashboard, meta: { requiresAuth: true } }, // ... 其他路由 ]; const router new VueRouter({ mode: history, base: process.env.BASE_URL, routes }); // 全局前置守卫 router.beforeEach(async (to, from, next) { const requiresAuth to.matched.some(record record.meta.requiresAuth); const hasAccessToken localStorage.getItem(accessToken); const hasRefreshToken localStorage.getItem(refreshToken); // 如果目标页面不需要认证直接放行 if (!requiresAuth) { next(); return; } // 需要认证的页面 if (hasAccessToken hasRefreshToken) { // 有Token可以尝试静默刷新一下确保Token新鲜可选非必须 // 也可以选择相信拦截器只在真正请求时处理过期 next(); } else { // 没有Token跳转到登录页 next(/login); } }); export default router;更激进一点的做法是在进入需要认证的页面时主动调用一次/auth/refresh接口或一个简单的验证接口如/auth/validate确保Token有效。如果无效直接跳转登录避免用户看到页面后才因Token失效而弹错。4. 安全增强与生产环境实践基础的跑通了但真要上线我们还得考虑更多安全细节。这里分享几个我踩过坑后总结的经验。4.1 Refresh Token的安全存储与传输前面我们简单地把Refresh Token存在了localStorage这有XSS风险。更安全的做法是后端通过HttpOnly Cookie下发在登录或刷新接口的响应中设置一个HttpOnly的Cookie来存储Refresh Token。// 在登录或刷新Token的Controller方法中 Cookie refreshTokenCookie new Cookie(refresh_token, newRefreshToken); refreshTokenCookie.setHttpOnly(true); refreshTokenCookie.setSecure(true); // 仅HTTPS传输 refreshTokenCookie.setPath(/api/auth/refresh); // 只对刷新接口生效 refreshTokenCookie.setMaxAge((int) (jwtProperties.getRefreshTokenExpire() / 1000)); // 单位秒 response.addCookie(refreshTokenCookie);前端在需要刷新时由于Cookie是HttpOnly的JS无法读取所以前端在拦截器中遇到401时不需要手动设置Refresh Token到请求头浏览器会自动在调用/api/auth/refresh接口时带上这个Cookie。后端过滤器或接口从Cookie中读取即可。注意这种方式需要妥善处理跨域CORS的凭证设置withCredentials: true和Cookie的SameSite属性防止CSRF攻击。通常配合CSRF Token使用会更安全。4.2 实现Token的黑名单与主动注销双Token方案的一个挑战是“注销”不完全。因为Refresh Token有效期长即使你删除了前端的Access Token如果别人拿到了还在有效期内的Refresh Token依然可以生成新的Access Token。所以我们需要一个服务端的“黑名单”机制。一个常见的做法是使用Redis用户登录时除了生成双Token还在Redis里以refresh_token:{userId}或refresh_token:{token指纹}为key存储这个Refresh Token并设置和Refresh Token一样的过期时间。用户注销时后端接收到注销请求从Redis中删除对应用户的Refresh Token记录。在刷新Token的接口/api/auth/refresh逻辑中增加一步检查请求带来的Refresh Token是否存在于Redis白名单中。如果不存在即使Token本身JWT验证有效也拒绝刷新返回“令牌已失效”。这样只要用户主动注销即使Refresh Token被泄露也无法再使用。这是生产级应用几乎必备的一环。4.3 控制并发请求与错误处理优化我们前端的拦截器已经用队列处理了并发刷新问题。但在生产环境还需要考虑刷新接口的防重放攻击可以给刷新请求加一个短时效的Nonce一次性随机数服务端校验后即失效。网络超时与重试在刷新Token的请求上设置合理的超时时间并考虑失败后的重试策略但不要无限重试。优雅降级如果刷新连续失败多次应直接判定为需要重新登录避免无限循环。4.4 监控与日志在生产环境一定要给Token的生成、验证、刷新、失效等关键节点加上详细的日志。这能帮你快速定位问题比如某个用户Refresh Token频繁使用可能意味着账号存在风险。大量401错误后紧接着200成功可能意味着你的Access Token有效期设置得太短需要调整。监控Refresh Token的调用频率异常高可能是有脚本在尝试攻击。5. 常见问题与调试技巧最后分享几个我实战中遇到的问题和解决办法希望能帮你少走弯路。问题1刷新死循环。现象前端不断收到401然后不断调用刷新接口页面卡死。原因通常是刷新接口本身也需要认证而你的过滤器或拦截器逻辑没有排除刷新接口的路径导致刷新请求也被要求携带Access Token而此时的Access Token正是过期的那个于是陷入循环。解决确保你的过滤器白名单WHITE_LIST包含了刷新Token的接口路径如/api/auth/refresh。或者像我们代码里那样在刷新请求的配置里加上特殊标记如isRefresh: true在拦截器里判断这个标记如果是刷新请求就不走401重试逻辑。问题2多个标签页/Tab同时操作导致Token混乱。现象用户打开了两个相同的系统标签页在一个页面上操作触发了Token刷新另一个页面的Token就失效了。原因每个标签页的JS上下文是独立的但共享localStorage。一个页面刷新了Token并更新了localStorage另一个页面不知道还在用旧的Token发请求。解决可以利用window的storage事件进行监听。在一个标签页更新Token后其他标签页能监听到localStorage的变化从而同步更新自己的内存中的Token引用。// 在Axios封装文件或全局入口处 window.addEventListener(storage, (event) { if (event.key accessToken) { // 更新当前页面的Axios实例默认请求头 service.defaults.headers.common[Authorization] Bearer ${event.newValue}; console.log(Access Token已从其他标签页同步更新); } });问题3如何测试Token过期逻辑总不能真的等15分钟吧有两种方法修改后端配置在开发环境把Access Token的过期时间设得非常短比如10秒。这样你登录后等10秒就能测试刷新逻辑。前端模拟在浏览器的开发者工具中手动删除或修改localStorage里的accessToken然后触发一个API请求观察拦截器是否正常工作。调试技巧在开发时打开浏览器的Network面板重点关注请求头Request Headers和响应头Response Headers。你会看到正常请求Authorization: Bearer xxxToken过期后触发的第一个请求返回状态码401。紧接着自动发起的刷新Token请求Refresh-Token: yyy刷新成功后重试的原请求Authorization: Bearer [新的xxx]并且这个请求的响应头里可能带有new-access-token。 通过观察这些网络请求的流向你能非常清晰地理解整个无感刷新的流程。整套流程实现下来你会发现前端代码变得异常简洁所有令人头疼的Token管理问题都被封装在了拦截器和后端的过滤器中。用户从此告别了烦人的“登录过期”提示体验得到了质的提升。而作为开发者我们虽然前期投入了一些设计成本但换来的是更安全、更健壮的认证体系和更少的用户支持请求。这种投入在我看来是非常值得的。