在开始做JWT认证之前,先要来学习一下JWT库的用法。
Header
Claims
签名部分
jjwt依赖
添加header信息
添加Claims信息
使用密钥进行签名
捕捉解析异常
一 JWT库
常用的JWT库是JJWT,这一章的主要内容也就是JJWT库的文档。
JSON Web Token
样子如下:
eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJKb2UifQ.ipevRNuRP6HflG8cFKnmUPtypruRC4fb1DWtoLL62SY
JWT是用.
分割的一个三部分的字符串。
这其中红色的部分叫做header
头部信息,绿色的部分叫做claims
,可以认为是body,蓝色的部分是签名信息,由header
和claim
加上一段密钥和指定的算法计算所得。
header
和claims
都是JSON字符串,里边装有数据,所以叫做JSON Web Token。
Header
Header其中的内容主要有两个键值对,一般都固定,无需设置,jjwt也会自动设置:
{
"typ": "JWT",
"alg": "HS512"
}
typ表示类型,alg表示算法。这个一般无需设置,jjwt在生成JWT的时候会自动设置对应的头部信息。
Claims
Claims可以认为是实际携带有效信息的部分。这其中根据RFC7519规范,有一些规定好名称的键,叫做Registered Claim
。除了Registered Claim
之外,可以添加自定义的键值对。
在JWT验证中,经常使用Claims来携带用户名,权限和过期时间这几个比较重要的内容,用于验证的时候确定身份,比如:
{
"iss": "cc.conyli",
"aud": "Vote-app",
"sub": "Jenny",
"exp": 1548242589,
"role": [
"ROLE_USER"
]
}
这其中除了红色的部分是自定义的键值对之外,剩下的是四个规范里的Registered Claim
。
签名部分
签名部分是由前两部分根据密钥和算法计算得来。具体的方法是先将header和claims用Base64URL-encode计算得出两个字符串,然后用.
拼接,再用密钥对这串字符串进行计算得到结果。
最终再把签名字符串也通过Base64URL-encode编码后,最后拼上去得到JWT。
jjwt的文档里详细讲了算法和需求的密钥的长度。一般使用的HS512密钥需要512位比特。密钥无需自动生成,Java标准库里可以直接通过字符串生成一个符合需求的key。
下边就来使用一下jjwt库,jjwt库的两个核心功能,一个是依据JSON信息和密钥生成JWT,一个是将JWT解析,从其中取得claims部分的数据。
jjwt库依赖
最新0.10.6版本的依赖如下:
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.10.5</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.10.5</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.10.5</version>
<scope>runtime</scope>
</dependency>
<!-- Uncomment this next dependency if you want to use RSASSA-PSS (PS256, PS384, PS512) algorithms:
<dependency>
<groupId>org.bouncycastle</groupId>
<artifactId>bcprov-jdk15on</artifactId>
<version>1.60</version>
<scope>runtime</scope>
</dependency>
-->
这里要注意的是,如果使用最下边的几种算法,需要JDK 11的支持,否则需要引入一些第三方库。这里不展开了。
二 生成JWT
jjwt采用了建造者模式。生成一个JWT的方法如下:
- 调用
Jwts.builder()
获得一个建造者实例
- 调用建造者的各个方法添加header和claims
- 调用
.signWith(key)
进行签名
- 调用
.compact()
生成JWT字符串
添加header信息
添加header信息主要有三种方法:
- 逐个添加:反复调用建造者实例的
.setHeaderParam("kid", "myKeyId")
方法,每次调用都会加上一个header键值对。
- 一次性添加:先创建一个Header实例,再设置上,这个用的不多。
- 一次性添加:创建一个
Map<String, Object>
对象,然后调用建造者的.setHeader(Map)
直接把所有的键值对一次性设置上去。
注意,在两个一次性添加的方法中,会覆盖所有同名的已经添加过的键值对,另外jjwt还会强行覆盖alg
和zip
这两个键。
如果数据量少一般逐个添加,如果数据量大,就是用一次性添加比较好。
这里还需要注意的是,一次性添加的对象是Object,jjwt会默认去寻找对应的JSON转换包来将Object转换为JSON,一般默认会先搜索Jackson,对于Spring开发来讲,这个过程无需配置。在添加Claims的时候也是如此。
添加Claims信息
添加Claims信息和添加Header很相似,也可以逐个添加和一次性添加。
不过由于claims里有几个标准规定的Registered Claim
,所以jjwt为这些键写好了添加方法,与普通添加自定义键值对的方法区分开来。
添加Registered Claim
的方法如下:
setIssuer
setSubject
setAudience
setExpiration
setNotBefore
setIssuedAt
setId
一般来说,使用setIssuer
设置签发者和setExpiration
设置过期时间就已经足够了。
添加自定义claims的方法和header类似也有三种:
- 逐个添加:反复调用建造者的
.claim("hello", "world")
方法来添加自定义键值对。
- 一次性添加:创建
claim
对象然后设置,这个用的比较少。
- 一次性添加:创建一个
Map<String, Object>
对象,然后调用建造者的setClaims(Map)
直接把所有的键值对一次性设置上去。
使用密钥进行签名
签名的方法.signWith(key)
还有一个重载.signWith(key, alg)
。
两者的区别是:
.signWith(key)
会让jjwt自动根据key的长度选择算法,在计算出签名的同时,会在header中写入alg
键值对。
.signWith(key, SignatureAlgorithm.RS512)
可以自行指定算法。
这里还需要提一下的是key如何获取。.signWith(key)
及重载方法需要一个java.security.Key
对象作为参数,可以随机生成或者通过指定的方法生成。
package cc.conyli.vote.jwt;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.security.Keys;
import javax.crypto.SecretKey;
import java.security.Key;
import java.util.Base64;
public class Testjjwt {
private SecretKey secretKey;
public static void main(String[] args) {
//随机生成Key
Key key = Keys.secretKeyFor(SignatureAlgorithm.HS512);
System.out.println(key.toString());
//用Base64解码可以获取Key对应的字符串
String encodedKey = Base64.getEncoder().encodeToString(key.getEncoded());
System.out.println(encodedKey);
//根据指定字符串生成Key,相同字符串生成的Key也相同的,这个字符串至少要有256bit长,推荐长一些,生成的密钥也会变长。
//推荐这种做法,每次都会生成同样的一串Key来使用
String secretString = "dsfa&*)#@)908v9109)V)(DS))(*FDS9082139889fds7v&78df8732";
byte[] bytes = secretString.getBytes();
//生成SHA密钥
Key key2 = Keys.hmacShaKeyFor(bytes);
String encodedKey2 = Base64.getEncoder().encodeToString(key2.getEncoded());
System.out.println(encodedKey2);
}
}
三 解析JWT
除了生成JWT之外,jjwt另外一大功能就是根据指定的密钥解析JWT,然后从中可以获取内容。
解析JWT的主要流程是:
- 使用
Jwts.parser()
获取一个解析器对象
- 调用解析器的
.setSigningKey(key)
传入使用的SecretKey
对象
- 调用
.parseClaimsJws(jwsString)
,jwsString是需要解析的JWT字符串.
- 上一步得到的是一个
Jws<Claims>
对象,具体操作看下边:
前三步基本上是固定的。
比如使用上一个例子中生成的key2生成一段JWT再解析:
//生成JWT
String token = Jwts.builder()
.setHeaderParam("saner", "gugug")
.setIssuer("cc.conyli")
.setSubject("username")
.claim("saner", "gugugugu")
.signWith(key2)
.compact();
//对应上边的1-3步,解析JWT
Jws<Claims> claims = Jwts.parser().setSigningKey(key2).parseClaimsJws(token);
//得到的是一个Jws<Claims>对象
//getBody()和.getHeader()得到的都是生成JWT时候传入的泛型,其实就可以当成Map<String,Object>
System.out.println(claims.getBody());
//取出Registered claim有特殊的方法
System.out.println(claims.getBody().getIssuer());
//取出自定义的键值就用.get()方法
System.out.println(claims.getBody().get("saner"));
System.out.println(claims.getHeader().get("saner"));
可见解析成功之后,可以分别获得header和claims其中的信息。这样就方便处理,在JWT相关的认证中,就可以通过JWT来携带用户信息。
捕捉异常
如果只是解析出数据,逻辑还需要我们自行处理,还是比较麻烦,实际上在解析的过程中,jjwt已经可以做过期检测等操作,如果不符合要求就抛出异常:
所以一般在解析语句中try-catch一下,就可以知道是否能通过验证。常见的异常有:
ExpiredJwtException
,这个非常常用,说明TOKEN解析成功,但是时间已经超过过期日,这个时候就可以引导用户去登录页。
UnsupportedJwtException
,不是有效的JWT字符串
SignatureException
,key错误
UnsupportedJwtException
,不支持的JWT
IllegalArgumentException
,JWT为空
一般情况下重点关注JWT的过期问题就可以了。