JSON Web Token 浅析

本文更新于 {{ days }} 天前,内容可能已经过时。

JSON Web Token 是一种用于安全传递信息的方案,目前被广泛应用于认证授权等场景,本文将介绍它的实现原理和用法实践。

什么是 JWT?#

JSON Web Token (JWT) 是一种开放标准 (RFC 7519),它定义了一种紧凑的、URL 安全的协议,被用于在各方之间以 JSON 对象的形式安全地传输信息。

JWT 的应用#

JWT 主要被应用跨域认证和授权。

在互联网时代,身份认证是重要的一环,一般我们通过 Cookie 和 Session 来进行身份认证,确保 HTTP 请求的用户身份和权限。比如将用户身份和权限信息存储到服务端 Session 存储中,然后将 Session ID 明文或加密存储到 Cookie 或浏览器 Storage 中。浏览器每次发起 HTTP 请求将携带 Session ID,然后服务端再根据 ID 获取具体的用户身份和权限信息。

随着业务的增长,服务端的架构可能发生变化,比如从单体架构变成分布式架构、微服务架构,甚至开始依赖一些其他公司的服务。这时候如果还使用 Session 来进行身份验证,那么 Session 存储将成为整个架构里面的单点依赖,存在稳定性风险;另一方面跨域的服务也不应该访问同一个存储。

JWT 的出现为这种场景提供了一个新的解决方案。由签发身份的一方为用户签发 JWT,JWT 里面记录了用户身份、权限等信息,并设置过期时间。用户将 JWT 存储在浏览器本地,当需要发起 HTTP 请求时携带 JWT,服务端接收到请求后通过 JWT 校验 JWT 是否合法,并得到用户身份、权限信息。

JWT 的另一个应用便是在各方之间传输信息,原理与携带用户身份、权限信息一致。

JWT 的结构#

下面是一个 JWT 的例子。(以下的换行仅仅是为了方便展示,实际 JWT 并没有换行)

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

JWT 有三段字符串组成,这三段字符串用 . 拼接,它们分别是:

  • Header:头部,携带 JWT 的元信息;
  • Payload:负载,存放需要传输的信息;
  • Signature:签名,用于防止 Header 和 Payload 被篡改。

JWT 通过 Base64 算法处理 Header 和 Payload 以确保 Token 本身是 URL 安全的,现在我们可以对前两段字符串进行解码分析一下。

通过浏览器控制台的 atob 函数可以将 Base64 字符串解码。

1
2
atob('eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9')
// => '{"alg":"HS256","typ":"JWT"}'

上面的代码中,我们将 JWT Header 的数据转换成明文可读的 JSON 字符串。其中,alg 属性表示签名的算法,这里的 HS256 就是 HMAC-SHA256 哈希算法,JWT 所支持的签名算法还有 RS256、ES256 等等;typ 表示 Token 的类型,由于是 JSON Web Token,所以这里固定填 JWT。

1
2
3
4
{
  "alg": "HS256",
  "typ": "JWT"
}

Payload#

Payload 也是一个 JSON 对象,与 Header 一样,经过 Base64 算法处理后填充到 JWT 的第二段字符串中。

1
2
atob('eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ')
// => '{"sub":"1234567890","name":"John Doe","iat":1516239022}'

上面的代码中,我们将 JWT Payload 数据转换成明文可读的 JSON 字符串。Payload 负责携带需要传输的数据,上面例子中可以看到有 sub、name、iat 属性,其中 sub 和 iat 是 JWT 官方规定的字段,而 name 属于我们定义的私有字段。

JWT 规定了以下几个官方字段,但并不要求必须携带这些字段,也不限制额外的字段数据。

  • iss (Issuer):签发人
  • exp (Expiration Time):过期时间
  • sub (Subject):主题
  • aud (Audience):受众

除此之外还有一些常用字段,可以查阅 IANA JWT 注册表

值得注意的是,由于 Payload 仅使用 Base64 处理,因此它不是加密的,不应该将敏感数据存放在此。

Signature#

Signature 是 JWT 的签名,用于防止 Header 和 Payload 被篡改。Signature 使用 Header 中指定签名算法进行加签,比如上面的例子中使用的是 HMAC-SHA256 哈希算法,会按照如下的方式进行加签。

1
HS256(base64(header) + '.' + base64(payload), secret)

上面的 secret 是加签时使用的密钥,这个密钥不能泄露,如果泄露了其他人就可以通过伪造合法的签名,以达到篡改 Header 和 Payload 的目的。

根据算法得到签名后,我们再将 Header、Payload、Signature 通过 . 拼接到一起即可得到最终的 JWT。

JWT 和安全#

前面提到 JWT 被广泛用于认证授权,那么 JWT 是怎么保证应用过程中的安全性呢?

认证的关键在于签名,我们将通过 JWT 的 Payload 携带我们的身份信息,通过签名使得系统信任我们的身份。首先 JWT 是由我们的系统签发,签名由 Header 和 Payload 信息以及未公开的密钥生成。当我们需要认证 JWT 的合法性时,只需要使用 Header 和 Payload 重新生成一个签名,再对比用户传入的签名是否一致即可。

当伪造者修改了 Header、Payload 时,最终得到的签名将不一致。当然,如果密钥被泄露的时候,安全性不再得到保证,因为其他人也可以使用密钥去创建一个合法的签名。

用户
用户
服务器
服务器
1. 通过用户名密码登录
1. 通过用户名密码登录
2. 签发 JWT
2. 签发 JWT
3. 返回登录成功,并返回 JWT
3. 返回登录成功,并返回 JWT
未登录态
未登录态
已登录态
已登录态
4. 请求执行动作,并带上 JWT
4. 请求执行动作,并带上 JWT
5. 生成 JWT 签名并对比
5. 生成 JWT 签名并对比
6. 执行动作
6. 执行动作
7. 返回执行成功
7. 返回执行成功
密钥
密钥
Text is not SVG - cannot display

如果是跨域认证,那以上的流程就走不通了。因为 JWT 认证的时候依赖密钥,而密钥又不能公开,公开密钥给第三方服务,那第三方服务也可以自行伪造身份。这时候我们就需要换一种算法,比如 RS256 (RSA + SHA256)。

RSA 是一种非对称加密算法,它依赖一个密钥对,使用私钥进行加密,使用公钥进行解密。在跨域认证的场景下,JWT 签发的服务可以先通过 SHA256 进行哈希,然后使用私钥对哈希值进行加密。公钥对外公开,第三方服务拿到 JWT 后,使用公钥对签名进行解密,然后再结合 Header 和 Payload 校验 Token 是否合法。

用户
用户
第三方服务器
第三方服务器
1. 通过用户名密码登录
1. 通过用户名密码登录
3. 返回登录成功,并返回 JWT
3. 返回登录成功,并返回 JWT
未登录态
未登录态
已登录态
已登录态
4. 请求执行动作,并带上 JWT
4. 请求执行动作,并带上 JWT
7. 返回执行成功
7. 返回执行成功
用户服务器
用户服务器
2. 签发 JWT
2. 签发 JWT
私钥
私钥
公钥
公钥
5. 认证 JWT
5. 认证 JWT
6. 执行动作
6. 执行动作
Text is not SVG - cannot display

JWT 的简单实现#

接下来我们尝试通过代码实现一下 JWT 的加签和验签流程。

以下代码均为 Golang 版本的简单实现,未考虑性能和各种异常问题,仅供参考。

Token 签发#

首先我们实现一个 HS256 算法的 JWT 生成函数。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
func marshalToBase64String(obj any) string {
	data, err := json.Marshal(obj)
	if err != nil {
		return ""
	}
	return base64.RawURLEncoding.EncodeToString(data)
}

func GenWithHS256(payload map[string]any, secret string) string {
	headerString := marshalToBase64String(map[string]string{"alg": "HS256", "typ": "JWT"})
	payloadString := marshalToBase64String(payload)

	hash := hmac.New(sha256.New, []byte(secret))
	hash.Write([]byte(headerString + "." + payloadString))
	signString := base64.RawURLEncoding.EncodeToString(hash.Sum(nil))
	return headerString + "." + payloadString + "." + signString
}

然后传入 Payload 即可生成 JWT。

1
2
3
payload := map[string]any{"exp": 1710518400, "uid": 1}
token := GenWithHS256(payload, "this_is_secret")
fmt.Println(token) // => eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3MTA1MTg0MDAsInVpZCI6MX0.aw8SWce0WOAE5nDlfFSLnaKvoMGKhuXhqsEEavFrToY

Token 校验#

接下来我们实现一个对 HS256 JWT 的校验函数。这里会判断 JWT 是否合法以及 Token 本身是否已过期,判断过期的逻辑可以自行按实际属性和数据来实现。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
func VerifyWithHS256(jwt string, secret string) (map[string]any, error) {
	// 校验 JWT 是否合法
	data := strings.Split(jwt, ".")
	if len(data) != 3 {
		return nil, errors.New("invalid token")
	}
	hash := hmac.New(sha256.New, []byte(secret))
	hash.Write([]byte(data[0] + "." + data[1]))
	signString := base64.RawStdEncoding.EncodeToString(hash.Sum(nil))
	if signString != data[2] {
		return nil, errors.New("wrong signature")
	}

	// 判断是否超时
	payload := unmarshalBase64String(data[1])
	if payload["exp"].(float64) < float64(time.Now().Unix()) {
		return nil, errors.New("token expire")
	}
	return payload, nil
}

然后传入 JWT 和密钥即可校验 Token 是否合法。

1
2
3
4
5
payload, err := VerifyWithHS256(
  "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3MTA1MTg0MDAsInVpZCI6MX0.aw8SWce0WOAE5nDlfFSLnaKvoMGKhuXhqsEEavFrToY",
  "this_is_secret",
)
fmt.Println(payload, err) // => map[exp:1.7105184e+09 uid:1] <nil>

RS256 实现#

相比 HS256 的实现,RS256 增加了一个非对称加密和解密的步骤。首先,我们创建一个用于测试的 RSA 密钥对,然后将加密的逻辑添加到 JWT 生成函数中。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
func GenWithRS256(payload map[string]any, privateKey *rsa.PrivateKey) string {
	headerString := marshalToBase64String(map[string]string{"alg": "RS256", "typ": "JWT"})
	payloadString := marshalToBase64String(payload)

	hash := sha256.New()
	hash.Write([]byte(headerString + "." + payloadString))
  // 通过 RSA 加密 SHA256 的结果
	signData, _ := rsa.SignPKCS1v15(rand.Reader, privateKey, crypto.SHA256, hash.Sum(nil))
	signString := base64.RawURLEncoding.EncodeToString(signData)
	return headerString + "." + payloadString + "." + signString
}

然后传入 Payload 和私钥信息,即可生成 JWT。

1
2
3
4
5
6
7
// 解析 RSA 私钥, 需要根据自己生成的密钥对的文本格式和类型,选择合适的解析算法
privateKeyData, _ := base64.StdEncoding.DecodeString("this_is_private_key")
privateKey, _ := x509.ParsePKCS8PrivateKey(privateKeyData)

payload := map[string]any{"exp": 1710518400, "uid": 1}
token := GenWithRS256(payload, privateKey.(*rsa.PrivateKey))
fmt.Println(token)

JWT 的校验同理,需要添加下 RSA 解密的逻辑。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
func VerifyWithRS256(jwt string, publicKey *rsa.PublicKey) (map[string]any, error) {
	// 校验 JWT 是否合法
	data := strings.Split(jwt, ".")
	if len(data) != 3 {
		return nil, errors.New("invalid token")
	}
	signData, _ := base64.RawURLEncoding.DecodeString(data[2])
	hash := sha256.New()
	hash.Write([]byte(data[0] + "." + data[1]))
	if rsa.VerifyPKCS1v15(publicKey, crypto.SHA256, hash.Sum(nil), signData) != nil {
		return nil, errors.New("wrong signature")
	}

	// 判断是否超时
	payload := unmarshalBase64String(data[1])
	if payload["exp"].(float64) < float64(time.Now().Unix())-86400*2 {
		return nil, errors.New("token expire")
	}
	return payload, nil
}

然后传入 JWT 和公钥信息即可校验是否合法。

1
2
3
4
5
6
// 解析 RSA 公钥, 需要根据自己生成的密钥对的文本格式和类型,选择合适的解析算法
publicKeyData, _ := base64.StdEncoding.DecodeString("this_is_public_key")
publicKey, _ := x509.ParsePKIXPublicKey(publicKeyData)

payload, err := VerifyWithRS256("this_is_jwt_token", publicKey.(*rsa.PublicKey))
fmt.Println(payload, err)

结语#

虽然 JWT 解决了跨域认证的问题,但是相比原来的 Session 方案,也引入了新的问题,比如 JWT 体积大小、无法撤销等问题。在实际项目方案制定中,应该结合实际情况决定采用哪种方案,甚至两种方案相互结合。关于这些问题的讨论,本文就不再继续展开了。