本节目标:搞清认证(你是谁)与授权(你能做什么)的分工,用标准库手写 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-Session | JWT(无状态 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:对前两段签名
两个必须刻进脑子的认知:
- payload 只是 base64,不是加密——任何人 base64 解码就能读。所以绝不放密码、身份证号等敏感数据。
- 签名保护的是完整性:改了 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/移动端的方式,核心是「令牌不经过浏览器前端直出」:
- 客户端把用户重定向到授权服务器,带上
client_id、redirect_uri、scope、state、code_challenge。 - 用户在授权页登录并同意,授权服务器把授权码 code 回调到
redirect_uri。 - 客户端(后端)用 code +
client_secret(+code_verifier)换 access token,这一步是服务器对服务器。 - 客户端拿到 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 面收窄。
延伸阅读
- Python 安全编程:输入验证、加密与常见漏洞防护 —— JWT、密码哈希、路径遍历的防御清单
- Pydantic V2 模型设计 —— 令牌里的 claim 同样该用模型校验
小结
- 认证≠授权:本节确定「你是谁」,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 模型与多租户行级隔离,并写出越权测试用例。
阅读导航:上一节:定时任务、幂等与死信处理 · 下一节:权限模型与多租户隔离 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。