元数据卡
- 前置知识:第1章(哈希、签名)、第2章(证书、PKI)
- 预计时间:60 分钟
- 核心难度:进阶
- 完成标志:能解释 JWT 的结构和验证流程,能画 OAuth 2.0 Authorization Code 流程,能区分 Authentication 与 Authorization
你的进度
现在你能和灰塔通信了——你检查了对方的符文证书,确认它是由受信任的符文认证中心签发的。你发过去的加密法术信函也只有灰塔能解密。
但灰塔不止一位塔卫。你的信件需要交给“守塔大法师”签署,而不是值班学徒。你怎么证明“我是我”?怎么确保“我的操作权限是大法师级别”?
这就是认证和授权的分野。前者问“你是谁”,后者问“你能做什么”——两件事完全不同,但经常被混为一谈。 你的任务
理解当代认证与授权的主流协议和机制:JWT 作为结构化令牌、OAuth 2.0 作为授权框架、OpenID Connect 作为身份层,以及多因素认证(MFA)作为防御纵深。
本章分层
- 必读:JWT 结构与验证、Session vs Token 对比、OAuth 2.0 Authorization Code 流程
- 选读:OIDC 的 ID Token 与 UserInfo 端点、PKCE
- 进阶:OAuth 2.0 的四种授权流程选择策略、Token Binding
破局 · 溯源
问题:你走到要塞门口,哨兵问"口令"。你把口令告诉他,他核对后放你进去。但下次他来要塞找你,他也能报你的口令吗?
这就是简单的密码认证——你知道一个秘密,服务器也知道。问题是:
- 服务器存着你的密码(或者它的哈希),如果服务器被攻破,你的密码就泄漏了
- 每次请求都要发送密码,增加被截获的风险
- 你没法证明"这个请求是你发的"而不交出密码
第一招:Session(会话 + Cookie)
传统 web 应用的解决方案:
浏览器 ←→ 服务器
1. POST /login(username + password)
2. 服务器验证密码,创建 session,返回 Set-Cookie: session_id=abc123
3. 浏览器后续所有请求都带 Cookie: session_id=abc123
4. 服务器查 session 存储,找到对应的用户信息2
3
4
5
缺点:
- Session 存储在服务器端(内存、Redis、数据库),需要共享或同步(分布式场景)
- Session ID 本质是"不透明令牌"——客户端不知道它的含义
- 跨域场景(移动端、第三方应用)不适合 Cookie
第二招:JWT(JSON Web Token)
JWT 把"你是谁"的信息编码在令牌本身,服务器不需要查存储就能验证。
一个 JWT 由三部分组成,用 . 分隔:
header.payload.signature
eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6Iui/meaYr+W9uSIsInJvbGUiOiLlrovnlJ_liY0ifQ.
dGhpcyBpcyB0aGUgc2lnbmF0dXJl...2
3
4
5
用 Base64 解码后:
Header:
{
"alg": "RS256",
"typ": "JWT"
}2
3
4
Payload(Claims,声明):
{
"sub": "user_10086",
"name": "边境守备队长",
"role": "captain",
"iat": 1680000000,
"exp": 1680086400,
"iss": "auth.borderfortress.com"
}2
3
4
5
6
7
8
Signature: 对前两部分做签名,防止篡改。
标准注册的 claim(RFC 7519):
| Claim | 全称 | 含义 |
|---|---|---|
iss | Issuer | 签发者 |
sub | Subject | 主题(通常是用户 ID) |
aud | Audience | 受众(哪个服务用这个 token) |
exp | Expiration | 过期时间(绝对时间戳) |
nbf | Not Before | 生效时间 |
iat | Issued At | 签发时间 |
jti | JWT ID | 唯一标识,防御重放 |
用 Python 签发和验证 JWT:
# pip install pyjwt[crypto]
import jwt
from datetime import datetime, timedelta, timezone
# 用 RSA 签名(推荐,比 HS256 更安全)
private_key = open("private.pem").read()
public_key = open("public.pem").read()
# 签发
payload = {
"sub": "user_10086",
"name": "边境守备队长",
"role": "captain",
"iat": datetime.now(timezone.utc),
"exp": datetime.now(timezone.utc) + timedelta(hours=2),
"iss": "auth.borderfortress.com",
}
token = jwt.encode(payload, private_key, algorithm="RS256")
print(f"JWT: {token}")
# 验证
try:
decoded = jwt.decode(
token,
public_key,
algorithms=["RS256"],
audience=None,
issuer="auth.borderfortress.com",
)
print(f"验证通过,用户: {decoded['name']}({decoded['role']})")
except jwt.ExpiredSignatureError:
print("令牌已过期")
except jwt.InvalidTokenError as e:
print(f"无效令牌: {e}")2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
注意:JWT 不是加密的,而是签名的。 payload 只是 Base64 编码,任何人都可以解码读取内容。不要把密码、信用卡号等敏感信息放在 JWT 中。如果需要保密,用 JWE(JSON Web Encryption,但在实践中很少用)。
Session vs Token 对比
| 维度 | Session | JWT |
|---|---|---|
| 存储位置 | 服务器端(Redis/DB) | 客户端(浏览器/localStorage) |
| 验证方式 | 查存储 | 验签名 |
| 分布式 | 需要共享存储或 sticky session | 天然无状态 |
| 吊销 | 直接删除 session | 需要黑名单(黑名单抵消无状态优势) |
| 数据量 | 小(session ID) | 较大(含 claims) |
| 跨域 | 难(Cookie SameSite) | 易(Authorization header) |
| 刷新 | 透明 | 需要 refresh token 机制 |
第三招:OAuth 2.0
现实场景中,你不会只有一个系统。你可能想用要塞的身份去访问"军需仓库系统",但不想把要塞的密码告诉仓库管理员。
OAuth 2.0 解决的就是"第三方授权"问题——让一个应用能在不获取用户密码的情况下,访问用户在另一个服务上的资源。
OAuth 2.0 的核心角色:
+--------+ +---------------+
| |--(A) 请求授权----------------->| |
| 客户端 | | 授权服务器 |
|(应用) |<-(B) 授权码/令牌---------------| (Auth Server)|
| | +---------------+
| | +---------------+
| |--(C) 用访问令牌请求资源--------->| |
| |<-(D) 返回资源------------------| 资源服务器 |
+--------+ +---------------+2
3
4
5
6
7
8
9
最常用的授权码流程(Authorization Code Grant):
1. 用户访问客户端应用,点击"用要塞账号登录"
2. 客户端引导用户跳转到授权服务器:
GET /authorize?
response_type=code&
client_id=my_app&
redirect_uri=https://myapp.com/callback&
scope=read:supplies&
state=random_csrf_token
3. 用户在授权服务器登录,确认授权
4. 授权服务器重定向回 redirect_uri:
GET https://myapp.com/callback?code=AUTHORIZATION_CODE&state=random_csrf_token
5. 客户端用 authorization code 向后端换取 access token:
POST /token
grant_type=authorization_code&
code=AUTHORIZATION_CODE&
redirect_uri=https://myapp.com/callback&
client_id=my_app&
client_secret=my_secret
6. 授权服务器返回:{ "access_token": "....", "token_type": "Bearer", "expires_in": 3600, "refresh_token": "..." }
7. 客户端用 access_token 请求资源服务器的 API2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
PKCE(Proof Key for Code Exchange)
如果是纯前端应用(移动端或 SPA),没法安全存储 client_secret。PKCE 解决了这个问题:
1. 客户端生成一个 code_verifier(随机字符串)
2. 计算 code_challenge = base64url(sha256(code_verifier))
3. 请求时带上 code_challenge
4. 换 token 时带上 code_verifier
5. 授权服务器验证 sha256(code_verifier) == code_challenge2
3
4
5
即使授权码被截获,攻击者没有 code_verifier 也无法换 token。
用 Python 实现 OAuth 2.0 客户端(简化版):
# 这只是一个概念示例,实际用 requests-oauthlib 或 authlib
import requests
import hashlib
import base64
import secrets
# PKCE
code_verifier = secrets.token_urlsafe(64)
code_challenge = base64.urlsafe_b64encode(
hashlib.sha256(code_verifier.encode()).digest()
).rstrip(b"=").decode()
# 步骤 1-2:构造授权 URL
auth_url = (
"https://auth.borderfortress.com/authorize?"
"response_type=code&"
"client_id=supply_app&"
"redirect_uri=https://supply.app/callback&"
"scope=read:supplies&"
f"code_challenge={code_challenge}&"
"code_challenge_method=S256&"
f"state={secrets.token_urlsafe(16)}"
)
print(f"请访问: {auth_url}")
# (用户在浏览器中授权后,授权服务器重定向回 callback)
# 步骤 5:换 token
callback_url = "https://supply.app/callback?code=xxx&state=yyy"
# 提取 code...
token_response = requests.post(
"https://auth.borderfortress.com/token",
data={
"grant_type": "authorization_code",
"code": extracted_code,
"redirect_uri": "https://supply.app/callback",
"client_id": "supply_app",
"code_verifier": code_verifier,
},
)
access_token = token_response.json()["access_token"]
# 步骤 7:用 token 访问资源
supplies = requests.get(
"https://api.borderfortress.com/v1/supplies",
headers={"Authorization": f"Bearer {access_token}"},
)2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
OpenID Connect(OIDC):认证层
OAuth 2.0 只解决授权("你能做什么"),不解决认证("你是谁")。OIDC 在 OAuth 2.0 之上加了一层身份信息。
OAuth 2.0 返回 access_token(给资源服务器看) OIDC 额外返回一个 id_token(JWT 格式,给客户端看)
id_token 包含:
{
"iss": "https://accounts.borderfortress.com",
"sub": "user_10086",
"aud": "supply_app",
"exp": 1680086400,
"iat": 1680000000,
"nonce": "xxx", // 防重放
"name": "边境守备队长",
"email": "captain@borderfortress.com"
}2
3
4
5
6
7
8
9
10
11
客户端验证 id_token 的签名(用授权服务器的公钥),就可以获得用户的身份信息——不需要额外调用 API。
第四招:MFA(多因素认证)
密码可能被盗。session 可能被窃取。所以需要多一层防御。
MFA 的三种因子:
- 知识因子:你知道的事(密码、PIN)
- 拥有因子:你有的东西(手机、硬件 key)
- 固有因子:你本身(指纹、人脸)
常用的 MFA 实现——TOTP(Time-based One-Time Password,RFC 6238):
原理:
1. 服务器生成一个共享密钥(通常以 QR 码显示在屏幕)
2. 用户用 Authenticator App(Google Authenticator / Authy)扫描 QR 码
3. 每次生成 OTP 时:
TOTP = HMAC-SHA1(shared_secret, current_time_window)
current_time_window = floor(unix_timestamp / 30)
# 每 30 秒变化一次,输出 6 位数字
4. 用户输入当前显示的 6 位数字
5. 服务器用同样的算法验证2
3
4
5
6
7
8
9
# 用 pyotp 库(pip install pyotp)
import pyotp
import qrcode
# 生成密钥(服务端存储)
secret = pyotp.random_base32()
# 生成用于展示的 URI(可以转成 QR 码供用户扫码)
uri = pyotp.totp.TOTP(secret).provisioning_uri(
name="captain@borderfortress.com",
issuer_name="BorderFortress",
)
# 用户扫 QR 码后,手机 App 每 30 秒生成一个 6 位数
# 验证用户输入的 OTP
totp = pyotp.TOTP(secret)
user_input = input("请输入 Authenticator 显示的 6 位数: ")
if totp.verify(user_input):
print("验证通过")
else:
print("验证失败或已过期")2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
更安全的 MFA:WebAuthn
TOTP 仍然有中间人攻击的风险(钓鱼网站骗取密码和 TOTP)。WebAuthn(FIDO2 标准)用非对称加密:
1. 注册时,用户的设备(YubiKey / 手机 / TPM)生成密钥对
2. 私钥永远不出设备,公钥注册到服务器
3. 登录时,服务器发送挑战(challenge)
4. 设备用私钥签名挑战,服务器用公钥验证签名
5. 没有密码可以窃取,没有 OTP 可以钓鱼2
3
4
5
WebAuthn 是目前最强的认证方案之一,被各大平台支持(Windows Hello、Apple Touch ID、Android 指纹)。
常见陷阱
- JWT 不加密,只签名。 敏感数据不放 payload。需要机密的场景用 JWE 或通过额外 API 获取。
- JWT 过期时间过长。 access token 应该短(15 分钟到 1 小时),配合 refresh token 使用。
- OAuth 2.0 Implicit Grant 已不推荐。 安全原因,改用 Authorization Code + PKCE。
- 忽略 state 参数。 OAuth 流程中不验证 state 会导致 CSRF 攻击——攻击者诱导用户使用攻击者自己的授权码。
- 纯前端存储 access token。 localStorage 可以被 XSS 读取。用 httpOnly cookie + BFF(Backend for Frontend)模式。
- 不使用 refresh token rotation。 每次 refresh 后返回新的 refresh token,旧的失效。减少 refresh token 泄露的风险。
- 密码复杂度要求 vs 密码长度。 强制要求特殊字符和大小写混合,不如要求最少 12 位字符加多因子认证有效。
通关挑战
- 热身:解码一个真实的 JWT(可以用
jwt.io或命令行jq -R 'split(".") | .[0], .[1] | @base64d'),检查它的 claims,观察 alg 字段。 - 挑战:用 Python 实现一个完整的 OAuth 2.0 Authorization Code 流程(用 Flask 或 FastAPI),包含授权端点、令牌端点和资源端点。至少支持 PKCE。
- 排障:你的 JWT 验证代码在本地正常运行,但在生产环境总是报
InvalidSignature。分析可能的原因(公钥格式问题?算法不一致?时区问题?)。 - 观察:打开浏览器的 DevTools > Network,找一个使用 OAuth 2.0 的站点(如 GitHub),观察授权流程的 HTTP 请求序列。跟踪 redirect_uri 的变化。
旅人笔记
- 认证(你是谁)和授权(你能做什么)是两回事,用不同机制处理
- JWT 是自包含的令牌,用签名保证完整性,payload 可读但不加密
- Session 适合传统 web 应用,JWT 适合分布式和移动端
- OAuth 2.0 解决第三方授权问题,Authorization Code + PKCE 是推荐流程
- OIDC 在 OAuth 2.0 上加身份层,用 id_token 携带用户信息
- MFA(尤其是 WebAuthn)大幅提升安全性,密码不足以保护重要系统
下一站预告
"我是谁"和"能做什么"的问题解决了。但你的应用暴露在公网上,随时可能收到恶意请求。下一章,我们看攻击者怎么利用 web 应用的漏洞,以及你如何在代码层面防御。