目录

  1. 概述
  2. Hello World
    1. 依赖
    2. 创建 JWT
    3. 解析 JWT
  3. JWS
    1. 创建 JWS
      1. 设置 Header Parameters
      2. 设置 Claims
      3. 签名
    2. 解析 JWS
      1. 校验 Key
      2. Claims 断言
    3. 压缩
      1. 默认压缩
      2. 自定义压缩
  4. 附录

JWT 的声明一般被用来在客户端和服务端间传递被认证的用户身份信息,以便于从资源服务器获取资源,也可以增加一些额外的其它业务逻辑信息

概述

JJWT 旨在成为最易使用和理解的库,用于在 JVM 和 Android 上创建和验证 JSON Web 令牌,JWT本身是支持加密签名的,在使用签名的JWT时,需要注意一下两点:

  • 保证 JWT 是由认识的人创建的(JWT 是真实的)
  • 保证在创建 JWT 之后没有操纵或改变 JWT(保持其完整性)

真实性完整性 保证JWT包含可以信任的信息。 如果JWT未通过真实性完整性检查,应该始终拒绝JWT,因为我们无法信任它

Hello World

仍然按照常见的套路(依赖,HelloWorld,详细概念)三步骤学习一个第三方库

依赖

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<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>

创建 JWT

1
2
3
4
5
6
7
8
public static void main(String[] args) {
SecretKey key = Keys.secretKeyFor(SignatureAlgorithm.HS256);
String jws = Jwts.builder()
.setSubject("pineapple-man").signWith(key)
.compact();
System.out.println(jws);
/**eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJwaW5lYXBwbGUtbWFuIn0.moDrltTQbK9mhQflt8_KfVtQY-5NrZg4uvRyz5k-DzQ*/
}

上述代码中,构建了一个主题为pineapple-man的 JWT,使用的是HMAC-SHA-256算法的密钥对 JWT 进行签名,并在最终将它压缩成 String 形式

解析 JWT

1
2
3
4
5
6
7
8
public static void main(String[] args) {
SecretKey key = Keys.secretKeyFor(SignatureAlgorithm.HS256);
String jws = Jwts.builder().setSubject("pineapple-man").signWith(key).compact();
System.out.println(jws);
//解析
System.out.println(Jwts.parser().setSigningKey(key).parseClaimsJws(jws)
.getBody().getSubject().equals("pineapple-man"));
}

将之前的密钥用于验证JWT的签名,如果它无法验证JWT,则抛出SignatureException。如果在做 JWT 解析时,发生了验证失败的现象,可以通过捕获JwtException异常来自定义失败情况下的处理

1
2
3
4
5
6
try {
Jwts.parser().setSigningKey(key).parseClaimsJws(compactJws);
//OK, we can trust this JWT
} catch (JwtException e) {
//don't trust the JWT!
}

JWS

JWS 就是已经签名的 JWT,下面展示了如何手动实现 JWS

1
2
3
4
5
6
7
8
9
10
11
12
13
//头部信息
String header = "{'alg':'HS256'}";
String claims = "{'sub':'pineapple-man'}";
//对他们分别进行`UTF_8`编码:
byte[] encodedHeader = Base64.getEncoder().encode(header.getBytes(StandardCharsets.UTF_8));
byte[] encodedClaims = Base64.getEncoder().encode(claims.getBytes(StandardCharsets.UTF_8));
//将编码后的Header和Body使用.进行分隔,并连接成一个字符串
String concatenated = Arrays.toString(encodedHeader) + "." + Arrays.toString(encodedClaims);
//使用加密私钥,选择签名算法(此处使用HMAC-SHA-256),并对连接的字符串进行签名
Key key = getMySecretKey()
byte[] signature = hmacSha256( concatenated, key )
//由于签名始终结果是字节数组,因此Base64URL对签名进行编码并使用.将它连接到字符串concatenated后面
String jws = concatenated + '.' + Base64.getEncoder().encode( signature )

以上就是 JWS(已经签名的 JWT)生成过程的实现方式

创建 JWS

可以使用这种方式创建JWS

  1. 使用Jwts.builder()方法创建JwtBuilder实例
  2. 调用JwtBuilder方法根据需要添加标头参数和声明
  3. 指定要用于对JWT进行签名的SecretKey非对称PrivateKey
  4. 调用compact()方法进行压缩和签名,生成最终的jws
1
2
3
4
5
String jws = Jwts.builder() 	// (1)
.setSubject("pineapple-man") // (2)
.signWith(key) // (3)
.compact(); // (4)

设置 Header Parameters

JWT Header提供关于JWT Claims相关的内容,格式和加密操作的元数据

如果需要设置一个或多个JWT头参数,则可以根据需要简单地多次调用JwtBuilder.setHeaderParam

1
2
3
String jws = Jwts.builder()
.setHeaderParam("kid", "myKeyId")
// ... etc ...

每次调用setHeaderParam时,它只是将键值对附加到内部Header实例,如果键值已经存在,则会覆盖任何现有的同名键/值对

🎶不需要设置algzip标头参数,因为JJWT会根据使用的签名算法或压缩算法自动设置它们

除此之外,还可以使用其他方式,设置JWT Header,由于第一种方式能够完成常见的业务场景,其余的方式这里并不阐述

设置 Claims

ClaimsJWT的正文部分,包含JWT创建者希望向JWT收件人提供的信息,常见的 API 如下

API含义
setIssuersets the iss (Issuer) Claim
setSubjectsets the sub (Subject) Claim
setAudiencesets the aud (Audience) Claim
setExpirationsets the exp (Expiration Time) Claim
setNotBeforesets the nbf (Not Before) Claim
setIssuedAtsets the iat (Issued At) Claim
setIdsets the jti (JWT ID) Claim
1
2
3
4
5
6
7
8
9
10
String jws = Jwts.builder()
.setIssuer("me")
.setSubject("Bob")
.setAudience("you")
.setExpiration(expiration) //a java.util.Date
.setNotBefore(notBefore) //a java.util.Date
.setIssuedAt(new Date()) // for example, now
.setId(UUID.randomUUID()) //just an example id

/// ... etc ...

当然也可以自定义 Claims,如果需要设置一个或多个与上面显示的标准 setter 方法声明不匹配的自定义声明,可以根据需要多次调用JwtBuilde.claim 声明:

1
2
3
String jws = Jwts.builder()
.claim("hello", "world")
// ... etc ...

每次调用claim时,它只是将键值对附加到内部claims实例,如果键值已经存在,则会覆盖任何现有的同名key/value对,同头部信息一样

签名

JWT 规范确定了 12 种标准签名算法,三种密钥算法和九种非对称密钥算法,由于也不是做密码学的这里也不过多展开。自己项目中常见的使用HMAC-SHA-256就足够了

👴建议通过调用JwtBuildersignWith方法来指定签名密钥,以及对应的摘要算法,示例如下:

1
2
3
4
String jws = Jwts.builder()
// ... etc ...
.signWith(key) // <---
.compact();

使用signWith时,JJWT还会自动使用相关的算法标识符设置所需的alg头。类似地,如果使用长度为 4096 位的RSA PrivateKey调用signWithJJWT将使用RS512算法并自动将alg头设置为RS512

🎶不能用PublicKeys签署JWT,因为这总是不安全的。 JJWT将拒绝任何指定的PublicKey的方式签名,并抛出异常:InvalidKeyException

解析 JWS

⛵解析 JWS 步骤如下:

  1. 使用Jwts.parser()方法创建JwtParser实例
  2. 指定要用于验证JWS签名的SecretKey非对称PublicKey
  3. 调用parseClaimsJws(String)方法,生成原始JWS

🎶如之前所述,整个调用将包装在try/catch块中,以防解析或签名验证失败

1
2
3
4
5
6
7
8
9
10
Jws<Claims> jws;
try {
jws = Jwts.parser() // (1)
.setSigningKey(key) // (2)
.parseClaimsJws(jwsString); // (3)
// we can safely trust the JWT
catch (JwtException ex) {
// we cannot use the JWT as intended by its creator
}

校验 Key

阅读JWS时,最重要的事情是指定用于验证 JWS 加密签名的密钥。 如果签名验证失败,则无法安全地信任此JWT,应将其丢弃。如果jws是使用SecretKey签名的,则应在JwtParser上指定相同SecretKey

1
2
3
Jwts.parser()
.setSigningKey(secretKey) // <----
.parseClaimsJws(jwsString);

如果jws是使用PrivateKey签名的,那么应该在JwtParser上指定该密钥相应PublicKey(不是PrivateKey

1
2
3
Jwts.parser()
.setSigningKey(publicKey) // <---- publicKey, not privateKey
.parseClaimsJws(jwsString);

❓如果应用程序不止使用一个 SecretKey 或 KeyPair 会怎么样? 如果可以使用不同的 SecretKeys 或公钥/私钥或两者的组合创建 JWS,该怎么办?

在这些情况下,无法使用单个键调用JwtParsersetSigningKey方法。相反,需要使用SigningKeyResolver方法指定解析 JWS 方法

1
2
3
4
5
//实现了 SigningKeyResolver 接口的自定义解析类
SigningKeyResolver signingKeyResolver = getMySigningKeyResolver();
Jwts.parser()
.setSigningKeyResolver(signingKeyResolver) // <----
.parseClaimsJws(jwsString);

通过实现SigningKeyResolverAdapter接口的resolveSigningKey(JwsHeader,Claims)方法来简化一些事情

1
2
3
4
5
6
public class MySigningKeyResolver extends SigningKeyResolverAdapter {
@Override
public Key resolveSigningKey(JwsHeader jwsHeader, Claims claims) {
// implement
}
}

在解析JWS JSON之后,JwtParser将在验证jws签名之前调用resolveSigningKey()方法。 这也就允许检查Jws HeaderClaims参数,以帮助查找用于验证特定jws的密钥的信息。 这对于复杂安全模型的应用程序非常强大,这些安全模型可能在不同时间使用不同的密钥或针对不同的用户或客JWT规范支持的方法是在创建JWS时,在JWS头中设置kid(Key ID)字段,例如:

1
2
3
4
5
6
Key signingKey = getSigningKey();
String keyId = getKeyId(signingKey); //any mechanism you have to associate a key with an ID is fine
String jws = Jwts.builder()
.setHeaderParam(JwsHeader.KEY_ID, keyId) // 1
.signWith(signingKey) // 2
.compact();

然后在解析期间,SigningKeyResolver可以检查JwsHeader以获取该kid,然后使用该值从某个位置查找密钥,如数据库。 例如:

1
2
3
4
5
6
7
8
9
public class MySigningKeyResolver extends SigningKeyResolverAdapter {
@Override
public Key resolveSigningKey(JwsHeader jwsHeader, Claims claims) {
//inspect the header or claims, lookup and return the signing key
String keyId = jwsHeader.getKeyId(); //or any other field that you need to inspect
Key key = lookupVerificationKey(keyId); //implement me
return key;
}
}

🎶检查jwsHeader.getKeyId()只是查找密钥的最常用方法,也可以检查任意数量的标头字段或声明,以确定如何查找验证密钥。

Claims 断言

如果正在解析的JWS具有特定的子sub值,可以使用JwtParser上的各种require *方法来获取数据

1
2
3
4
5
try {
Jwts.parser().requireSubject("pineapple-man").setSigningKey(key).parseClaimsJws(s);
} catch(InvalidClaimException ice) {
// the sub field was missing or did not have a 'pineapple-man' value
}

如果缺少某个值,那么就不会捕获InvalidClaimException,而是捕获MissingClaimExceptionIncorrectClaimException异常

1
2
3
4
5
6
7
try {
Jwts.parser().requireSubject("pineapple-man").setSigningKey(key).parseClaimsJws(s);
} catch(MissingClaimException mce) {
// the parsed JWT did not have the sub field
} catch(IncorrectClaimException ice) {
// the parsed JWT had a sub field, but its value was not equal to 'pineapple-man'
}

当然,也可以使用require(fieldName,requiredFieldValue)方法来获取自定义字段。

1
2
3
4
5
try {
Jwts.parser().require("field","requiredValue").setSigningKey(key).parseClaimsJws(s);
} catch(InvalidClaimException ice) {
// the 'myfield' field was missing or did not have a 'myRequiredValue' value
}

压缩

如果JWTClaim域可以足够大,包含许多key/value对;或者单个值非常冗长,可以通过压缩来减小创建的JWS的大小

例如:如果在URL中使用生成的JWS,压缩将很重要,因为浏览器,用户邮件代理或 HTTP 网关兼容性问题,URL最好保持在4096个字符以下,所以较小的JWT有助于降低带宽利用率

默认压缩

如果要压缩JWT,可以使用JwtBuilder 的compressWith(CompressionAlgorithm)方法。 例如:

1
2
3
Jwts.builder()
.compressWith(CompressionCodecs.DEFLATE) // or CompressionCodecs.GZIP
// .. etc ...

使用DEFLATEGZIP压缩编解码器,但是在解压缩时,不必执行任何操作,不需要配置JwtParserJJWT将按预期自动解压缩主体

自定义压缩

如果在创建JWT时使用自己的自定义压缩编解码器(通过JwtBuilder.compressWith),则需要使用setCompressionCodecResolver方法将编解码器提供给JwtParser。 例如:

1
2
3
4
CompressionCodecResolver ccr = new MyCompressionCodecResolver();
Jwts.parser()
.setCompressionCodecResolver(ccr) // <----
// .. etc ...

通常,CompressionCodecResolver实现将检查zip标头以找出使用的算法,然后返回支持该算法的编解码器实例。 例如:

1
2
3
4
5
6
7
8
9
10
11
12
public class MyCompressionCodecResolver implements CompressionCodecResolver {

@Override
public CompressionCodec resolveCompressionCodec(Header header) throws CompressionException {

String alg = header.getCompressionAlgorithm();

CompressionCodec codec = getCompressionCodec(alg); //implement

return codec;
}
}

附录

Java Web Token 之 JJWT 使用