前面完成了基于 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));
}
}
LoginRequest 和 AuthResponse 是简单的 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 安全强化与实践要点
- 密钥管理:私钥绝不能硬编码在代码中,可借助环境变量或配置中心(如 Vault)。对于 HS256,密钥长度不应低于 256 bits。生产环境建议使用非对称算法(RS256),认证服务持有私钥,资源服务使用公钥验签。
- Token 存储建议:SPA 应用中,JWT 存放于 HttpOnly Secure Cookie 可防 XSS 攻击,但 CSRF 保护便不可省。若放 localStorage,务必严格防御 XSS。
- 载荷最小化:JWT 的 Payload 会暴露给客户端,切勿存放密码等敏感信息。可只存用户 ID,服务端按需查询数据库获取最新权限。
- 失效策略:JWT 签发后,在过期前无法主动失效(除非维护黑名单)。可通过短有效期 + 刷新令牌减轻风险,或利用 Redis 等存储黑名单(牺牲部分无状态特性)。
- Cookie 与 Bearer 头选择:
- 浏览器环境使用 Cookie(
Set-Cookie: token=xxx; HttpOnly; Secure; SameSite=Strict)更安全。 - 移动端或服务间调用,Bear 头是标准选择。
- 自定义 Claims 标准:常见自定义声明如
"typ":"access"/"typ":"refresh"可防止刷新令牌充当访问令牌。
15.3.5 实战中的常见问题与解决方案
- Token 过期处理:前端在请求拦截器中监听 401 状态码,自动尝试
/auth/refresh,成功后重放原请求。 - 权限变更即时生效:JWT 无状态,权限变更后旧令牌仍有效。可引入短有效期的访问令牌(如 5 分钟),或配合黑名单机制。
- 并发刷新风险:多请求同时刷新令牌时,需要前端增加队列或加锁,避免重复刷新导致多次签发新令牌。
至此,一个生产可用的 JWT 无状态认证体系基本构建完成。下一节我们将结合 Spring Cloud Gateway 实现网关层统一鉴权,将认证职责从微服务中彻底解耦。