JWT令牌概述
传统的用户会话状态管理一般是服务端通过类似键(Key)、值(Value)的数据结构将用户会话Session信息存储到缓存或数据库中,然后把该数据的Key返回给客户端,为了保证唯一性和安全性,一般Key值会是类似UUID的一个值,UUID本身没有意义,必须和服务端的会话数据关联。这意味着管理用户Session的服务是有状态的,对于有状态的服务管理,随着用户量的增大就会面临状态一致性,扩展性、高可用等各种分布式问题。
客户端(cookie:a49bc98a946f47)------------------>服务端{"a49bc98a946f47":"userinfo"}
JWT令牌如:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
形式上也是类似UUID的形式,但JWT令牌是经过编码生成的里面可以包含各种字段信息,可包括对这些字段信息的防篡改签名以及用户签名的算法,类似HTTP协议一样,这些都已经成为了RFC行业标准。
使用JWT令牌有如下优点:
- 集中化认证:认证(Authentication)逻辑可以委托给独立认证服务,包括公司内部自己研发的集中式认证服务或者可以颁发JWT令牌的LDAP产品或一个外部的第三方商业的认证服务提供者,类似国外的Okta、Auth0,国内的Authing这些认证服务提供商。
- 密钥更安全:颁发JWT令牌的外部认证服务和使用JWT令牌的应用服务是完全分开的,认证服务也不需要和应用服务共享密钥,不需要在应用服务器上存储密钥,大大降低了密钥被泄露的风险。
- 认证高可用:应用服务每次在进行认证逻辑的过程中不需要实时调用认证服务,应用服务可以做到完全无状态。
简单总结下:JWT令牌可以应用服务完全无状态,可以让颁发令牌的认证服务和验证令牌的应用服务完全分开。在应用服务中只需要检查JWT令牌本身就可以做验证逻辑,应用服务器可以将登录认证这些逻辑完全委托给独立的认证服务,聚焦应用本身的业务逻辑职责,更加简单和安全,大部分的认证逻辑都由独立的认证服务完成,这些认证逻辑可以在不同的应用服务中复用。
什么是JWT
JWT是JSON Web Token的简称。简单来说是一个JSON对象,在这个对象中包含一些关键的属性声明,JWT最关键的特性是:如果要去验证JWT令牌是否有效,我们只需要检查JWT令牌本身就可以,而不需要依赖任何其它服务,也不需要把JWT令牌存储在第三方服务的缓存中,每次都去请求校验,因为JWT令牌自身会携带消息认证码MAC(Message Authentication Code),对JWT包体信息进行签名(后面会介绍MAC原理)。
一个JWT令牌通常是由以下三个部分组成:头部(Header)、包体(Payload)和签名(Signature)
JWT包体(Payload):
JWT令牌的包体其实就是一个普通的JSON对象,比如下面的示例就是一个有效的JWT令牌包体(Payload):
{
"id": "8888",
"name": "john",
"email": "john@exmpale.com",
"admin": false
}
在这个示例中,JWT的包体包含了一个用户的关键字段信息,在实际应用中,JWT令牌的包体可以包含任何有意义的字段信息,比如银行的转账账户。
JWT令牌包体中的字段和值是没有限制的,但是需要注意的是JWT令牌包体中的字段和值是没有加密的,相当于是明文传输的,所以任何在JWT令牌包体中传输的内容,如果被劫持了后,都会泄露,因此不应该在JWT的中包体中存放一些对攻击者有利的敏感信息。
JWT头部(Header):
使用JWT令牌的接收者收到JWT后,通过JWT令牌的签名(Signature)对包体内容进行验证。 但是实际中有很多种不同类型的签名算法,所以接收者需要知道JWT令牌具体使用了哪种签名算法,这部分标记签名算法类型的元信息就是通过JWT头部来承载的。
JWT头部本身的结构也是一个JSON对象,如下示例:
{
"alg": "RS256",
"typ": "JWT"
}
可以看到,在上面的JWT令牌头部中,JWT令牌的签名算法是RS256,当然实际中还有很多其它类型的签名算法,如HS256、EC256等,后面再详细说明,下面我们主要看下JWT令牌是怎么通过签名做有效合法性验证的
JWT签名&认证流程
JWT的最后一部分是签名(Signature),其实就是消息认证码MAC(Message Authentication Code),签名是通过JWT的头部、包体和一个保密的私钥通过哈希函数运算生成的
签名认证的基本流程如下:
--------------------------用户名+密码---------------------------->
用户 认证服务
<------------------------签名JWT令牌(包含用户信息)------------------
- 用户在用户端(浏览器等)输入用户名密码信息,提交到认证服务器,认证服务可能包括在我们的应用服务中,但大部分情况,如单点登录,认证服务是一个单独的服务。
- 认证服务验证用户名和密码组合通过后会生成JWT令牌,在这个JWT令牌的包体(Payload)中会包含用户的标志信息(用户id,用户名,邮箱等)和JWT令牌的过期时间。
- 认证服务接下来会使用一个保密的私钥对上面生成的JWT令牌头部和包体签名,然后将签名后的JWT令牌发送回用户端。
- 用户端接下来会在后续的每次请求中携带上面的JWT令牌,发送给应用服务器,签名后的JWT令牌其实是扮演一个临时的用户凭证,用来代替用户的用户名和密码组合凭证
应用服务器端的令牌认证原理如下:
------------------------签名JWT令牌---------------------------->
用户 应用服务(签名验证-->解析令牌-->用户信息)
<------------------------成功返回-------------------------------
- 应用服务JWT令牌后会检查令牌的签名来确保该JWT令牌确实是由拥有保密私钥的服务签名生成的
- 应用服务验证签名成功后通过JWT令牌的包体Payload可以获取特定用户的信息
- 只有认证服务器拥有保密私钥,而认证服务器仅在用户提交了正确的用户名和密码的条件下才会颁发JWT令牌给用户,因此应用服务器可以安全地确保JWT令牌的确是由认证服务颁发给该拥有正确密码的特定用户
- 应用服务继续处理请求,并且确保该请求是属于某个特定用户的。
攻击者攻击仿冒用户的唯一方式是:盗取用户的登录用户名和密码或者盗取认证服务的保密私钥。
由此可见,签名是JWT令牌的重要组成部分,签名可以让应用服务器完全无状态,只需要检查JWT令牌本身就可以确保客户端的请求确实属于某个特定用户,无需用户在每次请求中携带用户名和密码。
JWT令牌举例
下面是访问 jwt.io后里面的一个JWT令牌示例:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
上面的JWT令牌中没有前文中所说的JSON对象字符串,显示的JSON对象通过Base64Url编码后的结果,后面会详细介绍
可以看出上面的JWT令牌通过点号(.)分割为了三个部分,第一个点号前的服务是JWT头部(Header):
JWT Header: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
位于第一个点号和第二个点号之间的部分是JWT的包体(Payload):
JWT Payload: eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ
第二个点号后面的部分是JWT的签名(Signature):
JWT Signature: SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
可以将JWT令牌复制到在线验证工具中jwt.io查看JWT令牌里面的JSON结构信息,如果要在实际中排查问题分析JWT令牌,或者想知道JWT令牌工具怎么获取到令牌中的JSON结构信息的,我们需要理解JWT令牌的编码规则Base64URL
JWT令牌身份认证
前面我们提到过,JWT令牌的包体JSON对象中可以包含任何字段信息,不只是认证用户信息,因为JWT令牌在用户身份认证中使用非常普遍,所以JWT令牌规范就针对用户认证的场景专门定义了一些通用的字段去支持:用户识别和会话管理,各个支持JWT令牌编程语言库和框架也以常量的方式内置了这些字段值
下面是使用了JWT令牌中最常用的包体字段的示例:
{
"iss": "example.com",
"iat": 1655186856,
"sub": "john",
"exp": 1655273329
}
上面示例中的标准字段属性说明如下:
iss 表示颁发JWT令牌的实体(issuer),一般是认证服务的Domain
iat 表示创建JWT令牌的时间(issueAt),以秒为单位(从Epoch开始)
sub 包括JWT令牌的主题(subject),比如用户名|用户id等,该字段用于唯一标志某个主题
exp 表示令牌的过期时间(expiration),以秒为单位(从Epoch开始)
JWT令牌也经常常叫Bearer Token,表示认证服务可以知道该Bearer Token,是通过sub属性定义的id表示的用户,给这个用户访问权限
现在我们了解JWT令牌的包体(Payload)在用户认证中的应用,接下来我们看下签名(Signature)的部分
JWT令牌的签名有很多种类型包括HS{256,384,512},RS{256,384,512},RS{256,384,512},ES{256,384,512},EdDSA,所有的这些签名类型可以分为两类:以HS开头的基于共享私钥的签名和其它基于公钥算法的签名,后面的数字256,384,,512表示SHA哈希算法的强度或比特位。
下面主要介绍HS256和RS256签名算法,其它的可以类比理解:
JWT签名原理
Base64编码
Base64是将二进制的数据转换为可见文本,一般用于HTTP传输不可见字符,64是指64个基本字符,包括A-Z、a-z、0-9、+/=,也就是在键盘上能看到的常用ASCII字符,64表示2的6次方,也就是把8位二进制的字节序列,按照6位比特位进行切割,每6位比特编码为1个可见字符,如果末尾不够6位比特,通过填充的方式处理。标准的Base64编码会使用如下64个字符ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/。
因为在Base64中的+和/在HTTP协议URL中都属于特殊字符,其中+号表示转义的空格,/表示URL路径分割符,所以如果使用标准的Base64编码作为URL参数传输就可能就会出现错误,所以就出现了Base64URL编码解决这个问题,Base64URL编码就是分别使用-和_分别替换标准Base64中的+和/字符,即Base64使用下面64个字符编码:ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_。
所以JWT令牌中使用的是Base64URL编码,而不是标准的Base64编码。这样JWT令牌不仅能够作为请求头部和请求体参数传递,而且还能够作为URL参数传递,比如登录页面重定向的时候可能会携带一个JWT令牌作为URL参数
我们把上文中的示例JWT令牌中的包体(Payload)
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ
单独拿出来通过在线工具base64decode Base64解码得到如下结果
{
"sub":"1234567890",
"name":"John Doe",
"iat":1516239022
}
可以看出JWT令牌的头部和包体分别是两个完全独立JSON对象字符串,分别通过Base64URL编码后,然后通过点号(.)拼接起来的。
哈希函数
和大部分的数字签名类似,HS256签名是基于密码学中的哈希函数完成的,大部分的Web安全实现都会有密码中的哈希函数组件,下面我们介绍下其背后的原理:首先说明下什么是哈希函数,然后说明下哈希函数和保密的私钥结合怎么生产消息认证码(Message Authentication Code),即数字签名,最后我们可以使用一些在线的工具或者编程语言类库生成HS256的数字签名
哈希函数是一类特别的函数,在实际密码学中有广泛的用途,常用于生成随机数和数字签名等,比如最常用的SHA256哈希函数,这类函数主要以下四个特性:
哈希函数特性
- 特性1:不可逆性
例如我们把JWT的头部和包体输入到哈希函数,最后会得到一个输出,但是绝对不可能通过输出结果得到原始的输入
d63940be5b3ed4da6033fa3dd715b5d652e44c3e9fca5433a4e0298d010a74d4
上面的输出是64位字符串,每个字符是16进制值,总共表示 256比特的数字,如果是SHA384,最后生成的就是384位比特,SHA512类似,需要注意的是哈希并不是加密,加密是一个可逆的过程,我们可以通过密文解密得到明文,但哈希函数是单向不可逆的
- 特性2:可重现性
如果哈希函数的输入是相同的,无论运行多少次,它的输出结果一定是按比特位相同的,这意味着如果给定一组特定的输入和输出,我们可以验证给定的输出(签名)是否正确,因为只要有完整的输入,就可以很容易地重现哈希计算结果
- 特性3:无碰撞性
该特性表示如果我们给哈希函数不同的输入,对于每个不同的输入一定会得到唯一不同的输出。当然理论上一定会有哈希碰撞,因为哈希函数输出是一个有限域的值,比如SHA256输出有2的256次方个值,但输入是无限的,但因为2的256次方已经足够大到在实际上不可能有碰撞发送,也可以理解碰撞发生的概率为1/(2的256次方)。
因此可以认为实际上不会出现这样一种情形:不同的输入会得到相同的哈希输出,这意味着如果我们把一个JWT令牌的头部和包体输入给哈希函数就会得到一个确定的输出,没有任何其它不同的其它输入会得到相同的输出,也就意味着哈希函数的结果可以作为输入数据的唯一表示
另外如果对于一个特定的输入A1得到的函数结果输出H,如果能够找到另外一个输入A2也得到输出H,就意味着哈希函数被攻破,类似MD5,实际中不推荐使用
- 特性4:不可预测性
该特性表示给定一个输出结果,不可能通过某种连续增量逼近的方式去推测出输入,比如上面的哈希输出,为了尝试去找到产生该输出的输入,首先猜测一个输入去比对是否接近期望的输出,然后不断的修改输入检查输出是否逼近,不断重复这个过程,最后找到准确的输入。对于哈希函数,这完全是不可能做到的,因为在哈希函数中,如果只修改输入里面的一个字符、甚至一个比特位,输出的结果就会有50%的比特位发生变化,即很微小的输入改变就会产生完全不同的输出结果
JWT令牌HS256签名算法
JWT令牌HS256签名
前面介绍了哈希函数的特性,通过哈希函数怎么完成数字签名的,当然攻击者也可以获取JWT令牌的头部和包体伪造签名,任何人都可以通过SHA256哈希函数获取相同的哈希结果,然后把该结果拼接到JWT头部和包体的尾部作为签名?也就是说对于任何一个给定的JWT令牌的头部和包体,任何人都可以通过SHA265哈希函数生成一个相同的结果。
基于HS256算法的JWT签名不仅仅只把JWT的头部和包体作为输入,而是在头部和包体的后面还会添加一个保密的私钥作为哈希输入,最后输出结果就是SHA256 HMAC,实际中使用该方法的就是HMAC-SHA256函数,用于JWT令牌的HS256签名。
HMAC-SHA256函数的结果只有在用户的JWT令牌头部、包体和保密的私钥的都已知的情况下才能生成,这意味着该哈希结果可以作为签名使用。因为SHA256哈希结果可以证明,只有同时拥有保密私钥和JWT令牌包体的实体才能生成该哈希结果,任何其它方式都无法生成相同的哈希结果。哈希值作为一个数字凭证证明:JWT令牌的包体是合法有效的。
生成的哈希值会被追加到消息体上,为了能够让接收者认证, 这个哈希值被称为HMAC: Hash-Based Message Authentication Code,这是数字签名的一种形式,也就是JWT令牌的签名:JWT令牌的最后一部分(第二个点号后面的部分)是JWT头部和包体的SHA-256哈希值。
JWT令牌HS256验签
当接收到基于HS256签名的JWT令牌,为了验证签名:即令牌的包体确实是合法的,必须有对应的保密私钥。JWT的头部和包体再加上保密私钥做哈希运算去检查JWT令牌的签名,因此基于HS256签名的JWT令牌校验,接收者必须和发送者共享保密私钥。如果哈希计算的结果值和JWT令牌中的签名值是相同的,则证明JWT令牌是合法有效的,因为只有拥有保密私钥的用户才能生成该签名。这就是数字签名和HMAC的工作原理
JWT令牌HS256实现
下面以Go语言为例说明基于HS256算法的签名实现,Go语言的标准类库已经内置了哈希和加密算法,JWT的库主要使用第三方库github.com/golang-jwt/jwt/v4,其它语言如Java可以使用第三方加密库 org.bouncycastle:bcprov-jdk15on
package main
import (
"crypto/rand"
"fmt"
"github.com/golang-jwt/jwt/v4"
"time"
)
func main() {
// 设置JWT包体的字段
jwtClaimsMap := make(map[string]interface{})
jwtClaimsMap["sub"] = "1234567890"
jwtClaimsMap["name"] = "John Doe"
jwtClaimsMap["iat"] = time.Now().UnixMilli() / 1000
// 创建JWT Token对象
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims(jwtClaimsMap))
// 生成一个32字节(256比特)的共享密钥,实际中如果保存,可以生成Base64或Hex
var rangKey [32]byte
_, err := rand.Read(rangKey[:])
if err != nil {
panic(err)
}
// 数组转换为切片
secretKeyBytes := rangKey[:]
// 使用上面的共享密钥对JWT令牌进行签名
jwtToken, err := token.SignedString(secretKeyBytes)
if err != nil {
panic(err)
}
fmt.Println(jwtToken)
// 对签名后的JWT令牌进行验签,回调函数返回共享密钥
parsedToken, err := jwt.Parse(jwtToken, func(token *jwt.Token) (interface{}, error) {
return secretKeyBytes, nil
}, jwt.WithJSONNumber())
// 判断JWT令牌是否有效
fmt.Println(parsedToken.Valid)
// 获取JWT令牌中的包体字段
if claims, ok := parsedToken.Claims.(jwt.MapClaims); ok {
fmt.Printf("name: %s, sub:%s, iat: %s\n", claims["name"], claims["sub"], claims["iat"])
}
}
HS256签名问题
安全性问题
如果输入的保密私钥比较简单,HS256签名可以被暴力破解攻击,但是这是所有的基于私钥的加密签名算法都会遇到的问题。对于生产系统中特定的私钥长度,基于哈希的签名和其它的签名相比,更容易被暴力攻击。
不仅仅是安全的问题,实际中HS256签名最大的缺点是它需要在颁发JWT令牌的认证服务器和验证令牌的应用服务器或其它消费令牌的服务之间共享保密私钥,增加了保密私钥被泄露的风险。
私钥轮换问题
如果想去修改基于HS256的JWT令牌的保密私钥,就需要在所有需要私钥的网络节点中分发安装私钥,这是非常不方便的,而且容易出错,涉及到服务之间的协同依赖下线。而且在实际中有可能不同的服务是由不同的组织或团队管理,分发共享保密私钥是不切实际的。
令牌创建和校验耦合
基于HS256的签名算法,所有在网络中使用JWT令牌的节点都有保密私钥,所以都能够创建令牌并且校验令牌,对于JWT令牌的创建和校验能力没有做任何区分。
因为保密私钥被分发安装到了所有消费令牌的节点,在所有这些节点保密私钥都有可能丢失或者被盗,,因为不是所有的应用都有相同的安全保护级别。
解决上述问题的方式是对所有应用创建一个共享的保密私钥,但实际上,我们接下来要了解的RS256签名算法可以解决所有这些问题,现代的基于JWT令牌的解决方案默认都会使用类似RS256的基于公钥的签名算法。
JWT令牌RS256签名算法
HS256是所有签名类型中的一种,其它的签名中比较常用的是RS256,前面的大部分对HS256的介绍主要是可以更容易地去理解消息认证码(MAC)的原理,在实际中生产环境一些简单的应用中也会经常使用。但是JWT令牌更好的鉴权方式是使用类似基于公钥算法的签名,包括其它的ES256,EdDSA等,下面主要以RS256为例来说明原理,RS256相比HS256有很多优势
基于RS256的JWT令牌签名算法,和HS256类似也会产生消息认证码(Message Authentication Code),也是用来创建数字签名,证明JWT令牌的合法有效。但是基于RS256算法签名下,我们能够将创建JWT令牌的能力和验证JWT令牌的能力很好的分开,只有认证服务能够创建合法有效的JWT令牌,只有使用消费JWT令牌的服务能够校验JWT令牌。
要做到这一点,我们需要创建两个key,分别为公钥和私钥:
● 私钥被认证服务用来对JWT令牌签名
● 私钥仅仅只能用来对JWT令牌签名,而不能用来校验令牌
● 第二个秘钥为公钥,可以用来校验JWT令牌,但不能用来对JWT令牌签名
● 公钥无须保密,实际上也是公开的,即使攻击者获取了公钥,也是不能被用来伪造JWT令牌签名
RSA加密算法
RS256基于公钥加密算法(也叫非对称加密)RSA,有一对key,使用其中一个key加密,另一个key解密。需要注意的是RSA不是哈希函数,因为RSA加密的结果具有可逆性,可以逆向解密得到原始结果
RSA的公钥key如下所示:
-----BEGIN PUBLIC KEY-----
MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDdlatRjRjogo3WojgGHFHYLugdUWAY9iR3fy4arWNA1KoS8kVw33cJibXr8bvwUAUparCwlvdbH6dvEOfou0/gCFQsHUfQrSDv+MuSUMAe8jzKE4qW+jK+xQU9a03GUnKHkkle+Q0pX/g6jXZ7r1/xAK5Do2kQ+X5xK9cipRgEKwIDAQAB
-----END PUBLIC KEY-----
上面的中的公钥key可以通过OpenSSL命令行工具或者在线的RSA key生成工具http://travistidwell.com/jsencrypt/demo/ 生成,公钥key本身就是公开的,无须保密
与公钥key对应的是私钥key,如下所示:
-----BEGIN RSA PRIVATE KEY-----
MIICWwIBAAKBgQDdlatRjRjogo3WojgGHFHYLugdUWAY9iR3fy4arWNA1KoS8kVw33cJibXr8bvwUAUparCwlvdbH6dvEOfou0/gCFQsHUfQrSDv+MuSUMAe8jzKE4qW+jK+xQU9a03GUnKHkkle+Q0pX/g6jXZ7r1/xAK5Do2kQ+X5xK9cipRgEKwIDAQABAoGAD+onAtVye4ic7VR7V50DF9bOnwRwNXrARcDhq9LWNRrRGElESYYTQ6EbatXS3MCyjjX2eMhu/aF5YhXBwkppwxg+EOmXeh+MzL7Zh284OuPbkglAaGhV9bb6/5CpuGb1esyPbYW+Ty2PC0GSZfIXkXs76jXAu9TOBvD0ybc2YlkCQQDywg2R/7t3Q2OE2+yo382CLJdrlSLVROWKwb4tb2PjhY4XAwV8d1vy0RenxTB+K5Mu57uVSTHtrMK0GAtFr833AkEA6avx20OHo61Yela/4k5kQDtjEf1N0LfI+BcWZtxsS3jDM3i1Hp0KSu5rsCPb8acJo5RO26gGVrfAsDcIXKC+bQJAZZ2XIpsitLyPpuiMOvBbzPavd4gY6Z8KWrfYzJoI/Q9FuBo6rKwl4BFoToD7WIUS+hpkagwWiz+6zLoX1dbOZwJACmH5fSSjAkLRi54PKJ8TFUeOP15h9sQzydI8zJU+upvDEKZsZc/UhT/SySDOxQ4G/523Y0sz/OZtSWcol/UMgQJALesy++GdvoIDLfJX5GBQpuFgFenRiRDabxrE9MNUZ2aPFaFp+DyAe+b4nDwuJaW2LURbr8AEZga7oQj0uYxcYw==
-----END RSA PRIVATE KEY-----
对于上面的私钥key,如果不是被异常泄露,正常情况下攻击者是永远不会猜测到的
RSA加解密算法会生成一组公私钥key,使用其中一个key加密后,可以使用另外一个key解密,但是怎么使用RSA算法生成JWT令牌签名呢?
RSA加密对象
使用RSA加解密算法生成JWT令牌签名,一种方法是,使用RSA的私钥对JWT令牌的头部和包体进行加密,加密的结果作为签名,然后把带签名的JWT令牌分发出去。接收者接受到加密签名的JWT令牌后,通过对应的RSA的私钥进行解密,然后检查结果,如果能够正常解密,并且解密的结果是一个JSON包体,这意味着JWT令牌确实是由认证服务加密签名的,证明令牌是合法有效的。这种方式的确可以工作,可以有效验证令牌的合法性,但是实际中并不会这么做。因为和哈希函数计算相比,RSA加密的过程是十分缓慢的,对于包含很多的数据的比较大的JWT令牌包体来说,会产生性能问题。所以实际中并不是直接对JWT的包体数据进行加密的。
实际中,一般是先通过类似SHA256的哈希函数对JWT令牌的头部和包体进行计算,生成一个哈希值,这是一个计算很快的过程,得到的哈希值比原始的数据会小很多,而且该哈希结果长度是确定的。然后使用RSA的私钥对生成的哈希值进行加密,加密的结果就是最后JWT令牌的RS256签名。
RS256验签
接收者收到含有RS256的签名JWT令牌以后,通过下面的步骤校验
- 第一步使用SHA256哈希函数对JWT令牌中的头部和包体计算得到哈希值
- 第二步使用RSA的公钥对JWT令牌中的RS256签名进行解密,得到签名的哈希值
- 第三步比较第一中计算的哈希值步和第二步中解密得到的哈希值是否相同
第三步中如果比较结果相同,就能够证明JWT令牌是由认证服务生成颁发的,任何人都能够计算哈希值,但只有认证服务能够使用对应的RSA私钥加密JWT令牌包体生成令牌的签名。
所以说RS256是使用RSA加密算法对JWT令牌的头部和包体的SHA256哈希值进行加密后生成的,类似的ES256是使用ECC椭圆曲线加密算法对JWT令牌的头部和包体的SHA256哈希值进行加密后生成的。
JWT令牌RS256实现
下面以Go语言为例说明基于RS256算法的签名实现,使用和HS256类似的库,下面的代码实例为了说明主要逻辑,使用占位符(_)省略了错误检查和处理,实际中要注意加上
package main
import (
"bytes"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/pem"
"fmt"
"github.com/golang-jwt/jwt/v4"
"time"
)
func main() {
// 设置JWT包体的字段
jwtClaimsMap := make(map[string]interface{})
jwtClaimsMap["sub"] = "1234567890"
jwtClaimsMap["name"] = "John Doe"
jwtClaimsMap["iat"] = time.Now().UnixMilli() / 1000
// 创建JWT Token对象,签名算法为RS256
t := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims(jwtClaimsMap))
// 生成RSA的私钥
privateKey, _ := rsa.GenerateKey(rand.Reader, 2048)
// 把RSA的私钥转换为x509 PKCS8格式的字节序列
//privateDerBytes, _ := x509.MarshalPKCS8PrivateKey(privateKey)
// 基于RSA的私钥产生公钥,并且转换为x509对应格式的字节序列
pubDerBytes, _ := x509.MarshalPKIXPublicKey(&privateKey.PublicKey)
// RSA公钥转换为x509 PEM格式(-----BEGIN PUBLIC KEY-----) 实际中分发保存
var pemPubBuf bytes.Buffer
pem.Encode(&pemPubBuf, &pem.Block{Type: "PUBLIC KEY", Bytes: pubDerBytes})
fmt.Println(pemPubBuf.String())
jwtToken, _ := t.SignedString(privateKey)
fmt.Println(jwtToken)
// 对签名后的JWT令牌进行验签,回调函数返回共享密钥
parsedToken, _ := jwt.Parse(jwtToken, func(token *jwt.Token) (interface{}, error) {
return x509.ParsePKIXPublicKey(pubDerBytes)
}, jwt.WithJSONNumber())
// 判断JWT令牌是否有效
fmt.Println(parsedToken.Valid)
// 获取JWT令牌中的包体字段
if claims, ok := parsedToken.Claims.(jwt.MapClaims); ok {
fmt.Printf("name: %s, sub:%s, iat: %s\n", claims["name"], claims["sub"], claims["iat"])
}
}
JWT令牌RS256&HS256签名比较
基于RS256的签名算法,攻击者可以很容易通过SHA256哈希函数计算JWT令牌的头部和包体的哈希值,但是要去通过哈希值生成签名,就必须知道RSA的私钥,对于一个合理长度的RSA私钥,要想破解获取是不可能做到的。这并不是选择RS256而不是HS256的主要原因。使用RS256算法,最大好处是RSA的私钥只需要保存在认证服务这一个地方,不需要被共享到其它地方,这意味着私钥被泄露或者盗取的风险大大降低。另外使用RS256,还可以很方便地实现秘钥轮换。
JWT令牌密钥轮换
RS256密钥轮换
前面提到认证服务通过RSA私钥颁发了JWT令牌后,对应的公钥可以被发布到任何地方用来验证JWT令牌的合法性,并且攻击者拿到公钥后也不能做什么。毕竟对于攻击者来说,如果能够验证一个被盗的JWT令牌也没什么好处,事实上攻击者想做的是去伪造JWT令牌,而不是校验。但是万一公钥对应的私钥被泄露或者被盗,这时候对应的公钥也要发生变化,因此最好能够通过某种方式集中控制发布公钥,应用服务通过连接该服务获取公钥,在一些情况下,可以定期轮换密钥,公钥发生变化了,能够定期检查获取变化后的公钥。不需要让应用服务和认证服务同时下线,只需要在一个地方集中更新公钥即可。
JWKS规范
有很多种方式可以发布RSA的公钥,其中RFC中规定了一种格式叫JWKS,是JSON Web Key Set的缩写,一些框架库会直接使用JWKS的端点验证JWT令牌,JWKS可以同时表示多个公钥,下面是一个JWKS的示例:
{
"keys": [
{
"alg": "RS256",
"kty": "RSA",
"use": "sig",
"x5c": [
"MIIDJTCCAg2gAwIBAgIJUP6A/iwWqvedMA0GCSqGSIb3DQEBCwUAMDAxLjAsBgNVBAMTJWFuZ3VsYXJ1bml2LXNlY3VyaXR5LWNvdXJzZS5hdXRoMC5jb20wHhcNMTcwODI1MTMxNjUzWhcNMzEwNTA0MTMxNjUzWjAwMS4wLAYDVQQDEyVhbmd1bGFydW5pdi1zZWN1cml0eS1jb3Vyc2UuYXV0aDAuY29tMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwUvZ+4dkT2nTfCDIwyH9K0tH4qYMGcW/KDYeh+TjBdASUS9cd741C0XMvmVSYGRP0BOLeXeaQaSdKBi8uRWFbfdjwGuB3awvGmybJZ028OF6XsnKH9eh/TQ/8M/aJ/Ft3gBHJmSZCuJ0I3JYSBEUrpCkWjkS5LtyxeCPA+usFAfixPnU5L5lyacj3t+dwdFHdkbXKUPxdVwwkEwfhlW4GJ79hsGaGIxMq6PjJ//TKkGadZxBo8FObdKuy7XrrOvug4FAKe+3H4Y5ZDoZZm5X7D0ec4USjewH1PMDR0N+KUJQMRjVul9EKg3ygyYDPOWVGNh6VC01lZL2Qq244HdxRwIDAQABo0IwQDAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBRwgr0c0DYG5+GlZmPRFkg3+xMWizAOBgNVHQ8BAf8EBAMCAoQwDQYJKoZIhvcNAQELBQADggEBACBV4AyYA3bTiYWZvLtYpJuikwArPFD0J5wtAh1zxIVl+XQlR+S3dfcBn+90J8A677lSu0t7Q7qsZdcsrj28BKh5QF1dAUQgZiGfV3Dfe4/P5wUaaUo5Y1wKgFiusqg/mQ+kM3D8XL/Wlpt3p804dbFnmnGRKAJnijsvM56YFSTVO0JhrKv7XeueyX9LpifAVUJh9zFsiYMSYCgBe3NIhIfi4RkpzEwvFIBwtDe2k9gwIrPFJpovZte5uvi1BQAAoVxMuv7yfMmH6D5DVrAkMBsTKXU1z3WdIKbrieiwSDIWg88RD5flreeTDaCzrlgfXyNybi4UTUshbeo6SdkRiGs="
],
"n": "wUvZ-4dkT2nTfCDIwyH9K0tH4qYMGcW_KDYeh-TjBdASUS9cd741C0XMvmVSYGRP0BOLeXeaQaSdKBi8uRWFbfdjwGuB3awvGmybJZ028OF6XsnKH9eh_TQ_8M_aJ_Ft3gBHJmSZCuJ0I3JYSBEUrpCkWjkS5LtyxeCPA-usFAfixPnU5L5lyacj3t-dwdFHdkbXKUPxdVwwkEwfhlW4GJ79hsGaGIxMq6PjJ__TKkGadZxBo8FObdKuy7XrrOvug4FAKe-3H4Y5ZDoZZm5X7D0ec4USjewH1PMDR0N-KUJQMRjVul9EKg3ygyYDPOWVGNh6VC01lZL2Qq244HdxRw",
"e": "AQAB",
"kid": "QzY0NjREMjkyQTI4RTU2RkE4MUJBRDExNzY1MUY1N0I4QjFCODlBOQ",
"x5t": "QzY0NjREMjkyQTI4RTU2RkE4MUJBRDExNzY1MUY1N0I4QjFCODlBOQ"
}
]
}
其中kid表示key的标志符(keyId),x5c表示特定格式的公钥(x509 Certificate)
这种格式的最大好处是规范标准化,很多时候我们只需要知道对应JWTS的URL地址,三方库和框架会实现定时器自动通过URL地址获取JWKS验证JWT令牌的合法性,无需手动部署公钥到我们的服务。
企业JWT令牌应用
JWT令牌不仅经常用来做公网站点的认证,如微信等社会化用户登录,对于企业内部的应用来说也是非常有用的,典型的预认证(Pre-Authentication)在企业中的场景经常会有很多安全的问题。
传统登录预认证方案
很多企业使用的预认证(Pre-Authentication),应用服务运行一个私有网络中,在反向代理的后面,通常会从HTTP的请求头部中获取当前用户信息。标志当前用户的HTTP头部经常是由一个中心化的登录服务处理,如果用户会话过期了,反向代理服务会拦截用户的访问,重定向到登录页面,只有在用户重新登录后才能认证通过。在这之后,增加上标志当前的用户的HTTP头部信息,将请求转发到后端服务。
这种架构下,在该私有网络中的任何人,仅仅通过设置HTTP头部,就可以很容易地假冒用户通过访问。对于这种问题,有很多种解决方案,比如在应用服务的层面设置反向代理的IP白名单,仅仅允许反向代理通过或者使用客户端证书,但实际中落地实现比较复杂,大部分公司都没有这种安全措施。
JWT令牌预认证方案
预认证的方案有很多好处,它减轻了应用开发者的开发负担,他们不需要在应用程序中单独实现认证逻辑,节约了时间,同时避免了潜在的安全问题。
如果使用JWT令牌作为解决方案,既有预认证方案的好处,又能避免假冒用户的安全问题,代替传统的预认证方案中把用户名等信息放在HTTP头部中,我们把JWT令牌作为HTTP的头部。把用户名等信息放置到JWT令牌的包体中,通过认证服务签名。
对于应用服务来说,对传统的从HTTP头部中直接获取用户名等信息,在JWT令牌的方案中,首先会验证令牌的合法有效性,如果JWT令牌的签名是有效合法的,则当前的用户是合法的,请求允许通过,否则拒绝服务该请求。
通过使用JWT签名令牌,可以让认证在企业内部很好的工作。应用服务不需要再盲目地信任HTTP头部中仅仅含有用户名等信息的请求。通过使用JWT令牌,可以确保HTTP请求确实是来自反向代理并且是合法有效的,而不是某个用户冒充其它用户访问的。
总结
这篇文章中,我们对JWT令牌做了一个全面的了解,JWT令牌在认证中的工作原理做了详细的说明。简单来说,JWT令牌就是一个JSON的对象包体,包含一个不可伪造的签名。当然JWT令牌不仅仅只是作为身份认证而存在,我们可以在JWT令牌中包括任何信息,然后通过网络传输。JWT令牌的使用也可以是权限相关的场景,比如我们可以在JWT令牌中的包体中放置用户的权限角色列表:只读用户,管理员等。