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之前就已经被过滤器链处理掉了。

© 舟哥 all right reserved,powered by Gitbook文件修订时间: 2026-07-16 12:51:50
作者: 舟哥
链接:https://www.b919p4.com
来源: B919P4
本文原创发布于B919P4,©著作权归作者所有,转载请注明出处,谢谢合作!

undefined

results matching ""

    No results matching ""