在开始动手操作之前,我们需要先搞清楚JWT到底是什么。简单来说,JWT(JSON Web Token)就像是一张数字身份证,它用JSON格式安全地封装了用户信息,可以在网络间可靠传输。想象一下你去参加一个技术大会,主办方给你发了个带芯片的胸卡,里边存着你的姓名、公司和权限等级,各个分会场的门禁系统都能识别这张卡——JWT在网络世界里起的就是类似作用。
JWT最典型的应用场景是API鉴权。比如你用手机APP登录后,服务器会给你发一个JWT令牌,之后每次请求数据时带上这个令牌,服务器就知道你是谁了。相比传统的Session机制,JWT最大的优势是无状态——服务器不需要保存会话信息,特别适合分布式系统。我去年参与的一个微服务项目就全面采用了JWT,轻松应对了日均千万级的认证请求。
一个标准的JWT由三部分组成,用点号连接:
javascript复制// 典型JWT结构示例
eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.EkN-DOsQ...
在安全机制上,JWT支持对称加密(HMAC)和非对称加密(RSA/ECDSA)。对于生产环境,我强烈推荐使用RSA非对称加密——用私钥签名,公钥验证。这样即使公钥泄露,攻击者也无法伪造令牌。曾经有个客户项目为了图省事用了HMAC,结果密钥泄露导致全线系统沦陷,这个教训让我至今记忆犹新。
生成安全的RSA密钥对是JWT应用的第一道防线。Java生态中首推keytool工具,它是JDK自带的密钥管理瑞士军刀。虽然命令行参数看起来有点复杂,但实际操作起来很简单。下面这个命令是我在项目中常用的参数组合:
bash复制keytool -genkeypair -alias apiKey -keyalg RSA -keysize 2048 \
-keypass myKeyPassword -keystore jwt.jks \
-storepass myStorePassword -validity 365
解释下关键参数:
-keysize 2048:RSA密钥长度,2048位是当前的安全标准-validity 365:证书有效期(天),生产环境建议1-2年-alias apiKey:给密钥对起的别名,后面提取时会用到有个坑要特别注意:不同JDK版本的默认密钥库类型可能不同。有次我在JDK 8生成的.jks文件,到JDK 11环境就读不出来了。解决方法是指定-storetype JKS参数显式声明类型。
拿到密钥库后,我们需要把公钥提取出来给验证方使用。这里有个技巧:先用keytool输出RFC格式,再用OpenSSL提取公钥。这个组合拳比直接导出更可靠:
bash复制keytool -list -rfc --keystore jwt.jks | openssl x509 -inform pem -pubkey
执行后会提示输入密钥库密码,然后你会看到这样的输出:
code复制-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxXp8H9Y+t0J8...
-----END PUBLIC KEY-----
把BEGIN和END之间的内容(包括这两行)保存为public.pem文件。在Spring项目中,我习惯把公钥放在resources目录下,方便程序读取。记得检查公钥是否完整——有次我复制时漏了结尾的横线,调试了半天才发现问题。
现在进入实战环节,我们先在pom.xml中添加必要的依赖:
xml复制<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-core</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-rsa</artifactId>
</dependency>
建议锁定版本号,避免不同版本兼容性问题。我曾经因为没指定版本,导致测试环境和生产环境行为不一致,上线后出现签名验证失败。
下面是经过多个项目验证的可靠签发代码,关键步骤我都加了注释:
java复制public String generateToken(Map<String, Object> claims) {
// 1. 加载密钥库
ClassPathResource resource = new ClassPathResource("jwt.jks");
KeyStoreKeyFactory factory = new KeyStoreKeyFactory(
resource, "myStorePassword".toCharArray());
// 2. 获取私钥
KeyPair keyPair = factory.getKeyPair("apiKey", "myKeyPassword".toCharArray());
RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate();
// 3. 设置JWT声明
Map<String, Object> header = new HashMap<>();
header.put("typ", "JWT");
header.put("alg", "RS256");
// 4. 生成令牌
Jwt jwt = JwtHelper.encode(JSON.toJSONString(claims),
new RsaSigner(privateKey), header);
return jwt.getEncoded();
}
实际项目中,我通常会封装一个JwtService来管理这些操作。有三点经验分享:
资源服务端需要验证JWT的完整性和有效性。下面这个验证器模板可以直接用在你的Controller中:
java复制public Jwt verifyToken(String token) {
try {
// 读取公钥
String publicKey = Files.readString(
Paths.get(getClass().getResource("/public.pem").toURI()));
// 创建验证器
RsaVerifier verifier = new RsaVerifier(publicKey);
// 验证并解码
return JwtHelper.decodeAndVerify(token, verifier);
} catch (Exception e) {
throw new InvalidTokenException("JWT验证失败", e);
}
}
注意异常处理要细致。常见的验证失败场景包括:
拿到解码后的Jwt对象后,我们可以提取其中的信息:
java复制Jwt jwt = verifyToken(request.getHeader("Authorization"));
String claims = jwt.getClaims(); // 原始JSON字符串
String subject = JsonPath.parse(claims).read("$.sub"); // 使用JsonPath提取特定字段
在我的项目中,通常会把这些操作封装成注解,比如@JwtAuth,这样在Controller方法上直接标注就能自动完成验证和用户信息注入。对于微服务架构,建议把公钥统一配置在配置中心,避免各个服务单独维护。
长期使用同一对密钥存在安全风险。我设计的轮换方案包含以下要点:
java复制// 签发时指定密钥ID
header.put("kid", "202308-active");
// 验证时根据kid选择公钥
String kid = JwtHelper.headers(token).get("kid");
RsaVerifier verifier = keyMap.get(kid);
高并发场景下,JWT验证可能成为瓶颈。经过实测,这些优化手段效果显著:
有个反模式要避免:不要在JWT里塞大量数据。曾经看到有团队把用户权限树整个放进JWT,导致每个请求头都膨胀到几十KB。正确的做法是只放必要标识,详细数据通过服务查询。
这是最常遇到的问题,可能的原因包括:
我常用的排查命令:
bash复制# 检查密钥库内容
keytool -list -v -keystore jwt.jks
# 验证公钥指纹
openssl rsa -pubin -in public.pem -text
时间相关的问题往往很隐蔽:
可以在验证逻辑中加入时间容错:
java复制// 允许5分钟时钟偏差
Jwt jwt = JwtHelper.decodeAndVerify(token,
new RsaVerifier(publicKey), 300000);
根据OWASP建议,结合我的实战经验,列出这些必须遵守的安全规则:
传输安全
密钥管理
令牌设计
输入验证
最近帮一个金融客户做安全审计时,发现他们虽然实现了JWT,但缺少令牌刷新机制,导致用户需要频繁重新登录。后来我们增加了refresh token方案,用户体验和安全性的到了很好平衡。