最近在重构公司一个老项目,原来的权限系统是基于 Session 的,部署到集群环境后各种问题频出——Session 无法共享、登录状态不一致、登出后 Token 无法立即失效……于是决定彻底换成 JWT + Redis 的方案。折腾了几天,踩了不少坑,也理清了一些关键点,今天就来手把手带大家把这套权限体系搭起来。
为什么选 JWT + Redis?
先说结论:纯 JWT 不适合需要强控制登录状态的业务场景。
JWT 本身是无状态的,签发之后服务端就“放手不管”了。但现实需求往往是:
- 用户修改密码后,旧 Token 应该立即失效;
- 管理员强制下线某个用户;
- 支持“单设备登录”或“踢人”功能。
这些靠纯 JWT 根本做不到。所以,我们引入 Redis,用它来存一份“有效 Token 黑名单/白名单”,既保留 JWT 的轻量和跨域优势,又能实现服务端对 Token 的主动管控。
整体架构设计
css客户端 → 登录 → 返回 JWT Token(含 userId、role 等) ↓ 后续请求携带 Token(Header: Authorization: Bearer <token>) ↓ Spring Security 拦截 → 验证签名 + 查询 Redis 是否有效 ↓ 有效 → 放行;无效/过期/被拉黑 → 返回 401
关键点:
- Token 依然由 JWT 生成,包含必要声明(如用户ID、角色);
- 登录成功后,将 Token 的唯一标识(比如 jti 或 userId+时间戳)存入 Redis,设置过期时间 = Token 过期时间;
- 每次请求校验时,除了验证 JWT 签名和有效期,还要查 Redis 是否存在该 Token 记录;
- 用户登出 / 修改密码时,主动删除 Redis 中对应记录,实现“即时失效”。
代码实现(核心部分)
1. 依赖引入(Maven)
xml<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <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>
2. JWT 工具类(简化版)
typescript@Component public class JwtUtil { private final String secret = "MySuperSecretKeyForJWT2026"; // 实际项目请从配置文件读取 private final long expiration = 2 * 60 * 60 * 1000; // 2小时 public String generateToken(String userId, List<String> roles) { return Jwts.builder() .setId(UUID.randomUUID().toString()) // 作为 jti,用于 Redis key .setSubject(userId) .claim("roles", roles) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() + expiration)) .signWith(SignatureAlgorithm.HS512, secret) .compact(); } public boolean validateToken(String token) { try { Jwts.parser().setSigningKey(secret).parseClaimsJws(token); return true; } catch (Exception e) { return false; } } public String getUserIdFromToken(String token) { Claims claims = Jwts.parser().setSigningKey(secret).parseClaimsJws(token).getBody(); return claims.getSubject(); } public List<String> getRolesFromToken(String token) { Claims claims = Jwts.parser().setSigningKey(secret).parseClaimsJws(token).getBody(); return (List<String>) claims.get("roles"); } }
3. Redis 存储 Token 状态
typescript@Service public class TokenBlacklistService { @Autowired private RedisTemplate<String, Object> redisTemplate; private static final String TOKEN_PREFIX = "valid_token:"; // 登录成功后调用 public void saveToken(String userId, String tokenId, long expireMs) { String key = TOKEN_PREFIX + userId + ":" + tokenId; redisTemplate.opsForValue().set(key, "valid", Duration.ofMillis(expireMs)); } // 校验 Token 是否在 Redis 中有效 public boolean isTokenValid(String userId, String tokenId) { String key = TOKEN_PREFIX + userId + ":" + tokenId; return Boolean.TRUE.equals(redisTemplate.hasKey(key)); } // 登出 / 修改密码时调用 public void invalidateUserTokens(String userId) { // 实际可遍历所有以 userId 开头的 key 批量删除 // 简化处理:直接按规则删(假设一个用户只允许一个活跃 Token) Set<String> keys = redisTemplate.keys(TOKEN_PREFIX + userId + ":*"); if (keys != null && !keys.isEmpty()) { redisTemplate.delete(keys); } } }
⚠️ 注意:这里为了简化,假设一个用户同一时间只有一个有效 Token。如果支持多端登录,需在 Token 中加入设备标识,并在 Redis 中分别存储。
4. 自定义 Filter:解析并校验 Token
scsspublic class JwtAuthenticationFilter extends OncePerRequestFilter { @Autowired private JwtUtil jwtUtil; @Autowired private TokenBlacklistService tokenBlacklistService; @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException { String authHeader = request.getHeader("Authorization"); if (authHeader != null && authHeader.startsWith("Bearer ")) { String token = authHeader.substring(7); if (jwtUtil.validateToken(token)) { String userId = jwtUtil.getUserIdFromToken(token); String tokenId = getTokenIdFromJwt(token); // 从 JWT 的 jti 字段提取 // 关键:检查 Redis 中是否存在该 Token if (tokenBlacklistService.isTokenValid(userId, tokenId)) { // 构建 Authentication 对象 List<String> roles = jwtUtil.getRolesFromToken(token); List<SimpleGrantedAuthority> authorities = roles.stream() .map(SimpleGrantedAuthority::new) .collect(Collectors.toList()); UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userId, null, authorities); authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); SecurityContextHolder.getContext().setAuthentication(authentication); } } } chain.doFilter(request, response); } private String getTokenIdFromJwt(String token) { Claims claims = Jwts.parser() .setSigningKey("MySuperSecretKeyForJWT2026") .parseClaimsJws(token) .getBody(); return (String) claims.get(JwtClaimNames.JTI); } }
5. 配置 Security
scss@Configuration @EnableWebSecurity public class SecurityConfig { @Bean public JwtAuthenticationFilter jwtAuthenticationFilter() { return new JwtAuthenticationFilter(); } @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .csrf().disable() .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) .and() .authorizeHttpRequests(authz -> authz .requestMatchers("/login", "/public/**").permitAll() .anyRequest().authenticated() ) .addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class); return http.build(); } }
6. 登录与登出接口
less@RestController public class AuthController { @Autowired private UserService userService; @Autowired private JwtUtil jwtUtil; @Autowired private TokenBlacklistService tokenBlacklistService; @PostMapping("/login") public ResponseEntity<?> login(@RequestBody LoginRequest req) { User user = userService.findByUsername(req.getUsername()); if (user != null && passwordEncoder.matches(req.getPassword(), user.getPassword())) { List<String> roles = userService.getUserRoles(user.getId()); String token = jwtUtil.generateToken(user.getId(), roles); String tokenId = extractJtiFromToken(token); // 存入 Redis,有效期与 Token 一致 tokenBlacklistService.saveToken(user.getId(), tokenId, jwtUtil.getExpiration()); return ResponseEntity.ok(Map.of("token", token)); } return ResponseEntity.status(401).body("用户名或密码错误"); } @PostMapping("/logout") public ResponseEntity<?> logout(HttpServletRequest request) { String token = extractTokenFromHeader(request); if (token != null && jwtUtil.validateToken(token)) { String userId = jwtUtil.getUserIdFromToken(token); tokenBlacklistService.invalidateUserTokens(userId); // 删除 Redis 中所有该用户的 Token } return ResponseEntity.ok("登出成功"); } // 辅助方法略... }
踩过的坑 & 建议
- Redis Key 设计:建议用 valid_token:{userId}:{tokenId},方便按用户批量清理。
- Token 刷新机制:可考虑滑动过期(每次请求延长 Redis 中 Token 的 TTL),但要权衡安全性。
- 并发登出问题:登出时删除 Redis Key 是原子操作,无需担心。
- 密钥管理:JWT 密钥务必放在配置中心或环境变量,别硬编码!
- 性能:每次请求多一次 Redis 查询,但 Redis 响应通常在 1ms 内,影响极小。
总结
这套方案在我们生产环境已稳定运行三个月,支撑日均百万级请求。它兼顾了 JWT 的无状态优势和 Redis 的状态可控性,既能横向扩展,又能满足企业级权限管理需求。
如果你还在用 Session 做集群权限,或者被纯 JWT 的“无法即时失效”困扰,不妨试试这个组合。代码虽多,但逻辑清晰,维护成本其实比想象中低。