SpringBoot實(shí)現(xiàn)JWT令牌失效的6種方案
一、JWT基礎(chǔ)與失效挑戰(zhàn)
1.1 JWT的基本結(jié)構(gòu)
JWT由三部分組成,以點(diǎn)(.)分隔:
- Header(頭部) :包含令牌類型和使用的簽名算法
- Payload(負(fù)載) :包含聲明(claims),如用戶信息和權(quán)限
- Signature(簽名) :用于驗(yàn)證令牌的完整性和真實(shí)性
一個(gè)典型的JWT看起來像這樣:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
1.2 JWT的特點(diǎn)與失效挑戰(zhàn)
JWT的主要特點(diǎn)是無狀態(tài)性,服務(wù)器不需要存儲(chǔ)會(huì)話信息。這帶來了以下挑戰(zhàn):
- JWT一旦簽發(fā),在其有效期內(nèi)始終有效
- 無法直接撤銷或使令牌失效
- 服務(wù)器默認(rèn)無法跟蹤已發(fā)行的令牌
這些特性使得實(shí)現(xiàn)JWT的提前失效變得困難,特別是在以下場景:
- 用戶登出系統(tǒng)
- 用戶權(quán)限變更
- 賬戶被盜,需要使所有令牌失效
- 密碼更改后使舊令牌失效
二、短期令牌+刷新令牌方案
2.1 基本原理
該方案使用兩種令牌:
- 短期訪問令牌(Access Token) :有效期短(如15分鐘),用于API訪問
- 長期刷新令牌(Refresh Token) :有效期長(如7天),用于獲取新的訪問令牌
當(dāng)用戶需要登出時(shí),只需使刷新令牌失效,短期訪問令牌會(huì)自然過期。
2.2 SpringBoot實(shí)現(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;
}
}
}
實(shí)現(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;
}
}
實(shí)現(xiàn)認(rè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)缺點(diǎn)分析
優(yōu)點(diǎn):
- 無需維護(hù)黑名單,降低服務(wù)器負(fù)擔(dān)
- 訪問令牌有效期短,安全性較高
- 用戶體驗(yàn)良好,透明刷新令牌
- 實(shí)現(xiàn)簡單,容易理解
缺點(diǎn):
- 無法即時(shí)使訪問令牌失效,最多等待其自然過期
- 需要額外存儲(chǔ)刷新令牌,增加了狀態(tài)性
- 增加了客戶端復(fù)雜度,需要處理令牌刷新邏輯
- 如果刷新令牌泄露,可能導(dǎo)致長期安全風(fēng)險(xiǎn)
2.4 適用場景
- 一般的Web應(yīng)用和移動(dòng)應(yīng)用
- 對(duì)令牌即時(shí)失效要求不嚴(yán)格的場景
- 希望減輕服務(wù)器負(fù)擔(dān)的系統(tǒng)
- 用戶會(huì)話時(shí)間較長的應(yīng)用
三、Redis黑名單機(jī)制
3.1 基本原理
黑名單機(jī)制將已注銷或失效的令牌存儲(chǔ)在Redis等高性能緩存中,每次驗(yàn)證令牌時(shí)都會(huì)檢查它是否在黑名單中。
這種方法允許即時(shí)使令牌失效,同時(shí)保持良好的性能。
3.2 SpringBoot實(shí)現(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;
}
}
實(shí)現(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 {
// 獲取令牌過期時(shí)間
Claims claims = jwtTokenProvider.getClaimsFromToken(token);
Date expiration = claims.getExpiration();
long ttl = (expiration.getTime() - System.currentTimeMillis()) / 1000;
// 僅當(dāng)令牌未過期時(shí)添加到黑名單
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;
}
}
實(shí)現(xiàn)登出端點(diǎ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)缺點(diǎn)分析
優(yōu)點(diǎn):
- 可以即時(shí)使令牌失效
- 不影響令牌原有的有效期管理
- 無需修改客戶端邏輯
- Redis高性能,對(duì)系統(tǒng)影響小
缺點(diǎn):
- 引入了狀態(tài)存儲(chǔ),部分犧牲JWT的無狀態(tài)特性
- Redis需要存儲(chǔ)所有已注銷但未過期的令牌,增加存儲(chǔ)開銷
- 每次API請(qǐng)求都需要檢查黑名單,增加了延遲
3.4 適用場景
- 對(duì)安全性要求較高的應(yīng)用
- 需要即時(shí)令牌失效功能的系統(tǒng)
四、令牌版本/計(jì)數(shù)器機(jī)制
4.1 基本原理
該方案為每個(gè)用戶維護(hù)一個(gè)令牌版本號(hào)或計(jì)數(shù)器。當(dāng)用戶登出或需要使令牌失效時(shí),增加用戶的令牌版本號(hào)。
令牌中包含發(fā)行時(shí)的版本號(hào),驗(yàn)證時(shí)比較令牌中的版本號(hào)與用戶當(dāng)前的版本號(hào),如果不匹配則拒絕訪問。
4.2 SpringBoot實(shí)現(xiàn)
首先,創(chuàng)建用戶令牌版本實(shí)體:
@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> {
}
實(shí)現(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);
// 驗(yàn)證用戶名
boolean usernameMatches = claims.getSubject().equals(userDetails.getUsername());
// 驗(yàn)證令牌未過期
boolean isNotExpired = claims.getExpiration().after(new Date());
// 驗(yàn)證令牌版本
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);
// 使用版本驗(yàn)證令牌
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方法 ...
}
實(shí)現(xiàn)登出端點(diǎn):
@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthController {
// ... 其他代碼 ...
private final TokenVersionService tokenVersionService;
@PostMapping("/logout")
public ResponseEntity<?> logoutUser(Authentication authentication) {
String username = authentication.getName();
// 增加令牌版本號(hào),使所有現(xiàn)有令牌失效
tokenVersionService.incrementVersion(username);
return ResponseEntity.ok(new MessageResponse("Log out successful!"));
}
}
4.3 優(yōu)缺點(diǎn)分析
優(yōu)點(diǎn):
- 存儲(chǔ)開銷小,只需記錄用戶的當(dāng)前版本號(hào)
- 無需維護(hù)黑名單,降低了內(nèi)存需求
- 可以選擇性地使部分令牌失效
缺點(diǎn):
- 需要存儲(chǔ)用戶令牌版本
- 每次驗(yàn)證令牌都需要查詢數(shù)據(jù)庫或緩存
- 可能影響系統(tǒng)性能,特別是在用戶量大的情況下
4.4 適用場景
- 需要用戶主動(dòng)登出功能的系統(tǒng)
- 用戶量適中的系統(tǒng)
- 需要在特定操作后使令牌失效的場景
五、密鑰輪換策略
5.1 基本原理
密鑰輪換策略通過定期更換用于簽名JWT的密鑰來實(shí)現(xiàn)令牌失效。
當(dāng)系統(tǒng)需要使所有令牌失效時(shí),立即輪換密鑰,所有使用舊密鑰簽名的令牌將無法通過驗(yàn)證。
為了支持平滑過渡,系統(tǒng)通常保留多個(gè)最近的密鑰版本。
5.2 SpringBoot實(shí)現(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() {
// 初始化第一個(gè)密鑰
rotateKey();
}
@Scheduled(cron = "${jwt.key-rotation-cron:0 0 0 * * ?}") // 默認(rèn)每天零點(diǎn)
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個(gè)密鑰
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");
}
// 獲取對(duì)應(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)建管理員控制器,提供強(qiá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)缺點(diǎn)分析
優(yōu)點(diǎn):
- 可以立即使所有令牌失效
- 可以實(shí)現(xiàn)平滑過渡,支持舊密鑰一段時(shí)間
- 符合安全最佳實(shí)踐,定期輪換密鑰
缺點(diǎn):
- 無法選擇性使單個(gè)用戶的令牌失效
- 可能導(dǎo)致所有用戶被迫重新登錄
- 需要妥善管理密鑰
5.4 適用場景
- 安全要求高,需要定期輪換密鑰的系統(tǒng)
- 發(fā)生安全事件時(shí),需要緊急使所有令牌失效
- 偏好無狀態(tài)設(shè)計(jì)的應(yīng)用
- 系統(tǒng)重大升級(jí)或維護(hù)時(shí)
六、集中式令牌存儲(chǔ)
6.1 基本原理
這種方法將JWT作為訪問標(biāo)識(shí)符,但在服務(wù)器端維護(hù)一個(gè)集中式的令牌存儲(chǔ),存儲(chǔ)介質(zhì)可以使用數(shù)據(jù)庫或者緩存。
每次驗(yàn)證時(shí),不僅檢查JWT的簽名和有效期,還查詢存儲(chǔ)庫確認(rèn)令牌是否仍然有效。
這種方式結(jié)合了JWT的便利性和會(huì)話管理的靈活性。
6.2 SpringBoot實(shí)現(xiàn)
創(chuàng)建令牌實(shí)體:
@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);
}
實(shí)現(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();
// 將令牌保存到存儲(chǔ)中
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);
// 驗(yàn)證JWT基本屬性
boolean isNotExpired = claims.getExpiration().after(new Date());
// 驗(yàn)證令牌是否在存儲(chǔ)中有效
String tokenId = claims.getId();
boolean isValidInStorage = tokenStorageService.isTokenValid(tokenId);
return isNotExpired && isValidInStorage;
} catch (Exception e) {
return false;
}
}
// ... 其他方法 ...
}
實(shí)現(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)缺點(diǎn)分析
優(yōu)點(diǎn):
- 能夠即時(shí)使單個(gè)令牌或所有令牌失效
- 提供精細(xì)的令牌管理,如查看活躍會(huì)話
- 可以實(shí)現(xiàn)"記住我"等高級(jí)功能
- 便于審計(jì)和監(jiān)控
缺點(diǎn):
- 完全放棄了JWT的無狀態(tài)優(yōu)勢
- 每次請(qǐng)求都需要查詢存儲(chǔ)庫
- 系統(tǒng)復(fù)雜度提高
6.4 適用場景
- 對(duì)安全性要求極高的系統(tǒng)
- 需要精細(xì)令牌管理的應(yīng)用
- 已有會(huì)話管理需求的項(xiàng)目
- 多設(shè)備登錄管理
- 企業(yè)級(jí)應(yīng)用,需要詳細(xì)的審計(jì)日志
七、會(huì)話狀態(tài)監(jiān)控機(jī)制
7.1 基本原理
會(huì)話狀態(tài)監(jiān)控機(jī)制在保持JWT無狀態(tài)特性的同時(shí),通過跟蹤用戶會(huì)話狀態(tài)來間接控制令牌有效性。
系統(tǒng)維護(hù)用戶登錄狀態(tài)(如最后活動(dòng)時(shí)間、登錄設(shè)備等),當(dāng)狀態(tài)變更(如密碼修改、異常登錄)時(shí),可以拒絕特定令牌的訪問。
7.2 SpringBoot實(shí)現(xiàn)
創(chuàng)建用戶會(huì)話狀態(tài)實(shí)體:
@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)建會(huì)話狀態(tài)倉庫:
@Repository
public interface UserSessionStatusRepository extends JpaRepository<UserSessionStatus, String> {
}
實(shí)現(xiàn)會(huì)話狀態(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);
// 檢查令牌是否在密碼更改或強(qiáng)制登出之前簽發(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);
// 基本驗(yàn)證
boolean isNotExpired = claims.getExpiration().after(new Date());
if (!isNotExpired) {
return false;
}
// 驗(yàn)證會(huì)話狀態(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;
}
}
// ... 其他方法 ...
}
實(shí)現(xiàn)認(rèn)證和密碼更改接口:
@RestController
@RequiredArgsConstructor
public class AuthController {
// ... 其他依賴 ...
private final UserSessionService sessionService;
private final UserService userService;
@PostMapping("/login")
public ResponseEntity<?> login(@RequestBody LoginRequest loginRequest) {
// ... 認(rèn)證邏輯 ...
// 生成安全上下文(例如,設(shè)備信息、IP地址等)
String securityContext = generateSecurityContext(request);
// 更新用戶會(huì)話狀態(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());
// 更新密碼更改時(shí)間,使舊令牌失效
sessionService.updatePasswordChanged(username);
return ResponseEntity.ok(new MessageResponse("Password changed successfully"));
}
@PostMapping("/logout-all-devices")
public ResponseEntity<?> logoutAllDevices(Authentication authentication) {
String username = authentication.getName();
// 強(qiáng)制所有設(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)缺點(diǎn)分析
優(yōu)點(diǎn):
- 保持了JWT的大部分無狀態(tài)特性
- 可以基于用戶狀態(tài)變更使令牌失效
- 可以實(shí)現(xiàn)細(xì)粒度的會(huì)話控制
- 安全上下文可以防止令牌被盜用
缺點(diǎn):
- 每次請(qǐng)求需要檢查用戶會(huì)話狀態(tài)
- 狀態(tài)管理增加了系統(tǒng)復(fù)雜性
- 安全上下文驗(yàn)證可能導(dǎo)致合法用戶被拒絕(如IP變化)
7.4 適用場景
- 需要賬戶安全功能(如密碼更改后使令牌失效)的系統(tǒng)
- 對(duì)可疑活動(dòng)監(jiān)控有需求的應(yīng)用
- 需要防止令牌盜用的場景
- 平衡無狀態(tài)性和安全性的應(yīng)用
八、六種方案對(duì)比與選擇指南
| 方案 | 即時(shí)失效 | 存儲(chǔ)需求 | 性能影響 | 實(shí)現(xiàn)復(fù)雜度 | 維護(hù)成本 | 適用場景 |
|---|---|---|---|---|---|---|
| 短期令牌+刷新令牌 | 部分(僅刷新令牌) | 低 | 低 | 低 | 低 | 一般Web/移動(dòng)應(yīng)用 |
| Redis黑名單 | 完全 | 中 | 中 | 中 | 中 | 安全性要求高的應(yīng)用 |
| 令牌版本/計(jì)數(shù)器 | 完全 | 低 | 中 | 中 | 低 | 特定操作下需要控制Token有效性需求的應(yīng)用 |
| 密鑰輪換 | 全局 | 極低 | 低 | 中 | 中 | 需要定期輪換密鑰的系統(tǒng) |
| 集中式令牌存儲(chǔ) | 完全 | 高 | 高 | 高 | 高 | 企業(yè)級(jí)應(yīng)用,多設(shè)備管理 |
| 會(huì)話狀態(tài)監(jiān)控 | 條件性 | 中 | 中 | 高 | 中 | 平衡安全和性能的系統(tǒng) |
九、總結(jié)
每種方案都有其優(yōu)缺點(diǎn)和適用場景,選擇合適的方案取決于應(yīng)用的安全需求、性能要求和架構(gòu)設(shè)計(jì)。
在實(shí)際應(yīng)用中,常常需要組合使用多種策略,構(gòu)建多層次的安全防護(hù)。
以上就是SpringBoot實(shí)現(xiàn)JWT令牌失效的6種方案的詳細(xì)內(nèi)容,更多關(guān)于SpringBoot JWT令牌失效的資料請(qǐng)關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
java之TreeUtils生成一切對(duì)象樹形結(jié)構(gòu)案例
這篇文章主要介紹了java之TreeUtils生成一切對(duì)象樹形結(jié)構(gòu)案例,具有很好的參考價(jià)值,希望對(duì)大家有所幫助。一起跟隨小編過來看看吧2020-09-09
springboot返回圖片流的實(shí)現(xiàn)示例
本文主要介紹了springboot返回圖片流的實(shí)現(xiàn)示例,文中通過示例代碼介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價(jià)值,需要的朋友們下面隨著小編來一起學(xué)習(xí)學(xué)習(xí)吧2022-08-08
SpringBoot+kaptcha實(shí)現(xiàn)驗(yàn)證碼花式玩法詳解
這篇文章主要想和大家聊聊kaptcha的用法,畢竟這個(gè)已經(jīng)有16年歷史的玩意還在有人用,說明它的功能還是相當(dāng)強(qiáng)大的,感興趣的小伙伴可以了解一下2022-05-05
SpringBoot4.5.2 整合HikariCP 數(shù)據(jù)庫連接池操作
這篇文章主要介紹了SpringBoot4.5.2 整合HikariCP 數(shù)據(jù)庫連接池操作,具有很好的參考價(jià)值,希望對(duì)大家有所幫助。如有錯(cuò)誤或未考慮完全的地方,望不吝賜教2021-09-09
Java定時(shí)器例子_動(dòng)力節(jié)點(diǎn)Java學(xué)院整理
本文給大家分享了java定時(shí)器例子,非常不錯(cuò),具有參考借鑒價(jià)值,需要的的朋友參考下吧2017-05-05

