东东's Blog

再思 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 找到相应语言库支持的算法列表