概述
关于JWT的基础概念,如JWT组成部分,以及入门实战,如:如何生成Token、如何解析Token、怎么加入自定义字段等,可参考JWT入门教程。
如前文提到的blog所述,大多数公司都会使用如下(版本)的Maven依赖:
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>
看一下Maven仓库:
可以看到有64个CVE安全漏洞!很多!!
使用JWT(以及其他安全框架,如Spring Security或Spring Security OAuth2)的目标是加强应用的认证和鉴权,结果Java JWT工具包本身有这么多CVE安全隐患,有点搞笑了。
于是产生依赖库升级这样一个技术改造需求,本文记录升级遇到的问题。
问题
GAV变更
谈到依赖三方库升级,必然需要借助于Maven仓库。搜索不难发现,artifactId发生变更,需要引入如下依赖:
<properties>
<jjwt.version>0.12.5</jjwt.version>
</properties>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>${jjwt.version}</version>
</dependency>
编译问题
如上截图,编译报错是比@deprecated API更严重的问题。使用过期的,即将废弃的API,即@deprecated API,IDEA会给出屎黄色的warning提示。一般而言,某个过期API,再经过几次版本升级迭代后,就会变成removed API,即编译报错,也就是上面截图看到的红色。执行mvn compile
失败,应用启动失败。
代码片段如下:
public static Claims getClaimsFromToken(String token) {
Claims claims;
try {
claims = Jwts.parser()
.setSigningKey(secret)
.parseClaimsJws(token)
.getBody();
} catch (Exception e) {
claims = null;
}
return claims;
}
关于如何替换被标记为@deprecated或removed的API,一般都是看源码。
比如上面的setSigningKey
源码注释里有提到in favor of verifyWith(SecretKey) as explained in the above Deprecation Notice and will be removed in 1.0.0.
告知,故而可以考虑使用verifyWith(SecretKey)
来作为替换。
但是如果版本升级跨度太大(API被废弃后标红,根本就不能通过IDEA去查看源码),或是开源代码维护者没有提供替换API等解决方案,则比较麻烦。此时一般都是去查看官方文档,或Google搜索。好在Java JWT提供维护良好的文档。
调整后的代码片段如下:
public static Claims getClaimsFromToken(String token) {
SecretKey key = Keys.hmacShaKeyFor(Decoders.BASE64.decode(secret));
return Jwts.parser()
.verifyWith(key)
.build()
.parseSignedClaims(token)
.getPayload();
}
启动失败DefaultJwtBuilder
解决上面一个编译问题后,实际上还有另外一个废弃API的问题,后文再提。解决编译问题后,优先级自然是看应用能否启动,postman接口测试能否请求成功。结果遇到应用Debug模式启动失败的报错:
io.jsonwebtoken.lang.UnknownClassException: Unable to load class named [io.jsonwebtoken.impl.DefaultJwtBuilder] from the thread context, current, or system/application ClassLoaders. All heuristics have been exhausted. Class could not be found. Have you remembered to include the jjwt-impl.jar in your runtime classpath?
报错提示已经很明显,通过查看maven私服仓库。稍加分析可得出结论:自0.10.0版本后,之前的一个依赖拆开为多个依赖:
加入以下依赖,重启应用,报错消失,应用可以正常启动。
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>${jjwt.version}</version>
</dependency>
解决得到问题。
WeakKeyException HS512 algorithm
应用启动成功,postman请求login接口,结果遇到如下报错信息:
io.jsonwebtoken.security.WeakKeyException: The signing key's size is 72 bits which is not secure enough for the HS512 algorithm. The JWT JWA Specification (RFC 7518, Section 3.2) states that keys used with HS512 MUST have a size >= 512 bits (the key size must be greater than or equal to the hash output size). Consider using the io.jsonwebtoken.security.Keys class's 'secretKeyFor(SignatureAlgorithm.HS512)' method to create a key guaranteed to be secure enough for HS512. See https://tools.ietf.org/html/rfc7518#section-3.2 for more information.
大意就是使用的加密等级强度不够。之前遇到这类提示,一般都会选择性忽视;毕竟,能Run起来就说明能Work;warning嘛,有时间再去优化。结果好家伙,升级依赖后,直接报错,意思很明显:必须要提高密钥长度或使用破解难度更大的加密算法。
看了下代码,搜索HS512
,发现是生成token的如下代码片段报错:
public static String generateToken(Map<String, Object> claims) {
return Jwts.builder()
.setClaims(claims)
.setExpiration(generateExpirationDate())
.signWith(SignatureAlgorithm.HS512, secret)
.compact();
}
上面还提到有个废弃API没有优化,正好就是此处被废弃的API:
那先解决废弃API的问题吧。搜索GitHub官方文档,优化调整后的代码片段如下:
public static String generateToken(Map<String, Object> claims) {
SecretKey key = Keys.hmacShaKeyFor(Decoders.BASE64.decode(SECRET));
return Jwts.builder()
.claims(claims)
.expiration(generateExpirationDate())
.signWith(key)
.compact();
}
这里额外提一句:上面的报错提示是HS512算法。
一开始并没有找到HS512算法的示例代码,并没有做到升级前后【本质】保持不变的参考性原则。
WeakKeyException HMAC-SHA algorithm
没有找到HS512算法示例,使用Keys.hmacShaKeyFor()
方法,postman接口调试报错信息如下:
io.jsonwebtoken.security.WeakKeyException: The specified key byte array is 72 bits which is not secure enough for any JWT HMAC-SHA algorithm. The JWT JWA Specification (RFC 7518, Section 3.2) states that keys used with HMAC-SHA algorithms MUST have a size >= 256 bits (the key size must be greater than or equal to the hash output size). Consider using the Jwts.SIG.HS256.key() builder (or HS384.key() or HS512.key()) to create a key guaranteed to be secure enough for your preferred HMAC-SHA algorithm.
根据报错提示,是HMAC-SHA算法,使用的密钥是private static final String SECRET = "ThisIsASecret";
。长度不够?
于是无脑复制加长密钥,SECRET = "ThisIsASecretThisIsASecretThisIsASecretThisIsASecret"
,结果还真解决报错。
json Serializer
继续调试postman接口,又遇到报错信息如下:
io.jsonwebtoken.impl.lang.UnavailableImplementationException: Unable to find an implementation for interface io.jsonwebtoken.io.Serializer using java.util.ServiceLoader. Ensure you include a backing implementation .jar in the classpath, for example jjwt-jackson.jar, jjwt-gson.jar or jjwt-orgjson.jar, or your own .jar for custom implementations.
不难得知和上面的问题,加入以下依赖,重启应用解决问题:
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>${jjwt.version}</version>
</dependency>
Compact JWT strings may not contain whitespace
现在登录接口login
已经调试成功,postman里可以看到接口成功返回的JWT Token。后续的所有请求都需要带着这个Token。
继续调试其他接口,又遇到一个报错:Compact JWT strings may not contain whitespace.
。
有点懵啊。自认为我的英文阅读理解能力还挺不错啊,理智告诉我:JWT字符串不得包含空格
。
事实上,我生成的Token形式如下:eyJhbGciOiJIUzI1NiJ9.eyJ1c2VySWQiOiLDtcKHc1jCmcOCwrHDhcOMw5PDr8OPXHUwMDBCwobDim0iLCJleHAiOjE3MTAxNTM1OTd9.sKlGrAF7FFYk8hUZD7PeRddOG6azm_gNgnZ9a9mRu70
。
通过JWT在线解密网站,这个JWT可以成功解密:
说明生成的JWT Token是没有问题的。
回过头来,再仔细看看postman设置Token的选项,选择Bearer Token并没有问题啊:
空格??Bearer Token格式是Bearer jwt
,经过排查,发现一个很傻的问题:
// 少了个空格
public static final String TOKEN_PREFIX = "Bearer ";
Bearer Token和JWT Bearer
与此同时,在排查上面的空格问题时,发现postman功能强大支持好几种格式的Token。
其中Bearer Token是我们最常使用的。JWT Bearer是什么?选择JWT Bearer后,发现如下下拉列表
等等,这里的算法列表,不正好有一个上面提到的HS512算法吗?
继续研究HS512的初始化SecretKey
代码片段,还是在GitHub官网找到代码片段:
// SecretKey secret = Keys.hmacShaKeyFor(Decoders.BASE64.decode(SECRET));
SecretKey secret = Jwts.SIG.HS512.key().build();
使用下面这个HS512算法以及SecretKey
这个API后,则无需再额外定义一个密钥字符串常量SECRET
,当然也就不存在上面提到的密钥长度问题。
事实上,在写此文的过程中,认识到postman也在建议我们使用HS512加密算法,而不是HMAC-SHA算法。
使用HS512加密算法,生成的JWT Token更长一些:eyJhbGciOiJIUzUxMiJ9.eyJ1c2VySWQiOiLDtcKHc1jCmcOCwrHDhcOMw5PDr8OPXHUwMDBCwobDim0iLCJleHAiOjE3MTAxNzIyMTF9.ESVmAmm8rw9UGJG7he2EfTKz4xvYO5C5SSmkLbvEaK8VafKtOfPp64q8ONwDmQUoXsh0vn03ONFEeaQb9HqU_w
postman使用JWT Bearer,然后选择HS512算法,填入上面生成的Token,但是又遇到报错。
JWT signature does not match locally computed signature. JWT validity cannot be asserted and should not be trusted
承接上一个章节,又遇到另一个报错,还真是无穷无尽啊:
JWT signature does not match locally computed signature. JWT validity cannot be asserted and should not be trusted.
TODO:未解决。还是使用HMAC-SHA算法,及postman使用Bearer Token吧。。
加密自定义字段
另外,再多扯几句。不难发现上面在线解密JWT的截图里,解密后payload里有个自定义字段userId
。
看到熟悉的乱码?别慌!!
由于JWT可以轻易被截取,并能被解密。但是在真实的业务场景开发中,我们又经常会遇到需要在JWT Token里加塞字段的需求,去渠道,商户Id等。如果业务交互需要某个敏感字段,如手机号,怎么办呢?加密一下:
HashMap<String, Object> claims = new HashMap<>();
// put any data in the map
map.put(USER_ID, EncryptUtil.encrypt(userId));
userId
一般情况下都是无意义的UUID,上面的代码片段仅仅是demo示例。
附录
附录源码:
@Slf4j
public class JwtUtil {
public static final String TOKEN_PREFIX = "Bearer ";
public static final String HEADER_STRING = "Authorization";
public static final String USER_ID = "userId";
private static final Long EXPIRATION_TIME = 3600000L; // 1 hour
private static final String SECRET = "ThisIsASecretThisIsASecretThisIsASecretThisIsASecret";
public static String generateToken(String userId) {
HashMap<String, Object> map = new HashMap<>();
// put any data in the map
try {
map.put(USER_ID, EncryptUtil.encrypt(userId));
} catch (Exception e) {
log.warn("Encryption failed.", e);
throw new RuntimeException("Encryption failed");
}
return generateToken(map);
}
public static String generateToken(Map<String, Object> claims) {
SecretKey key = Keys.hmacShaKeyFor(Decoders.BASE64.decode(SECRET));
return Jwts.builder()
.claims(claims)
.expiration(new Date(System.currentTimeMillis() + EXPIRATION_TIME))
.signWith(key)
.compact();
}
public static Claims getClaimsFromToken(String token) {
SecretKey key = Keys.hmacShaKeyFor(Decoders.BASE64.decode(secret));
return Jwts.parser()
.verifyWith(key)
.build()
.parseSignedClaims(token)
.getPayload();
}
}
参考
- 官方GitHub文档