SpringBoot3 登录注册基础篇(三) - JJWT 0.12 核心 API 详解
第一章. JWT 三段式结构
在开始编写代码之前,我们需要先理解:JWT 到底是什么?它是如何工作的?
1.1. 电影票的类比
假设你现在去电影院看电影。你在售票处买票后,工作人员会给你一张电影票。这张票上印着:
- 电影名称(如《流浪地球 3》)
- 场次时间(如 2025-12-27 20:00)
- 座位号(如 5 排 8 座)
- 一个防伪水印(防止别人伪造票)
当你进入影厅时,检票员只需要看一眼票上的防伪水印,就知道这张票是真的,不需要回到售票处再次确认。
JWT 的工作原理和这张电影票非常相似:
- 电影票 = JWT Token
- 票上的信息(电影名称、场次、座位)= JWT 的 Payload(载荷)
- 防伪水印 = JWT 的 Signature(签名)
- 检票员 = 业务服务(验证 Token)
1.2. JWT 的三段式结构
一个完整的 JWT Token 由三部分组成,用 . 分隔:
1 2 3
| eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9. eyJ1c2VySWQiOjEwMDEsInVzZXJuYW1lIjoiYWRtaW4iLCJleHAiOjE3MzUyODAwMDB9. SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
|
这三部分分别是:
第一部分:Header(头部)
1 2 3 4
| { "alg": "RS256", "typ": "JWT" }
|
alg:签名算法(Algorithm),这里是 RS256typ:类型(Type),固定为 JWT
这部分经过Base64Url 编码一种 URL 安全的 Base64 编码方式后,就是 Token 的第一段。
第二部分:Payload(载荷)
1 2 3 4 5
| { "userId": 1001, "username": "admin", "exp": 1735280000 }
|
userId:用户 ID(自定义字段)username:用户名(自定义字段)exp:过期时间(Expiration,标准字段)
这部分经过 Base64Url 编码后,就是 Token 的第二段。
第三部分:Signature(签名)
1 2 3 4
| Signature = RSA_Sign( Base64Url(Header) + "." + Base64Url(Payload), 私钥 )
|
签名的作用是 防止 Token 被篡改。如果有人修改了 Payload 中的 userId,签名验证就会失败。
第二章. JJWT 0.12 vs 旧版本
2.1. 为什么要强调版本
JJWT 0.12 引入了 类型安全 的设计理念,API 发生了重大变化。如果你直接照搬网上的旧教程,代码很可能会报错。
旧版本(0.9.x)的写法:
1 2 3 4 5 6 7 8
| Jwts.builder() .
signWith(SignatureAlgorithm.HS256, rsaPrivateKey) .
compact();
|
这种写法的致命伤在于:算法与密钥类型分离。你指定了对称加密算法 HS256,却传入了非对称加密的 RSA私钥
。编译器无法发现这个逻辑错误,只有在代码运行到这一行时才会抛出异常。
新版本(0.12.x)的写法:
1 2 3 4 5 6 7 8
| Jwts.builder() .
signWith(rsaPrivateKey, Jwts.SIG.RS256) .
compact();
|
新版本强制要求你只传入密钥,算法由库自动推断或通过强类型常量指定。这样编译器就能在编译期发现错误。
2.2. 核心 API 对比
| 对比维度 | 旧版本(0.9.x) | 新版本(0.12.x) |
|---|
| 签名方式 | signWith(Algorithm, Key) | signWith(Key, Algorithm) |
| 类型安全 | ❌ 运行时才报错 | ✅ 编译期检查 |
| 解析器构建 | 直接使用 | 必须调用 build() |
| 算法常量 | SignatureAlgorithm.RS256 | Jwts.SIG.RS256 |
第三章. 封装 JwtUtil 工具类
3.1. 配置类设计
首先,我们需要定义一个配置类来承载 JWT 的相关配置(如有效期、密钥路径等)。
📄 文件路径:auth-core/src/main/java/com/example/auth/core/config/properties/JwtProperties.java
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36
| package com.example.auth.core.config.properties;
import lombok.Data; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.stereotype.Component;
@Data @Component @ConfigurationProperties(prefix = "jwt") public class JwtProperties {
private String issuer = "auth-service";
private Integer accessTokenExpireMinutes = 15;
private Long clockSkewSeconds = 30L;
private String publicKeyResource = "certs/public_key.pem";
private String privateKeyResource = "certs/private_key.pem"; }
|
在 auth-web/src/main/resources/application.yml 中添加对应的配置:
1 2 3 4 5 6
| jwt: issuer: pro-auth-service access-token-expire-minutes: 15 clock-skew-seconds: 30 public-key-resource: certs/public_key.pem private-key-resource: certs/private_key.pem
|
3.2. JwtUtil 核心代码
📄 文件路径:auth-core/src/main/java/com/example/auth/core/util/JwtUtil.java
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151
| package com.example.auth.core.util;
import cn.hutool.core.util.IdUtil; import com.example.auth.common.util.crypto.RsaKeyManager; import com.example.auth.core.config.properties.JwtProperties; import io.jsonwebtoken.Claims; import io.jsonwebtoken.JwtBuilder; import io.jsonwebtoken.JwtException; import io.jsonwebtoken.Jwts; import jakarta.annotation.PostConstruct; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component;
import java.security.PrivateKey; import java.security.PublicKey; import java.util.Date; import java.util.Map;
@Slf4j @Component @RequiredArgsConstructor public class JwtUtil {
private final JwtProperties jwtProperties; private final RsaKeyManager rsaKeyManager;
private PrivateKey privateKey; private PublicKey publicKey;
@PostConstruct public void init() throws Exception { String privateKeyContent = rsaKeyManager.readResourceFile(jwtProperties.getPrivateKeyResource()); this.privateKey = rsaKeyManager.loadPrivateKey(privateKeyContent); String publicKeyContent = rsaKeyManager.readResourceFile(jwtProperties.getPublicKeyResource()); this.publicKey = rsaKeyManager.loadPublicKey(publicKeyContent); log.info("JWT 密钥加载完成。"); }
public String createToken(Long userId, String username, Map<String, Object> extraClaims) { long nowMillis = System.currentTimeMillis(); Date now = new Date(nowMillis);
Date expiration = new Date(nowMillis + jwtProperties.getAccessTokenExpireMinutes() * 60 * 1000L); String jti = IdUtil.fastSimpleUUID(); JwtBuilder builder = Jwts.builder() .header().type("JWT").and() .issuer(jwtProperties.getIssuer()) .subject(userId.toString()) .issuedAt(now) .expiration(expiration) .id(jti) .claim("username", username) .claim("userId", userId); if (extraClaims != null && !extraClaims.isEmpty()) { builder.claims().add(extraClaims); } return builder.signWith(privateKey, Jwts.SIG.RS256).compact(); }
public Claims parseToken(String token) { return Jwts.parser() .verifyWith(publicKey) .clockSkewSeconds(jwtProperties.getClockSkewSeconds()) .build() .parseSignedClaims(token) .getPayload(); }
public boolean validateToken(String token) { try { parseToken(token); return true; } catch (JwtException | IllegalArgumentException e) { log.debug("Token验证失败: {}", e.getMessage()); return false; } }
public Long getUserIdFromToken(String token) { try { Claims claims = parseToken(token); return Long.parseLong(claims.getSubject()); } catch (Exception e) { log.warn("从Token中提取用户ID失败: {}", e.getMessage()); return null; } }
public String getUsernameFromToken(String token) { try { Claims claims = parseToken(token); return claims.get("username", String.class); } catch (Exception e) { log.warn("从Token中提取用户名失败: {}", e.getMessage()); return null; } }
public String getJtiFromToken(String token) { try { Claims claims = parseToken(token); return claims.getId(); } catch (Exception e) { log.warn("从Token中提取JTI失败: {}", e.getMessage()); return null; } } }
|
3.3. 关键设计细节
时钟偏差(Clock Skew)的必要性
在代码中我们设置了 .clockSkewSeconds(30)。这是一个非常重要的生产环境细节。
在微服务架构中,认证服务和网关服务可能部署在不同的物理机上,时间可能存在微小的偏差。如果认证服务时间快了 1 秒,它签发的
Token 对于网关来说就是 “未来 1 秒才生效” 的,会导致 PrematureJwtException。设置容忍度可以完美解决这个问题。
JTI(JWT ID)的战略意义
我们在创建 Token 时使用 Hutool 的 IdUtil.fastSimpleUUID() 生成了 JTI 并赋值给 .id(jti)。这不仅仅是一个随机数,它是后续实现
Token 黑名单(Token Revocation)的基础。当用户注销时,我们只需将这个 JTI 放入 Redis,即可让未过期的 Token 提前失效。
@PostConstruct 的性能优化
我们在 init() 方法上使用了 @PostConstruct 注解。这意味着密钥的加载和解析只会在应用启动时执行一次,然后缓存在
privateKey 和 publicKey 字段中。
如果不这样做,每次生成或验证 Token 都需要重新读取文件、解析 PEM 格式,性能会非常差。
第四章. 本章小结
我们完成了 JWT 工具类的构建,掌握了 JJWT 0.12 的核心 API。
核心成果:
| 步骤 | 操作 | 产出 |
|---|
| 1 | 理解 JWT 三段式结构 | Header + Payload + Signature |
| 2 | 掌握 JJWT 0.12 Builder 模式 | 类型安全的 API |
| 3 | 封装 JwtUtil 工具类 | 提供生成、解析、验证功能 |
方法速查:
| 类名 | 方法名 | 作用 |
|---|
| JwtUtil | createToken(userId, username, extraClaims) | 生成 Token |
| JwtUtil | parseToken(token) | 解析 Token |
| JwtUtil | validateToken(token) | 验证 Token |
| JwtUtil | getUserIdFromToken(token) | 提取用户 ID |
| JwtUtil | getUsernameFromToken(token) | 提取用户名 |
| JwtUtil | getJtiFromToken(token) | 提取 JTI |
JJWT 0.12 vs 旧版本:
| 对比维度 | 旧版本(0.9.x) | 新版本(0.12.x) |
|---|
| 签名方式 | signWith(Algorithm, Key) | signWith(Key, Algorithm) |
| 类型安全 | ❌ 运行时才报错 | ✅ 编译期检查 |
| 解析器构建 | 直接使用 | 必须调用 build() |