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());
}
实现上有三个要点:
- 只存哈希。数据库里保存 refresh token 的 SHA-256,明文只在响应里出现一次,泄库也无法直接使用。
markUsed必须是原子操作。UPDATE ... SET used = true WHERE id = ? AND used = false返回受影响行数,0 表示已被用过,这时把整个 token family 全部撤销,强制重新登录。- 家族(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 是合理的。
但有两种情况必须重新开启或另做防护:
- refresh token 放在 cookie 里。这时刷新接口会被跨站请求自动带上 cookie,必须用
SameSite=Strict(或Lax)+ CSRF token(double submit)。 - 用了 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. 踩坑清单
- 把
permitAll()写成anyRequest(),方法注解又漏加,整站裸奔。 - 密钥硬编码进仓库,或长度不足 256 位。
- 只校验签名不校验
exp、iss、aud,令牌可跨环境复用。 - refresh token 明文入库,泄库即全量失守。
- 轮换时先删旧再发新,中间失败导致用户被登出。
- 黑名单不设 TTL,Redis 无限增长。
- 登录失败提示区分「用户不存在」和「密码错误」。
- CORS 允许所有来源并带凭据。
- 权限判断用
roles.contains("ADMIN")这种字符串魔法值,而不是权限常量。
12. 小结
无状态 JWT 的完整方案不是「签个 token 就完事」,而是三件事的组合:短命的 access token 负责性能,长命的 refresh token 加轮换负责体验,版本号与黑名单负责撤销。三者缺一,都会在某个真实场景里露馅。
判断设计是否站得住,可以问自己:用户改密码后,旧 token 还能用多久?设备丢失后,最坏情况下攻击者还能操作多久?这两个问题的答案,就是这套方案的实际安全边界。
下一篇回到事务:@Transactional 在自调用、传播行为和异步事件上分别会怎么失效,以及怎样用 AFTER_COMMIT 把「写库」和「发消息」拆开。



评论
加载中…