JWT 深入解析
概述
JWT(JSON Web Token)是一种轻量级的令牌格式,用于在各方之间安全传输声明(Claims)。在 Spring Security 中,JWT 常用于无状态认证,特别适合移动端和微服务场景。
核心结构
header.payload.signature| 部分 | 内容 | 示例 |
|---|---|---|
| Header | 令牌类型和签名算法 | {"alg":"RS256","typ":"JWT"} |
| Payload | 声明(Claims) | {"sub":"123","name":"John","iat":1696924800} |
| Signature | 签名验证 | RS256(base64(header) + "." + base64(payload), privateKey) |
一、JWT 库选型
| 库 | 特点 | Spring Boot 集成 |
|---|---|---|
| jjwt(io.jsonwebtoken) | API 简洁,社区活跃 | io.jsonwebtoken:jjwt-api |
| nimbus-jose-jwt | Spring Security OAuth2 依赖底层使用 | com.nimbusds:nimbus-jose-jwt |
| auth0-java-jwt | 功能完整,支持多种算法 | com.auth0:java-jwt |
Spring Security 的
oauth2-resource-server底层使用 nimbus-jose-jwt。如果使用spring-boot-starter-oauth2-resource-server,nimbus 已自动引入。
jjwt 依赖
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>二、签名算法
2.1 HMAC(对称)
java
// HS256 — 使用同一个 Secret 签名和验证
SecretKey key = Keys.hmacShaKeyFor("my-secret-key-32-bytes-long-minimum".getBytes());
String token = Jwts.builder()
.subject("user123")
.issuedAt(new Date())
.expiration(new Date(System.currentTimeMillis() + 3600_000))
.signWith(key)
.compact();
// 验证
Claims claims = Jwts.parser()
.verifyWith(key)
.build()
.parseSignedClaims(token)
.getPayload();2.2 RSA(非对称)
java
// RS256 — 私钥签名,公钥验证
KeyPair keyPair = Keys.keyPairFor(SignatureAlgorithm.RS256);
PrivateKey privateKey = keyPair.getPrivate();
PublicKey publicKey = keyPair.getPublic();
// 签名(服务端)
String token = Jwts.builder()
.subject("user123")
.issuedAt(new Date())
.expiration(new Date(System.currentTimeMillis() + 3600_000))
.signWith(privateKey, SignatureAlgorithm.RS256)
.compact();
// 验证(其他服务或客户端)
Claims claims = Jwts.parser()
.verifyWith(publicKey)
.build()
.parseSignedClaims(token)
.getPayload();2.3 ECDSA(非对称,更高效)
java
// ES256 — 使用椭圆曲线,比 RSA 更短的密钥、更快的签名
KeyPair keyPair = Keys.keyPairFor(SignatureAlgorithm.ES256);
String token = Jwts.builder()
.subject("user123")
.signWith(keyPair.getPrivate(), SignatureAlgorithm.ES256)
.compact();算法对比
| 算法 | 类型 | 密钥长度 | 签名速度 | 验证速度 | 适用场景 |
|---|---|---|---|---|---|
| HS256 | 对称 | 256 bit | 快 | 快 | 单体应用 |
| RS256 | 非对称 | 2048 bit | 慢 | 快 | 微服务(公钥分发) |
| ES256 | 非对称 | 256 bit | 中 | 快 | 移动端(密钥体积小) |
三、Spring Security 集成
3.1 Resource Server 配置
java
@Configuration
@EnableWebSecurity
public class ResourceServerConfig {
// 方式 1:JWK Set URI(推荐,支持密钥轮换)
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt
.jwkSetUri("https://auth.example.com/.well-known/jwks.json")
)
);
return http.build();
}
// 方式 2:本地公钥
@Bean
public JwtDecoder jwtDecoder() {
return NimbusJwtDecoder.withPublicKey(publicKey())
.signatureAlgorithm(SignatureAlgorithm.RS256)
.build();
}
}3.2 自定义 JwtAuthenticationProvider
java
@Component
public class JwtAuthenticationProvider implements AuthenticationProvider {
@Autowired
private JwtDecoder jwtDecoder;
@Override
public Authentication authenticate(Authentication authentication) {
BearerTokenAuthenticationToken token =
(BearerTokenAuthenticationToken) authentication;
String accessToken = token.getToken();
try {
// 1. 解码 JWT
Jwt jwt = jwtDecoder.decode(accessToken);
// 2. 验证 JWT(解码器已包含签名验证)
// 3. 提取权限
Collection<GrantedAuthority> authorities = extractAuthorities(jwt);
// 4. 构建认证对象
JwtAuthenticationToken auth =
new JwtAuthenticationToken(jwt, authorities);
auth.setDetails(token.getDetails());
return auth;
} catch (JwtException e) {
throw new BadCredentialsException("Invalid JWT", e);
}
}
private Collection<GrantedAuthority> extractAuthorities(Jwt jwt) {
// 从 JWT 的 claims 中提取权限
List<String> roles = jwt.getClaimAsStringList("roles");
if (roles == null) {
return List.of();
}
return roles.stream()
.map(role -> new SimpleGrantedAuthority("ROLE_" + role))
.collect(Collectors.toList());
}
@Override
public boolean supports(Class<?> authentication) {
return BearerTokenAuthenticationToken.class.isAssignableFrom(authentication);
}
}四、Token 刷新
4.1 双 Token 模型
java
@Component
public class TokenService {
private static final long ACCESS_TOKEN_EXPIRE = 30 * 60 * 1000; // 30 分钟
private static final long REFRESH_TOKEN_EXPIRE = 7 * 24 * 3600_000; // 7 天
private final SecretKey signingKey = Keys.hmacShaKeyFor(
"my-256-bit-secret-key-at-least-thirty-two-characters".getBytes()
);
// 生成 Token 对
public TokenPair generateTokens(String userId, String role) {
String accessToken = generateAccessToken(userId, role);
String refreshToken = generateRefreshToken(userId);
return new TokenPair(accessToken, refreshToken, ACCESS_TOKEN_EXPIRE / 1000);
}
private String generateAccessToken(String userId, String role) {
return Jwts.builder()
.subject(userId)
.claim("role", role)
.claim("type", "access")
.issuedAt(new Date())
.expiration(new Date(System.currentTimeMillis() + ACCESS_TOKEN_EXPIRE))
.signWith(signingKey)
.compact();
}
private String generateRefreshToken(String userId) {
return Jwts.builder()
.subject(userId)
.claim("type", "refresh")
.id(UUID.randomUUID().toString()) // 唯一 ID,用于撤销
.issuedAt(new Date())
.expiration(new Date(System.currentTimeMillis() + REFRESH_TOKEN_EXPIRE))
.signWith(signingKey)
.compact();
}
// 刷新 Token
public TokenPair refreshAccessToken(String refreshToken) {
Claims claims = parseToken(refreshToken);
// 验证是 refresh token
if (!"refresh".equals(claims.get("type"))) {
throw new BadCredentialsException("Invalid refresh token type");
}
// 检查是否已撤销
if (isRefreshTokenRevoked(claims.getId())) {
throw new BadCredentialsException("Refresh token revoked");
}
// 生成新的 Token 对(Token 轮换)
return generateTokens(claims.getSubject(),
claims.get("role", String.class));
}
private Claims parseToken(String token) {
return Jwts.parser()
.verifyWith(signingKey)
.build()
.parseSignedClaims(token)
.getPayload();
}
}4.2 Token 轮换
每次刷新时同时更新 Access Token 和 Refresh Token,旧的 Refresh Token 立即失效。这是 OAuth 2.0 安全最佳实践。
初始登录
→ 返回 access_token(30min) + refresh_token(7天)
↓
第 1 次刷新(第 25 分钟)
→ 返回新的 access_token(30min) + 新的 refresh_token(7天)
→ 旧的 refresh_token 失效五、黑名单
5.1 基于 Redis 的黑名单
java
@Component
public class JwtBlacklistService {
@Autowired
private StringRedisTemplate redisTemplate;
private static final String BLACKLIST_PREFIX = "jwt:blacklist:";
// 加入黑名单
public void blacklist(String jti, long expireMs) {
redisTemplate.opsForValue()
.set(BLACKLIST_PREFIX + jti, "1", expireMs, TimeUnit.MILLISECONDS);
}
// 检查是否在黑名单
public boolean isBlacklisted(String jti) {
return Boolean.TRUE.equals(
redisTemplate.hasKey(BLACKLIST_PREFIX + jti));
}
}5.2 在 Filter 中检查
java
@Component
public class JwtBlacklistFilter extends OncePerRequestFilter {
@Autowired
private JwtBlacklistService blacklistService;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain) throws IOException, ServletException {
String token = extractToken(request);
if (token != null) {
String jti = extractJti(token);
if (blacklistService.isBlacklisted(jti)) {
sendError(response, 401, "Token revoked");
return;
}
}
chain.doFilter(request, response);
}
// 登出时将当前 Token 加入黑名单
public void logout(String token) {
Claims claims = parseToken(token);
String jti = claims.getId();
long expireMs = claims.getExpiration().getTime() - System.currentTimeMillis();
if (expireMs > 0) {
blacklistService.blacklist(jti, expireMs);
}
}
}六、无状态 vs 有状态选型
| 维度 | 无状态 JWT | 有状态 Session |
|---|---|---|
| 存储 | 客户端持有,服务端无存储 | Session 存储在服务端(内存/Redis) |
| 扩展性 | 天然支持水平扩展 | 需要 Session 共享(Redis) |
| 撤销 | 需要黑名单(增加存储和查询成本) | 直接删除 Session |
| 安全性 | Token 泄露后无法立即撤销 | 可主动失效 |
| Token 体积 | 随 Claims 增加而变大 | 只需 Session ID |
| 适用场景 | 移动端、微服务、第三方 API | 传统 Web 应用、内管系统 |
混合方案推荐
java
// 服务端维护 Token 状态表
// 1. 验证 JWT 签名(无状态快速验证)
// 2. 检查 Redis 中 Token 状态(有状态撤销能力)
public Authentication authenticate(String token) {
// 步骤 1:JWT 签名验证(无状态)
Jwt jwt = jwtDecoder.decode(token);
// 步骤 2:检查 Token 状态(轻量有状态)
TokenState state = tokenStateService.getState(jwt.getId());
if (state == TokenState.REVOKED || state == TokenState.LOGGED_OUT) {
throw new BadCredentialsException("Token disabled");
}
// 步骤 3:检查异地登录
if (state == TokenState.REFRESHED) {
// 旧 Token 被刷新,可能是 Token 被盗
log.warn("Using old token after refresh: user={}", jwt.getSubject());
throw new BadCredentialsException("Token has been refreshed");
}
return new JwtAuthenticationToken(jwt, extractAuthorities(jwt));
}七、实战:移动端 Token 认证
7.1 登录接口
java
@RestController
@RequestMapping("/auth")
public class AuthController {
@Autowired
private TokenService tokenService;
@Autowired
private JwtBlacklistService blacklistService;
@PostMapping("/login")
public TokenPair login(@RequestBody LoginRequest request) {
// 验证用户名密码
User user = authenticate(request.getUsername(), request.getPassword());
// 生成 Token 对
return tokenService.generateTokens(user.getId(), user.getRole());
}
@PostMapping("/refresh")
public TokenPair refresh(@RequestBody RefreshRequest request) {
// 旧的 Access Token 加入黑名单
blacklistService.blacklist(
extractJti(request.getOldAccessToken()),
30 * 60 * 1000 // 过期时间
);
// 使用 Refresh Token 刷新
return tokenService.refreshAccessToken(request.getRefreshToken());
}
@PostMapping("/logout")
public void logout(@RequestHeader("Authorization") String authHeader) {
String token = authHeader.replace("Bearer ", "");
// 将当前 Access Token 加入黑名单
blacklistService.blacklist(
extractJti(token),
30 * 60 * 1000
);
}
}7.2 异地登录检测
java
@Component
public class LoginDetectService {
@Autowired
private RedisTemplate<String, String> redisTemplate;
// 记录登录设备
public void recordLogin(String userId, String deviceId, String tokenJti) {
String key = "user:login:" + userId;
// 获取之前登录的设备
String previousDevice = redisTemplate.opsForValue().get(key);
if (previousDevice != null && !previousDevice.equals(deviceId)) {
// 异地登录,发送通知
notifyUser(userId, previousDevice, deviceId);
}
redisTemplate.opsForValue().set(key, deviceId, 7, TimeUnit.DAYS);
}
}八、总结
| 知识点 | 说明 |
|---|---|
| JWT 结构 | header.payload.signature,三部分 Base64 编码 |
| HS256 | 对称签名,适合单体 |
| RS256 | 非对称签名,适合微服务 |
| ES256 | 椭圆曲线,适合移动端 |
| jjwt | API 简洁,推荐使用 |
| Token 刷新 | Refresh Token 轮换,旧的自动失效 |
| 黑名单 | Redis 存储已撤销 Token 的 jti |
| 异地登录 | Refresh 时检查 device_id,发送通知 |
| 无状态 vs 有状态 | JWT 无状态扩展性好,Session 有状态可控性强 |
参考链接: