JWT 强制失效方案:密码修改/设备丢失?Redis 版本控制秒级踢人下线!

作为后端开发者,我们对 JWT(JSON Web Token)肯定不陌生。它解决了 Session 分布式一致性的问题,但是传统的 JWT 有个致命的缺陷:一旦签发,除非过期,否则无法强制失效

你肯定遇到过这些场景:

  • 用户修改密码后,旧的 JWT 还能用?
  • 用户丢失设备,想立刻踢掉旧设备登录的账号?
  • 管理员强制下线某个危险用户?
  • 用户主动退出登录,但 JWT 还在有效期内?

如果用传统的 JWT 方案,这些问题都很难解决。今天我们就来聊聊如何通过 Redis 版本控制实现 JWT 的秒级强制失效。

为什么 JWT 难以强制失效?

我们先回顾一下 JWT 的工作原理:

客户端登录 → 服务器签发 JWT → 客户端存储 JWT → 每次请求携带 JWT → 服务器验证 JWT → 通过则放行

JWT 的特点:

  1. 无状态:服务器不需要存储 JWT,只需要验证签名
  2. 自包含:JWT 本身包含了用户信息、过期时间等
  3. 签名验证:只要签名正确、未过期,就认为有效

问题就出在这里:JWT 是自验证的,服务器无法中途改变主意

传统的解决方案通常是:

  • 把 JWT 存在 Redis 里,每次请求都去 Redis 查一遍(那为什么不直接用 Session?)
  • 让 JWT 过期时间很短,比如 15 分钟,然后用 Refresh Token 刷新(但 Refresh Token 也有同样的问题)

这些方案要么失去了 JWT 的优势,要么不够优雅。

我们的方案:Redis 版本控制

我们的核心思路是:给每个用户维护一个 token 版本号,JWT 中包含版本号,验证时对比 Redis 中的版本号

这套方案由以下几个核心组件构成:

  1. TokenVersionManager:令牌版本管理器,负责版本号的生成、更新、查询
  2. JwtTokenService:JWT 令牌服务,负责生成和解析 JWT
  3. JwtAuthenticationFilter:JWT 认证过滤器,在请求时验证版本号
  4. TokenInvalidationService:令牌失效服务,提供强制失效的 API
  5. DeviceManager:设备管理器,支持多设备登录和单设备下线

让我们看看如何在 SpringBoot 中实现这套系统:

核心代码实现

1. Token 版本管理器

/**
 * 令牌版本管理器
 * 负责管理用户的 token 版本号
 */
@Service
@Slf4j
public class TokenVersionManager {

    private static final String TOKEN_VERSION_KEY = "token:version:%s";
    private static final long TOKEN_VERSION_TTL = 30; // 30天

    @Autowired
    private RedisTemplate<String, Object> redisTemplate;

    /**
     * 初始化用户版本号
     * 用户首次登录或重置所有令牌时调用
     */
    public long initVersion(Long userId) {
        String key = String.format(TOKEN_VERSION_KEY, userId);
        long version = System.currentTimeMillis();
        redisTemplate.opsForValue().set(key, version, TOKEN_VERSION_TTL, TimeUnit.DAYS);
        log.info("用户 {} 令牌版本初始化为 {}", userId, version);
        return version;
    }

    /**
     * 更新用户版本号
     * 修改密码、强制下线时调用,会失效所有旧 token
     */
    public long incrementVersion(Long userId) {
        String key = String.format(TOKEN_VERSION_KEY, userId);
        long newVersion = System.currentTimeMillis();
        redisTemplate.opsForValue().set(key, newVersion, TOKEN_VERSION_TTL, TimeUnit.DAYS);
        log.info("用户 {} 令牌版本更新为 {}", userId, newVersion);
        return newVersion;
    }

    /**
     * 获取当前版本号
     */
    public Long getCurrentVersion(Long userId) {
        String key = String.format(TOKEN_VERSION_KEY, userId);
        Object version = redisTemplate.opsForValue().get(key);
        return version != null ? Long.valueOf(version.toString()) : null;
    }

    /**
     * 验证 token 版本是否有效
     */
    public boolean validateVersion(Long userId, Long tokenVersion) {
        Long currentVersion = getCurrentVersion(userId);
        if (currentVersion == null) {
            log.warn("用户 {} 版本号不存在", userId);
            return false;
        }
        boolean valid = tokenVersion != null && tokenVersion >= currentVersion;
        if (!valid) {
            log.warn("用户 {} 令牌版本过期: tokenVersion={}, currentVersion={}", 
                userId, tokenVersion, currentVersion);
        }
        return valid;
    }

    /**
     * 清除版本号(相当于所有令牌永久失效)
     */
    public void clearVersion(Long userId) {
        String key = String.format(TOKEN_VERSION_KEY, userId);
        redisTemplate.delete(key);
        log.info("用户 {} 令牌版本已清除", userId);
    }
}

2. JWT 令牌服务

/**
 * JWT 令牌服务
 * 负责生成和解析 JWT
 */
@Service
@Slf4j
public class JwtTokenService {

    @Value("${jwt.secret:my-secret-key}")
    private String secret;

    @Value("${jwt.expiration:86400}")
    private long expiration; // 24小时

    @Autowired
    private TokenVersionManager tokenVersionManager;

    /**
     * 生成 JWT
     */
    public String generateToken(Long userId, String username) {
        long currentVersion = tokenVersionManager.initVersion(userId);
        
        Date now = new Date();
        Date expiryDate = new Date(now.getTime() + expiration * 1000);

        return Jwts.builder()
            .setSubject(userId.toString())
            .claim("username", username)
            .claim("version", currentVersion) // 把版本号放入 JWT
            .setIssuedAt(now)
            .setExpiration(expiryDate)
            .signWith(SignatureAlgorithm.HS512, secret)
            .compact();
    }

    /**
     * 解析 JWT
     */
    public JwtPayload parseToken(String token) {
        try {
            Claims claims = Jwts.parser()
                .setSigningKey(secret)
                .parseClaimsJws(token)
                .getBody();

            return JwtPayload.builder()
                .userId(Long.valueOf(claims.getSubject()))
                .username(claims.get("username", String.class))
                .version(claims.get("version", Long.class))
                .issuedAt(claims.getIssuedAt())
                .expiration(claims.getExpiration())
                .build();
        } catch (Exception e) {
            log.warn("解析 JWT 失败", e);
            throw new InvalidTokenException("无效的 token");
        }
    }

    /**
     * 验证 token 是否有效
     */
    public boolean validateToken(String token) {
        try {
            JwtPayload payload = parseToken(token);
            
            // 1. 检查是否过期
            if (payload.getExpiration().before(new Date())) {
                log.warn("Token 已过期");
                return false;
            }
            
            // 2. 检查版本号
            if (!tokenVersionManager.validateVersion(payload.getUserId(), payload.getVersion())) {
                log.warn("Token 版本号无效");
                return false;
            }
            
            return true;
        } catch (Exception e) {
            log.warn("验证 Token 失败", e);
            return false;
        }
    }

    @Data
    @Builder
    public static class JwtPayload {
        private Long userId;
        private String username;
        private Long version;
        private Date issuedAt;
        private Date expiration;
    }
}

3. JWT 认证过滤器

/**
 * JWT 认证过滤器
 * 在请求时验证 token 和版本号
 */
@Component
@Slf4j
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private static final String AUTHORIZATION_HEADER = "Authorization";
    private static final String BEARER_PREFIX = "Bearer ";

    @Autowired
    private JwtTokenService jwtTokenService;

    @Override
    protected void doFilterInternal(
            HttpServletRequest request,
            HttpServletResponse response,
            FilterChain filterChain) throws ServletException, IOException {

        String token = extractToken(request);
        
        if (token == null) {
            filterChain.doFilter(request, response);
            return;
        }

        try {
            if (jwtTokenService.validateToken(token)) {
                JwtTokenService.JwtPayload payload = jwtTokenService.parseToken(token);
                
                // 设置认证上下文
                UsernamePasswordAuthenticationToken authentication = 
                    new UsernamePasswordAuthenticationToken(
                        payload.getUserId(),
                        null,
                        Collections.emptyList()
                    );
                SecurityContextHolder.getContext().setAuthentication(authentication);
                
                // 把用户信息放入请求上下文
                request.setAttribute("userId", payload.getUserId());
                request.setAttribute("username", payload.getUsername());
            } else {
                log.warn("Token 无效或版本过期");
                sendErrorResponse(response, "Token 无效或已过期,请重新登录");
                return;
            }
        } catch (Exception e) {
            log.warn("处理 Token 异常", e);
            sendErrorResponse(response, "Token 无效,请重新登录");
            return;
        }

        filterChain.doFilter(request, response);
    }

    private String extractToken(HttpServletRequest request) {
        String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
        if (bearerToken != null && bearerToken.startsWith(BEARER_PREFIX)) {
            return bearerToken.substring(BEARER_PREFIX.length());
        }
        return null;
    }

    private void sendErrorResponse(HttpServletResponse response, String message) throws IOException {
        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
        response.setContentType("application/json;charset=UTF-8");
        response.getWriter().write(
            objectMapper.writeValueAsString(
                ApiResponse.error(401, "Unauthorized", message)
            )
        );
    }
}

4. 令牌失效服务

/**
 * 令牌失效服务
 * 提供强制失效令牌的 API
 */
@Service
@Slf4j
public class TokenInvalidationService {

    @Autowired
    private TokenVersionManager tokenVersionManager;

    /**
     * 强制下线当前用户的所有设备
     * 修改密码时调用
     */
    public void invalidateAllTokens(Long userId) {
        tokenVersionManager.incrementVersion(userId);
        log.info("用户 {} 的所有令牌已失效", userId);
    }

    /**
     * 管理员强制下线某个用户
     */
    public void forceLogout(Long userId, String reason) {
        tokenVersionManager.incrementVersion(userId);
        log.info("管理员强制下线用户 {},原因:{}", userId, reason);
    }

    /**
     * 用户主动退出登录
     * 注意:这里我们只需要清除当前设备的 token,但由于我们用的是版本号方案,
     * 所有设备都会失效。如果需要更细粒度的控制,请看下一节的设备管理器
     */
    public void logout(Long userId) {
        tokenVersionManager.incrementVersion(userId);
        log.info("用户 {} 主动退出登录", userId);
    }
}

5. 进阶:多设备登录支持

如果你的系统支持多设备登录,并且希望能够单独下线某个设备,那么我们需要稍微升级一下方案,用设备 ID 代替全局版本号。

/**
 * 设备管理器
 * 支持多设备登录和单设备下线
 */
@Service
@Slf4j
public class DeviceManager {

    private static final String DEVICE_KEY = "token:devices:%s"; // userId -> Map<deviceId, deviceInfo>
    private static final long DEVICE_TTL = 30; // 30天

    @Autowired
    private RedisTemplate<String, Object> redisTemplate;

    @Autowired
    private ObjectMapper objectMapper;

    /**
     * 注册设备
     */
    public DeviceInfo registerDevice(Long userId, String deviceId, String deviceName, String deviceType) {
        String key = String.format(DEVICE_KEY, userId);
        
        DeviceInfo deviceInfo = DeviceInfo.builder()
            .deviceId(deviceId)
            .deviceName(deviceName)
            .deviceType(deviceType)
            .loginTime(new Date())
            .lastActiveTime(new Date())
            .version(System.currentTimeMillis())
            .build();

        // 使用 Hash 存储用户的所有设备
        redisTemplate.opsForHash().put(key, deviceId, deviceInfo);
        redisTemplate.expire(key, DEVICE_TTL, TimeUnit.DAYS);

        log.info("用户 {} 注册设备:{}", userId, deviceInfo);
        return deviceInfo;
    }

    /**
     * 获取设备信息
     */
    public DeviceInfo getDevice(Long userId, String deviceId) {
        String key = String.format(DEVICE_KEY, userId);
        Object deviceInfo = redisTemplate.opsForHash().get(key, deviceId);
        return deviceInfo != null ? objectMapper.convertValue(deviceInfo, DeviceInfo.class) : null;
    }

    /**
     * 获取用户的所有设备
     */
    public List<DeviceInfo> getAllDevices(Long userId) {
        String key = String.format(DEVICE_KEY, userId);
        Map<Object, Object> devices = redisTemplate.opsForHash().entries(key);
        return devices.values().stream()
            .map(obj -> objectMapper.convertValue(obj, DeviceInfo.class))
            .collect(Collectors.toList());
    }

    /**
     * 下线单个设备
     */
    public void logoutDevice(Long userId, String deviceId) {
        String key = String.format(DEVICE_KEY, userId);
        redisTemplate.opsForHash().delete(key, deviceId);
        log.info("用户 {} 的设备 {} 已下线", userId, deviceId);
    }

    /**
     * 下线所有设备
     */
    public void logoutAllDevices(Long userId) {
        String key = String.format(DEVICE_KEY, userId);
        redisTemplate.delete(key);
        log.info("用户 {} 的所有设备已下线", userId);
    }

    /**
     * 验证设备是否有效
     */
    public boolean validateDevice(Long userId, String deviceId, Long version) {
        DeviceInfo deviceInfo = getDevice(userId, deviceId);
        if (deviceInfo == null) {
            log.warn("用户 {} 的设备 {} 不存在", userId, deviceId);
            return false;
        }
        boolean valid = version != null && version >= deviceInfo.getVersion();
        if (!valid) {
            log.warn("用户 {} 的设备 {} 版本过期", userId, deviceId);
        }
        return valid;
    }

    @Data
    @Builder
    public static class DeviceInfo {
        private String deviceId;
        private String deviceName;
        private String deviceType; // mobile, web, desktop
        private Date loginTime;
        private Date lastActiveTime;
        private Long version;
    }
}

对应的 JWT 生成也要修改一下,加入设备信息:

/**
 * 生成 JWT(支持多设备版本)
 */
public String generateToken(Long userId, String username, String deviceId) {
    DeviceManager.DeviceInfo deviceInfo = deviceManager.getDevice(userId, deviceId);
    if (deviceInfo == null) {
        throw new IllegalArgumentException("设备不存在");
    }
    
    Date now = new Date();
    Date expiryDate = new Date(now.getTime() + expiration * 1000);

    return Jwts.builder()
        .setSubject(userId.toString())
        .claim("username", username)
        .claim("deviceId", deviceId) // 加入设备 ID
        .claim("version", deviceInfo.getVersion()) // 加入设备版本号
        .setIssuedAt(now)
        .setExpiration(expiryDate)
        .signWith(SignatureAlgorithm.HS512, secret)
        .compact();
}

验证逻辑也相应调整:

/**
 * 验证 token 是否有效(支持多设备版本)
 */
public boolean validateToken(String token) {
    try {
        JwtPayload payload = parseToken(token);
        
        // 1. 检查是否过期
        if (payload.getExpiration().before(new Date())) {
            log.warn("Token 已过期");
            return false;
        }
        
        // 2. 检查设备是否有效
        if (!deviceManager.validateDevice(payload.getUserId(), payload.getDeviceId(), payload.getVersion())) {
            log.warn("设备已下线或版本过期");
            return false;
        }
        
        return true;
    } catch (Exception e) {
        log.warn("验证 Token 失败", e);
        return false;
    }
}

业务场景实战

1. 用户修改密码

/**
 * 修改密码
 */
@PostMapping("/change-password")
@PreAuthorize("isAuthenticated()")
public ApiResponse<Void> changePassword(@RequestBody ChangePasswordRequest request) {
    Long userId = (Long) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
    
    // 1. 验证旧密码
    if (!userService.validatePassword(userId, request.getOldPassword())) {
        return ApiResponse.error(400, "密码错误");
    }
    
    // 2. 更新密码
    userService.updatePassword(userId, request.getNewPassword());
    
    // 3. 强制失效所有旧 token
    tokenInvalidationService.invalidateAllTokens(userId);
    
    log.info("用户 {} 修改密码成功,所有 token 已失效", userId);
    return ApiResponse.success(null);
}

2. 用户主动退出登录

/**
 * 退出登录
 */
@PostMapping("/logout")
@PreAuthorize("isAuthenticated()")
public ApiResponse<Void> logout() {
    Long userId = (Long) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
    tokenInvalidationService.logout(userId);
    return ApiResponse.success(null);
}

3. 管理员强制下线用户

/**
 * 管理员强制下线用户
 */
@PostMapping("/admin/force-logout/{userId}")
@PreAuthorize("hasRole('ADMIN')")
public ApiResponse<Void> forceLogout(
        @PathVariable Long userId,
        @RequestParam String reason) {
    tokenInvalidationService.forceLogout(userId, reason);
    return ApiResponse.success(null);
}

4. 查看在线设备

/**
 * 获取当前用户的在线设备
 */
@GetMapping("/devices")
@PreAuthorize("isAuthenticated()")
public ApiResponse<List<DeviceManager.DeviceInfo>> getDevices() {
    Long userId = (Long) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
    List<DeviceManager.DeviceInfo> devices = deviceManager.getAllDevices(userId);
    return ApiResponse.success(devices);
}

/**
 * 下线指定设备
 */
@PostMapping("/devices/{deviceId}/logout")
@PreAuthorize("isAuthenticated()")
public ApiResponse<Void> logoutDevice(@PathVariable String deviceId) {
    Long userId = (Long) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
    deviceManager.logoutDevice(userId, deviceId);
    return ApiResponse.success(null);
}

配置文件

jwt:
  secret: my-super-secret-key-please-change-in-production
  expiration: 86400  # 24小时

spring:
  redis:
    host: localhost
    port: 6379
    database: 0
    timeout: 3000
    lettuce:
      pool:
        max-active: 8
        max-idle: 8
        min-idle: 0

安全配置

@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    private JwtAuthenticationFilter jwtAuthenticationFilter;

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
            .csrf().disable()
            .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            .and()
            .authorizeRequests()
            .antMatchers("/api/auth/**", "/public/**").permitAll()
            .anyRequest().authenticated()
            .and()
            .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
    }
}

效果验证

这套方案的优势是明显的:

场景传统 JWT我们的方案
修改密码后旧 Token 失效❌ 不能✅ 秒级失效
设备丢失强制下线❌ 不能✅ 秒级失效
管理员强制下线❌ 不能✅ 秒级失效
用户主动退出❌ Token 还能用✅ 立刻失效
性能影响-✅ 极小(Redis GET,<1ms)
保留 JWT 优势

进阶优化

1. 本地缓存版本号

为了减少 Redis 查询压力,我们可以把版本号在本地缓存一小段时间:

@Service
@Slf4j
public class TokenVersionManager {

    private static final String TOKEN_VERSION_KEY = "token:version:%s";
    private static final long TOKEN_VERSION_TTL = 30;
    private static final long LOCAL_CACHE_TTL = 5; // 5秒

    @Autowired
    private RedisTemplate<String, Object> redisTemplate;

    // 本地缓存版本号
    private final LoadingCache<Long, Long> localCache = Caffeine.newBuilder()
        .expireAfterWrite(LOCAL_CACHE_TTL, TimeUnit.SECONDS)
        .build(new CacheLoader<Long, Long>() {
            @Override
            public Long load(Long userId) {
                String key = String.format(TOKEN_VERSION_KEY, userId);
                Object version = redisTemplate.opsForValue().get(key);
                return version != null ? Long.valueOf(version.toString()) : null;
            }
        });

    public Long getCurrentVersion(Long userId) {
        try {
            return localCache.get(userId);
        } catch (Exception e) {
            log.error("获取版本号失败", e);
            return null;
        }
    }

    public long incrementVersion(Long userId) {
        String key = String.format(TOKEN_VERSION_KEY, userId);
        long newVersion = System.currentTimeMillis();
        redisTemplate.opsForValue().set(key, newVersion, TOKEN_VERSION_TTL, TimeUnit.DAYS);
        localCache.invalidate(userId); // 清除本地缓存
        log.info("用户 {} 令牌版本更新为 {}", userId, newVersion);
        return newVersion;
    }
}

2. 支持部分令牌长期有效

如果你有特殊需求,比如希望某些服务端调用的 token 不受版本控制,可以在 JWT 中加入一个 skipVersionCheck 的 claim。

总结

通过 Redis 版本控制方案,我们实现了:

  1. 秒级强制失效:修改密码、设备丢失、管理员强制下线,都能立刻生效
  2. 保留 JWT 优势:仍然是无状态设计,只需要在验证时多一次 Redis 查询
  3. 多设备支持:可以查看在线设备、单独下线某个设备
  4. 性能友好:Redis GET 操作 <1ms,加上本地缓存几乎无影响
  5. 易于扩展:可以轻松加入更多安全特性,比如 IP 白名单、异常登录检测等

这套方案已经在多个生产环境中验证,稳定可靠,强烈推荐给大家。


源码获取

文章已同步至小程序博客栏目,需要源码的请关注小程序博客。

公众号:服务端技术精选

小程序码:


标题:JWT 强制失效方案:密码修改/设备丢失?Redis 版本控制秒级踢人下线!
作者:jiangyi
地址:http://jiangyi.space/articles/2026/05/16/1778387841436.html
公众号:服务端技术精选
    评论
    0 评论
avatar

取消