首页 健康生活文章正文

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

健康生活 2025年08月02日 22:29 2 admin

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

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

然而,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实现

首先,添加必要的依赖:

<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工具类:

@Componentpublic 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@RequiredArgsConstructorpublic 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")@RequiredArgsConstructorpublic 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=yourVeryLongAndSecureSecretKeyHerePleaseMakeItAtLeast256Bitsjwt.accessTokenExpiration=900000    # 15分钟jwt.refreshTokenExpiration=604800000 # 7天

2.3 优缺点分析

优点:

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

缺点:

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

2.4 适用场景

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

三、Redis黑名单机制

3.1 基本原理

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

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

3.2 SpringBoot实现

首先,添加Redis依赖:

<dependency>    <groupId>org.springframework.boot</groupId>    <artifactId>spring-boot-starter-data-redis</artifactId></dependency>

创建Redis配置类:

@Configurationpublic 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@RequiredArgsConstructorpublic 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工具类:

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

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

@Component@RequiredArgsConstructorpublic 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")@RequiredArgsConstructorpublic 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")@Datapublic class UserTokenVersion {        @Id    private String username;        private int tokenVersion;        public void incrementVersion() {        this.tokenVersion++;    }}

创建令牌版本仓库:

@Repositorypublic interface UserTokenVersionRepository extends JpaRepository<UserTokenVersion, String> {}

实现令牌版本服务:

@Service@RequiredArgsConstructorpublic 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@RequiredArgsConstructorpublic 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@RequiredArgsConstructorpublic 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")@RequiredArgsConstructorpublic 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@Slf4jpublic 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@RequiredArgsConstructorpublic 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")@Datapublic 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());    }}

创建令牌仓库:

@Repositorypublic 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@RequiredArgsConstructorpublic 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@RequiredArgsConstructorpublic 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")@RequiredArgsConstructorpublic 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")@Datapublic 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));    }}

创建会话状态仓库:

@Repositorypublic interface UserSessionStatusRepository extends JpaRepository<UserSessionStatus, String> {}

实现会话状态服务:

@Service@RequiredArgsConstructorpublic 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@RequiredArgsConstructorpublic 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@RequiredArgsConstructorpublic 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有效性需求的应用

密钥轮换

全局

极低

需要定期轮换密钥的系统

集中式令牌存储

完全

企业级应用,多设备管理

会话状态监控

条件性

平衡安全和性能的系统

九、总结

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

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

发表评论

泰日号Copyright Your WebSite.Some Rights Reserved. 网站地图 备案号:川ICP备66666666号 Z-BlogPHP强力驱动