Spring Boot+Vue双Token无感刷新认证方案实战

发布时间:2026/7/17 18:48:44
Spring Boot+Vue双Token无感刷新认证方案实战 1. 项目概述为什么我们需要“双Token”在前后端分离架构成为主流的今天认证与授权是每个开发者绕不开的坎。你肯定用过JWTJSON Web Token它轻量、无状态一度是解决分布式认证的“银弹”。但用久了就会发现那个“有效时间”的设置让人头疼设短了用户每隔几十分钟就要重新登录一次体验极差设长了一旦Token泄露风险窗口期又太长安全性堪忧。这就是“双Token”机制要解决的核心矛盾在保障安全的前提下实现用户认证状态的无感知、平滑续期。简单说就是让用户感觉一直在线而系统又能随时、安全地让一个可疑的会话失效。这个项目就是基于 Spring Boot Vue Redis 这套非常经典的技术栈来亲手搭建一套这样的高安全认证体系。它不是什么高深的理论而是从无数实战坑里爬出来后总结出的一套可落地、可复用的最佳实践。无论你是正在为现有系统设计登录模块还是想深入理解现代Web安全这套方案都能给你直接的参考。2. 核心设计思路Access Token与Refresh Token的分工与协作双Token机制的精髓在于“分工”。我们把传统的单一Token拆成两个各司其职Access Token (访问令牌)短命、高频使用。它就是进入系统各个API大门的“临时门禁卡”。生命周期很短通常设置为15分钟到2小时。它被放在HTTP请求的Authorization请求头中每次访问受保护资源时都必须携带。因为它短命所以即使泄露危害也相对有限。Refresh Token (刷新令牌)长寿、低频使用。它是用来重新申请“临时门禁卡”的“凭证”。生命周期很长可以设置为7天、30天甚至更长。它绝不用于直接访问业务接口只用于一个场景当Access Token过期时用它去换取一组新的Access Token和Refresh Token。这个协作流程是这样的用户登录成功服务端同时签发Access Token和Refresh Token。客户端将Access Token用于日常API请求。Access Token过期后客户端不是让用户重新登录而是用保存的Refresh Token向一个特定的“刷新接口”发起请求。服务端验证Refresh Token的有效性是否过期、是否被加入黑名单等验证通过后签发一组全新的双Token返回给客户端。客户端更新本地存储的Token业务请求得以继续用户毫无感知。注意Refresh Token是安全的关键必须妥善存储。服务端通常将其与用户标识、设备信息等绑定并存入Redis等持久化存储以便于管理和主动吊销如用户修改密码、主动登出时使该用户的所有Refresh Token失效。2.1 为什么是Redis它在这里扮演什么角色你可能会问JWT不是号称无状态吗为什么还要引入Redis这正是将理论方案推向生产级应用的关键一步。JWT的无状态性是其优点也带来了管理难题我无法在服务端简单地让一个已签发的Token立刻失效。双Token机制中Refresh Token需要被管理它的状态是否有效、是否被吊销必须被服务端感知。Redis作为高性能的内存数据存储在这里扮演了“会话状态中心”和“安全哨兵”的角色Refresh Token白名单/黑名单管理我们可以选择将有效的Refresh Token ID或整个Token的哈希值存入Redis并设置其过期时间与Token本身一致。每次使用Refresh Token时先查Redis是否存在以此判断其有效性。更常见的“黑名单”做法是当用户登出或Token需要被主动吊销时将尚未过期的Refresh Token ID加入一个黑名单集合验证时先查黑名单。存储用户-令牌绑定信息可以将Refresh Token与用户ID、客户端指纹如IP、User-Agent的哈希绑定后存入Redis。这样在刷新时不仅可以验证Token本身还能验证本次请求是否来自通常的设备增加安全性。控制刷新频率与安全审计可以在Redis中记录每个Refresh Token的最近刷新时间、刷新次数。用于防止刷新接口被恶意高频调用防刷或用于异常行为审计。引入Redis让我们的无状态JWT方案拥有了部分“可管理状态”的能力在安全与灵活性上取得了更好的平衡。3. 服务端Spring Boot核心实现详解让我们从后端开始这是整个认证体系的大脑。我们将分步骤构建核心功能。3.1 依赖准备与基础配置首先创建一个Spring Boot项目引入核心依赖。这里以Maven为例dependencies !-- Spring Boot Web -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency !-- Spring Security (用于密码编码、安全上下文) -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-security/artifactId /dependency !-- JWT 库这里选用 jjwt -- dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-api/artifactId version0.11.5/version /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-impl/artifactId version0.11.5/version scoperuntime/scope /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-jackson/artifactId version0.11.5/version scoperuntime/scope /dependency !-- Redis -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency !-- 数据库访问 (根据你实际使用的数据库选择如MyBatis-Plus或JPA) -- dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.3.1/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId scoperuntime/scope /dependency /dependencies在application.yml中配置Redis连接和JWT密钥。JWT密钥务必足够复杂且不要硬编码在代码中应使用环境变量或配置中心。spring: redis: host: localhost port: 6379 # password: yourpassword # 如果Redis有密码 database: 0 timeout: 2000ms lettuce: pool: max-active: 8 max-wait: -1ms max-idle: 8 min-idle: 0 jwt: secret: your-256-bit-secret-your-256-bit-secret-your-256-bit-secret # 至少32位 access-token-expire: 30m # Access Token过期时间例如30分钟 refresh-token-expire: 7d # Refresh Token过期时间例如7天3.2 JWT工具类封装Token的生成与解析创建一个JwtUtil工具类负责Token的生成、解析和验证。这里的关键是区分Access Token和Refresh Token的载荷Claim。import io.jsonwebtoken.*; import io.jsonwebtoken.security.Keys; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; import javax.crypto.SecretKey; import java.util.Date; import java.util.HashMap; import java.util.Map; import java.util.UUID; Component public class JwtUtil { Value(${jwt.secret}) private String secret; Value(${jwt.access-token-expire}) private Long accessTokenExpire; Value(${jwt.refresh-token-expire}) private Long refreshTokenExpire; private SecretKey getSecretKey() { // 将配置的字符串密钥转换为安全的SecretKey对象 return Keys.hmacShaKeyFor(secret.getBytes()); } /** * 生成Access Token * param userId 用户ID * param username 用户名 * return Access Token字符串 */ public String generateAccessToken(String userId, String username) { MapString, Object claims new HashMap(); claims.put(userId, userId); claims.put(username, username); claims.put(tokenType, access); // 明确类型 return buildToken(claims, accessTokenExpire); } /** * 生成Refresh Token * param userId 用户ID * return Refresh Token字符串 */ public String generateRefreshToken(String userId) { MapString, Object claims new HashMap(); claims.put(userId, userId); claims.put(tokenType, refresh); // 明确类型 // Refresh Token可以有一个唯一的jti (JWT ID)用于在Redis中管理 claims.put(jti, UUID.randomUUID().toString()); return buildToken(claims, refreshTokenExpire); } private String buildToken(MapString, Object claims, Long expiration) { long now System.currentTimeMillis(); return Jwts.builder() .setClaims(claims) // 设置自定义载荷 .setIssuedAt(new Date(now)) // 签发时间 .setExpiration(new Date(now expiration)) // 过期时间 .signWith(getSecretKey(), SignatureAlgorithm.HS256) // 签名算法和密钥 .compact(); } /** * 从Token中解析Claims有效载荷 */ public Claims parseToken(String token) { try { return Jwts.parserBuilder() .setSigningKey(getSecretKey()) .build() .parseClaimsJws(token) .getBody(); } catch (ExpiredJwtException e) { // Token过期是一个预期内的异常有时我们仍需要获取其中的信息如userId // 这里直接抛出由调用方根据业务决定如何处理 throw e; } catch (JwtException e) { // 其他JWT异常签名无效、格式错误等 throw new RuntimeException(无效的Token, e); } } /** * 从Token中获取用户ID */ public String getUserIdFromToken(String token) { return parseToken(token).get(userId, String.class); } /** * 获取Token类型 (access 或 refresh) */ public String getTokenTypeFromToken(String token) { return parseToken(token).get(tokenType, String.class); } /** * 判断Token是否过期不解析只检查过期时间 */ public boolean isTokenExpired(String token) { try { parseToken(token); return false; } catch (ExpiredJwtException e) { return true; } catch (JwtException e) { // 其他无效情况也视为“不可用” return true; } } }实操心得在生成Refresh Token时我强烈建议加入一个唯一的jti(JWT ID) 字段。这个ID将成为我们在Redis中管理该Token如加入黑名单、绑定设备信息的关键索引比用整个Token字符串作为Key更高效、更清晰。3.3 认证接口实现登录与刷新接下来我们实现两个核心的RESTful接口/auth/login和/auth/refresh。首先定义登录请求体和Token响应体// LoginRequest.java Data public class LoginRequest { NotBlank private String username; NotBlank private String password; } // AuthResponse.java Data AllArgsConstructor public class AuthResponse { private String accessToken; private String refreshToken; private String tokenType Bearer; private Long expiresIn; // Access Token剩余有效时间秒 }然后创建AuthControllerRestController RequestMapping(/auth) public class AuthController { Autowired private JwtUtil jwtUtil; Autowired private RedisTemplateString, String redisTemplate; Autowired private UserService userService; // 假设你有一个根据用户名查询用户的服务 PostMapping(/login) public ResponseEntityAuthResponse login(Valid RequestBody LoginRequest request) { // 1. 验证用户凭证用户名密码 User user userService.findByUsername(request.getUsername()); if (user null || !passwordEncoder.matches(request.getPassword(), user.getPassword())) { throw new RuntimeException(用户名或密码错误); } // 2. 生成双Token String accessToken jwtUtil.generateAccessToken(user.getId(), user.getUsername()); String refreshToken jwtUtil.generateRefreshToken(user.getId()); // 3. 将Refresh Token存入Redis黑名单/白名单策略 Claims refreshClaims jwtUtil.parseToken(refreshToken); String jti refreshClaims.get(jti, String.class); long refreshExpire refreshClaims.getExpiration().getTime() - System.currentTimeMillis(); // 以 refresh_token:jti 为key用户ID为value存储并设置过期时间 String redisKey refresh_token: jti; redisTemplate.opsForValue().set(redisKey, user.getId(), refreshExpire, TimeUnit.MILLISECONDS); // 4. 返回Token Long expiresIn jwtUtil.parseToken(accessToken).getExpiration().getTime() - System.currentTimeMillis(); return ResponseEntity.ok(new AuthResponse(accessToken, refreshToken, expiresIn / 1000)); } PostMapping(/refresh) public ResponseEntityAuthResponse refresh(RequestHeader(Authorization) String authHeader) { // 1. 从请求头中提取Refresh Token // 格式应为 Bearer {refreshToken} if (authHeader null || !authHeader.startsWith(Bearer )) { throw new RuntimeException(缺少或格式错误的Authorization头); } String refreshToken authHeader.substring(7); // 2. 验证Token类型 if (!refresh.equals(jwtUtil.getTokenTypeFromToken(refreshToken))) { throw new RuntimeException(非法的Token类型); } // 3. 验证Refresh Token在Redis中是否有效未被加入黑名单/吊销 Claims claims jwtUtil.parseToken(refreshToken); String jti claims.get(jti, String.class); String redisKey refresh_token: jti; String userIdInRedis redisTemplate.opsForValue().get(redisKey); if (userIdInRedis null) { // Redis中不存在说明此Refresh Token已被主动吊销如用户登出或已过期被清理 throw new RuntimeException(Refresh Token已失效); } // 4. 验证Token中的用户ID与Redis中存储的是否一致额外安全层 String userIdInToken jwtUtil.getUserIdFromToken(refreshToken); if (!userIdInRedis.equals(userIdInToken)) { // 不一致可能是异常情况安全起见拒绝刷新 redisTemplate.delete(redisKey); // 清理异常数据 throw new RuntimeException(Token信息异常); } // 5. 一切验证通过生成新的双Token User user userService.findById(userIdInToken); // 重新获取用户信息 String newAccessToken jwtUtil.generateAccessToken(user.getId(), user.getUsername()); String newRefreshToken jwtUtil.generateRefreshToken(user.getId()); // 6. 使旧的Refresh Token失效一次性使用并存储新的 redisTemplate.delete(redisKey); // 删除旧的 Claims newRefreshClaims jwtUtil.parseToken(newRefreshToken); String newJti newRefreshClaims.get(jti, String.class); long newRefreshExpire newRefreshClaims.getExpiration().getTime() - System.currentTimeMillis(); String newRedisKey refresh_token: newJti; redisTemplate.opsForValue().set(newRedisKey, user.getId(), newRefreshExpire, TimeUnit.MILLISECONDS); // 7. 返回新的Token Long expiresIn jwtUtil.parseToken(newAccessToken).getExpiration().getTime() - System.currentTimeMillis(); return ResponseEntity.ok(new AuthResponse(newAccessToken, newRefreshToken, expiresIn / 1000)); } PostMapping(/logout) public ResponseEntityVoid logout(RequestHeader(Authorization) String authHeader) { // 登出时需要使当前有效的Refresh Token失效 if (authHeader ! null authHeader.startsWith(Bearer )) { String token authHeader.substring(7); try { if (refresh.equals(jwtUtil.getTokenTypeFromToken(token))) { // 如果是Refresh Token直接从Redis删除 Claims claims jwtUtil.parseToken(token); String jti claims.get(jti, String.class); redisTemplate.delete(refresh_token: jti); } // 注意Access Token无法主动失效只能等待其自然过期。 // 如果需要立即失效Access Token需要引入更复杂的黑名单机制将未过期的Access Token ID也存入Redis黑名单并在拦截器中校验。 } catch (Exception e) { // Token可能已过期或无效忽略 } } return ResponseEntity.ok().build(); } }注意事项在/refresh接口中我们实现了Refresh Token的一次性使用。每次刷新成功后旧的Refresh Token会立即从Redis中删除同时颁发全新的双Token。这被称为“滑动会话”或“Token轮转”是安全最佳实践。它能有效防止Refresh Token被重复使用即使被截获也只能用一次并能在一定程度上检测到Token泄露如果服务端收到一个已使用过的Refresh Token说明可能发生了重放攻击。3.4 安全拦截器校验Access Token现在我们需要一个机制来保护业务接口验证请求中的Access Token。这里我们使用Spring的HandlerInterceptor。Component public class JwtAuthenticationInterceptor implements HandlerInterceptor { Autowired private JwtUtil jwtUtil; Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 放行登录、刷新等认证相关接口 String uri request.getRequestURI(); if (uri.contains(/auth/login) || uri.contains(/auth/refresh) || uri.contains(/auth/logout)) { return true; } // 1. 从请求头获取Token String authHeader request.getHeader(Authorization); if (authHeader null || !authHeader.startsWith(Bearer )) { throw new RuntimeException(未提供认证Token); } String token authHeader.substring(7); // 2. 验证Token类型必须是Access Token if (!access.equals(jwtUtil.getTokenTypeFromToken(token))) { throw new RuntimeException(请使用Access Token访问资源); } // 3. 验证Token有效性是否过期、签名是否正确 // parseToken方法内部会抛出异常这里我们捕获并转换为统一的响应 try { Claims claims jwtUtil.parseToken(token); // 4. 将用户信息放入请求上下文供后续业务使用 String userId claims.get(userId, String.class); request.setAttribute(userId, userId); // 如果需要更多信息可以从数据库查询用户详情这里简单放入用户名 request.setAttribute(username, claims.get(username, String.class)); return true; } catch (ExpiredJwtException e) { // Token过期这是一个特殊状态前端需要据此发起刷新 // 我们可以返回一个特定的错误码而不是通用的401 response.setStatus(HttpStatus.UNAUTHORIZED.value()); response.setContentType(application/json); response.getWriter().write({\code\: 40101, \message\: \Access Token已过期请刷新\}); return false; } catch (JwtException e) { // 其他Token异常无效、被篡改等 throw new RuntimeException(无效的Access Token, e); } } }然后将这个拦截器注册到Spring MVC配置中Configuration public class WebMvcConfig implements WebMvcConfigurer { Autowired private JwtAuthenticationInterceptor jwtAuthenticationInterceptor; Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(jwtAuthenticationInterceptor) .addPathPatterns(/api/**) // 拦截所有/api/开头的请求 .excludePathPatterns(/auth/**, /public/**); // 排除认证和公开接口 } }4. 客户端Vue无感刷新逻辑实现前端是实现“无感”体验的关键。核心思路是在每次请求前判断Access Token是否即将过期或已过期如果是则先静默刷新Token再用新的Token重试原请求。4.1 封装Axios请求拦截器首先安装并封装Axios。我们创建一个request.js文件。import axios from axios; import router from /router; // 你的Vue Router实例 import { Message } from element-ui; // 示例用的UI库可按需替换 // 创建axios实例 const service axios.create({ baseURL: process.env.VUE_APP_BASE_API, // 你的API基础地址 timeout: 10000, }); // 是否正在刷新的标记防止并发刷新 let isRefreshing false; // 存储等待重试的请求队列 let requestsQueue []; // 请求拦截器 service.interceptors.request.use( (config) { // 从本地存储如localStorage获取Access Token const accessToken localStorage.getItem(access_token); if (accessToken) { config.headers[Authorization] Bearer ${accessToken}; } return config; }, (error) { return Promise.reject(error); } ); // 响应拦截器 - 核心逻辑在这里 service.interceptors.response.use( (response) { // 请求成功直接返回数据 return response.data; }, async (error) { const { config, response } error; const originalRequest config; // 1. 判断是否是Access Token过期的错误 // 这里需要和后端约定好过期状态码例如 40101 if (response response.status 401 response.data.code 40101) { // 2. 防止重复刷新 if (!isRefreshing) { isRefreshing true; // 3. 尝试刷新Token const refreshToken localStorage.getItem(refresh_token); if (!refreshToken) { // 没有Refresh Token跳转到登录页 Message.error(登录已过期请重新登录); router.push(/login); return Promise.reject(error); } try { // 调用刷新接口 const refreshResponse await axios.post(/auth/refresh, {}, { headers: { Authorization: Bearer ${refreshToken} } }); const { accessToken, refreshToken: newRefreshToken, expiresIn } refreshResponse.data; // 4. 存储新的Token localStorage.setItem(access_token, accessToken); localStorage.setItem(refresh_token, newRefreshToken); // 可以存储过期时间用于主动判断 localStorage.setItem(token_expire_time, Date.now() expiresIn * 1000); // 5. 用新的Access Token重试所有队列中的请求 requestsQueue.forEach(cb cb(accessToken)); requestsQueue []; // 6. 重试当前失败的请求 originalRequest.headers[Authorization] Bearer ${accessToken}; return service(originalRequest); } catch (refreshError) { // 刷新Token失败Refresh Token也过期或无效 console.error(刷新Token失败:, refreshError); Message.error(登录状态已失效请重新登录); // 清除所有Token localStorage.removeItem(access_token); localStorage.removeItem(refresh_token); localStorage.removeItem(token_expire_time); // 跳转到登录页 router.push(/login); // 清空请求队列 requestsQueue []; return Promise.reject(refreshError); } finally { isRefreshing false; } } else { // 如果正在刷新将当前请求加入队列等待刷新完成后重试 return new Promise((resolve) { requestsQueue.push((newAccessToken) { originalRequest.headers[Authorization] Bearer ${newAccessToken}; resolve(service(originalRequest)); }); }); } } // 其他错误如403无权限、404未找到、500服务器错误等 // 这里可以统一处理例如根据状态码提示用户 if (response response.status 403) { Message.error(没有操作权限); } else if (response response.status 500) { Message.error(服务器开小差了请稍后再试); } else { Message.error(error.message || 请求失败); } return Promise.reject(error); } ); export default service;4.2 主动刷新在Token过期前“续命”上面的方案是被动刷新等请求因Token过期失败后再刷新。更优的方案是主动刷新在Access Token即将过期但还未过期时就提前刷新它。这需要前端知道Token的过期时间。我们可以在登录或刷新成功后将Token的过期时间戳存储起来并设置一个定时器或在一个全局的请求前置检查中判断。// 在登录或刷新成功的回调中 function handleAuthSuccess(authData) { localStorage.setItem(access_token, authData.accessToken); localStorage.setItem(refresh_token, authData.refreshToken); // 计算并存储过期时间点假设后端返回expiresIn是剩余秒数 const expireTime Date.now() authData.expiresIn * 1000; localStorage.setItem(token_expire_time, expireTime); // 设置一个定时器在过期前5分钟刷新 const refreshThreshold 5 * 60 * 1000; // 5分钟 const timeUntilRefresh authData.expiresIn * 1000 - refreshThreshold; if (timeUntilRefresh 0) { setTimeout(() { silentRefreshToken(); }, timeUntilRefresh); } } // 静默刷新Token的函数 async function silentRefreshToken() { const refreshToken localStorage.getItem(refresh_token); if (!refreshToken) return; try { const response await axios.post(/auth/refresh, {}, { headers: { Authorization: Bearer ${refreshToken} } }); handleAuthSuccess(response.data); // 重新存储并设置新的定时器 console.log(Token已静默刷新); } catch (error) { console.error(静默刷新失败用户下次操作时会触发被动刷新或跳转登录, error); // 静默刷新失败不立即跳转登录等待用户下次操作 } } // 也可以在每次发起请求前做一个快速检查可选 service.interceptors.request.use( async (config) { const expireTime localStorage.getItem(token_expire_time); const now Date.now(); const refreshThreshold 5 * 60 * 1000; // 5分钟 // 如果Token即将过期5分钟内且当前不在刷新中则先刷新 if (expireTime (expireTime - now refreshThreshold) !isRefreshing) { await silentRefreshToken(); } // 后续逻辑添加Token到请求头... const accessToken localStorage.getItem(access_token); if (accessToken) { config.headers[Authorization] Bearer ${accessToken}; } return config; }, (error) { return Promise.reject(error); } );实操心得主动刷新和被动刷新可以结合使用。主动刷新能提供最平滑的体验但需要考虑页面长时间未操作、浏览器标签页休眠等情况定时器可能不准确或停止。因此保留响应拦截器中的被动刷新逻辑作为“兜底”方案是更健壮的做法。两者结合既能提前刷新避免401错误也能在异常情况下保证流程可恢复。5. 安全加固与进阶考量一个基础的架子搭好了但要用于生产环境还需要考虑更多安全细节。5.1 防范Refresh Token泄露与盗用Refresh Token是“命门”必须重点保护。绑定设备/客户端指纹在生成Refresh Token时不仅关联用户ID还关联一个客户端指纹如对用户IP、User-Agent、设备型号等信息的哈希。将这个指纹也存入Redis。刷新时校验本次请求的指纹与存储的是否一致。不一致则拒绝刷新并告警。这能有效防止Token从一个设备泄露后被用在另一个设备上。// 生成客户端指纹简单示例 String clientFingerprint DigestUtils.md5DigestAsHex( (request.getRemoteAddr() request.getHeader(User-Agent)).getBytes() ); // 存储时key - value: refresh_token:jti - userId:clientFingerprint设置Refresh Token使用次数限制虽然我们实现了一次性使用但可以更进一步在Redis中记录一个Refresh Token被成功使用的次数。如果短时间内如1分钟同一个Refresh Token被用于多次刷新请求可能意味着重放攻击则立即将其加入黑名单并告警。HTTPS是必须的所有涉及Token传输的请求登录、刷新、API调用都必须使用HTTPS防止网络嗅探。5.2 更细粒度的权限控制目前我们的拦截器只验证了“是否登录”Authentication。在实际业务中我们还需要验证“能做什么”Authorization。在Token中嵌入角色/权限可以在生成Access Token时将用户的角色如ROLE_ADMIN或权限列表如user:read,user:write放入Token的Claims中。claims.put(roles, ROLE_ADMIN,ROLE_USER); claims.put(perms, sys:user:read,sys:user:write);实现方法级或API级的权限注解结合Spring Security或自定义注解AOP在Controller方法上添加如PreAuthorize(hasRole(ADMIN))或RequiresPermissions(sys:user:delete)的注解。在拦截器或AOP切面中从解析后的Token Claims里取出权限信息与注解要求的权限进行比对。5.3 并发请求下的刷新竞态条件处理我们前端的代码已经通过isRefreshing标志和requestsQueue队列处理了并发请求下的刷新问题。但还需要注意队列去重如果多个完全相同的请求相同URL和参数同时失败可以给队列中的请求加一个标识符避免重复添加。设置队列最大长度与超时防止在极端网络问题下队列无限增长。可以设置一个最大长度超出的请求直接失败。也可以为队列中的每个等待请求设置一个超时时间超时后直接拒绝并提示用户稍后重试。5.4 分布式环境下的Redis一致性在微服务或集群部署中你的应用可能有多个实例它们共享同一个Redis。这本身没有问题。但要考虑Redis的高可用性使用Redis哨兵或集群模式避免单点故障。考虑多级缓存对于Access Token的验证主要是签名和过期时间由于JWT本身可以自校验其实不一定每次都要查Redis。只有需要主动吊销加入黑名单的Access Token才需要查。对于Refresh Token因为其状态完全由Redis管理所以必须保证Redis的可用性。6. 常见问题排查与实战技巧在实际开发和运维中你肯定会遇到各种奇怪的问题。这里记录一些典型的坑和解决思路。6.1 问题排查速查表问题现象可能原因排查步骤与解决方案登录成功但后续API请求返回4011. Access Token未正确放入请求头。2. Token已过期。3. 拦截器路径配置错误请求未被拦截/被错误拦截。4. Token签名验证失败密钥不一致。1. 检查浏览器开发者工具的Network面板查看请求头中Authorization字段格式是否为Bearer token。2. 检查Token过期时间。如果是过期前端应触发刷新流程。3. 检查后端WebMvcConfigurer中addPathPatterns和excludePathPatterns的配置。4. 确保生成和验证Token使用的是同一个密钥jwt.secret检查是否有多个服务实例配置不一致。刷新Token接口返回403或“Token无效”1. 请求头中未携带或格式错误。2. 携带的是Access Token而非Refresh Token。3. Refresh Token已过期。4. Refresh Token已被加入黑名单用户登出。5. Redis中对应的Key已丢失Redis重启或过期清理。1. 同上一问题检查请求头。2. 后端验证Token类型是否为refresh前端确保传递的是Refresh Token。3. 检查Refresh Token的过期时间通常很长。4. 检查用户是否执行了登出操作或管理员是否吊销了该令牌。5. 检查Redis服务是否正常Key的TTL设置是否正确。前端陷入无限刷新循环1. 刷新Token接口本身也返回401过期。2. 刷新接口的逻辑错误导致用新的Refresh Token再次刷新。3. 前端响应拦截器逻辑有误对非Token过期的错误也执行了刷新。1. 确保刷新接口 (/auth/refresh) 在拦截器中被正确排除excludePathPatterns。2. 检查后端刷新逻辑确保新的Refresh Token被正确存储旧的被删除。3. 在前端拦截器中严格限定触发刷新的条件如必须是特定的401状态码和错误码。添加日志打印错误响应便于调试。并发请求时多个请求同时触发刷新前端未处理好刷新状态锁导致同时发起多个刷新请求。使用我们示例中的isRefreshing标志和requestsQueue队列机制。确保刷新操作是单例的。用户登出后Token似乎还能用一会儿Access Token无法主动立即失效只能等待其自然过期。1. 对于安全性要求极高的场景可以考虑引入Access Token黑名单。将登出时未过期的Access Token的IDjti也存入Redis并设置一个较短的过期时间如原Token剩余有效期。在拦截器中除了验证JWT本身还要查一下这个黑名单。这会增加每次请求的Redis查询开销需要权衡。2. 将Access Token有效期设置得更短如15分钟以缩短风险窗口。6.2 实战技巧与优化建议Token存储位置前端将Token存储在哪里localStorage易于实现但存在XSS跨站脚本攻击风险。HttpOnly Cookie可以防止JavaScript读取防范XSS但要小心CSRF跨站请求伪造攻击并需要正确配置SameSite属性。对于单页应用SPAlocalStorage或sessionStorage是更常见的选择前提是你要确保网站没有XSS漏洞如对用户输入做好转义。注销的真正含义在双Token机制下“注销”主要是让Refresh Token失效。用户点击登出时前端调用/auth/logout接口后端将该用户的Refresh Token从Redis中移除。但用户浏览器本地存储的Access Token在过期前依然有效这是JWT无状态特性带来的权衡。因此对于敏感操作即使有有效的Access Token关键步骤如支付、修改密码也应再次验证密码或短信验证码。监控与审计在Redis中记录Refresh Token的创建、使用、刷新和吊销日志可以存到另一个Key或异步写入数据库。这有助于安全审计当发生可疑行为时如一个用户短时间内从多个异地IP刷新Token可以及时告警。多端登录与互踢一个很常见的需求是用户可以在手机和电脑同时登录但修改密码后所有设备都要下线。如何实现我们可以在Redis中存储Refresh Token时不仅关联用户ID还关联一个“会话ID”或“设备ID”。当用户修改密码时后端可以遍历Redis中所有以该用户ID为值的Key即他所有的Refresh Token并将其全部删除。这样所有设备的Refresh Token都会在下一次需要刷新时失效。这套基于 Spring Boot Vue Redis 的双Token无感刷新方案从设计原理到代码实现再到安全加固和问题排查基本覆盖了生产级应用所需的核心考量。它不是一个银弹但通过合理的分工长短Token、状态管理Redis和流程控制前端拦截在用户体验与系统安全之间找到了一个坚实的平衡点。在实际项目中你可以根据具体的业务体量、安全等级和架构复杂度对这个方案进行裁剪或增强。记住安全是一个持续的过程而不是一劳永逸的配置。