JWT双令牌认证与Redis黑名单机制:实现安全无感刷新

📅 发布时间:2026/7/21 17:01:07
JWT双令牌认证与Redis黑名单机制:实现安全无感刷新 在前后端分离架构中JWTJSON Web Token认证方案因其无状态、易扩展的特性被广泛采用。但随着业务复杂度提升单Token方案在安全性和用户体验上的短板逐渐暴露频繁登录影响用户体验Token泄露风险难以控制。本文将深入解析大厂标配的JWT双令牌认证方案结合Redis黑名单机制实现无感刷新与即时失效的安全平衡。1. JWT双令牌认证核心概念1.1 什么是JWT双令牌认证JWT双令牌认证通过拆分Token功能来解决安全与体验的矛盾。系统同时颁发两个TokenAccess Token访问令牌短期有效通常15-30分钟用于API访问认证Refresh Token刷新令牌长期有效通常7-30天专用于刷新Access Token这种设计遵循权限分离原则Access Token泄露的影响范围被严格控制而Refresh Token不直接参与业务请求降低了安全风险。1.2 为什么需要黑名单机制传统的JWT一旦签发就无法主动失效这是其无状态特性带来的双刃剑。黑名单机制通过Redis等内存数据库记录已失效但未过期的Token实现以下关键功能用户登出时立即失效Token刷新Token时使旧Token失效可疑活动时强制Token失效防止Token重放攻击1.3 无感刷新的用户体验价值无感刷新是指在Access Token过期时系统自动使用Refresh Token获取新Token用户无需重新登录。这种方案特别适合移动端长时间使用场景Web端单页应用SPA需要保持长时间会话的业务系统2. 环境准备与依赖配置2.1 技术栈选型说明本文示例基于Spring Boot 3.x Java 17技术栈其他语言框架可参考实现思路认证框架Spring Security 6.xJWT库jjwt 0.12.3兼容Java 17缓存数据库Redis 7.x项目构建Maven 3.62.2 Maven依赖配置在pom.xml中添加必要依赖!-- 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 !-- JWT支持 -- dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-api/artifactId version0.12.3/version /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-impl/artifactId version0.12.3/version scoperuntime/scope /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-jackson/artifactId version0.12.3/version scoperuntime/scope /dependency !-- Redis集成 -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency !-- 配置处理 -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-configuration-processor/artifactId optionaltrue/optional /dependency2.3 配置文件设置application.yml中配置关键参数# JWT配置 jwt: secret: your-256-bit-secret-key-at-least-32-chars # 实际项目应从环境变量读取 access-token-expire: 1800000 # 30分钟毫秒 refresh-token-expire: 2592000000 # 30天毫秒 # Redis配置 spring: redis: host: localhost port: 6379 password: # 生产环境必须设置密码 database: 0 timeout: 3000ms # 安全配置开发阶段可暂时关闭CSRF security: basic: enabled: false3. JWT工具类设计与实现3.1 配置参数封装创建JWT配置类统一管理参数Configuration ConfigurationProperties(prefix jwt) Data public class JwtProperties { private String secret; private long accessTokenExpire; private long refreshTokenExpire; // 密钥字节数组获取方法 public byte[] getSecretBytes() { return secret.getBytes(StandardCharsets.UTF_8); } }3.2 JWT工具类核心方法实现Token生成、解析、验证等核心功能Component Slf4j public class JwtTokenProvider { private final JwtProperties jwtProperties; public JwtTokenProvider(JwtProperties jwtProperties) { this.jwtProperties jwtProperties; } /** * 生成Access Token */ public String generateAccessToken(String username, MapString, Object claims) { return buildToken(username, claims, jwtProperties.getAccessTokenExpire()); } /** * 生成Refresh Token */ public String generateRefreshToken(String username) { return buildToken(username, Collections.emptyMap(), jwtProperties.getRefreshTokenExpire()); } private String buildToken(String username, MapString, Object claims, long expiration) { long now System.currentTimeMillis(); return Jwts.builder() .setClaims(claims) .setSubject(username) .setIssuedAt(new Date(now)) .setExpiration(new Date(now expiration)) .signWith(getSigningKey(), SignatureAlgorithm.HS256) .compact(); } /** * 解析Token获取用户名 */ public String getUsernameFromToken(String token) { return getClaimsFromToken(token).getSubject(); } /** * 验证Token有效性 */ public boolean validateToken(String token) { try { Jwts.parserBuilder() .setSigningKey(getSigningKey()) .build() .parseClaimsJws(token); return true; } catch (JwtException | IllegalArgumentException e) { log.warn(JWT token验证失败: {}, e.getMessage()); return false; } } /** * 获取Token过期时间 */ public Date getExpirationDateFromToken(String token) { return getClaimsFromToken(token).getExpiration(); } private Claims getClaimsFromToken(String token) { return Jwts.parserBuilder() .setSigningKey(getSigningKey()) .build() .parseClaimsJws(token) .getBody(); } private Key getSigningKey() { return Keys.hmacShaKeyFor(jwtProperties.getSecretBytes()); } }4. Redis黑名单管理实现4.1 黑名单服务设计黑名单服务负责Token的失效管理Service Slf4j public class TokenBlacklistService { private final RedisTemplateString, Object redisTemplate; private static final String BLACKLIST_KEY_PREFIX jwt:blacklist:; public TokenBlacklistService(RedisTemplateString, Object redisTemplate) { this.redisTemplate redisTemplate; } /** * 将Token加入黑名单 */ public void addToBlacklist(String token, long expirationTime) { String key BLACKLIST_KEY_PREFIX token; long ttl (expirationTime - System.currentTimeMillis()) / 1000; if (ttl 0) { redisTemplate.opsForValue().set(key, blacklisted, ttl, TimeUnit.SECONDS); log.debug(Token已加入黑名单TTL: {}秒, ttl); } } /** * 检查Token是否在黑名单中 */ public boolean isBlacklisted(String token) { String key BLACKLIST_KEY_PREFIX token; Boolean exists redisTemplate.hasKey(key); return Boolean.TRUE.equals(exists); } /** * 批量加入黑名单用于登出所有设备等场景 */ public void batchAddToBlacklist(ListString tokens, long expirationTime) { tokens.forEach(token - addToBlacklist(token, expirationTime)); } }4.2 Redis配置类配置Redis序列化方式确保性能和数据可读性Configuration public class RedisConfig { Bean public RedisTemplateString, Object redisTemplate(RedisConnectionFactory factory) { RedisTemplateString, Object template new RedisTemplate(); template.setConnectionFactory(factory); // 使用StringRedisSerializer来序列化和反序列化redis的key值 template.setKeySerializer(new StringRedisSerializer()); template.setHashKeySerializer(new StringRedisSerializer()); // 使用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); template.setValueSerializer(serializer); template.setHashValueSerializer(serializer); template.afterPropertiesSet(); return template; } }5. 双令牌认证完整流程实现5.1 用户登录接口登录成功后返回双令牌RestController RequestMapping(/api/auth) Slf4j public class AuthController { private final JwtTokenProvider tokenProvider; private final TokenBlacklistService blacklistService; private final UserService userService; PostMapping(/login) public ResponseEntityLoginResponse login(RequestBody LoginRequest request) { // 1. 验证用户凭证 User user userService.authenticate(request.getUsername(), request.getPassword()); // 2. 生成双令牌 String accessToken tokenProvider.generateAccessToken(user.getUsername(), Map.of(roles, user.getRoles())); String refreshToken tokenProvider.generateRefreshToken(user.getUsername()); // 3. 记录登录日志实际项目需要 log.info(用户登录成功: {}, user.getUsername()); // 4. 返回令牌 return ResponseEntity.ok(LoginResponse.builder() .accessToken(accessToken) .refreshToken(refreshToken) .tokenType(Bearer) .expiresIn(tokenProvider.getExpirationDateFromToken(accessToken)) .build()); } }5.2 令牌刷新接口实现无感刷新逻辑PostMapping(/refresh) public ResponseEntityRefreshResponse refreshToken(RequestBody RefreshRequest request) { String refreshToken request.getRefreshToken(); // 1. 验证Refresh Token有效性 if (!tokenProvider.validateToken(refreshToken)) { throw new InvalidTokenException(Refresh Token无效或已过期); } // 2. 检查Refresh Token是否在黑名单中 if (blacklistService.isBlacklisted(refreshToken)) { throw new InvalidTokenException(Refresh Token已被撤销); } // 3. 获取用户信息 String username tokenProvider.getUsernameFromToken(refreshToken); // 4. 使旧的Refresh Token失效可选增强安全性 blacklistService.addToBlacklist(refreshToken, tokenProvider.getExpirationDateFromToken(refreshToken).getTime()); // 5. 生成新的双令牌 String newAccessToken tokenProvider.generateAccessToken(username, Map.of(roles, getUserRoles(username))); String newRefreshToken tokenProvider.generateRefreshToken(username); return ResponseEntity.ok(RefreshResponse.builder() .accessToken(newAccessToken) .refreshToken(newRefreshToken) .build()); }5.3 用户登出接口登出时使Token立即失效PostMapping(/logout) public ResponseEntityLogoutResponse logout( RequestHeader(Authorization) String authHeader, RequestBody(required false) LogoutRequest request) { // 1. 从Header中提取Token String token extractTokenFromHeader(authHeader); // 2. 将Access Token加入黑名单 if (token ! null tokenProvider.validateToken(token)) { long expirationTime tokenProvider.getExpirationDateFromToken(token).getTime(); blacklistService.addToBlacklist(token, expirationTime); } // 3. 如果提供了Refresh Token也使其失效 if (request ! null request.getRefreshToken() ! null) { String refreshToken request.getRefreshToken(); if (tokenProvider.validateToken(refreshToken)) { long refreshExpiration tokenProvider.getExpirationDateFromToken(refreshToken).getTime(); blacklistService.addToBlacklist(refreshToken, refreshExpiration); } } return ResponseEntity.ok(LogoutResponse.builder() .message(登出成功) .timestamp(System.currentTimeMillis()) .build()); }6. Spring Security集成配置6.1 JWT认证过滤器实现基于JWT的认证逻辑Component public class JwtAuthenticationFilter extends OncePerRequestFilter { private final JwtTokenProvider tokenProvider; private final TokenBlacklistService blacklistService; private final UserDetailsService userDetailsService; Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { try { String token getJwtFromRequest(request); if (StringUtils.hasText(token) tokenProvider.validateToken(token)) { // 检查Token是否在黑名单中 if (!blacklistService.isBlacklisted(token)) { String username tokenProvider.getUsernameFromToken(token); UserDetails userDetails userDetailsService.loadUserByUsername(username); UsernamePasswordAuthenticationToken authentication new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities()); authentication.setDetails(new WebAuthenticationDetailsSource() .buildDetails(request)); SecurityContextHolder.getContext().setAuthentication(authentication); } } } catch (Exception ex) { logger.error(无法设置用户认证, ex); } filterChain.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; } }6.2 Security配置类配置Spring Security的认证规则Configuration EnableWebSecurity EnableMethodSecurity public class SecurityConfig { private final JwtAuthenticationFilter jwtAuthenticationFilter; public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter) { this.jwtAuthenticationFilter jwtAuthenticationFilter; } Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .csrf(AbstractHttpConfigurer::disable) // 前后端分离可禁用CSRF .sessionManagement(session - session .sessionCreationPolicy(SessionCreationPolicy.STATELESS)) // 无状态会话 .authorizeHttpRequests(authz - authz .requestMatchers(/api/auth/**).permitAll() // 认证接口公开 .requestMatchers(/api/public/**).permitAll() // 公开接口 .anyRequest().authenticated() // 其他接口需要认证 ) .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); } Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } }7. 前端无感刷新实现方案7.1 Axios拦截器配置前端通过拦截器实现自动刷新Tokenclass TokenManager { constructor() { this.isRefreshing false; this.requests []; } // 设置Token setTokens(accessToken, refreshToken) { localStorage.setItem(access_token, accessToken); localStorage.setItem(refresh_token, refreshToken); } // 获取Access Token getAccessToken() { return localStorage.getItem(access_token); } // 获取Refresh Token getRefreshToken() { return localStorage.getItem(refresh_token); } // 清除Token clearTokens() { localStorage.removeItem(access_token); localStorage.removeItem(refresh_token); } } // Axios请求拦截器 axios.interceptors.request.use( (config) { const token tokenManager.getAccessToken(); if (token) { config.headers.Authorization Bearer ${token}; } return config; }, (error) { return Promise.reject(error); } ); // Axios响应拦截器 - 处理Token刷新 axios.interceptors.response.use( (response) response, async (error) { const originalRequest error.config; if (error.response?.status 401 !originalRequest._retry) { if (tokenManager.isRefreshing) { // 如果正在刷新将请求加入队列 return new Promise((resolve) { tokenManager.requests.push(() { originalRequest.headers.Authorization Bearer ${tokenManager.getAccessToken()}; resolve(axios(originalRequest)); }); }); } originalRequest._retry true; tokenManager.isRefreshing true; try { const refreshToken tokenManager.getRefreshToken(); const response await axios.post(/api/auth/refresh, { refreshToken: refreshToken }); const { accessToken, refreshToken: newRefreshToken } response.data; tokenManager.setTokens(accessToken, newRefreshToken); // 重试所有挂起的请求 tokenManager.requests.forEach(callback callback()); tokenManager.requests []; // 重试原始请求 originalRequest.headers.Authorization Bearer ${accessToken}; return axios(originalRequest); } catch (refreshError) { // 刷新失败跳转到登录页 tokenManager.clearTokens(); window.location.href /login; return Promise.reject(refreshError); } finally { tokenManager.isRefreshing false; } } return Promise.reject(error); } );7.2 心跳检测机制定期检查Token状态提前刷新避免请求中断class TokenHealthCheck { constructor() { this.checkInterval 5 * 60 * 1000; // 5分钟检查一次 this.timer null; } start() { this.timer setInterval(() { this.checkTokenHealth(); }, this.checkInterval); } stop() { if (this.timer) { clearInterval(this.timer); this.timer null; } } async checkTokenHealth() { const accessToken tokenManager.getAccessToken(); if (!accessToken) return; try { // 解析Token获取过期时间不验证签名 const payload JSON.parse(atob(accessToken.split(.)[1])); const exp payload.exp * 1000; // 转为毫秒 const now Date.now(); const threshold 10 * 60 * 1000; // 提前10分钟刷新 if (exp - now threshold) { await this.refreshToken(); } } catch (error) { console.warn(Token健康检查失败:, error); } } async refreshToken() { const refreshToken tokenManager.getRefreshToken(); if (!refreshToken) return; try { const response await axios.post(/api/auth/refresh, { refreshToken: refreshToken }); tokenManager.setTokens( response.data.accessToken, response.data.refreshToken ); console.log(Token自动刷新成功); } catch (error) { console.error(Token自动刷新失败:, error); } } }8. 安全增强与最佳实践8.1 Token安全存储方案前端Token存储的安全考虑// 更安全的存储方案 - 根据环境选择 class SecureTokenStorage { constructor() { this.useHttpOnly process.env.NODE_ENV production; } setTokens(accessToken, refreshToken) { if (this.useHttpOnly) { // 生产环境建议使用HttpOnly Cookie this.setCookie(access_token, accessToken, 30); // 30分钟 this.setCookie(refresh_token, refreshToken, 30 * 24 * 60); // 30天 } else { // 开发环境使用localStorage localStorage.setItem(access_token, accessToken); localStorage.setItem(refresh_token, refreshToken); } } setCookie(name, value, minutes) { const expires new Date(); expires.setTime(expires.getTime() minutes * 60 * 1000); document.cookie ${name}${value};expires${expires.toUTCString()};path/;Secure;SameSiteStrict; } }8.2 防止Token重放攻击通过JTIJWT ID和Nonce机制增强安全性Service public class TokenReplayProtectionService { private final RedisTemplateString, Object redisTemplate; private static final String NONCE_KEY_PREFIX jwt:nonce:; /** * 生成并记录Nonce */ public String generateNonce(String token, long ttlSeconds) { String nonce UUID.randomUUID().toString(); String key NONCE_KEY_PREFIX nonce; redisTemplate.opsForValue().set(key, token, ttlSeconds, TimeUnit.SECONDS); return nonce; } /** * 验证Nonce有效性 */ public boolean validateNonce(String nonce, String expectedToken) { String key NONCE_KEY_PREFIX nonce; String actualToken (String) redisTemplate.opsForValue().get(key); if (actualToken ! null actualToken.equals(expectedToken)) { // 使用后立即删除防止重用 redisTemplate.delete(key); return true; } return false; } }8.3 监控与审计日志记录关键操作用于安全审计Aspect Component Slf4j public class SecurityAuditAspect { AfterReturning(pointcut execution(* com.example.auth.service.*.*(..)), returning result) public void auditSuccess(JoinPoint joinPoint, Object result) { String methodName joinPoint.getSignature().getName(); String username SecurityContextHolder.getContext().getAuthentication().getName(); log.info(安全操作成功 - 用户: {}, 方法: {}, 结果: {}, username, methodName, result); } AfterThrowing(pointcut execution(* com.example.auth.service.*.*(..)), throwing ex) public void auditFailure(JoinPoint joinPoint, Exception ex) { String methodName joinPoint.getSignature().getName(); Authentication auth SecurityContextHolder.getContext().getAuthentication(); String username auth ! null ? auth.getName() : anonymous; log.warn(安全操作失败 - 用户: {}, 方法: {}, 异常: {}, username, methodName, ex.getMessage()); } }9. 性能优化与高可用方案9.1 Redis集群配置生产环境Redis高可用方案spring: redis: cluster: nodes: - redis-node1:6379 - redis-node2:6379 - redis-node3:6379 max-redirects: 3 lettuce: pool: max-active: 8 max-wait: -1ms max-idle: 8 min-idle: 0 shutdown-timeout: 100ms9.2 本地缓存二级缓存减少Redis访问压力Component Slf4j public class TokenCacheService { private final TokenBlacklistService blacklistService; private final CacheString, Boolean localCache; public TokenCacheService(TokenBlacklistService blacklistService) { this.blacklistService blacklistService; this.localCache Caffeine.newBuilder() .expireAfterWrite(5, TimeUnit.MINUTES) // 5分钟本地缓存 .maximumSize(10000) // 最大缓存数量 .build(); } public boolean isBlacklisted(String token) { // 先查本地缓存 Boolean cached localCache.getIfPresent(token); if (cached ! null) { return cached; } // 本地缓存没有查Redis boolean blacklisted blacklistService.isBlacklisted(token); // 写入本地缓存 localCache.put(token, blacklisted); return blacklisted; } }10. 常见问题与解决方案10.1 Token过期时间设置策略场景Access Token过期时间Refresh Token过期时间说明高安全要求15分钟7天金融、支付等场景平衡型30分钟30天大多数业务系统用户体验优先2小时90天内部管理系统10.2 并发请求下的Token刷新防止多个并发请求同时触发Token刷新Service public class TokenRefreshCoordinator { private final MapString, CompletableFutureString refreshInProgress new ConcurrentHashMap(); public CompletableFutureString coordinateRefresh(String username, SupplierCompletableFutureString refreshSupplier) { return refreshInProgress.computeIfAbsent(username, k - { CompletableFutureString future refreshSupplier.get(); future.whenComplete((result, error) - { refreshInProgress.remove(username); }); return future; }); } }10.3 分布式环境下的黑名单同步多实例部署时的黑名单同步方案Component public class BlacklistSyncService { private final RedisTemplateString, Object redisTemplate; private final Topic blacklistTopic; EventListener public void handleBlacklistEvent(BlacklistEvent event) { // 通过Redis Pub/Sub同步黑名单事件 redisTemplate.convertAndSend(blacklistTopic.getTopic(), event); } RedisListener(topic blacklist.sync) public void onBlacklistSync(BlacklistEvent event) { // 处理其他实例同步过来的黑名单事件 // 更新本地缓存或直接操作Redis } }通过以上完整实现JWT双令牌认证方案既保证了安全性又提供了优秀的用户体验。在实际项目中需要根据具体业务需求调整Token过期时间、黑名单策略等参数。建议在正式上线前进行充分的安全测试和性能压测确保系统稳定可靠。