人人都会AI编程

15.3 JWT 无状态认证整合方案

更新时间:2026-07-11

前面完成了基于 Spring Security 的表单登录与授权,但面对前后端分离和微服务架构,服务端维护 Session 的方案会带来状态同步、扩展困难等问题。这时,JWT 便成为理想的认证凭证。本节将一步步给出一个可直接落地的 JWT 无状态认证方案。

15.3.1 方案目标

  • 用户名密码验证成功后,服务端签发 JWT。
  • 客户端将 JWT 存储(通常为 localStorage 或 HttpOnly Cookie),后续请求在 Authorization: Bearer <token> 中携带。
  • 服务端不保存任何会话状态,仅通过验证 JWT 的签名和有效期确定用户身份。
  • 支持 Token 刷新机制,避免频繁登录。

15.3.2 JWT 基础原理速览

JWT 全称 JSON Web Token,结构为 Header.Payload.Signature,三部分经过 Base64URL 编码后以 . 拼接。

  • Header:声明签名算法,通常 {"alg":"HS256","typ":"JWT"}
  • Payload:存放声明(Claims),如 sub(用户ID)、exp(过期时间)、iat(签发时间) 等,可以按需加入自定义字段。
  • Signature:使用密钥对前两部分签名,防止篡改。

验证 JWT 时,服务端只需用相同密钥验证签名,并检查 exp 等声明是否有效。因为签名验证不依赖数据库或共享 Session,所以服务天然无状态。

15.3.3 整合步骤

1. 添加依赖

pom.xml 中引入:

<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-api</artifactId>
    <version>0.12.5</version>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-impl</artifactId>
    <version>0.12.5</version>
    <scope>runtime</scope>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-jackson</artifactId>
    <version>0.12.5</version>
    <scope>runtime</scope>
</dependency>

这里使用流行的 JJWT 库。

2. 配置密钥与过期策略

application.yml 中定义 JWT 相关参数:

jwt:
  secret: YourBase64EncodedSecretKeyAtLeast256BitsLong
  expiration: 3600000          # 访问令牌有效期 1 小时(毫秒)
  refresh-expiration: 604800000 # 刷新令牌有效期 7 天

密钥生产建议至少 256 位,可利用 Keys.secretKeyFor(SignatureAlgorithm.HS256) 生成,并将 Base64 编码后的字符串写入配置。

3. JWT 工具类

创建 JwtUtils 统一管理 Token 的生成和验证:

import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

import javax.crypto.SecretKey;
import java.util.Base64;
import java.util.Date;
import java.util.Map;

@Component
public class JwtUtils {

    private final SecretKey secretKey;
    private final long expirationMs;
    private final long refreshExpirationMs;

    public JwtUtils(@Value("${jwt.secret}") String secret,
                    @Value("${jwt.expiration}") long expirationMs,
                    @Value("${jwt.refresh-expiration}") long refreshExpirationMs) {
        this.secretKey = Keys.hmacShaKeyFor(Base64.getDecoder().decode(secret));
        this.expirationMs = expirationMs;
        this.refreshExpirationMs = refreshExpirationMs;
    }

    public String generateToken(String userId, Map<String, Object> extraClaims) {
        Date now = new Date();
        return Jwts.builder()
                .claims(extraClaims)
                .subject(userId)
                .issuedAt(now)
                .expiration(new Date(now.getTime() + expirationMs))
                .signWith(secretKey)
                .compact();
    }

    public String generateRefreshToken(String userId) {
        Date now = new Date();
        return Jwts.builder()
                .subject(userId)
                .issuedAt(now)
                .expiration(new Date(now.getTime() + refreshExpirationMs))
                .signWith(secretKey)
                .compact();
    }

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

    public boolean validateToken(String token) {
        try {
            parseToken(token);
            return true;
        } catch (JwtException | IllegalArgumentException e) {
            return false;
        }
    }
}

4. 登录接口:签发 Token

创建 AuthController,暴露登录端点,使用 Spring Security 的 AuthenticationManager 完成认证:

@RestController
@RequestMapping("/auth")
public class AuthController {

    private final AuthenticationManager authenticationManager;
    private final JwtUtils jwtUtils;

    public AuthController(AuthenticationManager authenticationManager, JwtUtils jwtUtils) {
        this.authenticationManager = authenticationManager;
        this.jwtUtils = jwtUtils;
    }

    @PostMapping("/login")
    public ResponseEntity<?> login(@RequestBody LoginRequest request) {
        Authentication authentication = authenticationManager.authenticate(
                new UsernamePasswordAuthenticationToken(request.getUsername(), request.getPassword()));
        SecurityContextHolder.getContext().setAuthentication(authentication);

        String userId = authentication.getName(); // 或从 UserDetails 中取自定义 ID
        // 可以放入角色、权限等附加信息
        Map<String, Object> claims = new HashMap<>();
        claims.put("roles", authentication.getAuthorities().stream()
                .map(GrantedAuthority::getAuthority).toList());

        String accessToken = jwtUtils.generateToken(userId, claims);
        String refreshToken = jwtUtils.generateRefreshToken(userId);

        return ResponseEntity.ok(new AuthResponse(accessToken, refreshToken));
    }
}

LoginRequestAuthResponse 是简单的 DTO。需保证 AuthenticationManager Bean 可用,通常在安全配置中暴露。

SecurityFilterChain 配置中,将 /auth/login 放行:

http.authorizeHttpRequests(auth -> auth
    .requestMatchers("/auth/**").permitAll()
    .anyRequest().authenticated()
);

5. 配置 JWT 过滤器

我们需要一个过滤器,在每次请求时解析 Authorization 头中的 JWT,验证合法后将用户信息设置到 SecurityContext。这是无状态认证的核心。

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;
import java.util.List;
import java.util.stream.Collectors;

public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtUtils jwtUtils;

    public JwtAuthenticationFilter(JwtUtils jwtUtils) {
        this.jwtUtils = jwtUtils;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain filterChain) throws ServletException, IOException {
        String header = request.getHeader("Authorization");
        if (header != null && header.startsWith("Bearer ")) {
            String token = header.substring(7);
            if (jwtUtils.validateToken(token)) {
                Claims claims = jwtUtils.parseToken(token);
                String userId = claims.getSubject();
                List<String> roles = claims.get("roles", List.class);
                List<SimpleGrantedAuthority> authorities = roles.stream()
                        .map(SimpleGrantedAuthority::new).collect(Collectors.toList());

                UsernamePasswordAuthenticationToken authentication =
                        new UsernamePasswordAuthenticationToken(userId, null, authorities);
                SecurityContextHolder.getContext().setAuthentication(authentication);
            }
        }
        filterChain.doFilter(request, response);
    }
}

然后在安全配置中注册该过滤器,确保它在 UsernamePasswordAuthenticationFilter 之前执行:

@Bean
public SecurityFilterChain filterChain(HttpSecurity http, JwtUtils jwtUtils) throws Exception {
    http
        .csrf(AbstractHttpConfigurer::disable) // API 通常禁用 CSRF
        .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
        .authorizeHttpRequests(auth -> auth
            .requestMatchers("/auth/**").permitAll()
            .anyRequest().authenticated()
        )
        .addFilterBefore(new JwtAuthenticationFilter(jwtUtils), UsernamePasswordAuthenticationFilter.class);
    return http.build();
}

SessionCreationPolicy.STATELESS 显式告诉 Spring Security 不创建 Session。

6. 刷新 Token 机制

访问令牌有效期较短,用户可通过刷新令牌换取新的访问令牌。接口设计如下:

@PostMapping("/refresh")
public ResponseEntity<?> refresh(@RequestBody Map<String, String> body) {
    String refreshToken = body.get("refreshToken");
    if (!jwtUtils.validateToken(refreshToken)) {
        return ResponseEntity.status(401).body("Refresh token invalid");
    }
    Claims claims = jwtUtils.parseToken(refreshToken);
    String userId = claims.getSubject();
    // 可校验 refresh token 类型(如 payload 中带 "type":"refresh")
    String newAccessToken = jwtUtils.generateToken(userId, Map.of("roles", getUserRoles(userId)));
    String newRefreshToken = jwtUtils.generateRefreshToken(userId);
    return ResponseEntity.ok(new AuthResponse(newAccessToken, newRefreshToken));
}

生产环境可增加刷新令牌轮换(每次刷新作废旧刷新令牌),防止令牌泄漏风险。

15.3.4 安全强化与实践要点

  1. 密钥管理:私钥绝不能硬编码在代码中,可借助环境变量或配置中心(如 Vault)。对于 HS256,密钥长度不应低于 256 bits。生产环境建议使用非对称算法(RS256),认证服务持有私钥,资源服务使用公钥验签。
  1. Token 存储建议:SPA 应用中,JWT 存放于 HttpOnly Secure Cookie 可防 XSS 攻击,但 CSRF 保护便不可省。若放 localStorage,务必严格防御 XSS。
  1. 载荷最小化:JWT 的 Payload 会暴露给客户端,切勿存放密码等敏感信息。可只存用户 ID,服务端按需查询数据库获取最新权限。
  1. 失效策略:JWT 签发后,在过期前无法主动失效(除非维护黑名单)。可通过短有效期 + 刷新令牌减轻风险,或利用 Redis 等存储黑名单(牺牲部分无状态特性)。
  1. Cookie 与 Bearer 头选择
  • 浏览器环境使用 Cookie(Set-Cookie: token=xxx; HttpOnly; Secure; SameSite=Strict)更安全。
  • 移动端或服务间调用,Bear 头是标准选择。
  1. 自定义 Claims 标准:常见自定义声明如 "typ":"access" / "typ":"refresh" 可防止刷新令牌充当访问令牌。

15.3.5 实战中的常见问题与解决方案

  • Token 过期处理:前端在请求拦截器中监听 401 状态码,自动尝试 /auth/refresh,成功后重放原请求。
  • 权限变更即时生效:JWT 无状态,权限变更后旧令牌仍有效。可引入短有效期的访问令牌(如 5 分钟),或配合黑名单机制。
  • 并发刷新风险:多请求同时刷新令牌时,需要前端增加队列或加锁,避免重复刷新导致多次签发新令牌。

至此,一个生产可用的 JWT 无状态认证体系基本构建完成。下一节我们将结合 Spring Cloud Gateway 实现网关层统一鉴权,将认证职责从微服务中彻底解耦。