JWT 强制失效方案:密码修改/设备丢失?Redis 版本控制秒级踢人下线!
作为后端开发者,我们对 JWT(JSON Web Token)肯定不陌生。它解决了 Session 分布式一致性的问题,但是传统的 JWT 有个致命的缺陷:一旦签发,除非过期,否则无法强制失效。
你肯定遇到过这些场景:
- 用户修改密码后,旧的 JWT 还能用?
- 用户丢失设备,想立刻踢掉旧设备登录的账号?
- 管理员强制下线某个危险用户?
- 用户主动退出登录,但 JWT 还在有效期内?
如果用传统的 JWT 方案,这些问题都很难解决。今天我们就来聊聊如何通过 Redis 版本控制实现 JWT 的秒级强制失效。
为什么 JWT 难以强制失效?
我们先回顾一下 JWT 的工作原理:
客户端登录 → 服务器签发 JWT → 客户端存储 JWT → 每次请求携带 JWT → 服务器验证 JWT → 通过则放行
JWT 的特点:
- 无状态:服务器不需要存储 JWT,只需要验证签名
- 自包含:JWT 本身包含了用户信息、过期时间等
- 签名验证:只要签名正确、未过期,就认为有效
问题就出在这里:JWT 是自验证的,服务器无法中途改变主意。
传统的解决方案通常是:
- 把 JWT 存在 Redis 里,每次请求都去 Redis 查一遍(那为什么不直接用 Session?)
- 让 JWT 过期时间很短,比如 15 分钟,然后用 Refresh Token 刷新(但 Refresh Token 也有同样的问题)
这些方案要么失去了 JWT 的优势,要么不够优雅。
我们的方案:Redis 版本控制
我们的核心思路是:给每个用户维护一个 token 版本号,JWT 中包含版本号,验证时对比 Redis 中的版本号。
这套方案由以下几个核心组件构成:
- TokenVersionManager:令牌版本管理器,负责版本号的生成、更新、查询
- JwtTokenService:JWT 令牌服务,负责生成和解析 JWT
- JwtAuthenticationFilter:JWT 认证过滤器,在请求时验证版本号
- TokenInvalidationService:令牌失效服务,提供强制失效的 API
- 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 版本控制方案,我们实现了:
- 秒级强制失效:修改密码、设备丢失、管理员强制下线,都能立刻生效
- 保留 JWT 优势:仍然是无状态设计,只需要在验证时多一次 Redis 查询
- 多设备支持:可以查看在线设备、单独下线某个设备
- 性能友好:Redis GET 操作 <1ms,加上本地缓存几乎无影响
- 易于扩展:可以轻松加入更多安全特性,比如 IP 白名单、异常登录检测等
这套方案已经在多个生产环境中验证,稳定可靠,强烈推荐给大家。
源码获取
文章已同步至小程序博客栏目,需要源码的请关注小程序博客。
公众号:服务端技术精选
小程序码:
标题:JWT 强制失效方案:密码修改/设备丢失?Redis 版本控制秒级踢人下线!
作者:jiangyi
地址:http://jiangyi.space/articles/2026/05/16/1778387841436.html
公众号:服务端技术精选
评论
0 评论