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

九、总结

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

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

相关推荐
ChinaRainbowSea5 分钟前
补充:问题:CORS ,前后端访问跨域问题
java·spring boot·后端·spring
顺丰同城前端技术团队10 分钟前
DeepSeek 国产大模型新标杆
前端·后端·程序员
KiddoStone15 分钟前
多实例schedule job同步数据流的数据一致性设计和实现方案
java
YaHuiLiang25 分钟前
小微互联网公司与互联网创业公司 -- 学历之殇
前端·后端·面试
冬天的风滚草28 分钟前
Higress开源版 大规模 MCP Server 部署配置方案
后端
雨落倾城夏未凉28 分钟前
4.信号与槽
后端·qt
岁忧36 分钟前
(LeetCode 每日一题) 1865. 找出和为指定值的下标对 (哈希表)
java·c++·算法·leetcode·go·散列表
YuTaoShao39 分钟前
【LeetCode 热题 100】240. 搜索二维矩阵 II——排除法
java·算法·leetcode
考虑考虑1 小时前
JDK9中的dropWhile
java·后端·java ee
想躺平的咸鱼干2 小时前
Volatile解决指令重排和单例模式
java·开发语言·单例模式·线程·并发编程