JWT 最容易被误读的一点,是把「服务端能验签」当成「服务端能撤销」。签名只证明令牌没被篡改,不证明它仍然被授权。这篇按登录、续期、踢人三条链路,拆 Spring Security 6 的无状态配置、双令牌轮换和撤销方案,以及纯 Token 场景下 CORS 与 CSRF 到底该怎么处理。示例为 Spring Boot 3.3 / Java 21 独立实现,与仓库中的 Go 认证中间件无关。

1. 无状态登录的错觉

先说一个真实会发生的场景:用户手机丢了,在另一台设备上点了「退出所有设备」。如果系统只验签,旧 access token 在过期前仍然能调用接口,退出操作等于没做。改密码、封号、降权也一样。

很多人这时才意识到,JWT 的「无状态」指的是认证过程不依赖服务端 session,不是「服务端什么都不用记」。只要业务存在撤销需求,就一定有一部分状态要落地,区别只在于放在哪里、查多快、允许多大延迟。

JWT 也未必比 session 安全:session 泄漏能立刻删,JWT 泄漏只能等过期或拉黑。它的收益是可水平扩展、跨服务传递,代价是撤销变复杂。

2. SecurityFilterChain:先把默认行为关掉

Spring Security 6 的配置入口是 SecurityFilterChain,不再继承 WebSecurityConfigurerAdapter。无状态场景要把 session、表单登录、HTTP Basic、CSRF 全部关掉,只留下一个 JWT 过滤器。配置类上标 @EnableWebSecurity 与 @EnableMethodSecurity,核心 Bean 只有这一段:

@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http.csrf(AbstractHttpConfigurer::disable)
        .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
        .authorizeHttpRequests(a -> a
            .requestMatchers("/api/auth/login", "/api/auth/refresh").permitAll()
            .anyRequest().authenticated())
        .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class);
    return http.build();
}

几个必须明确的点:

  • SessionCreationPolicy.STATELESS 不只是「不创建 session」,还意味着 SecurityContext 不会跨请求保存。别指望在 Controller 里从 session 取用户。
  • permitAll() 只放行认证入口和公开内容,不要图省事写 anyRequest().permitAll() 再靠方法注解兜底。
  • 自定义 AuthenticationEntryPoint:默认实现返回 403 或跳转登录页,前端会误判为权限不足。
  • 过滤器要加在 UsernamePasswordAuthenticationFilter 之前,否则请求到达业务层时 SecurityContext 还是空的。

3. 签发与校验:对称还是非对称

HS256 用一个共享密钥,签得快、配置简单,适合单体应用;RS256 / ES256 用私钥签、公钥验,适合多个服务都要验签、但只有认证服务能签发的场景。代价是非对称签名慢一个量级,密钥管理也更麻烦。

public String issue(String sub, Set<String> roles) {
    Instant now = Instant.now();
    return Jwts.builder().subject(sub).id(UUID.randomUUID().toString())
            .claim("roles", roles)
            .expiration(Date.from(now.plus(accessTtl)))
            .signWith(key)
            .compact();
}

public Claims parse(String token) {
    return Jwts.parser().verifyWith(key).build().parseSignedClaims(token).getPayload();
}

public Collection<GrantedAuthority> authorities(Claims claims) {
    List<String> roles = claims.get("roles", List.class);
    return roles == null ? List.of() : roles.stream().map(SimpleGrantedAuthority::new).toList();
}

TokenProvider 的字段很简单:key = Keys.hmacShaKeyFor(Decoders.BASE64.decode(secret)),accessTtl 来自 app.jwt.access-ttl。把签发、校验、权限转换收在一个类里,过滤器就只依赖它一个。

配置里的 secret 必须是 Base64 编码的随机值,至少 256 位,用占位符管理:app.jwt.secret: <your-secret>;短密钥会直接抛 WeakKeyException。

非对称方案只需把 signWith(key) 换成 signWith(privateKey, Jwts.SIG.RS256),校验侧换成 verifyWith(publicKey)。密钥轮换要在 header 里带 kid,让校验方按 kid 选公钥,否则轮换瞬间所有旧 token 一起失效。

parse 失败不要吞掉异常返回 null,ExpiredJwtException、SignatureException 都交给过滤器统一处理。

4. 认证过滤器:只做认证,不做业务

过滤器的职责只有一件:从 Authorization 头取出 Bearer token,验签、解析、构造 Authentication 放进 SecurityContext。它不该查数据库、不该判断用户是否被封禁,更不该写审计日志。

@Override
protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res,
                                FilterChain chain) throws ServletException, IOException {
    String header = req.getHeader(HttpHeaders.AUTHORIZATION);
    if (header != null && header.startsWith("Bearer ")) {
        try {
            Claims claims = tokenProvider.parse(header.substring(7));
            var auth = new UsernamePasswordAuthenticationToken(
                    claims.getSubject(), null, tokenProvider.authorities(claims));
            SecurityContextHolder.getContext().setAuthentication(auth);
        } catch (JwtException | IllegalArgumentException ex) {
            SecurityContextHolder.clearContext();
        }
    }
    chain.doFilter(req, res);
}

注意 clearContext():线程池复用线程时,残留的认证信息会造成越权。SecurityContextHolderFilter 会清理,但显式清理更稳妥。

还有一个容易忽略的细节:filter 要跳过 OPTIONS 预检请求,否则浏览器预检不带 Authorization 头,会被当成未登录直接 401,CORS 就此失败。

5. access + refresh:双令牌与轮换

单个 access token 的要求自相矛盾:短才安全,长才不影响体验。双令牌把它拆开:access token 短(15 分钟),refresh token 长(14 天),且 refresh token 只能换新令牌。

轮换(rotation)是关键:每次刷新都作废旧 refresh token 并签发新的。攻击者使用被窃取的 token 后,合法用户的下次刷新就会失败,服务端由此发现重放。

@Transactional
public TokenPair refresh(String rawToken) {
    RefreshToken stored = refreshTokenRepository.findByHash(sha256(rawToken))
            .orElseThrow(() -> new UnauthorizedException("invalid_refresh"));
    if (stored.isRevoked() || stored.getExpiresAt().isBefore(Instant.now())) {
        throw new UnauthorizedException("invalid_refresh");
    }
    if (refreshTokenRepository.markUsed(stored.getId()) == 0) {
        refreshTokenRepository.revokeFamily(stored.getFamilyId());
        throw new UnauthorizedException("refresh_reused");
    }
    User user = userRepository.findById(stored.getUserId()).orElseThrow();
    RefreshToken next = RefreshToken.issue(user.getId(), stored.getFamilyId());
    refreshTokenRepository.save(next);
    return new TokenPair(tokenProvider.issue(user.getUsername(), user.getRoles()), next.rawValue());
}

实现上有三个要点:

  1. 只存哈希。数据库里保存 refresh token 的 SHA-256,明文只在响应里出现一次,泄库也无法直接使用。
  2. markUsed 必须是原子操作。UPDATE ... SET used = true WHERE id = ? AND used = false 返回受影响行数,0 表示已被用过,这时把整个 token family 全部撤销,强制重新登录。
  3. 家族(family)标识。同一登录会话派生出的 refresh token 共享 familyId,检测到重放就整族撤销,而不是只撤销那一个。

响应里 access token 放 body,refresh token 放 HttpOnly cookie 更安全;如果放 body,就要接受 XSS 风险。两种做法都有人用,关键是在文档里写清楚。

6. 撤销:jti 黑名单还是版本号

只要业务需要「立即失效」,就必须在服务端保存一点状态。主流两种做法:

jti 黑名单:登出时把 jti 写入 Redis,TTL 设为 token 剩余有效期,校验时多查一次 EXISTS。优点是粒度精确;缺点是每个请求多一次往返,Redis 挂了要在「放行」和「全拒」之间选。

token 版本号:用户表加 token_version,签发时写进 claim,改密码或踢人时自增,校验时比对。优点是不用为每个 token 存记录;缺点是无法单独撤销某个设备,且版本号通常走本地缓存,「立即失效」变成「几秒内失效」。

我的取舍是:两者都用,但职责不同。版本号负责「全局踢人」(改密码、封号、权限大改),jti 黑名单负责「单设备登出」。版本号缓存在本地 10 秒,能接受这点延迟;单设备登出是用户主动操作,走 Redis 精确撤销。

无论哪种,都别把黑名单做成永久的。按 token 剩余 TTL 设置过期时间,否则 Redis 会变成一张只增不减的表。

7. 登录失败限流与账号锁定

登录接口是唯一一个「匿名可写」的入口,不限流等于把密码交给撞库脚本。我的做法是三层:

  • 按 IP 限流:同一 IP 每分钟最多 10 次登录尝试,超出返回 429。
  • 按账号 + IP 计数:连续失败 5 次后锁定 15 分钟,成功登录清零。
  • 全局异常检测:同一账号短时间内从大量 IP 失败,直接临时锁定账号并告警。

计数器放 Redis,用 INCR + EXPIRE,不要放本地内存——多实例部署时本地计数等于把阈值放大 N 倍。

错误响应必须统一:账号不存在和密码错误都返回同一句话,且耗时接近(不存在的账号也跑一次 bcrypt),否则可以通过响应差异枚举账号。锁定提示也要含糊。

8. 密码编码:bcrypt 的 cost 怎么定

密码只存哈希,用 BCryptPasswordEncoder,不要用 MD5 / SHA-1,也不要用「加盐 SHA-256」自己发明。bcrypt 的 cost 是 2 的指数,cost=10 约 60 毫秒,cost=12 约 240 毫秒,cost=14 接近 1 秒。

代码上只是一个 new BCryptPasswordEncoder(12) 的 PasswordEncoder Bean,真正需要判断的是这个 12。

定 cost 的方法不是抄一个数字,而是在目标机器上实测:单次校验控制在 100–300 毫秒。太低挡不住离线爆破,太高会让登录接口在高峰期排队,反而给攻击者提供了廉价的 DoS 手段。

另外两点:bcrypt 只取前 72 字节,超长密码要在前端限长;升级 cost 后老哈希不会自动升级,可在下次登录成功时用 upgradeEncoding(hash) 判断并重新编码。

9. CORS 与 CSRF:纯 Token 场景怎么处理

CSRF 的前提是浏览器自动携带凭据(cookie、Basic 认证)。如果 access token 放在 Authorization: Bearer 头里,由前端代码显式添加,跨站请求带不上这个头,CSRF 就不成立,关掉 csrf 是合理的。

但有两种情况必须重新开启或另做防护:

  1. refresh token 放在 cookie 里。这时刷新接口会被跨站请求自动带上 cookie,必须用 SameSite=Strict(或 Lax)+ CSRF token(double submit)。
  2. 用了 HTTP Basic 或 session。那 CSRF 防护本来就不该关。

CORS 则必须显式配置,不能写 allowedOrigins("*") 配 allowCredentials(true)——浏览器会直接拒绝,Spring 启动时也会报错。列出管理后台的确切来源,只放行必要的 method 和 header。

还有一个常见坑:CORS 过滤器要排在认证过滤器之前。预检请求不带 token,如果先过认证就会被 401,浏览器看到的却是 CORS 错误,排查方向很容易跑偏。

10. 权限模型:RBAC 加方法级注解

角色适合粗粒度(ADMIN / EDITOR / READER),权限适合细粒度(article:publish、article:delete)。我的做法是角色只用于分组,鉴权一律落到权限字符串上,避免出现「判断是不是 ADMIN」这种散落各处的硬编码。

登录时由自定义 UserDetailsService 把用户、密码哈希和权限一起交给 AuthenticationManager:

public UserDetails loadUserByUsername(String username) {
    User user = userRepository.findByUsername(username)
            .orElseThrow(() -> new UsernameNotFoundException(username));
    return User.withUsername(username).password(user.getPasswordHash())
            .authorities(user.roleAuthorities()).build();
}

这里查不到用户时抛 UsernameNotFoundException 即可,不要抛「用户不存在」的业务异常——认证失败信息必须统一,否则又回到账号枚举。

方法上直接声明权限,例如 @PreAuthorize("hasAuthority('article:publish')"),需要校验收稿人身份时写 @PreAuthorize("hasRole('EDITOR') and #ownerId == authentication.name")。业务代码不再出现 if (hasRole(...))。

@EnableMethodSecurity 打开后,@PreAuthorize 才生效。表达式里的 authentication.name 是 subject,若 subject 存的是用户 ID,就要保证比较双方类型一致——字符串和 Long 比较永远为 false。

权限变更后,旧 token 里的 roles 仍是签发时的快照。要么接受「过期后生效」,要么在过滤器里查当前权限(用缓存)。这是无状态与实时性之间必须做的选择。

11. 踩坑清单

  1. 把 permitAll() 写成 anyRequest(),方法注解又漏加,整站裸奔。
  2. 密钥硬编码进仓库,或长度不足 256 位。
  3. 只校验签名不校验 exp、iss、aud,令牌可跨环境复用。
  4. refresh token 明文入库,泄库即全量失守。
  5. 轮换时先删旧再发新,中间失败导致用户被登出。
  6. 黑名单不设 TTL,Redis 无限增长。
  7. 登录失败提示区分「用户不存在」和「密码错误」。
  8. CORS 允许所有来源并带凭据。
  9. 权限判断用 roles.contains("ADMIN") 这种字符串魔法值,而不是权限常量。

12. 小结

无状态 JWT 的完整方案不是「签个 token 就完事」,而是三件事的组合:短命的 access token 负责性能,长命的 refresh token 加轮换负责体验,版本号与黑名单负责撤销。三者缺一,都会在某个真实场景里露馅。

判断设计是否站得住,可以问自己:用户改密码后,旧 token 还能用多久?设备丢失后,最坏情况下攻击者还能操作多久?这两个问题的答案,就是这套方案的实际安全边界。

下一篇回到事务:@Transactional 在自调用、传播行为和异步事件上分别会怎么失效,以及怎样用 AFTER_COMMIT 把「写库」和「发消息」拆开。