Skip to content

元数据卡

  • 前置知识:第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

破局 · 溯源

问题:你走到要塞门口,哨兵问"口令"。你把口令告诉他,他核对后放你进去。但下次他来要塞找你,他也能报你的口令吗?

这就是简单的密码认证——你知道一个秘密,服务器也知道。问题是:

  1. 服务器存着你的密码(或者它的哈希),如果服务器被攻破,你的密码就泄漏了
  2. 每次请求都要发送密码,增加被截获的风险
  3. 你没法证明"这个请求是你发的"而不交出密码

第一招:Session(会话 + Cookie)

传统 web 应用的解决方案:

浏览器 ←→ 服务器
1. POST /login(username + password)
2. 服务器验证密码,创建 session,返回 Set-Cookie: session_id=abc123
3. 浏览器后续所有请求都带 Cookie: session_id=abc123
4. 服务器查 session 存储,找到对应的用户信息

缺点:

  • Session 存储在服务器端(内存、Redis、数据库),需要共享或同步(分布式场景)
  • Session ID 本质是"不透明令牌"——客户端不知道它的含义
  • 跨域场景(移动端、第三方应用)不适合 Cookie

第二招:JWT(JSON Web Token)

JWT 把"你是谁"的信息编码在令牌本身,服务器不需要查存储就能验证。

一个 JWT 由三部分组成,用 . 分隔:

header.payload.signature

eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6Iui/meaYr+W9uSIsInJvbGUiOiLlrovnlJ_liY0ifQ.
dGhpcyBpcyB0aGUgc2lnbmF0dXJl...

用 Base64 解码后:

Header:

json
{
  "alg": "RS256",
  "typ": "JWT"
}

Payload(Claims,声明):

json
{
  "sub": "user_10086",
  "name": "边境守备队长",
  "role": "captain",
  "iat": 1680000000,
  "exp": 1680086400,
  "iss": "auth.borderfortress.com"
}

Signature: 对前两部分做签名,防止篡改。

标准注册的 claim(RFC 7519):

Claim全称含义
issIssuer签发者
subSubject主题(通常是用户 ID)
audAudience受众(哪个服务用这个 token)
expExpiration过期时间(绝对时间戳)
nbfNot Before生效时间
iatIssued At签发时间
jtiJWT ID唯一标识,防御重放

用 Python 签发和验证 JWT:

python
# 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}")

注意:JWT 不是加密的,而是签名的。 payload 只是 Base64 编码,任何人都可以解码读取内容。不要把密码、信用卡号等敏感信息放在 JWT 中。如果需要保密,用 JWE(JSON Web Encryption,但在实践中很少用)。

Session vs Token 对比

维度SessionJWT
存储位置服务器端(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) 返回资源------------------|  资源服务器    |
+--------+                               +---------------+

最常用的授权码流程(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 请求资源服务器的 API

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_challenge

即使授权码被截获,攻击者没有 code_verifier 也无法换 token。

用 Python 实现 OAuth 2.0 客户端(简化版):

python
# 这只是一个概念示例,实际用 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}"},
)

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"
}

客户端验证 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. 服务器用同样的算法验证
python
# 用 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("验证失败或已过期")

更安全的 MFA:WebAuthn

TOTP 仍然有中间人攻击的风险(钓鱼网站骗取密码和 TOTP)。WebAuthn(FIDO2 标准)用非对称加密:

1. 注册时,用户的设备(YubiKey / 手机 / TPM)生成密钥对
2. 私钥永远不出设备,公钥注册到服务器
3. 登录时,服务器发送挑战(challenge)
4. 设备用私钥签名挑战,服务器用公钥验证签名
5. 没有密码可以窃取,没有 OTP 可以钓鱼

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 应用的漏洞,以及你如何在代码层面防御。

Built with VitePress | Software Systems Atlas