JWT、JWE、JWS 、JWK基础概念与应用

有些故事,除了回忆,谁也不会留;有些无奈,除了沉默,谁也不会说;有些东西,除了自己,谁也不会懂

Posted by yishuifengxiao on 2023-04-13

一 基本概念

1.1 JWT

JSON Web 令牌(JWT)是一个开放标准(RFC 7519),它定义了一种紧凑且自包含的方式,用于在各方之间作为 JSON 对象安全地传输信息

也就是说 JWT 实际上是一种规范,并确定使用 JSON 作为表达,JWS 和 JWE 则是对这种规范的实现以及增强。

1.2 JWS (JSON Web Signature)

JWS 使用 Base64 进行编码,它包含三个部分,分别以 . 进行分割,如xxxx.yyyy.zzz的形式,分别对应 Header, Payload, Signature。

  • Header: 存储一些元数据:类型以及其签名使用的算法
  • Payload:按照标准存储相关信息,包括 iss-签发者,exp-过期时间,sub-对象主体(一般是用户信息),iat-签发时间,aud-接收方
  • Signature:签名信息,由密钥签署,可以使用公钥来验证。

JWS 一般用于交换非敏感信息,若 token 中包含用户敏感信息,则需要对其加密,这就用到了 JWE

1.3 JWE(JSON Web Encryption)

JWE 本质上是对 Jwt 中的 Payload 进行加密。当然 JWE 是可以包含 JWS 的,也就是说当前 token 既有签名保证完整性,又有加密来保证安全性。

既然要加密,则需要公钥和私钥了,密钥一般使用 OpenSSL 生成 x509 格式。这里又引伸出 JWKs 的概念:

JSON Web 密钥集(JWKS)包含公钥,用于验证授权服务器发布并使用 RS256 签名算法签名的 JWT。

JWKs 包含一系列的公钥,授权服务器可以通过接口对外暴露相关的公钥,用于资源服务器进行认证。更多的 JWK 规范可以参考https://self-issued.info/docs/d

1.4 JWK

JWT的密钥,也就是我们常说的scret


https://mkjwk.org/ jwk在线生成

https://jwt.io/ jwt官网

http://jwt.calebb.net/ jwt反解

二 JWT

2.1 基础概念

一个JWT,应该是如下形式的:

1
2
3
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.
TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ

JWT 的一些名词解释

  • JWS:Signed JWT签名过的jwt
  • JWE:Encrypted JWT部分payload经过加密的jwt;目前加密payload的操作不是很普及;
  • JWK:JWT的密钥,也就是我们常说的scret;
  • JWKset:JWT key set在非对称加密中,需要的是密钥对而非单独的密钥
  • JWA:当前JWT所用到的密码学算法;
  • nonsecure JWT:当头部的签名算法被设定为none的时候,该JWT是不安全的;因为签名的部分空缺,所有人都可以修改。

JWT 解决的问题

JWT的主要目的是在服务端和客户端之间以安全的方式来转移声明。主要的应用场景如下所示:

  • 认证 Authentication;
  • 授权 Authorization // 注意这两个单词的区别;
  • 联合识别;
  • 客户端会话(无状态的会话);
  • 客户端机密。

2.2 JWT的组成

一个通常你看到的jwt,由以下三部分组成,它们分别是:

  • header:主要声明了JWT的签名算法;
  • payload:主要承载了各种声明并传递明文数据;
  • signture:拥有该部分的JWT被称为JWS,也就是签了名的JWS;没有该部分的JWT被称为nonsecure JWT 也就是不安全的JWT,此时header中声明的签名算法为none。

三个部分用·分割。形如 xxxxx.yyyyy.zzzzz的样式。

2.2.1 JWT header

1
2
3
4
5
{
"typ": "JWT",
"alg": "none",
"jti": "4f1g23a12aa"
}

jwt header 的组成

头通常由两部分组成:令牌的类型,即JWT,以及正在使用的散列算法,例如HMAC SHA256或RSA。

当然,还有两个可选的部分,一个是jti,也就是JWT ID,代表了正在使用JWT的编号,这个编号在对应服务端应当唯一。当然,jti也可以放在payload中。

另一个是cty,也就是content type。这个比较少见,当payload为任意数据的时候,这个头无需设置,但是当内容也带有jwt的时候。也就是嵌套JWT的时候,这个值必须设定为jwt。这种情况比较少见。

jwt header 的加密算法

加密的方式如下:

1
2
base64UrlEncode(header)
>> eyJ0eXAiOiJKV1QiLCJhbGciOiJub25lIiwianRpIjoiNGYxZzIzYTEyYWEifQ

2.2.2 JWT payload

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{
"iss": "http://shaobaobaoer.cn",
"aud": "http://shaobaobaoer.cn/webtest/jwt_auth/",
"jti": "4f1g23a12aa",
"iat": 1534070547,
"nbf": 1534070607,
"exp": 1534074147,
"uid": 1,
"data": {
"uname": "shaobao",
"uEmail": "shaobaobaoer@126.com",
"uID": "0xA0",
"uGroup": "guest"
}
}

jwt payload的组成

payload通常由三个部分组成,分别是 Registered Claims ; Public Claims ; Private Claims ;每个声明都有各自的字段。

Registered Claims

iss 【issuer】发布者的url地址

sub 【subject】该JWT所面向的用户,用于处理特定应用,不是常用的字段

aud 【audience】接受者的url地址

exp 【expiration】 该jwt销毁的时间;unix时间戳

nbf 【not before】 该jwt的使用时间不能早于该时间;unix时间戳

iat 【issued at】 该jwt的发布时间;unix 时间戳

jti 【JWT ID】 该jwt的唯一ID编号

Public Claims这些可以由使用JWT的那些标准化组织根据需要定义,应当参考文档IANA JSON Web Token Registry

Private Claims这些是为在同意使用它们的各方之间共享信息而创建的自定义声明,既不是注册声明也不是公开声明。上面的payload中,没有public claims只有private claims。

jwt payload 的加密算法

加密的方式如下:

1
2
base64UrlEncode(payload)
>> eyJpc3MiOiJodHRwOi8vc2hhb2Jhb2Jhb2VyLmNuIiwiYXVkIjoiaHR0cDovL3NoYW9iYW9iYW9lci5jbi93ZWJ0ZXN0L2p3dF9hdXRoLyIsImp0aSI6IjRmMWcyM2ExMmFhIiwiaWF0IjoxNTM0MDcwNTQ3LCJuYmYiOjE1MzQwNzA2MDcsImV4cCI6MTUzNDA3NDE0NywidWlkIjoxLCJkYXRhIjp7InVuYW1lIjoic2hhb2JhbyIsInVFbWFpbCI6InNoYW9iYW9iYW9lckAxMjYuY29tIiwidUlEIjoiMHhBMCIsInVHcm91cCI6Imd1ZXN0In19

暴露的信息

所以,在JWT中,不应该在载荷里面加入任何敏感的数据。在上面的例子中,我们传输的是用户的User ID,邮箱等。这个值实际上不是什么敏感内容,一般情况下被知道也是安全的。但是像密码这样的内容就不能被放在JWT中了。如果将用户的密码放在了JWT中,那么怀有恶意的第三方通过Base64解码就能很快地知道你的密码了。

当然,这也是有解决方案的,那就是加密payload。在之后会说到。

三 JWS

3.1 JWS 的结构

JWS ,也就是JWT Signature,其结构就是在之前nonsecure JWT的基础上,在头部声明签名算法,并在最后添加上签名。创建签名,是保证jwt不能被他人随意篡改。

为了完成签名,除了用到header信息和payload信息外,还需要算法的密钥,也就是secret。当利用非对称加密方法的时候,这里的secret为私钥。

为了方便后文的展开,我们把JWT的密钥或者密钥对,统一称为JSON Web Key,也就是JWK。

jwt signature 的签名算法

1
2
3
4
5
6
RSASSA || ECDSA || HMACSHA256(
base64UrlEncode(header) + "." +
base64UrlEncode(payload),
secret)
>> GQPGEpixjPZSZ7CmqXB-KIGNzNl4Y86d3XOaRsfiXmQ
>> # 上面这个是用 HMAC SHA256生成的

到目前为止,jwt的签名算法有三种。

  • 对称加密HMAC【哈希消息验证码】:HS256/HS384/HS512
  • 非对称加密RSASSA【RSA签名算法】(RS256/RS384/RS512)
  • ECDSA【椭圆曲线数据签名算法】(ES256/ES384/ES512)

最后将签名与之前的两段内容用.连接,就可以得到经过签名的JWT,也就是JWS。

1
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiIsImp0aSI6IjRmMWcyM2ExMmFhIn0.eyJpc3MiOiJodHRwOi8vc2hhb2Jhb2Jhb2VyLmNuIiwiYXVkIjoiaHR0cDovL3NoYW9iYW9iYW9lci5jbi93ZWJ0ZXN0L2p3dF9hdXRoLyIsImp0aSI6IjRmMWcyM2ExMmFhIiwiaWF0IjoxNTM0MDcwNTQ3LCJuYmYiOjE1MzQwNzA2MDcsImV4cCI6MTUzNDA3NDE0NywidWlkIjoxLCJkYXRhIjp7InVuYW1lIjoic2hhb2JhbyIsInVFbWFpbCI6InNoYW9iYW9iYW9lckAxMjYuY29tIiwidUlEIjoiMHhBMCIsInVHcm91cCI6Imd1ZXN0In19.GQPGEpixjPZSZ7CmqXB-KIGNzNl4Y86d3XOaRsfiXmQ

当验证签名的时候,利用公钥或者密钥来解密Sign,和 base64UrlEncode(header) + “.” + base64UrlEncode(payload) 的内容完全一样的时候,表示验证通过。

3.2 JWS 的额外头部声明

如果对于CA有些概念的话,这些内容会比较好理解一些。为了确保服务器的密钥对可靠有效,同时也方便第三方CA机构来签署JWT而非本机服务器签署JWT,对于JWS的头部,可以有额外的声明,以下声明是可选的,具体取决于JWS的使用方式。如下所示:

jku: 发送JWK的地址;最好用HTTPS来传输

jwk: 就是之前说的JWK

kid: jwk的ID编号

x5u: 指向一组X509公共证书的URL

x5c: X509证书链

x5t:X509证书的SHA-1指纹

x5t#S256: X509证书的SHA-256指纹

typ: 在原本未加密的JWT的基础上增加了 JOSE 和 JOSE+ JSON。JOSE序列化后文会说及。适用于JOSE标头的对象与此JWT混合的情况。

crit: 字符串数组,包含声明的名称,用作实现定义的扩展,必须由 this->JWT的解析器处理。不常见。

3.3 多重验证与JWS序列化

当需要多重签名或者JOSE表头的对象与JWS混合的时候,往往需要用到JWS的序列化。JWS的序列化结构如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
{
"payload": "eyJpc3MiOiJqb2UiLA0KICJleHAiOjEzMDA4MTkzODAsDQogImh0dHA6Ly9leGFtcGxlLmNvbS9pc19yb290Ijp0cnVlfQ",
"signatures":
[
{
"protected": "eyJhbGciOiJSUzI1NiJ9",
"header": { "kid": "2010-12-29" },
"signature":"signature1"
},
{
"protected": "eyJhbGciOiJSUzI1NiJ9",
"header": { "kid": "e9bc097a-ce51-4036-9562-d2ade882db0d" },
"signature":"signature2"
},
...
]
}

结构很容易理解。首先是payload字段,这个不用多讲,之后是signatures字段,这是一个数组,代表着多个签名。每个签名的结构如下:

  • protected:之前的头部声明,利用b64uri加密;
  • header:JWS的额外声明,这段内容不会放在签名之中,无需验证;
  • signature:也就是对当前header+payload的签名。

3.4 ECDSA|RSASSA or HMAC ? 应该选用哪个?

之前看JWT的时候看到论坛里的一个话题,觉得很有意思,用自己的理解来说一下https://stackoverflow.com/questions/38588319/understanding-rsa-signing-for-jwt。

首先,我们必须明确一点,无论用的是 HMAC,RSASSA,ECDSA;密钥,公钥,私钥都不会发送给客户端,仅仅会保留在服务端上。

对称的算法HMAC适用于单点登录,一对一的场景中。速度很快。

但是面对一对多的情况,比如一个APP中的不同服务模块,需要JWT登录的时候,主服务端【APP】拥有一个私钥来完成签名即可,而用户带着JWT在访问不同服务模块【副服务端】的时候,副服务端只要用公钥来验证签名就可以了。从一定程度上也减少了主服务端的压力。

当然,还有一种情况就是不同成员进行开发的时候,大家可以用统一的私钥来完成签名,然后用各自的公钥去完成对JWT的认证,也是一种非常好的开发手段。

因此,构建一个没有多个小型“微服务应用程序”的应用程序,并且开发人员只有一组的,选择HMAC来签名即可。其他情况下,尽量选择RSA。

四 JWE

JWE是一个很新的概念,总之,除了jwt的官方手册外,很少有网站或者博客会介绍这个东西。也并非所有的库都支持JWE。这里记录一下自己看官方手册后理解下来的东西。

JWS是去验证数据的,而JWE(JSON Web Encryption)是保护数据不被第三方的人看到的。通过JWE,JWT变得更加安全。

JWE和JWS的公钥私钥方案不相同,JWS中,私钥持有者加密令牌,公钥持有者验证令牌。而JWE中,私钥一方应该是唯一可以解密令牌的一方。

在JWE中,公钥持有可以将新的数据放入JWT中,但是JWS中,公钥持有者只能验证数据,不能引入新的数据。因此,对于公钥/私钥的方案而言,JWS和JWE是互补的。

JWS JWE
producer pri_key pub_key
consumer pub_key pri_key

4.1 JWE 的构成

一个JWE,应该是如下形式的:

1
2
3
4
5
6
7
eyJhbGciOiJSU0ExXzUiLCJlbmMiOiJBMTI4Q0JDLUhTMjU2In0.
UGhIOguC7IuEvf_NPVaXsGMoLOmwvc1GyqlIKOK1nN94nHPoltGRhWhw7Zx0-kFm1NJn8LE9XShH59_
i8J0PH5ZZyNfGy2xGdULU7sHNF6Gp2vPLgNZ__deLKxGHZ7PcHALUzoOegEI-8E66jX2E4zyJKxYxzZIItRzC5hlRirb6Y5Cl_p-ko3YvkkysZIFNPccxRU7qve1WYPxqbb2Yw8kZqa2rMWI5ng8Otv
zlV7elprCbuPhcCdZ6XDP0_F8rkXds2vE4X-ncOIM8hAYHHi29NX0mcKiRaD0-D-ljQTPcFPgwCp6X-nZZd9OHBv-B3oWh2TbqmScqXMR4gp_A.
AxY8DCtDaGlsbGljb3RoZQ.
KDlTtXchhZTGufMYmOYGS4HffxPSUrfmqCHXaI9wOGY.
9hH0vgRfYgPnAHOd8stkvw

如你所见JWE一共有五个部分,分别是:

The protected header,类似于JWS的头部;

The encrypted key,用于加密密文和其他加密数据的对称密钥;

The initialization vector,初始IV值,有些加密方式需要额外的或者随机的数据;

The encrypted data (cipher text),密文数据;

The authentication tag,由算法产生的附加数据,来防止密文被篡改。

4.2 JWE 密钥加密算法

一般来说,JWE需要对密钥进行加密,这就意味着同一个JWT中至少有两种加密算法在起作用。但是并非将密钥拿来就能用,我们需要对密钥进行加密后,利用JWK密钥管理模式来导出这些密钥。JWK的管理模式有以下五种,分别是:

Key Encryption

Key Wrapping

Direct Key Agreement

Key Agreement with Key Wrapping

Direct Encryption

并不是所有的JWA都能够支持这五种密钥管理管理模式,也并非每种密钥管理模式之间都可以相互转换。可以参考Spomky-Labs/jose中给出的表格至于各个密钥管理模式的细节,还请看JWT的官方手册,解释起来较为复杂。

4.3 JWE Header

就好像是JWS的头部一样。JWE的头部也有着自己规定的额外声明字段,如下所示:

type:一般是 jwt

alg:算法名称,和JWS相同,该算法用于加密稍后用于加密内容的实际密钥

enc:算法名称,用上一步生成的密钥加密内容的算法。

zip:加密前压缩数据的算法。该参数可选,如果不存在则不执行压缩,通常的值为 DEF,也就是deflate算法

jku/jkw/kid/x5u/x5c/x5t/x5t#S256/typ/cty/crit:和JWS额额外声明一样。

4.4 JWE 的加密过程

步骤2和步骤3,更具不同的密钥管理模式,应该有不同的处理方式。在此只罗列一些通常情况。

之前谈及,JWE一共有五个部分。现在来详细说一下加密的过程:

  1. 根据头部alg的声明,生成一定大小的随机数;
  2. 根据密钥管理模式确定加密密钥;
  3. 根据密钥管理模式确定JWE加密密钥,得到CEK;
  4. 计算初始IV,如果不需要,跳过此步骤;
  5. 如果ZIP头申明了,则压缩明文;
  6. 使用CEK,IV和附加认证数据,通过enc头声明的算法来加密内容,结果为加密数据和认证标记;
  7. 压缩内容,返回token。
1
base64(header) + '.' +base64(encryptedKey) + '.' + // Steps 2 and 3base64(initializationVector) + '.' + // Step 4base64(ciphertext) + '.' + // Step 6base64(authenticationTag) // Step 6

4.5 多重验证与JWE序列化

和JWS类似,JWE也定义了紧凑的序列化格式,用来完成多种形式的加密。大致格式如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
{
"protected": "eyJlbmMiOiJBMTI4Q0JDLUhTMjU2In0",
"unprotected": { "jku":"https://server.example.com/keys.jwks" },
"recipients":[
{
"header": { "alg":"RSA1_5","kid":"2011-04-29" },
"encrypted_key":
"UGhIOguC7Iu...cqXMR4gp_A"
},
{
"header": { "alg":"A128KW","kid":"7" },
"encrypted_key": "6KB707dM9YTIgH...9locizkDTHzBC2IlrT1oOQ"
}
],
"iv": "AxY8DCtDaGlsbGljb3RoZQ",
"ciphertext": "KDlTtXchhZTGufMYmOYGS4HffxPSUrfmqCHXaI9wOGY",
"tag": "Mz-VPPyU4RlcuYv1IwIvzw"
}

结构很容易理解,如下所示:

protected:之前的头部声明,利用b64uri加密;

unprotected:一般放JWS的额外声明,这段内容不会被b64加密;

iv:64加密后的iv参数;

add:额外认证数据;

ciphertext:b64加密后的加密数据;

recipients:b64加密后的认证标志-加密链,这是一个数组,每个数组中包含了两个信息;

header:主要是声明当前密钥的算法;

encrypted_key:JWE加密密钥。

五 JWT 的工作原理

这里通过juice shop来说下jwt是如何工作的。

在身份验证中,当用户使用其凭据成功登录时,将返回JSON Web令牌。

每当用户想要访问受保护的路由或资源时,用户将使用承载【bearer】模式发送JWT,通常在Authorization标头中。标题的内容应如下所示:

1
Authorization: Bearer <token>

随后,服务器会取出token中的内容,来返回对应的内容。须知,这个token不一定会储存在cookie中,如果存在cookie中的话,需要设置为http-only,防止XSS。另外,还可以放在别的地方,比如localStorage、sessionStorage。如果使用vue的话,还可以存在vuex里面。

另外,如果在如Authorization: Bearer中发送令牌,则跨域资源共享(CORS)将不会成为问题,因为它不使用cookie。

关于更多的关于JWT认证的内容,可以看八幅漫画理解使用JSON Web Token设计单点登录系统 ——— by John Wu

六 spring boot security集成

资源服务器的全称是 OAuth2 Resource Server ,它实际上是OAuth 2.0 协议的一部分,通常我们借助于Json Web Token来实现。 OAuth2.0授权服务器负责发“证件”,资源服务器负责对“证件”进行校验。在去中心化的架构中,每一个API服务本身也承担资源服务器的功能。

作为授权服务一般还是中心化比较好,统一管理用户的认证授权并发放给客户端Token。每个具体的服务自行承担对Token的校验功能,我们只需要抽象好访问控制的接口就可以了。

1
2
3
4
5
6
7
8
9
10
11
12
13
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>

<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-oauth2-resource-server</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-oauth2-jose</artifactId>
</dependency>

Spring Security 5.x 移除了OAuth2.0授权服务器,保留了OAuth2.0资源服务器

6.1 基础使用

要校验JWT就必须实现对JWT的解码功能,在Spring Security OAuth2 Resource Server模块中,默认提供了解码器,这个解码器需要调用基于:

1
spring.security.oauth2.resourceserver

配置下的元数据来生成解码配置,这里的配置大部分是调用授权服务器开放的well-known端点,包含了解析验证JWT一系列参数:

  • jwkSetUri 一般是授权服务器提供的获取JWK配置的well-known端点,用来校验JWT Token。
  • jwsAlgorithm 指定jwt使用的算法,默认 RSA-256
  • issuerUri 获取OAuth2.0 授权服务器元数据的端点。
  • publicKeyLocation 用于解码的公钥路径,作为资源服务器来说将只能持有公钥,不应该持有私钥。

例如配置

1
2
3
4
5
6
spring:
security:
oauth2:
resourceserver:
jwt:
jwk-set-uri: http://localhost:8080/.well-known/jwks.json

接口返回的数据内容

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
{
"keys": [
{
// key 使用的算法族(RSA/EC)
"kty": "RSA",
"e": "AQAB",
// 用途: sig-签名 enc-加密
"use": "sig",
// 自定义的 key id,用于验证解密方使用
"kid": "jwt-test",
// 加密算法
"alg": "RS256",
// 公钥
"n": "kyGzFh-nlzeYoTfmi3Tn0zhX3b2dL7OBczVVLornTg7SlmIC7xbx-A8t5HZebCfwesseKrfYO1J__bgsFJzVnDSMKlpyGVSoyGAKK46MUgDd_6_MmH_S2x4JcmOOdryw1zghpMXMO8C7i7fKdWr8hvQfxeoj0rK9A37Mtywlu2ur1GxEmUuiGEufOpiHhIldyJmEK5K4wD7woAoko6NvYx4kR-uGCij_RuyW_4_a737t4I57Ab50RoCknLJUj2_q355A0q-LANiskfRYUDGsqSxGX_0l7O7u_kU-GicY0mUsfC19nQ6MkzcTGcIbxhdLmIUGmSLZ3AFI4EyyfvtEhw"
}
]
}

在配置类里,简单的将 JWT 解析方法配置进去就好。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Configuration
@EnableWebSecurity(debug = true)
public class MySecurityConfig extends WebSecurityConfigurerAdapter {
@Value("${spring.security.oauth2.resourceserver.jwt.jwk-set-uri}")
private String jwkSetUri;

@Override
protected void configure(HttpSecurity http) throws Exception{
http
.oauth2ResourceServer(OAuth2ResourceServerConfigurer::jwt)
.csrf().disable()
.authorizeRequests()
.antMatchers("/hello/**").hasAuthority("SCOPE_all")
.anyRequest().authenticated();
}

@Bean
JwtDecoder jwtDecoder() {
return NimbusJwtDecoder.withJwkSetUri(this.jwkSetUri).build();
}
}

分离公私钥

资源服务器只能保存公钥,所以需要从之前的jks文件中导出一个公钥。

1
keytool -export -alias felordcn -keystore <jks证书全路径>  -file <导出cer的全路径>

例如:

1
keytool -export -alias felordcn -keystore D:\keystores\felordcn.jks  -file d:\keystores\publickey.cer

把分离的cer公钥文件放到原来jks文件的路径下面,资源服务器不再保存jks

自定义jwt解码器

spring-security-oauth2-jose是Spring Security的jose规范依赖。我将根据该类库来实现自定义的JWT解码器。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/**
* 基于Nimbus的jwt解码器,并增加了一些自定义校验策略
*
* @param validator the validator
* @return the jwt decoder
*/
@SneakyThrows
@Bean
public JwtDecoder jwtDecoder(@Qualifier("delegatingTokenValidator") DelegatingOAuth2TokenValidator<Jwt> validator) {
CertificateFactory certificateFactory = CertificateFactory.getInstance("X.509");
// 从classpath路径读取cer公钥证书来配置解码器
ClassPathResource resource = new ClassPathResource(this.jwtProperties.getCertInfo().getPublicKeyLocation());
Certificate certificate = certificateFactory.generateCertificate(resource.getInputStream());
PublicKey publicKey = certificate.getPublicKey();
NimbusJwtDecoder nimbusJwtDecoder = NimbusJwtDecoder.withPublicKey((RSAPublicKey) publicKey).build();
nimbusJwtDecoder.setJwtValidator(validator);
return nimbusJwtDecoder;
}

6.2 自定义资源服务器配置

6.2.1 核心流程和概念

资源服务器其实也就是配置了一个过滤器BearerTokenAuthenticationFilter来拦截并验证Bearer Token。验证通过而且权限符合要求就放行,不通过就不放行。

和之前不太一样的是验证成功后凭据不再是UsernamePasswordAuthenticationToken而是JwtAuthenticationToken

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
@Transient
public class JwtAuthenticationToken extends AbstractOAuth2TokenAuthenticationToken<Jwt> {

private static final long serialVersionUID = SpringSecurityCoreVersion.SERIAL_VERSION_UID;

private final String name;

/**
* Constructs a {@code JwtAuthenticationToken} using the provided parameters.
* @param jwt the JWT
*/
public JwtAuthenticationToken(Jwt jwt) {
super(jwt);
this.name = jwt.getSubject();
}

/**
* Constructs a {@code JwtAuthenticationToken} using the provided parameters.
* @param jwt the JWT
* @param authorities the authorities assigned to the JWT
*/
public JwtAuthenticationToken(Jwt jwt, Collection<? extends GrantedAuthority> authorities) {
super(jwt, authorities);
this.setAuthenticated(true);
this.name = jwt.getSubject();
}

/**
* Constructs a {@code JwtAuthenticationToken} using the provided parameters.
* @param jwt the JWT
* @param authorities the authorities assigned to the JWT
* @param name the principal name
*/
public JwtAuthenticationToken(Jwt jwt, Collection<? extends GrantedAuthority> authorities, String name) {
super(jwt, authorities);
this.setAuthenticated(true);
this.name = name;
}

@Override
public Map<String, Object> getTokenAttributes() {
return this.getToken().getClaims();
}

/**
* jwt 中的sub 值 用户名比较合适
*/
@Override
public String getName() {
return this.name;
}

}

这个我们改造的时候要特别注意,尤其是从SecurityContext获取的时候用户凭证信息的时候。

6.2.2 资源管理器配置

从Spring Security 5的某版本开始不需要再集成适配类了,只需要这样就能配置Spring Security,资源管理器也是这样:

1
2
3
4
5
6
7
8
9
10
11
@Bean
SecurityFilterChain jwtSecurityFilterChain(HttpSecurity http) throws Exception {
return http.authorizeRequests(request -> request.anyRequest()
.access("@checker.check(authentication,request)"))
.exceptionHandling()
.accessDeniedHandler(new SimpleAccessDeniedHandler())
.authenticationEntryPoint(new SimpleAuthenticationEntryPoint())
.and()
.oauth2ResourceServer(OAuth2ResourceServerConfigurer::jwt)
.build();
}

这里只需要声明使用JWT校验的资源服务器,同时配置好定义的401端点和403处理器即可。

6.2.3 JWT个性化解析

从JWT Token中解析数据并生成JwtAuthenticationToken的操作是由JwtAuthenticationConverter来完成的。你可以定制这个转换器来实现一些个性化功能。比如默认情况下解析出来的权限都是带SCOPE_前缀的,而项目用ROLE_,你就可以通过这个类兼容一下老项目。

1
2
3
4
5
6
7
8
9
10
11
12
13
     @Bean
JwtAuthenticationConverter jwtAuthenticationConverter() {
JwtAuthenticationConverter jwtAuthenticationConverter = new JwtAuthenticationConverter();
JwtGrantedAuthoritiesConverter jwtGrantedAuthoritiesConverter = new JwtGrantedAuthoritiesConverter();
// 如果不按照规范 解析权限集合Authorities 就需要自定义key
// jwtGrantedAuthoritiesConverter.setAuthoritiesClaimName("scopes");
// OAuth2 默认前缀是 SCOPE_ Spring Security 是 ROLE_
jwtGrantedAuthoritiesConverter.setAuthorityPrefix("");
jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(jwtGrantedAuthoritiesConverter);
// 设置jwt中用户名的key 默认就是sub 你可以自定义
jwtAuthenticationConverter.setPrincipalClaimName(JwtClaimNames.SUB);
return jwtAuthenticationConverter;
}

这里基本上就改造完成了,当带着令牌来访问API时,资源服务器会对令牌进行校验以进行访问控制。

在实际生产中建议把资源服务器封装为依赖集成到需要保护资源的的服务中即可。

6.3 附加说明

为了测试资源服务器,假设我们有一个颁发令牌的授权服务器。这里简单模拟了一个发令牌的方法用来获取Token:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
/**
* 资源服务器不应该生成JWT 但是为了测试 假设这是个认证服务器
*/
@SneakyThrows
@Test
public void imitateAuthServer() {

JwtEncoder jwsEncoder = new NimbusJwsEncoder(jwkSource());

JwtTokenGenerator jwtTokenGenerator = new JwtTokenGenerator(jwsEncoder);
OAuth2AccessTokenResponse oAuth2AccessTokenResponse = jwtTokenGenerator.tokenResponse();

System.out.println("oAuth2AccessTokenResponse = " + oAuth2AccessTokenResponse.getAccessToken().getTokenValue());
}

@SneakyThrows
private JWKSource<SecurityContext> jwkSource() {
ClassPathResource resource = new ClassPathResource("felordcn.jks");
KeyStore jks = KeyStore.getInstance("jks");
String pass = "123456";
char[] pem = pass.toCharArray();
jks.load(resource.getInputStream(), pem);

RSAKey rsaKey = RSAKey.load(jks, "felordcn", pem);

JWKSet jwkSet = new JWKSet(rsaKey);
return new ImmutableJWKSet<>(jwkSet);
}

参考文章列表