JWT双令牌认证+Redis黑名单:Spring Boot实现无感刷新与安全登出

发布时间:2026/7/21 6:44:13
JWT双令牌认证+Redis黑名单:Spring Boot实现无感刷新与安全登出 在微服务架构和前后端分离成为主流的今天如何安全、高效地管理用户认证状态是每个后端开发者必须面对的课题。传统的Session机制在分布式环境下存在扩展性瓶颈而单纯的JWTJSON Web Token又面临着令牌过期与安全失效的两难困境。你是否遇到过这样的场景用户正在填写重要表单时突然被强制退出或者出于安全考虑不得不设置很短的令牌有效期导致用户体验下降本文将深入解析大厂普遍采用的JWT双令牌认证方案通过Access Token与Refresh Token的协同工作结合Redis黑名单机制实现真正的无感刷新和即时失效能力。本文适合有一定Spring Boot和JWT基础的开发者无论是正在重构认证模块的资深工程师还是希望提升项目安全性的初学者都能从中获得可直接落地的完整解决方案。我们将从核心概念剖析开始逐步搭建完整的认证体系涵盖令牌生成、刷新逻辑、黑名单管理以及生产环境的最佳实践。1. JWT双令牌认证的核心原理与设计思路1.1 为什么需要双令牌机制在传统的单令牌JWT方案中开发者通常面临一个两难选择如果将token有效期设置过长一旦令牌泄露攻击者可以在较长时间内冒充用户身份如果设置过短用户需要频繁重新登录体验极差。双令牌机制通过职责分离完美解决了这一矛盾。Access Token访问令牌负责日常的API访问认证有效期较短通常15-30分钟。即使被泄露影响范围也有限。Refresh Token刷新令牌专门用于获取新的Access Token有效期较长通常7-30天但使用频率低且可以结合黑名单机制实现即时撤销。1.2 双令牌的工作流程时序双令牌认证的核心在于两种令牌的协同工作机制。以下是完整的认证流程用户登录客户端提交用户名密码服务端验证通过后同时签发Access Token和Refresh TokenAPI访问客户端在Authorization头中携带Access Token访问受保护接口Token过期处理当Access Token过期时服务端返回特定的错误码如401无感刷新客户端自动使用Refresh Token请求新的Access Token无需用户干预重新请求获取新Access Token后客户端自动重试之前失败的请求安全登出用户主动登出时服务端将Refresh Token加入黑名单实现即时失效1.3 Redis在黑名单机制中的关键作用Redis作为高性能的内存数据库在黑名单管理中扮演着重要角色。其高速读写特性非常适合令牌失效验证场景即时生效令牌加入黑名单后立即生效无需等待自然过期分布式支持在微服务架构中所有节点可以共享同一个Redis实例进行黑名单验证自动清理可以设置TTL生存时间自动清理过期的黑名单记录避免内存泄漏2. 环境准备与项目搭建2.1 技术栈与版本要求本项目基于Spring Boot 2.7构建主要依赖如下Spring Boot 2.7.18基础框架JJWT 0.9.1JWT生成与解析Spring Data RedisRedis黑名单管理Spring Security安全认证框架Lombok简化代码编写确保你的开发环境已安装JDK 8、Maven 3.6和Redis 5.0。Redis的安装配置将在下一节详细说明。2.2 Redis安装与配置Redis是黑名单机制的核心组件以下是Windows和Linux环境下的安装指南Windows环境安装访问Redis官网下载Windows版本Redis解压到指定目录如D:\redis运行命令行进入Redis目录执行redis-server.exe redis.windows.conf新开命令行窗口执行redis-cli.exe测试连接Linux环境安装以Ubuntu为例# 更新包管理器 sudo apt update # 安装Redis sudo apt install redis-server # 启动Redis服务 sudo systemctl start redis # 设置开机自启 sudo systemctl enable redis # 验证安装 redis-cli pingRedis基本配置修改找到Redis配置文件通常为redis.conf修改以下关键参数# 允许远程连接生产环境需配合防火墙 bind 0.0.0.0 # 设置密码强烈建议生产环境启用 requirepass your_secure_password # 最大内存限制 maxmemory 256mb # 内存淘汰策略 maxmemory-policy allkeys-lru2.3 项目初始化与依赖配置创建Spring Boot项目在pom.xml中添加必要依赖dependencies !-- Spring Boot Starter -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency !-- Spring Security -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-security/artifactId /dependency !-- Redis支持 -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency !-- JWT支持 -- dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt/artifactId version0.9.1/version /dependency !-- Lombok -- dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency /dependencies配置application.yml文件spring: redis: host: localhost port: 6379 password: # 如果设置了密码则填写 database: 0 timeout: 3000ms lettuce: pool: max-active: 8 max-wait: -1ms max-idle: 8 min-idle: 0 jwt: secret: your-jwt-secret-key-at-least-256-bits-long access-token-expire: 1800000 # 30分钟 refresh-token-expire: 2592000000 # 30天3. JWT工具类设计与实现3.1 JWT配置参数封装首先创建JWT配置类集中管理令牌相关参数Data Configuration ConfigurationProperties(prefix jwt) public class JwtProperties { private String secret; private long accessTokenExpire; private long refreshTokenExpire; // 获取Access Token过期时间毫秒 public long getAccessTokenExpireInMillis() { return accessTokenExpire; } // 获取Refresh Token过期时间毫秒 public long getRefreshTokenExpireInMillis() { return refreshTokenExpire; } }3.2 JWT工具类完整实现JWT工具类负责令牌的生成、解析和验证是整个认证体系的核心Component public class JwtTokenUtil { Autowired private JwtProperties jwtProperties; // 生成Access Token public String generateAccessToken(String username) { return generateToken(username, jwtProperties.getAccessTokenExpireInMillis()); } // 生成Refresh Token public String generateRefreshToken(String username) { return generateToken(username, jwtProperties.getRefreshTokenExpireInMillis()); } private String generateToken(String username, long expiration) { Date now new Date(); Date expiryDate new Date(now.getTime() expiration); return Jwts.builder() .setSubject(username) .setIssuedAt(now) .setExpiration(expiryDate) .signWith(SignatureAlgorithm.HS512, jwtProperties.getSecret()) .compact(); } // 从Token中获取用户名 public String getUsernameFromToken(String token) { Claims claims getClaimsFromToken(token); return claims.getSubject(); } // 获取Token的过期时间 public Date getExpirationDateFromToken(String token) { Claims claims getClaimsFromToken(token); return claims.getExpiration(); } // 验证Token是否有效 public boolean validateToken(String token, String username) { final String tokenUsername getUsernameFromToken(token); return (tokenUsername.equals(username) !isTokenExpired(token)); } // 检查Token是否过期 private boolean isTokenExpired(String token) { final Date expiration getExpirationDateFromToken(token); return expiration.before(new Date()); } // 解析Token中的Claims private Claims getClaimsFromToken(String token) { return Jwts.parser() .setSigningKey(jwtProperties.getSecret()) .parseClaimsJws(token) .getBody(); } }3.3 令牌刷新逻辑实现刷新令牌机制是实现无感认证的关键需要仔细设计防止安全漏洞Service public class TokenRefreshService { Autowired private JwtTokenUtil jwtTokenUtil; Autowired private RedisTemplateString, String redisTemplate; // 刷新Access Token public TokenRefreshResult refreshToken(String refreshToken) { try { // 验证Refresh Token是否有效 if (!jwtTokenUtil.validateToken(refreshToken, jwtTokenUtil.getUsernameFromToken(refreshToken))) { return TokenRefreshResult.error(无效的刷新令牌); } // 检查Refresh Token是否在黑名单中 if (isTokenBlacklisted(refreshToken)) { return TokenRefreshResult.error(令牌已失效请重新登录); } String username jwtTokenUtil.getUsernameFromToken(refreshToken); // 生成新的Access Token String newAccessToken jwtTokenUtil.generateAccessToken(username); String newRefreshToken jwtTokenUtil.generateRefreshToken(username); // 将旧的Refresh Token加入黑名单 blacklistToken(refreshToken, jwtTokenUtil.getExpirationDateFromToken(refreshToken)); return TokenRefreshResult.success(newAccessToken, newRefreshToken); } catch (Exception e) { return TokenRefreshResult.error(令牌刷新失败: e.getMessage()); } } // 检查令牌是否在黑名单中 private boolean isTokenBlacklisted(String token) { return Boolean.TRUE.equals(redisTemplate.hasKey(blacklist: token)); } // 将令牌加入黑名单 private void blacklistToken(String token, Date expiration) { long ttl expiration.getTime() - System.currentTimeMillis(); if (ttl 0) { redisTemplate.opsForValue().set(blacklist: token, 1, ttl, TimeUnit.MILLISECONDS); } } }4. Redis黑名单管理实现4.1 Redis配置类正确配置RedisTemplate是确保黑名单机制高效运行的基础Configuration public class RedisConfig { Bean public RedisTemplateString, Object redisTemplate(RedisConnectionFactory factory) { RedisTemplateString, Object template new RedisTemplate(); template.setConnectionFactory(factory); // 使用Jackson2JsonRedisSerializer来序列化和反序列化redis的value值 Jackson2JsonRedisSerializerObject serializer new Jackson2JsonRedisSerializer(Object.class); ObjectMapper mapper new ObjectMapper(); mapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY); mapper.activateDefaultTyping( mapper.getPolymorphicTypeValidator(), ObjectMapper.DefaultTyping.NON_FINAL ); serializer.setObjectMapper(mapper); // 使用StringRedisSerializer来序列化和反序列化redis的key值 template.setKeySerializer(new StringRedisSerializer()); template.setValueSerializer(serializer); template.setHashKeySerializer(new StringRedisSerializer()); template.setHashValueSerializer(serializer); template.afterPropertiesSet(); return template; } }4.2 黑名单服务类黑名单服务提供令牌失效管理的核心功能Service public class TokenBlacklistService { private static final String BLACKLIST_PREFIX blacklist:; Autowired private RedisTemplateString, Object redisTemplate; // 将Access Token加入黑名单 public void blacklistAccessToken(String token) { // Access Token通常有效期较短加入黑名单直到自然过期 long expireTime getTokenRemainingTTL(token); if (expireTime 0) { redisTemplate.opsForValue().set( BLACKLIST_PREFIX token, blacklisted, expireTime, TimeUnit.MILLISECONDS ); } } // 将Refresh Token加入黑名单登出时使用 public void blacklistRefreshToken(String token, String username) { // Refresh Token登出后立即失效设置较短的过期时间 redisTemplate.opsForValue().set( BLACKLIST_PREFIX refresh: username : token, blacklisted, 24, // 24小时确保用户无法再使用该令牌 TimeUnit.HOURS ); } // 检查令牌是否在黑名单中 public boolean isTokenBlacklisted(String token) { return Boolean.TRUE.equals( redisTemplate.hasKey(BLACKLIST_PREFIX token) ); } // 获取令牌剩余有效时间 private long getTokenRemainingTTL(String token) { try { // 这里需要根据实际JWT解析库来获取过期时间 // 简化实现实际项目中应该从JWT中解析exp字段 return 1800000L; // 默认30分钟 } catch (Exception e) { return 0L; } } // 用户登出时批量黑名单处理 public void logoutUser(String username, String accessToken, String refreshToken) { // 黑名单当前Access Token blacklistAccessToken(accessToken); // 黑名单当前Refresh Token blacklistRefreshToken(refreshToken, username); // 可以扩展记录用户登出时间用于安全审计 redisTemplate.opsForValue().set( logout_time: username, System.currentTimeMillis(), 30, TimeUnit.DAYS ); } }4.3 黑名单验证过滤器在Spring Security过滤链中加入黑名单验证Component public class JwtAuthenticationFilter extends OncePerRequestFilter { Autowired private JwtTokenUtil jwtTokenUtil; Autowired private TokenBlacklistService blacklistService; Autowired private UserDetailsService userDetailsService; Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException { String requestToken getJwtFromRequest(request); if (StringUtils.hasText(requestToken)) { try { // 检查令牌是否在黑名单中 if (blacklistService.isTokenBlacklisted(requestToken)) { response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.getWriter().write({\error\: \令牌已失效请重新登录\}); return; } String username jwtTokenUtil.getUsernameFromToken(requestToken); if (username ! null SecurityContextHolder.getContext().getAuthentication() null) { UserDetails userDetails userDetailsService.loadUserByUsername(username); if (jwtTokenUtil.validateToken(requestToken, userDetails.getUsername())) { UsernamePasswordAuthenticationToken authentication new UsernamePasswordAuthenticationToken( userDetails, null, userDetails.getAuthorities()); authentication.setDetails( new WebAuthenticationDetailsSource().buildDetails(request)); SecurityContextHolder.getContext().setAuthentication(authentication); } } } catch (Exception e) { logger.error(无法设置用户认证, e); } } chain.doFilter(request, response); } private String getJwtFromRequest(HttpServletRequest request) { String bearerToken request.getHeader(Authorization); if (StringUtils.hasText(bearerToken) bearerToken.startsWith(Bearer )) { return bearerToken.substring(7); } return null; } }5. Spring Security配置整合5.1 安全配置类将JWT过滤器整合到Spring Security配置中Configuration EnableWebSecurity EnableGlobalMethodSecurity(prePostEnabled true) public class SecurityConfig extends WebSecurityConfigurerAdapter { Autowired private JwtAuthenticationFilter jwtAuthenticationFilter; Autowired private UserDetailsService userDetailsService; Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } Override protected void configure(AuthenticationManagerBuilder auth) throws Exception { auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder()); } Bean Override public AuthenticationManager authenticationManagerBean() throws Exception { return super.authenticationManagerBean(); } Override protected void configure(HttpSecurity http) throws Exception { http.cors().and().csrf().disable() .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) .and() .authorizeRequests() .antMatchers(/api/auth/**).permitAll() .antMatchers(/api/public/**).permitAll() .anyRequest().authenticated(); // 添加JWT过滤器 http.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); } }5.2 认证控制器实现提供完整的认证API接口RestController RequestMapping(/api/auth) public class AuthController { Autowired private AuthenticationManager authenticationManager; Autowired private JwtTokenUtil jwtTokenUtil; Autowired private TokenRefreshService tokenRefreshService; Autowired private TokenBlacklistService blacklistService; PostMapping(/login) public ResponseEntity? login(RequestBody LoginRequest loginRequest) { try { Authentication authentication authenticationManager.authenticate( new UsernamePasswordAuthenticationToken( loginRequest.getUsername(), loginRequest.getPassword() ) ); SecurityContextHolder.getContext().setAuthentication(authentication); String username ((UserDetails) authentication.getPrincipal()).getUsername(); // 生成双令牌 String accessToken jwtTokenUtil.generateAccessToken(username); String refreshToken jwtTokenUtil.generateRefreshToken(username); AuthResponse response AuthResponse.builder() .accessToken(accessToken) .refreshToken(refreshToken) .tokenType(Bearer) .expiresIn(jwtTokenUtil.getExpirationDateFromToken(accessToken).getTime()) .username(username) .build(); return ResponseEntity.ok(response); } catch (AuthenticationException e) { return ResponseEntity.status(HttpStatus.UNAUTHORIZED) .body(用户名或密码错误); } } PostMapping(/refresh) public ResponseEntity? refreshToken(RequestBody RefreshTokenRequest request) { TokenRefreshResult result tokenRefreshService.refreshToken( request.getRefreshToken()); if (result.isSuccess()) { AuthResponse response AuthResponse.builder() .accessToken(result.getAccessToken()) .refreshToken(result.getRefreshToken()) .tokenType(Bearer) .expiresIn(jwtTokenUtil.getExpirationDateFromToken( result.getAccessToken()).getTime()) .username(jwtTokenUtil.getUsernameFromToken(result.getAccessToken())) .build(); return ResponseEntity.ok(response); } else { return ResponseEntity.status(HttpStatus.UNAUTHORIZED) .body(result.getMessage()); } } PostMapping(/logout) public ResponseEntity? logout(RequestBody LogoutRequest request) { String username jwtTokenUtil.getUsernameFromToken(request.getAccessToken()); blacklistService.logoutUser( username, request.getAccessToken(), request.getRefreshToken() ); return ResponseEntity.ok(登出成功); } }6. 客户端无感刷新实现策略6.1 Axios拦截器实现前端示例前端需要配合实现令牌自动刷新机制以下以Axios为例class TokenManager { constructor() { this.accessToken localStorage.getItem(accessToken); this.refreshToken localStorage.getItem(refreshToken); this.isRefreshing false; this.requestsQueue []; } // 设置令牌 setTokens(accessToken, refreshToken) { this.accessToken accessToken; this.refreshToken refreshToken; localStorage.setItem(accessToken, accessToken); localStorage.setItem(refreshToken, refreshToken); } // 清除令牌 clearTokens() { this.accessToken null; this.refreshToken null; localStorage.removeItem(accessToken); localStorage.removeItem(refreshToken); } // 刷新令牌 async refreshToken() { if (this.isRefreshing) { return new Promise((resolve) { this.requestsQueue.push(resolve); }); } this.isRefreshing true; try { const response await axios.post(/api/auth/refresh, { refreshToken: this.refreshToken }); const { accessToken, refreshToken } response.data; this.setTokens(accessToken, refreshToken); // 执行队列中的请求 this.requestsQueue.forEach(callback callback()); this.requestsQueue []; return Promise.resolve(); } catch (error) { this.clearTokens(); window.location.href /login; return Promise.reject(error); } finally { this.isRefreshing false; } } } // Axios请求拦截器 axios.interceptors.request.use( (config) { const token localStorage.getItem(accessToken); if (token) { config.headers.Authorization Bearer ${token}; } return config; }, (error) { return Promise.reject(error); } ); // Axios响应拦截器 axios.interceptors.response.use( (response) { return response; }, async (error) { const originalRequest error.config; if (error.response.status 401 !originalRequest._retry) { originalRequest._retry true; const tokenManager new TokenManager(); await tokenManager.refreshToken(); // 更新Authorization头 const newToken localStorage.getItem(accessToken); originalRequest.headers.Authorization Bearer ${newToken}; return axios(originalRequest); } return Promise.reject(error); } );6.2 双令牌的存储策略为了安全考虑需要合理设计令牌在前端的存储方式Access Token存储建议存储在内存中页面刷新后失效提高安全性。Refresh Token存储可以存储在HttpOnly Cookie中防止XSS攻击窃取。// 安全存储示例 class SecureTokenStorage { // 存储Access Token内存存储 setAccessToken(token) { sessionStorage.setItem(accessToken, token); } // 存储Refresh TokenCookie存储 setRefreshToken(token) { document.cookie refreshToken${token}; path/; Secure; SameSiteStrict; } // 获取Refresh Token getRefreshToken() { const match document.cookie.match(/refreshToken([^;])/); return match ? match[1] : null; } }7. 生产环境最佳实践与安全加固7.1 密钥管理与轮换策略JWT密钥的安全管理至关重要以下是一些推荐实践Component public class JwtSecretManager { Value(${jwt.secret.current}) private String currentSecret; Value(${jwt.secret.previous}) private String previousSecret; // 多密钥支持平滑轮换 public boolean validateTokenWithKeyRotation(String token) { try { // 先用当前密钥验证 return validateToken(token, currentSecret); } catch (Exception e) { try { // 当前密钥失败尝试用旧密钥验证 return validateToken(token, previousSecret); } catch (Exception ex) { return false; } } } private boolean validateToken(String token, String secret) { Jwts.parser().setSigningKey(secret).parseClaimsJws(token); return true; } // 定期轮换密钥 Scheduled(cron 0 0 0 1 * ?) // 每月1号执行 public void rotateSecrets() { // 密钥轮换逻辑 // 1. 生成新密钥 // 2. 更新配置 // 3. 保留旧密钥一段时间 } }7.2 速率限制与防滥用防止令牌接口被恶意滥用Service public class RateLimitService { Autowired private RedisTemplateString, Object redisTemplate; // 登录尝试限制 public boolean isLoginAllowed(String username) { String key login_attempts: username; Integer attempts (Integer) redisTemplate.opsForValue().get(key); if (attempts null) { redisTemplate.opsForValue().set(key, 1, 1, TimeUnit.HOURS); return true; } if (attempts 5) { // 1小时内最多5次尝试 return false; } redisTemplate.opsForValue().increment(key); return true; } // 刷新令牌频率限制 public boolean isRefreshAllowed(String username) { String key refresh_attempts: username; Long count redisTemplate.opsForValue().increment(key, 1); if (count 1) { redisTemplate.expire(key, 1, TimeUnit.MINUTES); } return count 10; // 每分钟最多10次刷新 } }7.3 安全头配置增强应用的整体安全性Configuration public class SecurityHeaderConfig { Bean public FilterRegistrationBeanCorsFilter corsFilter() { UrlBasedCorsConfigurationSource source new UrlBasedCorsConfigurationSource(); CorsConfiguration config new CorsConfiguration(); config.setAllowCredentials(true); config.addAllowedOriginPattern(*); config.addAllowedHeader(*); config.addAllowedMethod(*); source.registerCorsConfiguration(/**, config); FilterRegistrationBeanCorsFilter bean new FilterRegistrationBean(new CorsFilter(source)); bean.setOrder(Ordered.HIGHEST_PRECEDENCE); return bean; } }8. 常见问题排查与性能优化8.1 高频问题解决方案问题现象可能原因解决方案刷新令牌返回401Refresh Token已过期或被加入黑名单引导用户重新登录检查Redis黑名单记录Redis连接超时网络问题或Redis配置不当检查Redis服务器状态调整连接超时配置内存使用过高黑名单记录过多未清理优化TTL设置定期清理过期黑名单并发刷新问题多个请求同时触发刷新实现刷新队列机制避免重复刷新8.2 性能优化建议Redis优化使用Redis集群分担压力合理设置内存淘汰策略对黑名单键使用适当的数据结构JWT优化避免在JWT中存储过多信息使用对称加密算法减少计算开销合理设置令牌有效期平衡安全与性能代码层面优化// 使用缓存减少数据库查询 Cacheable(value userDetails, key #username) public UserDetails loadUserByUsername(String username) { // 数据库查询逻辑 } // 批量操作减少Redis往返 public void batchBlacklistTokens(ListString tokens) { redisTemplate.executePipelined(new RedisCallbackObject() { Override public Object doInRedis(RedisConnection connection) throws DataAccessException { for (String token : tokens) { connection.setEx( (blacklist: token).getBytes(), 1800, // 30分钟 1.getBytes() ); } return null; } }); }8.3 监控与日志建立完善的监控体系及时发现和解决问题Component public class AuthMetrics { private final MeterRegistry meterRegistry; public AuthMetrics(MeterRegistry meterRegistry) { this.meterRegistry meterRegistry; } // 记录认证成功次数 public void recordLoginSuccess() { meterRegistry.counter(auth.login.success).increment(); } // 记录认证失败次数 public void recordLoginFailure() { meterRegistry.counter(auth.login.failure).increment(); } // 记录令牌刷新次数 public void recordTokenRefresh() { meterRegistry.counter(auth.token.refresh).increment(); } }通过本文的完整实现我们建立了一个生产级可用的JWT双令牌认证系统。关键在于理解每种令牌的职责划分Access Token负责短期认证Refresh Token负责长期会话维护Redis黑名单确保即时失效能力。在实际项目中还需要根据具体业务需求调整令牌有效期、黑名单策略和安全配置。这种方案既保证了用户体验的无感刷新又通过黑名单机制提供了严格的安全控制真正实现了安全与体验的平衡。建议在正式上线前进行充分的压力测试和安全审计确保系统在各种边界情况下都能稳定运行。