在开始做JWT认证之前,先要来学习一下JWT库的用法。

一 JWT库

Header
Claims
签名部分
jjwt依赖

二 生成JWT

添加header信息
添加Claims信息
使用密钥进行签名

三 解析JWT

捕捉解析异常

一 JWT库

常用的JWT库是JJWT,这一章的主要内容也就是JJWT库的文档。

JSON Web Token样子如下:

eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJKb2UifQ.ipevRNuRP6HflG8cFKnmUPtypruRC4fb1DWtoLL62SY

JWT是用.分割的一个三部分的字符串。

这其中红色的部分叫做header头部信息,绿色的部分叫做claims,可以认为是body,蓝色的部分是签名信息,由headerclaim加上一段密钥和指定的算法计算所得。

headerclaims都是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的方法如下:

  1. 调用Jwts.builder()获得一个建造者实例
  2. 调用建造者的各个方法添加header和claims
  3. 调用.signWith(key)进行签名
  4. 调用.compact()生成JWT字符串

添加header信息

添加header信息主要有三种方法:

  1. 逐个添加:反复调用建造者实例的.setHeaderParam("kid", "myKeyId")方法,每次调用都会加上一个header键值对。
  2. 一次性添加:先创建一个Header实例,再设置上,这个用的不多。
  3. 一次性添加:创建一个Map<String, Object>对象,然后调用建造者的.setHeader(Map)直接把所有的键值对一次性设置上去。

注意,在两个一次性添加的方法中,会覆盖所有同名的已经添加过的键值对,另外jjwt还会强行覆盖algzip这两个键。

如果数据量少一般逐个添加,如果数据量大,就是用一次性添加比较好。

这里还需要注意的是,一次性添加的对象是Object,jjwt会默认去寻找对应的JSON转换包来将Object转换为JSON,一般默认会先搜索Jackson,对于Spring开发来讲,这个过程无需配置。在添加Claims的时候也是如此。

添加Claims信息

添加Claims信息和添加Header很相似,也可以逐个添加和一次性添加。

不过由于claims里有几个标准规定的Registered Claim,所以jjwt为这些键写好了添加方法,与普通添加自定义键值对的方法区分开来。

添加Registered Claim的方法如下:

  1. setIssuer
  2. setSubject
  3. setAudience
  4. setExpiration
  5. setNotBefore
  6. setIssuedAt
  7. setId

一般来说,使用setIssuer设置签发者和setExpiration设置过期时间就已经足够了。

添加自定义claims的方法和header类似也有三种:

  1. 逐个添加:反复调用建造者的.claim("hello", "world")方法来添加自定义键值对。
  2. 一次性添加:创建claim对象然后设置,这个用的比较少。
  3. 一次性添加:创建一个Map<String, Object>对象,然后调用建造者的setClaims(Map)直接把所有的键值对一次性设置上去。

使用密钥进行签名

签名的方法.signWith(key)还有一个重载.signWith(key, alg)

两者的区别是:

  1. .signWith(key)会让jjwt自动根据key的长度选择算法,在计算出签名的同时,会在header中写入alg键值对。
  2. .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的主要流程是:

  1. 使用Jwts.parser()获取一个解析器对象
  2. 调用解析器的.setSigningKey(key)传入使用的SecretKey对象
  3. 调用.parseClaimsJws(jwsString),jwsString是需要解析的JWT字符串.
  4. 上一步得到的是一个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一下,就可以知道是否能通过验证。常见的异常有:

  1. ExpiredJwtException,这个非常常用,说明TOKEN解析成功,但是时间已经超过过期日,这个时候就可以引导用户去登录页。
  2. UnsupportedJwtException,不是有效的JWT字符串
  3. SignatureException,key错误
  4. UnsupportedJwtException,不支持的JWT
  5. IllegalArgumentException,JWT为空

一般情况下重点关注JWT的过期问题就可以了。