JWT校验
概述
JWT签发以后,真正的工作才刚开始。每个受保护的接口都要从请求里取出Token,校验签名和过期时间,再把用户身份放到Spring Security上下文里。
在Spring Security里,这件事最适合放在OncePerRequestFilter中完成。它能保证同一个请求只执行一次,比普通Filter更适合写认证逻辑。
请求格式
前端或小程序访问接口时,把Token放在Authorization请求头里。
GET /api/profile HTTP/1.1
Host: api.zhouzhou.net
Authorization: Bearer eyJhbGciOiJIUzI1NiJ9...
Bearer是Token类型,后面跟着真正的JWT字符串。服务端解析时只处理这种格式,不要让Token同时出现在Header、Query、Body多个地方,否则排查问题会很麻烦。
Token服务
项目里可以先抽一个JwtTokenService,把签发、解析、校验都放进去。下面示例使用jjwt,实际项目也可以换成java-jwt或Nimbus JOSE JWT。
@Service
public class JwtTokenService {
private final SecretKey secretKey;
private final Duration accessTokenTtl = Duration.ofHours(1);
public JwtTokenService(@Value("${app.security.jwt-secret}") String secret) {
byte[] keyBytes = Decoders.BASE64.decode(secret);
this.secretKey = Keys.hmacShaKeyFor(keyBytes);
}
public String createAccessToken(LoginUser user) {
Instant now = Instant.now();
return Jwts.builder()
.subject(user.getId().toString())
.claim("username", user.getUsername())
.claim("authorities", user.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.toList())
.issuedAt(Date.from(now))
.expiration(Date.from(now.plus(accessTokenTtl)))
.signWith(secretKey)
.compact();
}
public JwtUser parseAccessToken(String token) {
Claims claims = Jwts.parser()
.verifyWith(secretKey)
.build()
.parseSignedClaims(token)
.getPayload();
Long userId = Long.valueOf(claims.getSubject());
String username = claims.get("username", String.class);
List<String> authorities = claims.get("authorities", List.class);
return new JwtUser(userId, username, authorities);
}
}
密钥建议放在配置中心或环境变量里,不要写死在代码里。HMAC密钥要有足够长度,生产环境最好使用Base64后的随机密钥。
用户对象
JWT里解析出来的是一份轻量用户信息。它不一定等于数据库里的完整用户对象,但要能满足当前请求的权限判断。
public record JwtUser(
Long id,
String username,
List<String> authorities
) {
}
如果权限变化需要立即生效,就不要完全相信JWT里的权限列表。可以只在JWT里放用户ID和权限版本,每次请求查缓存或数据库加载最新权限。
校验过滤器
过滤器负责三件事:取Token、解析Token、写入SecurityContext。
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtTokenService jwtTokenService;
public JwtAuthenticationFilter(JwtTokenService jwtTokenService) {
this.jwtTokenService = jwtTokenService;
}
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String token = resolveToken(request);
if (token != null && SecurityContextHolder.getContext().getAuthentication() == null) {
try {
JwtUser jwtUser = jwtTokenService.parseAccessToken(token);
Authentication authentication = buildAuthentication(jwtUser);
SecurityContext context = SecurityContextHolder.createEmptyContext();
context.setAuthentication(authentication);
SecurityContextHolder.setContext(context);
} catch (JwtException | IllegalArgumentException ex) {
SecurityContextHolder.clearContext();
}
}
filterChain.doFilter(request, response);
}
private String resolveToken(HttpServletRequest request) {
String authorization = request.getHeader(HttpHeaders.AUTHORIZATION);
if (authorization == null || !authorization.startsWith("Bearer ")) {
return null;
}
return authorization.substring(7);
}
private Authentication buildAuthentication(JwtUser jwtUser) {
List<GrantedAuthority> authorities = jwtUser.authorities().stream()
.map(SimpleGrantedAuthority::new)
.toList();
LoginUser principal = new LoginUser(jwtUser.id(), jwtUser.username(), authorities);
return new UsernamePasswordAuthenticationToken(principal, null, authorities);
}
}
这里捕获Token异常以后没有直接写响应,而是清空上下文继续往后走。后面的Spring Security会发现当前请求没有认证信息,再交给AuthenticationEntryPoint返回401。这样异常出口会更统一。
加入过滤器链
JWT过滤器要放到Spring Security过滤器链里,通常放在UsernamePasswordAuthenticationFilter之前。
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http,
JwtAuthenticationFilter jwtAuthenticationFilter) throws Exception {
return http
.csrf(AbstractHttpConfigurer::disable)
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
.authorizeHttpRequests(authorize -> authorize
.requestMatchers("/auth/**").permitAll()
.anyRequest().authenticated()
)
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
.build();
}
如果请求没有带Token,会继续走到权限判断阶段,然后返回401。如果Token有效,当前请求就会拥有对应用户身份。
过期和刷新
Access Token不要设置得太长。常见做法是Access Token短一些,Refresh Token长一些。
Access Token : 15分钟到2小时
Refresh Token : 7天到30天
Access Token负责访问接口,Refresh Token只负责换新的Access Token。Refresh Token最好存到服务端,或者至少保存一个可撤销的标识。用户退出登录、修改密码、账号被禁用时,要能让Refresh Token失效。
一个简单的刷新接口可以这样设计:
@PostMapping("/refresh")
public TokenResponse refresh(@RequestBody RefreshTokenRequest request) {
LoginUser user = refreshTokenService.verify(request.refreshToken());
String accessToken = jwtTokenService.createAccessToken(user);
return new TokenResponse(accessToken, "Bearer", 3600);
}
不要用已经过期的Access Token直接刷新自己。刷新动作应该依赖Refresh Token,否则Token泄露后风险会变大。
主动失效
JWT的麻烦在于它默认是无状态的。无状态意味着服务端不保存Token,也意味着签发出去的Token很难主动撤回。
常见处理方式有三种:
- 黑名单:把要失效的Token ID放到Redis里,直到它自然过期。
- 版本号:JWT里保存
token_version,数据库或缓存里保存当前版本。 - 短有效期:Access Token很短,风险窗口自然变小。
我更喜欢版本号加短有效期。黑名单适合临时封禁,但如果所有请求都要查黑名单,系统会重新变成有状态。
Payload可以加一个版本号:
{
"sub": "10001",
"token_version": 4,
"exp": 1710003600
}
校验时比较版本:
Integer tokenVersion = claims.get("token_version", Integer.class);
Integer currentVersion = userSecurityService.getTokenVersion(userId);
if (!Objects.equals(tokenVersion, currentVersion)) {
throw new JwtException("Token已经失效");
}
用户修改密码、管理员封禁账号、用户主动退出全部设备时,只要递增当前版本号,旧Token就不能继续使用。
常见问题
JWT不要放敏感信息。Payload只是Base64Url编码,任何人拿到Token都能解码查看。
JWT不要放太多权限。Token太大会让每次请求都变重,也会让权限变化难以及时生效。
JWT不要长期不过期。没有过期时间的Token一旦泄露,就很难收回。
JWT不要通过URL传递。URL会出现在浏览器历史、网关日志、监控系统里,比Header更容易泄露。
调试方式
如果接口一直返回401,可以按这个顺序查:
- 请求头是否真的带了
Authorization。 - 是否以
Bearer开头,注意后面有一个空格。 - Token是否过期。
- 签名密钥是否和签发时一致。
- JWT过滤器是否加入了过滤器链。
- 放行规则是否把目标接口排除在外。
SecurityContext里是否写入了Authentication。
安全问题不要只看Controller。多数情况下,请求在进入Controller之前就已经被过滤器链处理掉了。
作者: 舟哥
链接:https://www.b919p4.com
来源: B919P4
本文原创发布于B919P4,©著作权归作者所有,转载请注明出处,谢谢合作!