再思 JWT 的使用场景和算法选择

Published: 2024-10-06

Tags: JWT

本文总阅读量

在使用第三方服务的时候,我们总会看到其提供 API Key,这是一种简单的验证用户身份的方法,服务端存储着这个 API Key,可以通过 Key 来确定用户权限、吊销 Key 等

相比于 API Key,JWT 有自己的特点和适用场景,如果你不太了解 JWT,可以阅读阮一峰的:《JSON Web Token 入门教程》

回顾 JWT 特点

自包含

JWT 是一个自包含的 Token,它包含了所有必要的信息,不需要服务端存储 Token 的状态。JWT 的载荷(payload)部分可以包含用户信息、权限信息、有效期等。

无状态

由于 JWT 自包含所有必要的信息,服务端不需要存储 Token 的状态,所以 JWT 非常适合用于无状态的应用架构,减少了服务端的负担。

传输安全

JWT 使用密钥进行签名,可以验证 Token 的合法性和完整性,签名或加密确保了 JWT 在传输过程中不会被篡改。

扩展性

JWT 的载荷部分是一个 JSON 对象,可以包含自定义的声明(claims),这使得 JWT 非常灵活,可以根据具体需求扩展其功能。

注意事项:在 JWT 中,避免包含密码等敏感信息,JWT 可防篡改但内容可见。

不可撤销

JWT 一旦生成,无法单独撤销。如果 JWT 被泄露,攻击者可以滥用 Token,直到 Token 过期,因此,JWT 的有效期通常设置得较短,以减少风险。(撤销需要借助服务器有状态机制)

例举一种 JWT 场景误用

以 HS256 算法为例子,以下是生成和验证的代码示例

import time
import jwt

KEY_SECRET = "abncdsr462*********zk52hav3x86dm"

# 生成 JWT 令牌
def generate_jwt_token(key_secret, algorithm="HS256"):
    payload = {
        "iat": int(time.time())
    }
    token = jwt.encode(payload, key_secret, algorithm=algorithm)
    return token

# 校验 JWT 令牌
def verify_jwt_token(token, key_secret, algorithm="HS256"):
    try:
        payload = jwt.decode(token, key_secret, algorithms=[algorithm])
        return payload
    except jwt.InvalidTokenError:
        return None

# 生成令牌
t = generate_jwt_token(KEY_SECRET)
print(f"生成的 JWT:", t)

# 校验令牌
verified_payload = verify_jwt_token(t, KEY_SECRET)
if verified_payload:
    print(f"JWT 验证成功:", verified_payload)
else:
    print("JWT 验证失败")

正确的 JWT 使用场景,会将 JWT eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE3Mjc2NjU2MTR9.ItXRitJXiwcDx7X1PHeilgwAs9vt4xaa1lHNKgC3wak 提供给客户端

客户端调用服务端时将其再传给服务端,服务端可以验证这个 Token 没有被篡改、同时从中获取信息

一个错误的用法是将 HS256 的 KEY_SECRET 提供给外部平台,由外部平台生成 JWT。这种方式相当于将 JWT 机制降级为 API Key,使得 JWT 的安全性从“JWT 丢失则其权限会被滥用” 降级为 “API Key 泄露则攻击者可无限制地伪造 JWT,可以对系统进行任意请求”,这属于比较严重的安全漏洞

所以,当决定采用 JWT,就应尽量遵守其规范,避免 “新奇” 的使用方式

不同 JWT 算法使用示例(Python3)

JWT 支持 HS256(密钥)、RS256(基于 RSA)、ES256(基于椭圆曲线)、PS256(基于 RSA-PSS)等加密算法,数字表示位数,越长越安全,除 256 外还支持 384 和 512

HS256 在上文已提供示例,它比较简单、使用的是密钥字符串,也是最常用的 JWT 加密算法,另外三者基于的公钥密码对

生成 RS256 公钥密码对

# 私钥
$ openssl genpkey -algorithm RSA -out rsa_private_key.pem -pkeyopt rsa_keygen_bits:2048

# 公钥
$ openssl rsa -in rsa_private_key.pem -pubout -out rsa_public_key.pem

生成 ES256 公钥密码对

# 私钥
$ openssl ecparam -name prime256v1 -genkey -noout -out ec_private.pem

# 公钥
$ openssl ec -in ec_private.pem -pubout -out ec_public.pem

这里选择的曲线进一步说明,prime256v1 P-256,Go 语言的 x509 包默认支持的椭圆曲线是 P-256 和 P-384,另一种常见的曲线是 secp256k1,它是比特币和其他加密货币中常用的椭圆曲线,Go 语言的 x509 包默认不支持它,Python3 是支持的

生成 PS256 公钥密码对

# 私钥
$ openssl genpkey -algorithm RSA -out ps_private_key.pem -pkeyopt rsa_keygen_bits:2048

# 公钥
$ openssl rsa -in ps_private_key.pem -pubout -out ps_public_key.pem

代码示例

import time
import jwt
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.backends import default_backend

# 定义常量
RSA_PRIVATE_KEY_PATH = "./id_rsa"
RSA_PUBLIC_KEY_PATH = "./id_rsa_pem.pub"
EC_PRIVATE_KEY_PATH = "./ec_private.pem"
EC_PUBLIC_KEY_PATH = "./ec_public.pem"
PS_PRIVATE_KEY_PATH = "./ps_private_key.pem"
PS_PUBLIC_KEY_PATH = "./ps_public_key.pem"

# 加载私钥
def load_private_key(private_key_path):
    with open(private_key_path, "rb") as key_file:
        private_key = serialization.load_pem_private_key(
            key_file.read(),
            password=None,
            backend=default_backend()
        )
    return private_key

# 加载公钥
def load_public_key(public_key_path):
    with open(public_key_path, "rb") as key_file:
        public_key = serialization.load_pem_public_key(
            key_file.read(),
            backend=default_backend()
        )
    return public_key

# 生成 JWT 令牌
def generate_jwt_token(private_key, algorithm):
    payload = {
        "iat": int(time.time())  # 使用秒级时间戳
    }
    return jwt.encode(payload, private_key, algorithm=algorithm)

# 验证 JWT
def verify_jwt_token(token, public_key, algorithm):
    try:
        decoded = jwt.decode(token, public_key, algorithms=[algorithm])
        return decoded
    except jwt.InvalidTokenError:
        return None

# 主函数
def __main__():
    algorithms = [
        ("RS256", RSA_PRIVATE_KEY_PATH, RSA_PUBLIC_KEY_PATH),
        ("ES256", EC_PRIVATE_KEY_PATH, EC_PUBLIC_KEY_PATH),
        ("PS256", PS_PRIVATE_KEY_PATH, PS_PUBLIC_KEY_PATH),
    ]

    for algorithm, private_key_path, public_key_path in algorithms:
        # 加载私钥和公钥
        private_key = load_private_key(private_key_path)
        public_key = load_public_key(public_key_path)

        # 生成 JWT
        jwt_token = generate_jwt_token(private_key, algorithm)
        print(f"生成的 {algorithm} JWT:", jwt_token)

        # 验证 JWT
        decoded_payload = verify_jwt_token(jwt_token, public_key, algorithm)
        if decoded_payload:
            print(f"{algorithm} JWT 验证成功:", decoded_payload)
        else:
            print(f"{algorithm} JWT 验证失败")

if __name__ == "__main__":
    __main__()

执行输出

基于椭圆曲线算法的 ES256 生成的签名长度,相较于其它算法可谓之遥遥领先,在 JWT 长度敏感的场景下可以优先选择

另外 PS256 和 RS256 相比较,查阅的资料如下:

PS256 使用 RSASSA-PSS,这是一种概率签名方案,引入了随机性,使得每次签名都是唯一的,因为引入了随机性,PS256 提供了比 RS256 更高的安全性,同样资源消耗也会更高一些

另外从 JWT 的是用场景来看,签发与核验 JWT 都由服务端自己完成,所以不存在跟客户端的兼容性问题,选择算法会更加的灵活

综上:推荐优先使用 ES256 算法,安全性高,同时签名、密钥对都是最小的

线上校验工具 JWT.io

这个调试工具支持的算法广泛,本文中提及的算法生成的 JWT 内容都通过 https://jwt.io/ 进行了二次核对

另外可以在 https://jwt.io/libraries 找到相应语言库支持的算法列表