既用 JWT 又查 Redis,这个认证方案不矛盾

学认证的时候,都会遇到一个选择题:JWT 无状态,服务端不存 session,天然可扩展,但没法主动失效(踢人下线、改密码让旧 token 失效都做不到);Session 有状态,可主动失效,但要维护状态、分布式要共享。
我在这套系统里选了个「看似矛盾」的组合------既用 JWT,又用 Redis 存登录态。很多人会问:JWT 不是无状态的吗,你查 Redis 不是又变回有状态了?没错,这正是我想要的。这篇说清楚为什么。
JWT 负责「证明你是谁」
JWT 的核心作用是安全地传递身份信息,服务端不用存储就能验证:
java
@Component
public class JwtUtil {
private static SecretKey KEY;
public JwtUtil(@Value("${app.jwt.secret}") String secret) {
byte[] keyBytes = secret.getBytes(StandardCharsets.UTF_8);
if (keyBytes.length < 32) {
throw new IllegalArgumentException("app.jwt.secret 长度必须至少 32 字节");
}
KEY = Keys.hmacShaKeyFor(keyBytes);
}
public static String generateToken(String subject, long ttlMillis, Map<String, Object> claims) {
return Jwts.builder()
.setSubject(subject)
.addClaims(claims)
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + ttlMillis))
.signWith(KEY, SignatureAlgorithm.HS256)
.compact();
}
public static Claims parseToken(String token) {
return Jwts.parserBuilder().setSigningKey(KEY).build().parseClaimsJws(token).getBody();
}
}
两个安全细节:密钥从配置注入绝不硬编码,生产用 JWT_SECRET 环境变量覆盖;启动时校验密钥长度,HS256 要求至少 32 字节,不足直接启动失败,把配置错误挡在启动期而不是线上才炸。Token 有效期设 7 天,用户不用频繁登录。
Redis 负责「你还能不能用」
JWT 一旦签发,服务端就没法让它作废,除非等它过期。这在很多场景下不可接受:用户改密码希望旧 token 立即失效、管理员想踢掉某个用户、用户主动退出登录。所以我用 Redis 补上「主动失效」的能力。
登录时把 token 作为 key 存进 Redis:auth:token:{token},value 是用户信息 JSON,TTL 7 天和 JWT 一致。校验时不仅验 JWT,还要查 Redis:
java
public class AuthTokenInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
String token = extractToken(request);
if (token == null) {
unauthorized(response, "Missing token");
return false;
}
// ① 验 JWT:签名 + 过期
Claims claims = JwtUtil.parseToken(token);
if (JwtUtil.isExpired(claims)) {
unauthorized(response, "Token expired");
return false;
}
// ② 查 Redis:token 是否还「活着」
String userJson = redisTemplate.opsForValue().get("auth:token:" + token);
if (userJson == null) {
unauthorized(response, "Session expired");
return false;
}
// ③ 还原用户,写入上下文
User user = objectMapper.readValue(userJson.getBytes(), User.class);
UserContext.set(user);
return true;
}
}
登出的时候直接删掉 Redis 里的 key,token 立即失效------这是 JWT 单靠自身做不到的。
Token 怎么传
我支持两种方式,任选其一。Header 方式适合前后端分离、小程序、App 调 API;Cookie 方式适合浏览器端服务端渲染页面(我的 Thymeleaf 页面就靠这个):
java
private String extractToken(HttpServletRequest request) {
String header = request.getHeader("Authorization");
if (header != null && header.startsWith("Bearer ")) {
return header.substring(7);
}
if (request.getCookies() != null) {
for (var c : request.getCookies()) {
if ("auth_token".equals(c.getName())) {
return c.getValue();
}
}
}
return null;
}
一个最容易泄漏的坑
拦截器里用了 UserContext(本质是 ThreadLocal)保存当前用户。这里有个必须注意的点------ThreadLocal 用完必须清理:
java
UserContext.set(user); // 存入 ThreadLocal
@Override
public void afterCompletion(...) {
UserContext.clear(); // 必须清理
}
因为 ThreadLocal 的生命周期和线程绑定,而 Spring 的请求处理线程是从线程池复用的。如果不清理,下一个请求复用同一个线程,就会读到上一个请求的用户信息------这在「串号」场景下是严重的安全事故,A 用户看到了 B 用户的数据。所以铁律是:ThreadLocal 用完必须 clear,且要在 afterCompletion(无论成功失败都执行)里做。
放行路径
拦截器不是一刀切全拦,要精确放行不需要鉴权的路径:
java
if (uri.startsWith("/static/") || uri.startsWith("/css/") || uri.startsWith("/js/") ||
uri.startsWith("/images/") || uri.startsWith("/login") || uri.startsWith("/api/auth/")) {
return true;
}
静态资源(css/js/图片)和登录、注册、验证码这些「还没登录就能用」的接口必须放行,否则用户连登录都登不进去。
「JWT + Redis」这套混合方案本质是各取所长:JWT 无状态地证明身份、可扩展;Redis 补上主动失效能力;ThreadLocal 在请求内传递当前用户。安全上记住三条就够了------密钥不硬编码、ThreadLocal 用完必 clear、放行路径精确。
🌐 项目官网:智能翻译助手 AI Agent