解讀Token失效的6種方案
JWT(JSON Web Token)作為一種輕量級的認證方式,被廣泛應(yīng)用于現(xiàn)代Web應(yīng)用和微服務(wù)架構(gòu)中。
然而,JWT的無狀態(tài)特性雖然帶來了擴展性優(yōu)勢,卻也帶來了令牌管理的挑戰(zhàn),特別是當(dāng)需要使令牌提前失效時。
本文將介紹在SpringBoot應(yīng)用中實現(xiàn)JWT令牌失效的6種方案。
一、JWT基礎(chǔ)與失效挑戰(zhàn)
1.1 JWT的基本結(jié)構(gòu)
JWT由三部分組成,以點(.)分隔:
- Header(頭部) :包含令牌類型和使用的簽名算法
- Payload(負載) :包含聲明(claims),如用戶信息和權(quán)限
- Signature(簽名) :用于驗證令牌的完整性和真實性
一個典型的JWT看起來像這樣:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
1.2 JWT的特點與失效挑戰(zhàn)
JWT的主要特點是無狀態(tài)性,服務(wù)器不需要存儲會話信息。
這帶來了以下挑戰(zhàn):
- JWT一旦簽發(fā),在其有效期內(nèi)始終有效
- 無法直接撤銷或使令牌失效
- 服務(wù)器默認無法跟蹤已發(fā)行的令牌
這些特性使得實現(xiàn)JWT的提前失效變得困難,特別是在以下場景:
- 用戶登出系統(tǒng)
- 用戶權(quán)限變更
- 賬戶被盜,需要使所有令牌失效
- 密碼更改后使舊令牌失效
二、短期令牌+刷新令牌方案
2.1 基本原理
該方案使用兩種令牌:
- 短期訪問令牌(Access Token) :有效期短(如15分鐘),用于API訪問
- 長期刷新令牌(Refresh Token) :有效期長(如7天),用于獲取新的訪問令牌
當(dāng)用戶需要登出時,只需使刷新令牌失效,短期訪問令牌會自然過期。
2.2 SpringBoot實現(xiàn)
首先,添加必要的依賴:
<dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-api</artifactId> <version>0.11.5</version> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-impl</artifactId> <version>0.11.5</version> <scope>runtime</scope> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-jackson</artifactId> <version>0.11.5</version> <scope>runtime</scope> </dependency>
創(chuàng)建JWT工具類:
@Component public class JwtTokenProvider { @Value("${jwt.secret}") private String jwtSecret; @Value("${jwt.accessTokenExpiration}") private long accessTokenExpiration; @Value("${jwt.refreshTokenExpiration}") private long refreshTokenExpiration; public String generateAccessToken(UserDetails userDetails) { return generateToken(userDetails, accessTokenExpiration); } public String generateRefreshToken(UserDetails userDetails) { return generateToken(userDetails, refreshTokenExpiration); } private String generateToken(UserDetails userDetails, long expiration) { Date now = new Date(); Date expiryDate = new Date(now.getTime() + expiration); return Jwts.builder() .setSubject(userDetails.getUsername()) .setIssuedAt(now) .setExpiration(expiryDate) .signWith(Keys.hmacShaKeyFor(jwtSecret.getBytes()), SignatureAlgorithm.HS512) .compact(); } public String getUsernameFromToken(String token) { Claims claims = Jwts.parserBuilder() .setSigningKey(Keys.hmacShaKeyFor(jwtSecret.getBytes())) .build() .parseClaimsJws(token) .getBody(); return claims.getSubject(); } public boolean validateToken(String token) { try { Jwts.parserBuilder() .setSigningKey(Keys.hmacShaKeyFor(jwtSecret.getBytes())) .build() .parseClaimsJws(token); return true; } catch (Exception e) { return false; } } }
實現(xiàn)刷新令牌服務(wù):
@Service @RequiredArgsConstructor public class RefreshTokenService { private final RefreshTokenRepository refreshTokenRepository; private final JwtTokenProvider jwtTokenProvider; @Transactional public RefreshToken createRefreshToken(String username) { RefreshToken refreshToken = new RefreshToken(); refreshToken.setUsername(username); refreshToken.setToken(UUID.randomUUID().toString()); refreshToken.setExpiryDate(Instant.now().plusMillis( jwtTokenProvider.getRefreshTokenExpiration())); return refreshTokenRepository.save(refreshToken); } @Transactional public void deleteByUsername(String username) { refreshTokenRepository.deleteByUsername(username); } public Optional<RefreshToken> findByToken(String token) { return refreshTokenRepository.findByToken(token); } public RefreshToken verifyExpiration(RefreshToken token) { if (token.getExpiryDate().compareTo(Instant.now()) < 0) { refreshTokenRepository.delete(token); throw new TokenRefreshException(token.getToken(), "Refresh token was expired. Please make a new signin request"); } return token; } }
實現(xiàn)認證控制器:
@RestController @RequestMapping("/api/auth") @RequiredArgsConstructor public class AuthController { private final AuthenticationManager authenticationManager; private final UserDetailsService userDetailsService; private final JwtTokenProvider jwtTokenProvider; private final RefreshTokenService refreshTokenService; @PostMapping("/login") public ResponseEntity<?> authenticateUser(@Valid @RequestBody LoginRequest loginRequest) { Authentication authentication = authenticationManager.authenticate( new UsernamePasswordAuthenticationToken( loginRequest.getUsername(), loginRequest.getPassword() ) ); SecurityContextHolder.getContext().setAuthentication(authentication); UserDetails userDetails = (UserDetails) authentication.getPrincipal(); String accessToken = jwtTokenProvider.generateAccessToken(userDetails); RefreshToken refreshToken = refreshTokenService.createRefreshToken(userDetails.getUsername()); return ResponseEntity.ok(new JwtResponse(accessToken, refreshToken.getToken())); } @PostMapping("/refresh") public ResponseEntity<?> refreshToken(@Valid @RequestBody TokenRefreshRequest request) { String requestRefreshToken = request.getRefreshToken(); return refreshTokenService.findByToken(requestRefreshToken) .map(refreshTokenService::verifyExpiration) .map(RefreshToken::getUsername) .map(username -> { UserDetails userDetails = userDetailsService.loadUserByUsername(username); String accessToken = jwtTokenProvider.generateAccessToken(userDetails); return ResponseEntity.ok(new TokenRefreshResponse(accessToken, requestRefreshToken)); }) .orElseThrow(() -> new TokenRefreshException(requestRefreshToken, "Refresh token is not in database!")); } @PostMapping("/logout") public ResponseEntity<?> logoutUser(@Valid @RequestBody LogoutRequest logoutRequest) { refreshTokenService.deleteByUsername(logoutRequest.getUsername()); return ResponseEntity.ok(new MessageResponse("Log out successful!")); } }
application.properties配置:
jwt.secret=yourVeryLongAndSecureSecretKeyHerePleaseMakeItAtLeast256Bits jwt.accessTokenExpiration=900000 # 15分鐘 jwt.refreshTokenExpiration=604800000 # 7天
2.3 優(yōu)缺點分析
優(yōu)點:
- 無需維護黑名單,降低服務(wù)器負擔(dān)
- 訪問令牌有效期短,安全性較高
- 用戶體驗良好,透明刷新令牌
- 實現(xiàn)簡單,容易理解
缺點:
- 無法即時使訪問令牌失效,最多等待其自然過期
- 需要額外存儲刷新令牌,增加了狀態(tài)性
- 增加了客戶端復(fù)雜度,需要處理令牌刷新邏輯
- 如果刷新令牌泄露,可能導(dǎo)致長期安全風(fēng)險
2.4 適用場景
- 一般的Web應(yīng)用和移動應(yīng)用
- 對令牌即時失效要求不嚴(yán)格的場景
- 希望減輕服務(wù)器負擔(dān)的系統(tǒng)
- 用戶會話時間較長的應(yīng)用
三、Redis黑名單機制
3.1 基本原理
黑名單機制將已注銷或失效的令牌存儲在Redis等高性能緩存中,每次驗證令牌時都會檢查它是否在黑名單中。
這種方法允許即時使令牌失效,同時保持良好的性能。
3.2 SpringBoot實現(xiàn)
首先,添加Redis依賴:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency>
創(chuàng)建Redis配置類:
@Configuration public class RedisConfig { @Bean public RedisTemplate<String, String> redisTemplate(RedisConnectionFactory factory) { RedisTemplate<String, String> template = new RedisTemplate<>(); template.setConnectionFactory(factory); template.setKeySerializer(new StringRedisSerializer()); template.setValueSerializer(new StringRedisSerializer()); return template; } }
實現(xiàn)JWT黑名單服務(wù):
@Service @RequiredArgsConstructor public class JwtBlacklistService { private final RedisTemplate<String, String> redisTemplate; private final JwtTokenProvider jwtTokenProvider; private static final String BLACKLIST_PREFIX = "jwt:blacklist:"; public void blacklistToken(String token) { try { // 獲取令牌過期時間 Claims claims = jwtTokenProvider.getClaimsFromToken(token); Date expiration = claims.getExpiration(); long ttl = (expiration.getTime() - System.currentTimeMillis()) / 1000; // 僅當(dāng)令牌未過期時添加到黑名單 if (ttl > 0) { String key = BLACKLIST_PREFIX + token; redisTemplate.opsForValue().set(key, "blacklisted", ttl, TimeUnit.SECONDS); } } catch (Exception e) { // 令牌已無效,無需加入黑名單 } } public boolean isBlacklisted(String token) { String key = BLACKLIST_PREFIX + token; return Boolean.TRUE.equals(redisTemplate.hasKey(key)); } }
更新JWT工具類:
@Component public class JwtTokenProvider { // ... 之前的代碼 ... public Claims getClaimsFromToken(String token) { return Jwts.parserBuilder() .setSigningKey(Keys.hmacShaKeyFor(jwtSecret.getBytes())) .build() .parseClaimsJws(token) .getBody(); } }
添加JWT過濾器,檢查黑名單:
@Component @RequiredArgsConstructor public class JwtTokenFilter extends OncePerRequestFilter { private final JwtTokenProvider jwtTokenProvider; private final JwtBlacklistService blacklistService; private final UserDetailsService userDetailsService; @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { try { String jwt = getJwtFromRequest(request); if (StringUtils.hasText(jwt) && jwtTokenProvider.validateToken(jwt)) { // 檢查令牌是否在黑名單中 if (blacklistService.isBlacklisted(jwt)) { response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.getWriter().write("Token has been revoked"); return; } String username = jwtTokenProvider.getUsernameFromToken(jwt); 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("Could not set user authentication in security context", 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; } }
實現(xiàn)登出端點:
@RestController @RequestMapping("/api/auth") @RequiredArgsConstructor public class AuthController { // ... 之前的代碼 ... private final JwtBlacklistService blacklistService; @PostMapping("/logout") public ResponseEntity<?> logoutUser(HttpServletRequest request) { String jwt = getJwtFromRequest(request); if (StringUtils.hasText(jwt)) { blacklistService.blacklistToken(jwt); } return ResponseEntity.ok(new MessageResponse("Log out successful!")); } private String getJwtFromRequest(HttpServletRequest request) { String bearerToken = request.getHeader("Authorization"); if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) { return bearerToken.substring(7); } return null; } }
3.3 優(yōu)缺點分析
優(yōu)點:
- 可以即時使令牌失效
- 不影響令牌原有的有效期管理
- 無需修改客戶端邏輯
- Redis高性能,對系統(tǒng)影響小
缺點:
- 引入了狀態(tài)存儲,部分犧牲JWT的無狀態(tài)特性
- Redis需要存儲所有已注銷但未過期的令牌,增加存儲開銷
- 每次API請求都需要檢查黑名單,增加了延遲
3.4 適用場景
- 對安全性要求較高的應(yīng)用
- 需要即時令牌失效功能的系統(tǒng)
四、令牌版本/計數(shù)器機制
4.1 基本原理
該方案為每個用戶維護一個令牌版本號或計數(shù)器。當(dāng)用戶登出或需要使令牌失效時,增加用戶的令牌版本號。
令牌中包含發(fā)行時的版本號,驗證時比較令牌中的版本號與用戶當(dāng)前的版本號,如果不匹配則拒絕訪問。
4.2 SpringBoot實現(xiàn)
首先,創(chuàng)建用戶令牌版本實體:
@Entity @Table(name = "user_token_versions") @Data public class UserTokenVersion { @Id private String username; private int tokenVersion; public void incrementVersion() { this.tokenVersion++; } }
創(chuàng)建令牌版本倉庫:
@Repository public interface UserTokenVersionRepository extends JpaRepository<UserTokenVersion, String> { }
實現(xiàn)令牌版本服務(wù):
@Service @RequiredArgsConstructor public class TokenVersionService { private final UserTokenVersionRepository repository; @Transactional public int getCurrentVersion(String username) { return repository.findById(username) .orElseGet(() -> { UserTokenVersion newVersion = new UserTokenVersion(); newVersion.setUsername(username); newVersion.setTokenVersion(0); return repository.save(newVersion); }) .getTokenVersion(); } @Transactional public void incrementVersion(String username) { UserTokenVersion version = repository.findById(username) .orElseGet(() -> { UserTokenVersion newVersion = new UserTokenVersion(); newVersion.setUsername(username); newVersion.setTokenVersion(0); return newVersion; }); version.incrementVersion(); repository.save(version); } }
修改JWT工具類,在令牌中包含版本信息:
@Component @RequiredArgsConstructor public class JwtTokenProvider { @Value("${jwt.secret}") private String jwtSecret; @Value("${jwt.expiration}") private long jwtExpiration; private final TokenVersionService tokenVersionService; public String generateToken(UserDetails userDetails) { Date now = new Date(); Date expiryDate = new Date(now.getTime() + jwtExpiration); // 獲取當(dāng)前令牌版本 int tokenVersion = tokenVersionService.getCurrentVersion(userDetails.getUsername()); return Jwts.builder() .setSubject(userDetails.getUsername()) .claim("tokenVersion", tokenVersion) // 添加版本信息 .setIssuedAt(now) .setExpiration(expiryDate) .signWith(Keys.hmacShaKeyFor(jwtSecret.getBytes()), SignatureAlgorithm.HS512) .compact(); } public boolean validateToken(String token, UserDetails userDetails) { try { Claims claims = getClaimsFromToken(token); // 驗證用戶名 boolean usernameMatches = claims.getSubject().equals(userDetails.getUsername()); // 驗證令牌未過期 boolean isNotExpired = claims.getExpiration().after(new Date()); // 驗證令牌版本 int tokenVersion = claims.get("tokenVersion", Integer.class); int currentVersion = tokenVersionService.getCurrentVersion(userDetails.getUsername()); boolean versionMatches = tokenVersion == currentVersion; return usernameMatches && isNotExpired && versionMatches; } catch (Exception e) { return false; } } // ... 其他方法 ... }
更新JWT過濾器:
@Component @RequiredArgsConstructor public class JwtTokenFilter extends OncePerRequestFilter { private final JwtTokenProvider jwtTokenProvider; private final UserDetailsService userDetailsService; @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { try { String jwt = getJwtFromRequest(request); if (StringUtils.hasText(jwt)) { String username = jwtTokenProvider.getUsernameFromToken(jwt); UserDetails userDetails = userDetailsService.loadUserByUsername(username); // 使用版本驗證令牌 if (jwtTokenProvider.validateToken(jwt, userDetails)) { UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities()); authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); SecurityContextHolder.getContext().setAuthentication(authentication); } } } catch (Exception ex) { logger.error("Could not set user authentication in security context", ex); } filterChain.doFilter(request, response); } // ... getJwtFromRequest方法 ... }
實現(xiàn)登出端點:
@RestController @RequestMapping("/api/auth") @RequiredArgsConstructor public class AuthController { // ... 其他代碼 ... private final TokenVersionService tokenVersionService; @PostMapping("/logout") public ResponseEntity<?> logoutUser(Authentication authentication) { String username = authentication.getName(); // 增加令牌版本號,使所有現(xiàn)有令牌失效 tokenVersionService.incrementVersion(username); return ResponseEntity.ok(new MessageResponse("Log out successful!")); } }
4.3 優(yōu)缺點分析
優(yōu)點:
- 存儲開銷小,只需記錄用戶的當(dāng)前版本號
- 無需維護黑名單,降低了內(nèi)存需求
- 可以選擇性地使部分令牌失效
缺點:
- 需要存儲用戶令牌版本
- 每次驗證令牌都需要查詢數(shù)據(jù)庫或緩存
- 可能影響系統(tǒng)性能,特別是在用戶量大的情況下
4.4 適用場景
- 需要用戶主動登出功能的系統(tǒng)
- 用戶量適中的系統(tǒng)
- 需要在特定操作后使令牌失效的場景
五、密鑰輪換策略
5.1 基本原理
密鑰輪換策略通過定期更換用于簽名JWT的密鑰來實現(xiàn)令牌失效。
當(dāng)系統(tǒng)需要使所有令牌失效時,立即輪換密鑰,所有使用舊密鑰簽名的令牌將無法通過驗證。
為了支持平滑過渡,系統(tǒng)通常保留多個最近的密鑰版本。
5.2 SpringBoot實現(xiàn)
創(chuàng)建密鑰管理服務(wù):
@Service @Slf4j public class KeyRotationService { private final Map<String, Key> keyStore = new ConcurrentHashMap<>(); private String currentKeyId; @PostConstruct public void init() { // 初始化第一個密鑰 rotateKey(); } @Scheduled(cron = "${jwt.key-rotation-cron:0 0 0 * * ?}") // 默認每天零點 public void scheduledRotation() { log.info("Performing scheduled key rotation"); rotateKey(); } public synchronized void rotateKey() { String keyId = UUID.randomUUID().toString(); Key key = generateKey(); keyStore.put(keyId, key); // 只保留最近3個密鑰 if (keyStore.size() > 3) { List<String> keyIds = new ArrayList<>(keyStore.keySet()); keyIds.sort(null); // 自然排序 for (int i = 0; i < keyIds.size() - 3; i++) { keyStore.remove(keyIds.get(i)); } } currentKeyId = keyId; log.info("Key rotated, new key ID: {}", keyId); } public String getCurrentKeyId() { return currentKeyId; } public Key getKey(String keyId) { return keyStore.get(keyId); } public Key getCurrentKey() { return keyStore.get(currentKeyId); } private Key generateKey() { return Keys.secretKeyFor(SignatureAlgorithm.HS512); } public void forceRotation() { log.info("Forcing key rotation to invalidate all tokens"); rotateKey(); } }
更新JWT工具類以支持密鑰輪換:
@Component @RequiredArgsConstructor public class JwtTokenProvider { @Value("${jwt.expiration}") private long jwtExpiration; private final KeyRotationService keyRotationService; public String generateToken(UserDetails userDetails) { Date now = new Date(); Date expiryDate = new Date(now.getTime() + jwtExpiration); String keyId = keyRotationService.getCurrentKeyId(); Key key = keyRotationService.getCurrentKey(); return Jwts.builder() .setSubject(userDetails.getUsername()) .setIssuedAt(now) .setExpiration(expiryDate) .setHeaderParam("kid", keyId) // 設(shè)置密鑰ID .signWith(key, SignatureAlgorithm.HS512) .compact(); } public Claims getClaimsFromToken(String token) { // 從令牌頭部提取密鑰ID String kid = extractKeyId(token); if (kid == null) { throw new JwtException("Invalid JWT: Missing key ID"); } // 獲取對應(yīng)的密鑰 Key key = keyRotationService.getKey(kid); if (key == null) { throw new JwtException("Invalid JWT: Unknown key ID"); } return Jwts.parserBuilder() .setSigningKey(key) .build() .parseClaimsJws(token) .getBody(); } private String extractKeyId(String token) { try { String header = token.split("\.")[0]; String decodedHeader = new String(Base64.getDecoder().decode(header)); JsonNode headerNode = new ObjectMapper().readTree(decodedHeader); return headerNode.get("kid").asText(); } catch (Exception e) { return null; } } public boolean validateToken(String token) { try { getClaimsFromToken(token); return true; } catch (Exception e) { return false; } } // ... 其他方法 ... }
創(chuàng)建管理員控制器,提供強制失效所有令牌的功能:
@RestController @RequestMapping("/api/admin") @RequiredArgsConstructor @PreAuthorize("hasRole('ADMIN')") public class AdminController { private final KeyRotationService keyRotationService; @PostMapping("/invalidate-all-tokens") public ResponseEntity<?> invalidateAllTokens() { keyRotationService.forceRotation(); return ResponseEntity.ok(new MessageResponse("All tokens have been invalidated")); } }
5.3 優(yōu)缺點分析
優(yōu)點:
- 可以立即使所有令牌失效
- 可以實現(xiàn)平滑過渡,支持舊密鑰一段時間
- 符合安全最佳實踐,定期輪換密鑰
缺點:
- 無法選擇性使單個用戶的令牌失效
- 可能導(dǎo)致所有用戶被迫重新登錄
- 需要妥善管理密鑰
5.4 適用場景
- 安全要求高,需要定期輪換密鑰的系統(tǒng)
- 發(fā)生安全事件時,需要緊急使所有令牌失效
- 偏好無狀態(tài)設(shè)計的應(yīng)用
- 系統(tǒng)重大升級或維護時
六、集中式令牌存儲
6.1 基本原理
這種方法將JWT作為訪問標(biāo)識符,但在服務(wù)器端維護一個集中式的令牌存儲,存儲介質(zhì)可以使用數(shù)據(jù)庫或者緩存。
每次驗證時,不僅檢查JWT的簽名和有效期,還查詢存儲庫確認令牌是否仍然有效。
這種方式結(jié)合了JWT的便利性和會話管理的靈活性。
6.2 SpringBoot實現(xiàn)
創(chuàng)建令牌實體:
@Entity @Table(name = "active_tokens") @Data public class ActiveToken { @Id private String tokenId; private String username; private Date expiryDate; private boolean revoked; @CreationTimestamp private Date createdAt; public boolean isExpired() { return expiryDate.before(new Date()); } }
創(chuàng)建令牌倉庫:
@Repository public interface ActiveTokenRepository extends JpaRepository<ActiveToken, String> { List<ActiveToken> findByUsername(String username); @Modifying @Query("UPDATE ActiveToken t SET t.revoked = true WHERE t.username = :username") void revokeAllUserTokens(@Param("username") String username); @Modifying @Query("DELETE FROM ActiveToken t WHERE t.expiryDate < :now") void deleteExpiredTokens(@Param("now") Date now); }
實現(xiàn)令牌服務(wù):
@Service @RequiredArgsConstructor public class TokenStorageService { private final ActiveTokenRepository tokenRepository; @Transactional public void saveToken(String tokenId, String username, Date expiryDate) { ActiveToken token = new ActiveToken(); token.setTokenId(tokenId); token.setUsername(username); token.setExpiryDate(expiryDate); token.setRevoked(false); tokenRepository.save(token); } @Transactional(readOnly = true) public boolean isTokenValid(String tokenId) { return tokenRepository.findById(tokenId) .map(token -> !token.isRevoked() && !token.isExpired()) .orElse(false); } @Transactional public void revokeToken(String tokenId) { tokenRepository.findById(tokenId).ifPresent(token -> { token.setRevoked(true); tokenRepository.save(token); }); } @Transactional public void revokeAllUserTokens(String username) { tokenRepository.revokeAllUserTokens(username); } @Scheduled(fixedRate = 86400000) // 每天清理一次 @Transactional public void cleanExpiredTokens() { tokenRepository.deleteExpiredTokens(new Date()); } }
更新JWT工具類:
@Component @RequiredArgsConstructor public class JwtTokenProvider { @Value("${jwt.secret}") private String jwtSecret; @Value("${jwt.expiration}") private long jwtExpiration; private final TokenStorageService tokenStorageService; public String generateToken(UserDetails userDetails) { Date now = new Date(); Date expiryDate = new Date(now.getTime() + jwtExpiration); // 生成唯一的令牌ID String tokenId = UUID.randomUUID().toString(); String token = Jwts.builder() .setSubject(userDetails.getUsername()) .setIssuedAt(now) .setExpiration(expiryDate) .setId(tokenId) // 設(shè)置JWT ID (jti) .signWith(Keys.hmacShaKeyFor(jwtSecret.getBytes()), SignatureAlgorithm.HS512) .compact(); // 將令牌保存到存儲中 tokenStorageService.saveToken(tokenId, userDetails.getUsername(), expiryDate); return token; } public String getTokenId(String token) { return getClaimsFromToken(token).getId(); } public boolean validateToken(String token) { try { Claims claims = getClaimsFromToken(token); // 驗證JWT基本屬性 boolean isNotExpired = claims.getExpiration().after(new Date()); // 驗證令牌是否在存儲中有效 String tokenId = claims.getId(); boolean isValidInStorage = tokenStorageService.isTokenValid(tokenId); return isNotExpired && isValidInStorage; } catch (Exception e) { return false; } } // ... 其他方法 ... }
實現(xiàn)登出功能:
@RestController @RequestMapping("/api/auth") @RequiredArgsConstructor public class AuthController { // ... 其他代碼 ... private final JwtTokenProvider jwtTokenProvider; private final TokenStorageService tokenStorageService; @PostMapping("/logout") public ResponseEntity<?> logoutUser(HttpServletRequest request) { String jwt = getJwtFromRequest(request); if (StringUtils.hasText(jwt)) { String tokenId = jwtTokenProvider.getTokenId(jwt); tokenStorageService.revokeToken(tokenId); } return ResponseEntity.ok(new MessageResponse("Log out successful!")); } @PostMapping("/logout-all") public ResponseEntity<?> logoutAllDevices(Authentication authentication) { String username = authentication.getName(); tokenStorageService.revokeAllUserTokens(username); return ResponseEntity.ok(new MessageResponse("Logged out from all devices")); } // ... 其他方法 ... }
6.3 優(yōu)缺點分析
優(yōu)點:
能夠即時使單個令牌或所有令牌失效
提供精細的令牌管理,如查看活躍會話
可以實現(xiàn)"記住我"等高級功能
便于審計和監(jiān)控
缺點:
完全放棄了JWT的無狀態(tài)優(yōu)勢
每次請求都需要查詢存儲庫
系統(tǒng)復(fù)雜度提高
6.4 適用場景
對安全性要求極高的系統(tǒng)
需要精細令牌管理的應(yīng)用
已有會話管理需求的項目
多設(shè)備登錄管理
企業(yè)級應(yīng)用,需要詳細的審計日志
七、會話狀態(tài)監(jiān)控機制
7.1 基本原理
會話狀態(tài)監(jiān)控機制在保持JWT無狀態(tài)特性的同時,通過跟蹤用戶會話狀態(tài)來間接控制令牌有效性。
系統(tǒng)維護用戶登錄狀態(tài)(如最后活動時間、登錄設(shè)備等),當(dāng)狀態(tài)變更(如密碼修改、異常登錄)時,可以拒絕特定令牌的訪問。
7.2 SpringBoot實現(xiàn)
創(chuàng)建用戶會話狀態(tài)實體:
@Entity @Table(name = "user_sessions") @Data public class UserSessionStatus { @Id private String username; private Date passwordLastChanged; private Date lastForcedLogout; private String securityContext; @Version private Long version; public boolean hasChangedAfter(Date tokenIssuedAt) { return (passwordLastChanged != null && passwordLastChanged.after(tokenIssuedAt)) || (lastForcedLogout != null && lastForcedLogout.after(tokenIssuedAt)); } }
創(chuàng)建會話狀態(tài)倉庫:
@Repository public interface UserSessionStatusRepository extends JpaRepository<UserSessionStatus, String> { }
實現(xiàn)會話狀態(tài)服務(wù):
@Service @RequiredArgsConstructor public class UserSessionService { private final UserSessionStatusRepository repository; @Transactional(readOnly = true) public UserSessionStatus getSessionStatus(String username) { return repository.findById(username) .orElseGet(() -> { UserSessionStatus status = new UserSessionStatus(); status.setUsername(username); return status; }); } @Transactional public void updatePasswordChanged(String username) { UserSessionStatus status = getSessionStatus(username); status.setPasswordLastChanged(new Date()); repository.save(status); } @Transactional public void forceLogout(String username) { UserSessionStatus status = getSessionStatus(username); status.setLastForcedLogout(new Date()); repository.save(status); } @Transactional public void updateSecurityContext(String username, String securityContext) { UserSessionStatus status = getSessionStatus(username); status.setSecurityContext(securityContext); repository.save(status); } public boolean isTokenValid(String username, Date tokenIssuedAt, String tokenSecurityContext) { UserSessionStatus status = getSessionStatus(username); // 檢查令牌是否在密碼更改或強制登出之前簽發(fā) if (status.hasChangedAfter(tokenIssuedAt)) { return false; } // 檢查安全上下文是否匹配(可選) if (status.getSecurityContext() != null && tokenSecurityContext != null) { return status.getSecurityContext().equals(tokenSecurityContext); } return true; } }
更新JWT工具類:
@Component @RequiredArgsConstructor public class JwtTokenProvider { @Value("${jwt.secret}") private String jwtSecret; @Value("${jwt.expiration}") private long jwtExpiration; private final UserSessionService sessionService; public String generateToken(UserDetails userDetails, String securityContext) { Date now = new Date(); Date expiryDate = new Date(now.getTime() + jwtExpiration); return Jwts.builder() .setSubject(userDetails.getUsername()) .setIssuedAt(now) .setExpiration(expiryDate) .claim("securityContext", securityContext) .signWith(Keys.hmacShaKeyFor(jwtSecret.getBytes()), SignatureAlgorithm.HS512) .compact(); } public boolean validateToken(String token) { try { Claims claims = getClaimsFromToken(token); // 基本驗證 boolean isNotExpired = claims.getExpiration().after(new Date()); if (!isNotExpired) { return false; } // 驗證會話狀態(tài) String username = claims.getSubject(); Date issuedAt = claims.getIssuedAt(); String securityContext = claims.get("securityContext", String.class); return sessionService.isTokenValid(username, issuedAt, securityContext); } catch (Exception e) { return false; } } // ... 其他方法 ... }
實現(xiàn)認證和密碼更改接口:
@RestController @RequiredArgsConstructor public class AuthController { // ... 其他依賴 ... private final UserSessionService sessionService; private final UserService userService; @PostMapping("/login") public ResponseEntity<?> login(@RequestBody LoginRequest loginRequest) { // ... 認證邏輯 ... // 生成安全上下文(例如,設(shè)備信息、IP地址等) String securityContext = generateSecurityContext(request); // 更新用戶會話狀態(tài) sessionService.updateSecurityContext(userDetails.getUsername(), securityContext); // 生成令牌,包含安全上下文 String token = jwtTokenProvider.generateToken(userDetails, securityContext); // ... 返回令牌 ... } @PostMapping("/change-password") public ResponseEntity<?> changePassword(@RequestBody PasswordChangeRequest request, Authentication authentication) { String username = authentication.getName(); // 更改密碼 userService.changePassword(username, request.getOldPassword(), request.getNewPassword()); // 更新密碼更改時間,使舊令牌失效 sessionService.updatePasswordChanged(username); return ResponseEntity.ok(new MessageResponse("Password changed successfully")); } @PostMapping("/logout-all-devices") public ResponseEntity<?> logoutAllDevices(Authentication authentication) { String username = authentication.getName(); // 強制所有設(shè)備登出 sessionService.forceLogout(username); return ResponseEntity.ok(new MessageResponse("Logged out from all devices")); } private String generateSecurityContext(HttpServletRequest request) { // 生成包含設(shè)備信息、IP地址等的安全上下文 String ipAddress = request.getRemoteAddr(); String userAgent = request.getHeader("User-Agent"); return DigestUtils.md5DigestAsHex((ipAddress + ":" + userAgent).getBytes()); } }
7.3 優(yōu)缺點分析
優(yōu)點:
保持了JWT的大部分無狀態(tài)特性
可以基于用戶狀態(tài)變更使令牌失效
可以實現(xiàn)細粒度的會話控制
安全上下文可以防止令牌被盜用
缺點:
每次請求需要檢查用戶會話狀態(tài)
狀態(tài)管理增加了系統(tǒng)復(fù)雜性
安全上下文驗證可能導(dǎo)致合法用戶被拒絕(如IP變化)
7.4 適用場景
需要賬戶安全功能(如密碼更改后使令牌失效)的系統(tǒng)
對可疑活動監(jiān)控有需求的應(yīng)用
需要防止令牌盜用的場景
平衡無狀態(tài)性和安全性的應(yīng)用
八、六種方案對比與選擇指南
方案 | 即時失效 | 存儲需求 | 性能影響 | 實現(xiàn)復(fù)雜度 | 維護成本 | 適用場景 |
短期令牌+刷新令牌 | 部分(僅刷新令牌) | 低 | 低 | 低 | 低 | 一般Web/移動應(yīng)用 |
Redis黑名單 | 完全 | 中 | 中 | 中 | 中 | 安全性要求高的應(yīng)用 |
令牌版本/計數(shù)器 | 完全 | 低 | 中 | 中 | 低 | 特定操作下需要控制Token有效性需求的應(yīng)用 |
密鑰輪換 | 全局 | 極低 | 低 | 中 | 中 | 需要定期輪換密鑰的系統(tǒng) |
集中式令牌存儲 | 完全 | 高 | 高 | 高 | 高 | 企業(yè)級應(yīng)用,多設(shè)備管理 |
會話狀態(tài)監(jiān)控 | 條件性 | 中 | 中 | 高 | 中 | 平衡安全和性能的系統(tǒng) |
九、總結(jié)
每種方案都有其優(yōu)缺點和適用場景,選擇合適的方案取決于應(yīng)用的安全需求、性能要求和架構(gòu)設(shè)計。
在實際應(yīng)用中,常常需要組合使用多種策略,構(gòu)建多層次的安全防護。
以上為個人經(jīng)驗,希望能給大家一個參考,也希望大家多多支持腳本之家。
相關(guān)文章
Java結(jié)合Swing實現(xiàn)龍年祝福語生成工具
Swing是一個為Java設(shè)計的GUI工具包,屬于Java基礎(chǔ)類的一部分,本文將使用Java和Swing實現(xiàn)龍年祝福語生成工具,感興趣的小伙伴可以了解下2024-01-01java InterruptedException 異常中斷的實現(xiàn)
本文主要介紹了java InterruptedException 異常中斷的實現(xiàn),文中通過示例代碼介紹的非常詳細,對大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價值,需要的朋友們下面隨著小編來一起學(xué)習(xí)學(xué)習(xí)吧2023-08-08