JWT(JSON Web Token)作为一种轻量级的认证方式,被广泛应用于现代Web应用和微服务架构中。
然而,JWT的无状态特性虽然带来了扩展性优势,却也带来了令牌管理的挑战,特别是当需要使令牌提前失效时。
本文将介绍在SpringBoot应用中实现JWT令牌失效的6种方案。
1.1 JWT的基本结构
JWT由三部分组成,以点(.)分隔:
- • Header(头部) :包含令牌类型和使用的签名算法
- • Payload(负载) :包含声明(claims),如用户信息和权限
- • Signature(签名) :用于验证令牌的完整性和真实性
一个典型的JWT看起来像这样:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV\_adQssw5c
1.2 JWT的特点与失效挑战
JWT的主要特点是无状态性,服务器不需要存储会话信息。这带来了以下挑战:
- • JWT一旦签发,在其有效期内始终有效
- • 无法直接撤销或使令牌失效
- • 服务器默认无法跟踪已发行的令牌
这些特性使得实现JWT的提前失效变得困难,特别是在以下场景:
- • 用户登出系统
- • 用户权限变更
- • 账户被盗,需要使所有令牌失效
- • 密码更改后使旧令牌失效
2.1 基本原理
该方案使用两种令牌:
短期访问令牌(Access Token) :有效期短(如15分钟),用于API访问
长期刷新令牌(Refresh Token) :有效期长(如7天),用于获取新的访问令牌
当用户需要登出时,只需使刷新令牌失效,短期访问令牌会自然过期。
2.2 SpringBoot实现
首先,添加必要的依赖:
<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>
创建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;
}
}
}
实现刷新令牌服务:
@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;
}
}
实现认证控制器:
@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 优缺点分析
优点:
- • 无需维护黑名单,降低服务器负担
- • 访问令牌有效期短,安全性较高
- • 用户体验良好,透明刷新令牌
- • 实现简单,容易理解
缺点:
- • 无法即时使访问令牌失效,最多等待其自然过期
- • 需要额外存储刷新令牌,增加了状态性
- • 增加了客户端复杂度,需要处理令牌刷新逻辑
- • 如果刷新令牌泄露,可能导致长期安全风险
2.4 适用场景
- • 一般的Web应用和移动应用
- • 对令牌即时失效要求不严格的场景
- • 希望减轻服务器负担的系统
- • 用户会话时间较长的应用
3.1 基本原理
黑名单机制将已注销或失效的令牌存储在Redis等高性能缓存中,每次验证令牌时都会检查它是否在黑名单中。
这种方法允许即时使令牌失效,同时保持良好的性能。
3.2 SpringBoot实现
首先,添加Redis依赖:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
创建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;
}
}
实现JWT黑名单服务:
@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;
// 仅当令牌未过期时添加到黑名单
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;
}
}
实现登出端点:
@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 优缺点分析
优点:
- • 可以即时使令牌失效
- • 不影响令牌原有的有效期管理
- • 无需修改客户端逻辑
- • Redis高性能,对系统影响小
缺点:
- • 引入了状态存储,部分牺牲JWT的无状态特性
- • Redis需要存储所有已注销但未过期的令牌,增加存储开销
- • 每次API请求都需要检查黑名单,增加了延迟
3.4 适用场景
- • 对安全性要求较高的应用
- • 需要即时令牌失效功能的系统
4.1 基本原理
该方案为每个用户维护一个令牌版本号或计数器。当用户登出或需要使令牌失效时,增加用户的令牌版本号。
令牌中包含发行时的版本号,验证时比较令牌中的版本号与用户当前的版本号,如果不匹配则拒绝访问。
4.2 SpringBoot实现
首先,创建用户令牌版本实体:
@Entity
@Table(name = "user\_token\_versions")
@Data
public class UserTokenVersion {
@Id
private String username;
private int tokenVersion;
public void incrementVersion() {
this.tokenVersion++;
}
}
创建令牌版本仓库:
@Repository
public interface UserTokenVersionRepository extends JpaRepository<UserTokenVersion, String> {
}
实现令牌版本服务:
@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);
// 获取当前令牌版本
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方法 ...
}
实现登出端点:
@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthController {
// ... 其他代码 ...
private final TokenVersionService tokenVersionService;
@PostMapping("/logout")
public ResponseEntity<?> logoutUser(Authentication authentication) {
String username = authentication.getName();
// 增加令牌版本号,使所有现有令牌失效
tokenVersionService.incrementVersion(username);
return ResponseEntity.ok(new MessageResponse("Log out successful!"));
}
}
4.3 优缺点分析
优点:
- • 存储开销小,只需记录用户的当前版本号
- • 无需维护黑名单,降低了内存需求
- • 可以选择性地使部分令牌失效
缺点:
- • 需要存储用户令牌版本
- • 每次验证令牌都需要查询数据库或缓存
- • 可能影响系统性能,特别是在用户量大的情况下
4.4 适用场景
- • 需要用户主动登出功能的系统
- • 用户量适中的系统
- • 需要在特定操作后使令牌失效的场景
5.1 基本原理
密钥轮换策略通过定期更换用于签名JWT的密钥来实现令牌失效。
当系统需要使所有令牌失效时,立即轮换密钥,所有使用旧密钥签名的令牌将无法通过验证。
为了支持平滑过渡,系统通常保留多个最近的密钥版本。
5.2 SpringBoot实现
创建密钥管理服务:
@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) // 设置密钥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");
}
// 获取对应的密钥
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;
}
}
// ... 其他方法 ...
}
创建管理员控制器,提供强制失效所有令牌的功能:
@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 优缺点分析
优点:
- • 可以立即使所有令牌失效
- • 可以实现平滑过渡,支持旧密钥一段时间
- • 符合安全最佳实践,定期轮换密钥
缺点:
- • 无法选择性使单个用户的令牌失效
- • 可能导致所有用户被迫重新登录
- • 需要妥善管理密钥
5.4 适用场景
- • 安全要求高,需要定期轮换密钥的系统
- • 发生安全事件时,需要紧急使所有令牌失效
- • 偏好无状态设计的应用
- • 系统重大升级或维护时
6.1 基本原理
这种方法将JWT作为访问标识符,但在服务器端维护一个集中式的令牌存储,存储介质可以使用数据库或者缓存。
每次验证时,不仅检查JWT的签名和有效期,还查询存储库确认令牌是否仍然有效。
这种方式结合了JWT的便利性和会话管理的灵活性。
6.2 SpringBoot实现
创建令牌实体:
@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());
}
}
创建令牌仓库:
@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);
}
实现令牌服务:
@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) // 设置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;
}
}
// ... 其他方法 ...
}
实现登出功能:
@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 优缺点分析
优点:
- • 能够即时使单个令牌或所有令牌失效
- • 提供精细的令牌管理,如查看活跃会话
- • 可以实现"记住我"等高级功能
- • 便于审计和监控
缺点:
- • 完全放弃了JWT的无状态优势
- • 每次请求都需要查询存储库
- • 系统复杂度提高
6.4 适用场景
- • 对安全性要求极高的系统
- • 需要精细令牌管理的应用
- • 已有会话管理需求的项目
- • 多设备登录管理
- • 企业级应用,需要详细的审计日志
7.1 基本原理
会话状态监控机制在保持JWT无状态特性的同时,通过跟踪用户会话状态来间接控制令牌有效性。
系统维护用户登录状态(如最后活动时间、登录设备等),当状态变更(如密码修改、异常登录)时,可以拒绝特定令牌的访问。
7.2 SpringBoot实现
创建用户会话状态实体:
@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));
}
}
创建会话状态仓库:
@Repository
public interface UserSessionStatusRepository extends JpaRepository<UserSessionStatus, String> {
}
实现会话状态服务:
@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);
// 检查令牌是否在密码更改或强制登出之前签发
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;
}
// 验证会话状态
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;
}
}
// ... 其他方法 ...
}
实现认证和密码更改接口:
@RestController
@RequiredArgsConstructor
public class AuthController {
// ... 其他依赖 ...
private final UserSessionService sessionService;
private final UserService userService;
@PostMapping("/login")
public ResponseEntity<?> login(@RequestBody LoginRequest loginRequest) {
// ... 认证逻辑 ...
// 生成安全上下文(例如,设备信息、IP地址等)
String securityContext = generateSecurityContext(request);
// 更新用户会话状态
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();
// 强制所有设备登出
sessionService.forceLogout(username);
return ResponseEntity.ok(new MessageResponse("Logged out from all devices"));
}
private String generateSecurityContext(HttpServletRequest request) {
// 生成包含设备信息、IP地址等的安全上下文
String ipAddress = request.getRemoteAddr();
String userAgent = request.getHeader("User-Agent");
return DigestUtils.md5DigestAsHex((ipAddress + ":" + userAgent).getBytes());
}
}
7.3 优缺点分析
优点:
- • 保持了JWT的大部分无状态特性
- • 可以基于用户状态变更使令牌失效
- • 可以实现细粒度的会话控制
- • 安全上下文可以防止令牌被盗用
缺点:
- • 每次请求需要检查用户会话状态
- • 状态管理增加了系统复杂性
- • 安全上下文验证可能导致合法用户被拒绝(如IP变化)
7.4 适用场景
- • 需要账户安全功能(如密码更改后使令牌失效)的系统
- • 对可疑活动监控有需求的应用
- • 需要防止令牌盗用的场景
- • 平衡无状态性和安全性的应用
| 方案 | 即时失效 | 存储需求 | 性能影响 | 实现复杂度 | 维护成本 | 适用场景 | | 短期令牌+刷新令牌 | 部分(仅刷新令牌) | 低 | 低 | 低 | 低 | 一般Web/移动应用 | | Redis黑名单 | 完全 | 中 | 中 | 中 | 中 | 安全性要求高的应用 | | 令牌版本/计数器 | 完全 | 低 | 中 | 中 | 低 | 特定操作下需要控制Token有效性需求的应用 | | 密钥轮换 | 全局 | 极低 | 低 | 中 | 中 | 需要定期轮换密钥的系统 | | 集中式令牌存储 | 完全 | 高 | 高 | 高 | 高 | 企业级应用,多设备管理 | | 会话状态监控 | 条件性 | 中 | 中 | 高 | 中 | 平衡安全和性能的系统 |
每种方案都有其优缺点和适用场景,选择合适的方案取决于应用的安全需求、性能要求和架构设计。
在实际应用中,常常需要组合使用多种策略,构建多层次的安全防护。
最后欢迎加入苏三的星球,你将获得:商城微服务实战、AI开发项目课程、苏三AI项目、秒杀系统实战、商城系统实战、秒杀系统实战、代码生成工具、系统设计、性能优化、技术选型、底层原理、Spring源码解读、工作经验分享、痛点问题、面试八股文等多个优质专栏。
还有1V1答疑、修改简历、职业规划、送书活动、技术交流。
扫描下方二维码,即可加入星球(非常有价值的一次决定):
目前星球已经更新了5200+篇优质内容,还在持续爆肝中.....
星球已经被官方推荐了3次,收到了小伙伴们的一致好评。戳我加入学习,已有1700+小伙伴加入学习。
最后推荐一下我的技术专栏《性能优化35讲》,里面包含了:接口调用、Java、JVM、并发编程、MySQL、Redis、ElasticSearch、Spring、SpringBoot等多个性能优化技巧。无论在工作,还是在面试中,都会经常遇到,非常有参考价值。