文章目录
-
- [1. 什么是 JWT 与 SSO?](#1. 什么是 JWT 与 SSO?)
- [2. JWT 的标准认证流程](#2. JWT 的标准认证流程)
- [3. JWT 的三部分构成深度拆解](#3. JWT 的三部分构成深度拆解)
-
- [3.1 头部(Header):声明令牌的"元数据"](#3.1 头部(Header):声明令牌的“元数据”)
- [3.2 载荷(Payload):携带数据的"核心车厢"](#3.2 载荷(Payload):携带数据的“核心车厢”)
-
- [① 标准声明(Registered Claims)](#① 标准声明(Registered Claims))
- [② 公共声明(Public Claims)](#② 公共声明(Public Claims))
- [③ 私有声明(Private Claims)](#③ 私有声明(Private Claims))
- [3.3 签证(Signature):不可逆的"安全防伪锁"](#3.3 签证(Signature):不可逆的“安全防伪锁”)
- 核心结构拆解对比表
- [4. JWT 生成的 Python 完整实现](#4. JWT 生成的 Python 完整实现)
- [5. JWT 解密与验签的 Python 完整实现](#5. JWT 解密与验签的 Python 完整实现)
-
- [为什么不直接用现成的第三方库(如 `PyJWT`)?](#为什么不直接用现成的第三方库(如
PyJWT)?)
- [为什么不直接用现成的第三方库(如 `PyJWT`)?](#为什么不直接用现成的第三方库(如
- [6. 生产环境下的 JWT 避坑指南](#6. 生产环境下的 JWT 避坑指南)
-
- [① 绝对不要在 Payload 中存放敏感信息](#① 绝对不要在 Payload 中存放敏感信息)
- [② 密钥(Secret)的安全管理](#② 密钥(Secret)的安全管理)
- [③ 核心痛点:无法主动使 Token 失效的问题](#③ 核心痛点:无法主动使 Token 失效的问题)
1. 什么是 JWT 与 SSO?
JWT(JSON Web Token) 是为了在网络应用环境间传递声明而执行的一种基于 JSON 的开放标准(RFC 7519)。它被设计为紧凑且安全的,特别适用于分布式站点的单点登录(SSO)场景。
在传统的 Session 认证中,分布式系统需要做 Session 共享(如使用 Redis 集群同步 Session)。而 JWT 是一种无状态(Stateless)的认证机制:服务端不保存任何 Session 数据,所有的认证信息都保存在 JWT 令牌中,由客户端每次请求时携带,完美解决了分布式系统的横向扩展性问题。
2. JWT 的标准认证流程
在深入代码之前,我们先通过一张时序图清晰地了解客户端(Client)与服务端(Server)的交互逻辑:
Code snippet
- 登录与令牌颁发 验证账号密码 根据用户ID和角色生成 JWT 将 Token 存入 localStorage / Cookie 2. 携带令牌请求与业务鉴权 拦截请求,解析 Token 1. 验证签名是否被篡改 2. 检查是否过期 (exp) alt 验证通过 验证失败 (Token篡改/过期) 客户端 (Browser/App) 服务端 (API Server) 发送用户名 + 密码 (POST /login) 1 登录成功,返回 jwt_token 2 请求受保护的接口 (携带 Authorization: Bearer <token>) 3 返回业务数据 (200 OK) 4 返回未授权错误 (401 Unauthorized) 5 客户端 (Browser/App) 服务端 (API Server)
流程核心步骤解析:
-
登录请求:客户端向服务端发送用户名和密码。
-
生成令牌:服务端校验账号密码正确后,基于用户信息生成 JWT 令牌,并返回给客户端。服务端不需要存储这个 Token。
-
本地存储 :客户端将 JWT 存储在
localStorage、sessionStorage或Cookie中。 -
携带请求 :客户端后续请求受保护的接口时,在 HTTP 请求头中携带 JWT,通常放在
Authorization字段中,格式为:Bearer <token>。 -
验签放行 :服务端拦截请求,利用本地保存的
Secret校验签名和有效期。验证通过则直接解析出用户信息并放行,正常返回业务数据。
3. JWT 的三部分构成深度拆解
JWT 表面上是一个由点(.)分隔的长字符串,但它的底层结构设计得非常精妙。
JWT = Base64URL(Header) + " . " + Base64URL(Payload) + " . " + Signature \text{JWT} = \text{Base64URL(Header)} + "." + \text{Base64URL(Payload)} + "." + \text{Signature} JWT=Base64URL(Header)+"."+Base64URL(Payload)+"."+Signature
3.1 头部(Header):声明令牌的"元数据"
Header 是一个 JSON 对象,用来描述这个 Token 的基本元信息。它就像是包裹上的快递单,告诉物流系统(服务器)这个包裹该怎么处理。
通常包含两个核心字段:
-
typ(Type) :令牌的类型,对于 JWT 而言,其值固定为JWT。 -
alg(Algorithm) :用来生成签名(第三部分)的加密算法。最常用的是对称加密算法HS256(HMAC-SHA256)和非对称加密算法RS256(RSA-SHA256)。
json
{
"typ": "JWT",
"alg": "HS256"
}
底层处理 :这一段 JSON 会被使用 Base64URL 编码转换为字符串,成为 JWT 的第一部分。因为没有加密,任何人拿到第一段字符串都能通过 Base64 解码还原出这段 JSON。
3.2 载荷(Payload):携带数据的"核心车厢"
Payload 也是一个 JSON 对象,是 JWT 的核心实体,用来存放真正想要传输的业务数据(即"声明" Claims)。
公共规范将 Payload 的字段分为了三类:
① 标准声明(Registered Claims)
这些是 JWT 官方推荐(但非强制)的标准字段,每个都有特定含义:
-
iss(Issuer):签发者。表示这个 Token 是谁创建的(例如:auth.yourcompany.com)。 -
sub(Subject):面向的用户。通常放用户的唯一标识(如 User ID)。 -
aud(Audience):接收方。声明这个 Token 是给哪个系统用的。 -
exp(Expiration Time):过期时间戳 (秒级)。服务器验证时发现当前时间大于exp就会拒绝服务。这是防范 Token 无限期生效的关键。 -
nbf(Not Before):生效时间戳。在此时间之前,该 Token 不可用。 -
iat(Issued At):签发时间戳。 -
jti(JWT ID):当前 Token 的唯一编号。通常配合 Redis 用来防止"重放攻击"(同一个 Token 被黑客反复提交)。
② 公共声明(Public Claims)
由开发者在生产环境中自行定义。为了避免命名冲突,官方建议公共声明的 Key 可以定义为类似外部可查的 URL 或带有特定命名空间的字符串(实际开发中用得较少)。
③ 私有声明(Private Claims)
业务中最常用的自定义字段。由服务端和客户端约定好,用来传递业务必不可少的非敏感信息。
json
{
// 标准声明
"sub": "1234567890",
"exp": 1782294400,
"iat": 1782290800,
// 私有声明(自定义业务字段)
"name": "wangxiaoming",
"role": "admin",
"user_id": 1
}
铁律提醒 :Payload 与 Header 一样,同样只是通过 Base64URL 编码成了字符串(第二部分)。它依然是明文可见的! ---
3.3 签证(Signature):不可逆的"安全防伪锁"
如果说 Header 和 Payload 都是"裸奔"的明文,那 Signature 则是保证 JWT 无法被黑客篡改的核心守护神。
它是如何生成的?
服务端拿着已经编码好的(Base64后的) Header 和 Payload,用一根小黑点(.)拼接起来,再引入一个只有服务器知道的机密密钥(Secret) 。最后,把这三者送入 Header 中声明的 alg 算法(比如 HS256)进行哈希哈希,公式如下:
Signature = HMAC-SHA256 ( Header + " . " + Payload , secret ) \text{Signature} = \text{HMAC-SHA256}(\text{Header} + "." + \text{Payload}, \text{secret}) Signature=HMAC-SHA256(Header+"."+Payload,secret)
它是如何防伪的?
-
黑客改了数据 :假设黑客拦截了 Token,把 Payload 里的
role从"user"改成了"admin"。 -
重新编码发送:黑客把修改后的 Payload 重新用 Base64URL 编码,拼上原有的 Header 和 Signature 发给服务器。
-
服务器校验 :服务器收到后,取出黑客传过来的 Header 和篡改后的 Payload,加上服务器内部的
Secret,在本地重新计算一次签名。 -
对不上,穿帮! :因为黑客不知道
Secret是什么,他伪造不出能够匹配篡改后数据的 Signature。服务器计算出的本地新签名,和黑客传过来的旧签名对不上,服务器直接抛出401 Unauthorized异常。
核心结构拆解对比表
| 组成部分 | 官方名称 | 角色定位 | 编码/加密方式 | 客户端能否解密? | 允许存放敏感数据吗? |
|---|---|---|---|---|---|
| 第一部分 | Header | 协议说明书 | Base64URL 编码 | 能(直接解开) | 不允许 |
| 第二部分 | Payload | 业务数据集 | Base64URL 编码 | 能(直接解开) | 绝对不要放密码 |
| 第三部分 | Signature | 防伪防篡改锁 | HMAC-SHA256 签名 | 绝对不能(不可逆哈希) | 纯哈希值,无实际业务数据 |
4. JWT 生成的 Python 完整实现
技术纠错提示 :标准的 JWT 签名(HS256)并不是直接将
header + payload + secret做普通的 SHA256 哈希,而是使用 HMAC-SHA256 算法。同时,Base64 编码在 JWT 规范中使用的是 Base64URL 编码(移除=,将+和/替换为-和_),避免在 URL 或 HTTP Header 传输时因特殊符号出错。
以下是完全符合官方官方规范的标准 Python 手动实现示例:
python
import base64
import json
import hmac
import hashlib
import time
def base64url_encode(data: bytes) -> str:
"""符合 JWT 规范的 Base64URL 编码(移除末尾的 = 并替换特殊符号)"""
return base64.urlsafe_b64encode(data).decode('utf-8').rstrip('=')
def generate_jwt():
# 1. Header (头部)
header_data = {"typ": "JWT", "alg": "HS256"}
# separators=(',', ':') 用于消除空格,保证序列化后的字符串最紧凑
header_json = json.dumps(header_data, separators=(',', ':')).encode('utf-8')
header = base64url_encode(header_json)
# 2. Payload (载荷)
current_time = int(time.time())
payload_data = {
"sub": "root",
"exp": current_time + 3600, # 1小时后过期
"iat": current_time, # 签发时间
"name": "wangxiaoming",
"user_id": 1,
"admin": True
}
payload_json = json.dumps(payload_data, separators=(',', ':')).encode('utf-8')
payload = base64url_encode(payload_json)
# 3. Signature (签证)
# 服务端密钥,绝不可泄露
secret = b'django-insecure-hbcv-y9ux0&8qhtkgmh1skvw#v7ru%t(z-#chw#9g5x1r3z=$p'
# 签名原始数据:header.payload
signing_input = f"{header}.{payload}".encode('utf-8')
# 使用 HMAC-SHA256 算法生成标准签名
signature_bytes = hmac.new(secret, signing_input, digestmod=hashlib.sha256).digest()
signature = base64url_encode(signature_bytes)
# 4. 拼接完整 JWT Token
jwt_token = f"{header}.{payload}.{signature}"
return jwt_token
if __name__ == '__main__':
token = generate_jwt()
print("生成的标准 JWT Token:\n", token)
5. JWT 解密与验签的 Python 完整实现
在实际业务中,当客户端带着 Token 过来请求资源时,服务端需要做三件事:
-
切割字符串 :拿到
Header,Payload,Signature。 -
验签(防篡改) :用同样的
Secret和算法对Header.Payload重新计算签名,对比客户端传过来的是否一致。 -
验过期(防超时) :解出 Payload,对比当前时间戳是否大于
exp。
技术提示 :解密(Verify)过程中,最精妙的地方在于必须使用"恒定时间字符串比较" (如 Python 的
hmac.compare_digest)。这是为了防止黑客通过计时攻击(Timing Attack),根据服务器返回错误的速度一点点猜出正确的签名。
python
import base64
import json
import hmac
import hashlib
import time
# 依然使用我们生成 Token 时的那个唯一密钥
SECRET = b'django-insecure-hbcv-y9ux0&8qhtkgmh1skvw#v7ru%t(z-#chw#9g5x1r3z=$p'
def base64url_decode(payload: str) -> bytes:
"""符合 JWT 规范的 Base64URL 解码"""
# 补齐 Base64 编码所需的 '='
rem = len(payload) % 4
if rem > 0:
payload += '=' * (4 - rem)
return base64.urlsafe_b64decode(payload.encode('utf-8'))
def verify_jwt(token: str) -> dict:
"""
校验并解密 JWT 令牌
:param token: 客户端传来的完整 JWT 字符串
:return: 解析成功返回 Payload 字典,失败则抛出异常或返回 None
"""
try:
# 1. 按点(.)拆分三部分
parts = token.split('.')
if len(parts) != 3:
raise ValueError("非法的 JWT 格式")
header_segment, payload_segment, crypto_segment = parts
# 2. 重新计算签名(验签逻辑)
# 签名的原始数据是前两段拼接
signing_input = f"{header_segment}.{payload_segment}".encode('utf-8')
# 服务端本地计算新签名
expected_signature_bytes = hmac.new(SECRET, signing_input, digestmod=hashlib.sha256).digest()
# 标准的 Base64URL 编码签名
# 移除末尾的 '='
expected_signature = base64.urlsafe_b64encode(expected_signature_bytes).decode('utf-8').rstrip('=')
# 3. 安全对比签名(防计时攻击)
if not hmac.compare_digest(crypto_segment, expected_signature):
raise ValueError("签名验证失败,Token 已被篡改!")
# 4. 签名无误后,解密 Payload 拿到业务数据
payload_json = base64url_decode(payload_segment)
payload = json.loads(payload_json.decode('utf-8'))
# 5. 校验过期时间(exp)
current_time = int(time.time())
if 'exp' in payload and current_time > payload['exp']:
raise ValueError("Token 已过期,请重新登录")
print("鉴权成功!")
return payload
except Exception as e:
print(f"鉴权失败: {e}")
return None
if __name__ == '__main__':
# 模拟测试:假设这是从生成函数拿到的合法的 Token
# 这里直接调用上一章生成的 Token 进行测试
from __main__ import generate_jwt # 如果你在同一个文件里
valid_token = generate_jwt()
print("--- 场景1:正常携带合法 Token 请求 ---")
user_info = verify_jwt(valid_token)
if user_info:
print("解析出的用户信息:", user_info)
print("\n--- 场景2:黑客拦截并篡改 Payload 后请求 ---")
# 模拟黑客把明文部分的 payload 替换掉(比如伪造 user_id)
token_parts = valid_token.split('.')
fake_payload = {"sub": "root", "user_id": 999, "admin": True} # 试图把自己改成管理员
fake_payload_encoded = base64.urlsafe_b64encode(json.dumps(fake_payload).encode()).decode().rstrip('=')
# 拼接黑客篡改后的 Token 发送给服务器
hacked_token = f"{token_parts}.{fake_payload_encoded}.{token_parts}"
verify_jwt(hacked_token)
为什么不直接用现成的第三方库(如 PyJWT)?
发博客时,你可以特别加上这一段总结:
"虽然在实际的 Python(Django/Flask/FastAPI)开发中,我们通常直接引入
PyJWT库(jwt.encode()和jwt.decode())。但自己手动用标准库造一遍轮子,才能真正把 Base64URL、HMAC-SHA256 算法和防篡改的闭环逻辑彻底吃透。搞懂了这个底层逻辑,换到任何一门语言(Java/Go/Node.js),你的鉴权思路都是通透的!"
6. 生产环境下的 JWT 避坑指南
在实际业务落地时,JWT 有几个非常核心的安全隐患和痛点,这也是技术面试中最常问的高频题:
① 绝对不要在 Payload 中存放敏感信息
正如前文解码分析所示,Header 和 Payload 仅仅只是做了 Base64 编码,并没有加密!任何人拿到 Token 都可以直接通过网页工具解码看到里面的明文数据。
-
错误做法:在 Payload 中存放密码、身份证号、银行卡号。
-
正确做法:只存放不敏感的用户唯一标识(如 UUID、UserID)或业务权限标签。
② 密钥(Secret)的安全管理
签名的大前提是 Secret 只有服务器知道。如果私钥泄露,黑客就可以在外面任意篡改 Payload 并生成合法的签名,直接接管整个系统的最高权限。
- 安全建议 :在生产环境中,务必将 Secret 写入服务器的环境变量中(如
.env),严禁直接硬编码在代码里提交到 GitHub。
③ 核心痛点:无法主动使 Token 失效的问题
由于 JWT 是无状态的,一经签发,在到期(exp)之前它都是绝对合法的。如果中途用户修改了密码,或者管理员想强制恶意用户下线,服务端由于不保存状态,无法直接宣布某个 Token 废弃。
-
主流企业级解决方案:
-
黑名单机制:将注销或失效的 Token 放入 Redis 缓存中,设置过期时间为 Token 的剩余寿命。每次鉴权时先查一下 Redis,如果在黑名单中则拒绝。该方案虽然引入了状态,但只在注销时写缓存,依然减轻了数据库压力。
-
双 Token 机制 :服务端同时颁发两个 Token。一个短寿命的
AccessToken(如15分钟),负责高频的业务请求鉴权;一个长寿命的RefreshToken(如7天),专门负责在 AccessToken 过期后异步换取新的 AccessToken。这样既保证了安全,又维持了无状态的优势。
-