SpringBoot实现6种JWT令牌失效方案

JWT(JSON Web Token)作为一种轻量级的认证方式,被广泛应用于现代Web应用和微服务架构中。

然而,JWT的无状态特性虽然带来了扩展性优势,却也带来了令牌管理的挑战,特别是当需要使令牌提前失效时。

本文将介绍在SpringBoot应用中实现JWT令牌失效的6种方案。

一、JWT基础与失效挑战

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实现

首先,添加必要的依赖:

xml 复制代码
<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工具类:

typescript 复制代码
@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;
        }
    }
}

实现刷新令牌服务:

typescript 复制代码
@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;
    }
}

实现认证控制器:

less 复制代码
@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配置:

ini 复制代码
jwt.secret=yourVeryLongAndSecureSecretKeyHerePleaseMakeItAtLeast256Bits
jwt.accessTokenExpiration=900000    # 15分钟
jwt.refreshTokenExpiration=604800000 # 7天

2.3 优缺点分析

优点:

  • 无需维护黑名单,降低服务器负担
  • 访问令牌有效期短,安全性较高
  • 用户体验良好,透明刷新令牌
  • 实现简单,容易理解

缺点:

  • 无法即时使访问令牌失效,最多等待其自然过期
  • 需要额外存储刷新令牌,增加了状态性
  • 增加了客户端复杂度,需要处理令牌刷新逻辑
  • 如果刷新令牌泄露,可能导致长期安全风险

2.4 适用场景

  • 一般的Web应用和移动应用
  • 对令牌即时失效要求不严格的场景
  • 希望减轻服务器负担的系统
  • 用户会话时间较长的应用

三、Redis黑名单机制

3.1 基本原理

黑名单机制将已注销或失效的令牌存储在Redis等高性能缓存中,每次验证令牌时都会检查它是否在黑名单中。

这种方法允许即时使令牌失效,同时保持良好的性能。

3.2 SpringBoot实现

首先,添加Redis依赖:

xml 复制代码
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

创建Redis配置类:

arduino 复制代码
@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黑名单服务:

typescript 复制代码
@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工具类:

scss 复制代码
@Component
public class JwtTokenProvider {
    
    // ... 之前的代码 ...
    
    public Claims getClaimsFromToken(String token) {
        return Jwts.parserBuilder()
                .setSigningKey(Keys.hmacShaKeyFor(jwtSecret.getBytes()))
                .build()
                .parseClaimsJws(token)
                .getBody();
    }
}

添加JWT过滤器,检查黑名单:

scala 复制代码
@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;
    }
}

实现登出端点:

less 复制代码
@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实现

首先,创建用户令牌版本实体:

less 复制代码
@Entity
@Table(name = "user_token_versions")
@Data
public class UserTokenVersion {
    
    @Id
    private String username;
    
    private int tokenVersion;
    
    public void incrementVersion() {
        this.tokenVersion++;
    }
}

创建令牌版本仓库:

typescript 复制代码
@Repository
public interface UserTokenVersionRepository extends JpaRepository<UserTokenVersion, String> {
}

实现令牌版本服务:

scss 复制代码
@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工具类,在令牌中包含版本信息:

typescript 复制代码
@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过滤器:

scala 复制代码
@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方法 ...
}

实现登出端点:

less 复制代码
@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实现

创建密钥管理服务:

typescript 复制代码
@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工具类以支持密钥轮换:

typescript 复制代码
@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;
        }
    }
    
    // ... 其他方法 ...
}

创建管理员控制器,提供强制失效所有令牌的功能:

less 复制代码
@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实现

创建令牌实体:

less 复制代码
@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());
    }
}

创建令牌仓库:

less 复制代码
@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);
}

实现令牌服务:

typescript 复制代码
@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工具类:

typescript 复制代码
@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;
        }
    }
    
    // ... 其他方法 ...
}

实现登出功能:

less 复制代码
@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实现

创建用户会话状态实体:

less 复制代码
@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));
    }
}

创建会话状态仓库:

typescript 复制代码
@Repository
public interface UserSessionStatusRepository extends JpaRepository<UserSessionStatus, String> {
}

实现会话状态服务:

typescript 复制代码
@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工具类:

typescript 复制代码
@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;
        }
    }
    
    // ... 其他方法 ...
}

实现认证和密码更改接口:

less 复制代码
@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有效性需求的应用
密钥轮换 全局 极低 需要定期轮换密钥的系统
集中式令牌存储 完全 企业级应用,多设备管理
会话状态监控 条件性 平衡安全和性能的系统

九、总结

每种方案都有其优缺点和适用场景,选择合适的方案取决于应用的安全需求、性能要求和架构设计。

在实际应用中,常常需要组合使用多种策略,构建多层次的安全防护。

相关推荐
缘来是庄17 分钟前
设计模式之访问者模式
java·设计模式·访问者模式
Bug退退退12342 分钟前
RabbitMQ 高级特性之死信队列
java·分布式·spring·rabbitmq
梵高的代码色盘1 小时前
后端树形结构
java
代码的奴隶(艾伦·耶格尔)1 小时前
后端快捷代码
java·开发语言
虾条_花吹雪1 小时前
Chat Model API
java
双力臂4041 小时前
MyBatis动态SQL进阶:复杂查询与性能优化实战
java·sql·性能优化·mybatis
六毛的毛2 小时前
Springboot开发常见注解一览
java·spring boot·后端
AntBlack2 小时前
拖了五个月 ,不当韭菜体验版算是正式发布了
前端·后端·python
31535669132 小时前
一个简单的脚本,让pdf开启夜间模式
前端·后端
程序漫游人2 小时前
centos8.5安装jdk21详细安装教程
java·linux