JSON Web Token 浅析

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 时,最终得到的签名将不一致。当然,如果密钥被泄露的时候,安全性不再得到保证,因为其他人也可以使用密钥去创建一个合法的签名。

process

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

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

process-cross

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 体积大小、无法撤销等问题。在实际项目方案制定中,应该结合实际情况决定采用哪种方案,甚至两种方案相互结合。关于这些问题的讨论,本文就不再继续展开了。