《Python编程实战》9.1 认证与会话:JWT / OAuth2

认证回答「你是谁」:本节用标准库 hmac/hashlib 手写 HS256 的 JWT 签发与校验并真跑,讲透算法混淆、过期与时钟偏移三类坑,再把 Bearer 依赖接进 FastAPI,最后梳理 OAuth2 授权码流程与 PKCE 的工程要点。

本节目标:搞清认证(你是谁)与授权(你能做什么)的分工,用标准库手写 JWT 的 HS256 签发与校验并真跑通,避开算法混淆、过期、时钟偏移三类坑,再把 Bearer 依赖接进 FastAPI,并看懂 OAuth2 授权码流程与 PKCE。
适用版本:Python 3.12+(实测 3.14.6);FastAPI 0.143.0、Starlette 1.7.0

9.1 认证与会话:JWT / OAuth2

第 8 章我们解决了「数据放哪、任务怎么跑」;这一章进入安全。第一个必须分清的概念是认证(Authentication)与授权(Authorization):认证回答「你是谁」,授权回答「你能做什么」。两者常被合成「鉴权」一词,但代码里必须拆开——本节只讲认证,把会话身份确定下来;授权(角色、权限、多租户隔离)留给 9.2。

会话方案主要有两派:服务端 Session 和 无状态 Token(JWT)。先看怎么选。

9.1.1 会话方案选型

维度Cookie-SessionJWT(无状态 Token)
状态存放服务端(内存/Redis)客户端自持,服务端不存
横向扩容需共享 Session 存储天然无状态,任意实例可验
主动撤销删 Session 即失效难,需黑名单/短有效期
每次请求开销查一次 Session 存储本地算一次签名
跨域/移动端Cookie 有跨域限制Header 携带,天然友好
适合场景传统 Web、强撤销需求微服务、API、移动端

结论:单体内网后台用 Session 更简单;多服务、面向 App/第三方的 API 用 JWT。真实项目常两者混用——JWT 做短命 access token,长命 refresh token 存服务端以便撤销。

9.1.2 JWT 的结构:三段点分

JWT 是一串 header.payload.signature,三段都是 base64url(注意不是标准 base64:+→-、/→_、去掉 = 填充):

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9   ← header:{"alg":"HS256","typ":"JWT"}
.
eyJzdWIiOiI0MiIsInJvbGUiOiJhZG1pbiJ9   ← payload:claims(用户、角色、exp…)
.
yRhCk-4O6YC-UKFxJVyiIMYQa0Kx__hUDW6c3vIx5Y4   ← signature:对前两段签名

两个必须刻进脑子的认知:

  1. payload 只是 base64,不是加密——任何人 base64 解码就能读。所以绝不放密码、身份证号等敏感数据。
  2. 签名保护的是完整性:改了 header 或 payload 任何一个字节,签名就对不上。校验的核心就是「重算签名并比对」。

9.1.3 手写 HS256 签发(标准库真跑)

本机没有 PyJWT / python-jose,但 JWT 的 HS256 完全可以用标准库 hmac + hashlib + base64 + json 手写——这也最能讲清它到底是什么:

import base64, hashlib, hmac, json, time

def b64url_encode(raw: bytes) -> str:
    # JWT 用 base64url 且去掉 = 填充
    return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")

def b64url_decode(segment: str) -> bytes:
    pad = "=" * (-len(segment) % 4)          # 还原被去掉的填充
    return base64.urlsafe_b64decode(segment + pad)

def sign(payload: dict, secret: str, *, expires_in: int = 900) -> str:
    header = {"alg": "HS256", "typ": "JWT"}
    now = int(time.time())
    body = {**payload, "iat": now, "exp": now + expires_in}
    seg = ".".join(
        b64url_encode(json.dumps(part, separators=(",", ":"), ensure_ascii=False).encode())
        for part in (header, body)
    )
    sig = hmac.new(secret.encode(), seg.encode("ascii"), hashlib.sha256).digest()
    return f"{seg}.{b64url_encode(sig)}"

真实输出(/tmp/python_book/venv/bin/python,3.14.6):

token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI0MiIsInJvbGUiOiJhZG1pbiIsImlhdCI6MTc5MTUxNjg5NywiZXhwIjoxNzkxNTE3Nzk3fQ.pG4G4OP0KMs4_cbzeJQLYoOCvcsNROjJmPiYKK0BVxA
段数: 3

注意两点:separators=(",", ":") 去掉多余空格,保证字节级可复现(否则同一 payload 每次签出的串都不一样,虽然都能验过,但没法做缓存比对);ensure_ascii=False 让中文 claim 保持原样。

9.1.4 校验:签名、过期与 nbf

校验比签发更需要小心,因为它面对的是不可信输入。逐条把关:

class InvalidToken(Exception):
    pass

def verify(token: str, secret: str, *, now: int | None = None) -> dict:
    now = int(time.time()) if now is None else now
    try:
        head_b64, body_b64, sig_b64 = token.split(".")
    except ValueError:
        raise InvalidToken("token 结构不是三段") from None

    try:
        header = json.loads(b64url_decode(head_b64))
    except (ValueError, UnicodeDecodeError):
        raise InvalidToken("header 不是合法 base64url/JSON") from None
    if header.get("alg") != "HS256":                 # 白名单,挡住 alg=none
        raise InvalidToken(f"不接受的算法: {header.get('alg')!r}")

    signing_input = f"{head_b64}.{body_b64}".encode("ascii")
    expected = hmac.new(secret.encode(), signing_input, hashlib.sha256).digest()
    try:
        got = b64url_decode(sig_b64)
    except ValueError:
        raise InvalidToken("签名段不是合法 base64url") from None
    if not hmac.compare_digest(expected, got):        # 恒定时间比较,防时序攻击
        raise InvalidToken("签名不匹配")

    try:
        body = json.loads(b64url_decode(body_b64))
    except (ValueError, UnicodeDecodeError):
        raise InvalidToken("payload 不是合法 base64url/JSON") from None
    if "exp" in body and now >= int(body["exp"]):
        raise InvalidToken("已过期")
    if "nbf" in body and now < int(body["nbf"]):
        raise InvalidToken("尚未生效")
    return body

四个必须讲清的点:

  • hmac.compare_digest 而非 ==:字符串 == 会在首个不同字节处短路返回,攻击者能靠响应时间逐字节猜签名。compare_digest 恒定时间比较。
  • 先验签名,再读 payload:签名没过就别相信 payload 里的任何字段,包括 exp。
  • alg 白名单:只接受 HS256。若信任 header 里的 alg,攻击者可以改成 none(无签名)或把 RS256 混淆成 HS256,用公钥当 HMAC 密钥。
  • 异常要兜住:split、base64 解码、json.loads 都可能因垃圾输入抛异常,必须转成统一的「令牌无效」,否则一个畸形 token 就能让接口 500。

真跑校验 + 四类攻击/错误输入(实测):

校验: {'sub': '42', 'role': 'admin', 'iat': 1791516897, 'exp': 1791517797}
篡改 payload 被拒: 签名不匹配
篡改签名被拒: 签名不匹配
alg=none 被拒: 不接受的算法: 'none'
错误密钥被拒: 签名不匹配

9.1.5 过期与时钟偏移

exp(过期)、nbf(生效前)、iat(签发时间)是三个时间 claim,都用 Unix 秒。用一个固定的 now 注入来测(避免依赖真实时间):

# 同一 token,用不同的 now 校验
verify(make(exp_offset=900), SECRET, now=base)   # exp 在 900 秒后

真实输出:

未过期: 42
exp=    0: 拒绝 -> 已过期
exp=   -1: 拒绝 -> 已过期
exp= -300: 拒绝 -> 已过期
nbf 未到: 拒绝 -> 尚未生效

注意 now >= exp 判为过期——到点即失效。工程上还有一个容易忽略的坑:多实例部署时钟不同步。签发实例和校验实例的时间可能差几秒,导致刚签发的 token 在另一台被判「尚未生效」或被提前判过期。标准做法是给校验留一个时钟偏移容忍(leeway),通常 30–60 秒,把 verify() 里两处时间判断的边界放宽即可:

# verify() 内两处时间判断改为带 leeway 的版本(leeway 默认 60 秒)
if "exp" in body and now >= int(body["exp"]) + leeway:   # 过期后 leeway 秒内仍接受
    raise InvalidToken("已过期")
if "nbf" in body and now < int(body["nbf"]) - leeway:     # 提前 leeway 秒内先接受
    raise InvalidToken("尚未生效")

两个方向都要放宽:exp 往后挪(容忍「对方时钟慢」)、nbf 往前挪(容忍「对方时钟快」)。只放宽一个方向是常见错误——只松 exp 会让时钟快的实例签出的 token 在别的实例被判「尚未生效」而反复 401。

令牌本身无状态,所以「登出」和「改密后立即踢下线」做不到。工程解法是引入 jti(唯一 ID)+ 服务端黑名单(存 Redis,键带 token 剩余寿命的 TTL),或者干脆把 access token 有效期压到 15 分钟以内,靠短命换撤销能力。

9.1.6 接进 FastAPI:Bearer 依赖

把上面的 verify 包成一个依赖,端点只声明「我要一个当前用户」。FastAPI 用 HTTPBearer 从 Authorization: Bearer <token> 里取令牌:

from fastapi import Depends, FastAPI, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from typing import Annotated

app = FastAPI()
bearer = HTTPBearer(auto_error=False)   # 自己控错误信息,别让它默认抛

def current_user(creds: Annotated[HTTPAuthorizationCredentials | None, Depends(bearer)]) -> dict:
    if creds is None or creds.scheme.lower() != "bearer":
        raise HTTPException(status.HTTP_401_UNAUTHORIZED, "缺少 Bearer 令牌")
    try:
        return verify(creds.credentials, SECRET)
    except InvalidToken as e:
        raise HTTPException(status.HTTP_401_UNAUTHORIZED, f"令牌无效: {e}") from e

@app.get("/me")
def me(user: Annotated[dict, Depends(current_user)]) -> dict:
    return {"sub": user["sub"], "role": user["role"]}

用 TestClient 真跑登录 + 受保护端点:

登录: 200 bearer
带令牌: 200 {'sub': 'ada', 'role': 'user'}
无令牌: 401 缺少 Bearer 令牌
坏令牌: 401 令牌无效: header 不是合法 base64url/JSON
错密码: 401 用户名或密码错误

这里 auto_error=False 是有意为之:默认的 HTTPBearer 遇到缺令牌会返回 403,而语义上「没认证」应该是 401、「认证了但没权限」才是 403。自己接管错误码,接口语义才干净。

9.1.7 OAuth2 授权码流程与 PKCE

JWT 解决「拿到身份之后怎么传递」,OAuth2 解决「用户如何授权第三方拿到访问权」。授权码流程(Authorization Code) 是唯一推荐给 Web/移动端的方式,核心是「令牌不经过浏览器前端直出」:

  1. 客户端把用户重定向到授权服务器,带上 client_id、redirect_uri、scope、state、code_challenge。
  2. 用户在授权页登录并同意,授权服务器把授权码 code 回调到 redirect_uri。
  3. 客户端(后端)用 code + client_secret(+ code_verifier)换 access token,这一步是服务器对服务器。
  4. 客户端拿到 access token 访问资源。

state 防 CSRF,redirect_uri 必须白名单精确匹配。公共客户端(SPA、移动 App)拿不住 client_secret,于是引入 PKCE:先用随机 code_verifier 算出 code_challenge 随第一步发出,换令牌时再交 code_verifier 供服务端复算比对。用标准库真跑:

import base64, hashlib, secrets

def b64url(b: bytes) -> str:
    return base64.urlsafe_b64encode(b).rstrip(b"=").decode()

verifier = b64url(secrets.token_bytes(32))               # 43 字符,高熵随机
challenge = b64url(hashlib.sha256(verifier.encode()).digest())

真实输出:

code_verifier : WSF7vycD_gTbbQbtDTaUsJAhvjJDSjRkUbOLuXJdBus 43
code_challenge: DC7eY1tNmU_4NyDv1E3vMJQHIXS6UCwiMjDa_zAaToE 43
method: S256
校验通过: True

verifier 必须用 secrets(密码学安全随机)而非 random。没有 PKCE 时,授权码若被拦截(比如恶意 App 抢注同款 redirect_uri),攻击者就能直接换令牌;PKCE 让拦截到的 code 也换不出令牌,因为攻击者不知道 verifier。

9.1.8 令牌该放哪:Cookie 还是 localStorage

存放位置XSS 风险CSRF 风险适用
localStorage高(JS 可读,XSS 直接偷走)无不推荐存 access token
httpOnly + Secure Cookie低(JS 读不到)有(需 SameSite + CSRF token 兜底)推荐

工程共识:access token 放内存(JS 变量)或 httpOnly Cookie,refresh token 放 httpOnly Cookie;localStorage 只适合无敏感性的 UI 状态。同时 Cookie 要带 SameSite=Lax/Strict、Secure、Path 限定,把 CSRF 面收窄。

延伸阅读

小结

  • 认证≠授权:本节确定「你是谁」,9.2 才决定「你能做什么」,代码里必须分开。
  • JWT 是 header.payload.signature 三段 base64url;payload 可被任何人解码,绝不能放敏感数据,签名只保完整性。
  • HS256 用标准库 hmac+hashlib 就能手写;校验时必须白名单 alg、先验签名后读 payload、用 compare_digest、兜住解析异常。
  • exp/nbf 到点即判,多实例部署要留 leeway(30–60 秒)吸收时钟偏移;无状态令牌靠短有效期 + jti 黑名单实现撤销。
  • FastAPI 里用 HTTPBearer(auto_error=False) + 依赖注入拿当前用户,缺令牌返 401、无权限返 403,语义要分清。
  • OAuth2 授权码流程用 state 防 CSRF、redirect_uri 白名单;公共客户端必须加 PKCE(S256),code_verifier 用 secrets 生成。

认证解决了「请求带着一个可信身份进来」。但一个通过认证的用户,凭什么能读别人的订单、删别的租户的数据?下一节我们把身份变成权限——RBAC/ABAC 模型与多租户行级隔离,并写出越权测试用例。

阅读导航:上一节:定时任务、幂等与死信处理 · 下一节:权限模型与多租户隔离 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「python」更多文章

  1. 《Python高级编程》目录
  2. 《Python高级编程》11.3 PEP 流程与版本迁移策略
  3. 《Python高级编程》11.2 嵌入式与自由线程运行时