Python 基础体系 · 第 91/112 篇。示例统一以 Python 3.14 为语言基线;第三方库使用与其兼容的现代稳定版本,版本敏感行为会单独说明。
Python Web 认证与授权:Session、JWT、OAuth 2.0、RBAC 和 CSRF
Web 安全中最容易混淆的几个概念是:认证、授权、会话、令牌和请求来源校验。
它们解决的不是同一个问题:
- 认证(Authentication):你是谁?
- 授权(Authorization):你可以做什么?
- Session:服务器如何记住“你已经登录”?
- JWT:如何把一组可验证的声明编码成令牌?
- OAuth 2.0:如何让一个客户端在不获得用户密码的情况下,获得受限访问权限?
- RBAC:如何根据角色决定权限?
- CSRF:如何防止攻击者借助浏览器自动携带的凭据伪造请求?
FastAPI 提供了 OAuth2、Bearer Token、JWT、OAuth2 Scope 和依赖注入等基础设施,但它不会自动替应用决定令牌生命周期、权限模型、会话撤销策略或 CSRF 防护策略。FastAPI 的安全依赖主要负责从请求中提取认证信息,并把认证流程接入 OpenAPI;真正的安全边界仍由应用代码和部署环境决定。(fastapi.tiangolo.com)
一、先建立完整模型:认证、授权和会话分别是什么
一次受保护的 HTTP 请求可以抽象成以下流程:
sequenceDiagram
participant C as 客户端
participant A as 认证组件
participant P as 授权策略
participant R as 资源接口
C->>R: 请求资源 + 凭据
R->>A: 验证凭据
A-->>R: 用户身份 principal
R->>P: 检查 principal 是否拥有权限
P-->>R: allow 或 deny
R-->>C: 资源响应或错误
这里有一个重要对象:principal。
principal 是系统已经确认的调用主体,可以是:
{
"user_id": "u-123",
"username": "alice",
"roles": ["editor"],
"scopes": ["article:read", "article:write"]
}
它不一定是人,也可能是:
- 用户;
- 服务账号;
- 移动应用;
- 后台任务;
- 另一个微服务。
认证的输出是一个 principal。授权的输入也是这个 principal。
因此,下面这种实现是不完整的:
@app.delete("/users/{user_id}")
async def delete_user(user_id: str, current_user=Depends(get_current_user)):
return await delete_user_from_database(user_id)
get_current_user() 只能证明调用者是谁。如果没有授权检查,任何已登录用户都可能删除任意用户。
更完整的逻辑是:
current_user = authenticate(request)
authorize(
principal=current_user,
action="user:delete",
resource={"user_id": user_id},
)
认证失败与授权失败也应区分:
| 情况 | 含义 | 常见 HTTP 状态 |
|---|---|---|
| 没有凭据 | 调用者未认证 | 401 Unauthorized |
| 凭据无效或过期 | 不能确认身份 | 401 Unauthorized |
| 身份有效但无权限 | 已知是谁,但禁止操作 | 403 Forbidden |
| 资源本身不存在 | 目标对象不存在 | 404 Not Found |
不要把所有安全失败都返回成 200,也不要把“没有权限”伪装成“服务器错误”。客户端、监控系统和审计系统需要依靠这些差异诊断问题。
二、Session:把登录状态放在服务器端
2.1 Session 的核心机制
Session 是一种服务端状态会话。
典型结构如下:
浏览器 Web 应用 Session Store
| | |
| POST /login | |
| 用户名 + 密码 | |
|------------------------------>| |
| | 验证密码 |
| | 创建 session_id |
| |--------------------------->|
| | |
| Set-Cookie: session_id=... | |
|<------------------------------| |
| | |
| GET /profile | |
| Cookie: session_id=... | |
|------------------------------>| |
| | 根据 session_id 查询 |
| |<---------------------------|
| | 得到 user_id、过期时间等 |
|<------------------------------| |
客户端保存的通常只是一个不可预测的随机标识符:
session_id = 4db2...随机字节...
真正的状态存放在服务器端:
{
"session_id": "4db2...",
"user_id": "u-123",
"created_at": "2026-09-01T08:00:00Z",
"expires_at": "2026-09-01T16:00:00Z",
"csrf_secret": "..."
}
Session ID 必须满足两个条件:
- 不可预测:攻击者不能通过枚举或推导得到其他用户的 Session ID;
- 不可伪造:攻击者不能自行构造一个能被服务器接受的 Session ID。
服务器端状态的优势是容易撤销:
用户点击“退出所有设备”
↓
删除 user_id 对应的所有 session 记录
↓
旧 Cookie 立即失效
JWT 通常不能做到这一点,后文会解释原因。
2.2 Session Cookie 的安全属性
设置会话 Cookie 时,至少要理解这些属性:
Set-Cookie: session_id=...;
Path=/;
Secure;
HttpOnly;
SameSite=Lax;
Max-Age=28800
Secure:只允许通过 HTTPS 发送;HttpOnly:禁止浏览器 JavaScript 直接读取;SameSite=Lax:限制跨站请求携带 Cookie;Path=/:限定 Cookie 生效路径;Max-Age:限制客户端保存时间。
HttpOnly 不是 XSS 防护的完整方案。它可以降低 JavaScript 直接窃取 Cookie 的风险,但如果页面存在 XSS,攻击脚本仍可能代表当前用户发起请求,因为浏览器会自动携带 Cookie。
SameSite 也不是 CSRF 的完整替代品。它受浏览器行为、跨站导航方式、业务是否需要第三方嵌入等因素影响。对于修改状态的请求,仍应设计显式的 CSRF 校验。
2.3 Session 固定攻击与登录后轮换
Session Fixation 指攻击者先诱导受害者使用一个攻击者知道的 Session ID,受害者登录后,服务器继续沿用这个 ID。攻击者随后就可以使用同一个 ID 进入受害者账户。
错误流程:
1. 攻击者获得 session_id = S
2. 攻击者让受害者使用 S
3. 受害者提交用户名和密码
4. 服务器把 S 标记为已登录
5. 攻击者继续使用 S
正确做法是在登录成功时轮换 Session ID:
登录前:S_old -> 匿名会话
登录成功:S_old 失效,创建 S_new
登录后:S_new -> user_id
注销时也应:
- 服务端删除或吊销 Session;
- 返回过期 Cookie;
- 清理与该 Session 相关的 CSRF 状态。
2.4 多进程和多实例部署
单进程内存字典不能作为生产 Session Store:
sessions: dict[str, dict] = {}
在多进程部署中,请求可能被分配到不同进程:
请求 1 -> worker A:创建 session
请求 2 -> worker B:查询不到 session
解决方式通常是:
- Redis;
- 数据库;
- 具有共享状态能力的专用会话存储。
ASGI 应用通过 scope、receive 和 send 与服务器交互;HTTP 请求的 Cookie、Header 和 Body 都属于请求作用域中的输入,Session 中间件只是对这些输入和输出进行封装,并不会改变认证本身的安全语义。(asgi.readthedocs.io)
三、JWT:可验证的声明,不是加密容器
3.1 JWT 的组成
JWT,即 JSON Web Token,通常由三段 Base64URL 编码内容组成:
header.payload.signature
例如:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
.
eyJzdWIiOiJ1LTEyMyIsInJvbGVzIjpbImVkaXRvciJdLCJleHAiOjE3NTY3MTM2MDB9
.
signature
第一段 Header:
{
"alg": "HS256",
"typ": "JWT"
}
第二段 Payload:
{
"sub": "u-123",
"roles": ["editor"],
"exp": 1756713600
}
第三段是签名:
signature = HMAC-SHA256(
base64url(header) + "." + base64url(payload),
secret_key
)
JWT 默认是编码和签名,不是加密。任何拿到 JWT 的人都可以读取 Header 和 Payload;签名只能证明内容没有被未授权修改。FastAPI 文档也明确说明 JWT 的内容不是加密的,而是通过签名验证发行者和内容完整性。(fastapi.tiangolo.com)
因此,不能把密码、身份证号、银行卡号或内部 Secret 放入 Payload。
3.2 JWT 验证的完整条件
仅仅调用一个 JWT 解码函数并不等于安全验证。一个访问令牌至少要满足:
各变量含义如下:
SignatureValid:签名正确;AlgorithmAllowed:算法属于服务端明确允许的集合;IssuerValid:iss是可信的签发者;AudienceValid:aud包含当前 API;TimeValid:exp未过期,nbf已生效;SubjectPresent:sub能标识调用主体。
危险写法:
payload = jwt.decode(token, key, options={"verify_signature": False})
这只能用于调试或解析,不得用于认证。
另一个危险写法是根据 Token Header 中的 alg 自动选择验证算法:
algorithm = header["alg"]
jwt.decode(token, key, algorithms=[algorithm])
算法必须由服务端配置决定,而不是由不可信的 Token 决定。
3.3 Access Token、Refresh Token 和撤销问题
通常会把令牌分成两类:
- Access Token:访问 API,生命周期较短;
- Refresh Token:换取新的 Access Token,生命周期较长,使用范围更窄。
典型流程:
登录
↓
Access Token:短期访问 API
Refresh Token:长期换取新 Access Token
↓
Access Token 过期
↓
提交 Refresh Token
↓
服务器验证并签发新的 Access Token
JWT 的一个核心边界是:它是自包含的。
签发 JWT
↓
服务器只需验证签名和时间
↓
不必每次查询 Session Store
但这意味着服务器也很难即时撤销已经签发的 JWT:
用户点击退出
↓
服务器无法让一个已经签名且尚未过期的 JWT 自动消失
常见补救方法包括:
- 缩短 Access Token 有效期;
- 服务端维护
jti黑名单; - 为用户保存
token_version; - 轮换 Refresh Token;
- 撤销 Refresh Token 家族;
- 对高风险操作重新认证。
token_version 的示例:
{
"sub": "u-123",
"token_version": 7
}
数据库中的当前版本也是 7。用户修改密码或执行“退出所有设备”后,数据库版本变成 8,旧 Token 即使签名正确,也因版本不一致而失效。
3.4 FastAPI 中的 JWT API
下面是一个可运行的最小示例。它展示:
- OAuth2 Password Bearer 的请求格式;
- Argon2 密码哈希;
- JWT 签发;
- JWT 验证;
- RBAC 权限检查。
安装依赖:
python -m venv .venv
source .venv/bin/activate
pip install "fastapi[standard]" pyjwt "pwdlib[argon2]"
Windows PowerShell 可使用:
.venv\Scripts\Activate.ps1
创建密钥:
export JWT_SECRET_KEY="$(openssl rand -hex 32)"
不要把生产密钥硬编码到源代码、Dockerfile 或 Git 仓库中。FastAPI 的 JWT 示例也建议使用随机 Secret,并明确提醒不要直接使用示例中的密钥。(fastapi.tiangolo.com)
保存为 main.py:
from datetime import datetime, timedelta, timezone
from typing import Annotated
import jwt
from fastapi import Depends, FastAPI, HTTPException, status
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from jwt.exceptions import InvalidTokenError
from pwdlib import PasswordHash
from pydantic import BaseModel
import os
app = FastAPI()
JWT_SECRET_KEY = os.environ["JWT_SECRET_KEY"]
JWT_ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 15
password_hash = PasswordHash.recommended()
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/token")
class User(BaseModel):
username: str
role: str
disabled: bool = False
class UserInDB(User):
hashed_password: str
class Token(BaseModel):
access_token: str
token_type: str
users = {
"alice": UserInDB(
username="alice",
role="editor",
hashed_password=password_hash.hash("alice-password"),
),
"bob": UserInDB(
username="bob",
role="viewer",
hashed_password=password_hash.hash("bob-password"),
),
}
def authenticate(username: str, password: str) -> UserInDB | None:
user = users.get(username)
# 用户不存在时也执行一次哈希验证,降低用户名枚举造成的时间差。
if user is None:
password_hash.verify(password, password_hash.hash("dummy-password"))
return None
if not password_hash.verify(password, user.hashed_password):
return None
return user
def create_access_token(user: UserInDB) -> str:
now = datetime.now(timezone.utc)
payload = {
"sub": user.username,
"role": user.role,
"iss": "example-auth-service",
"aud": "example-api",
"iat": now,
"nbf": now,
"exp": now + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES),
}
return jwt.encode(payload, JWT_SECRET_KEY, algorithm=JWT_ALGORITHM)
async def get_current_user(
token: Annotated[str, Depends(oauth2_scheme)],
) -> UserInDB:
credentials_error = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="invalid credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = jwt.decode(
token,
JWT_SECRET_KEY,
algorithms=[JWT_ALGORITHM],
issuer="example-auth-service",
audience="example-api",
)
username = payload.get("sub")
if not isinstance(username, str):
raise credentials_error
except InvalidTokenError:
raise credentials_error
user = users.get(username)
if user is None or user.disabled:
raise credentials_error
return user
def require_role(required_role: str):
async def dependency(
user: Annotated[UserInDB, Depends(get_current_user)],
) -> UserInDB:
if user.role != required_role:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="insufficient permissions",
)
return user
return dependency
@app.post("/token", response_model=Token)
async def issue_token(
form: Annotated[OAuth2PasswordRequestForm, Depends()],
) -> Token:
user = authenticate(form.username, form.password)
if user is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="incorrect username or password",
headers={"WWW-Authenticate": "Bearer"},
)
return Token(
access_token=create_access_token(user),
token_type="bearer",
)
@app.get("/me")
async def read_me(
user: Annotated[UserInDB, Depends(get_current_user)],
) -> dict:
return {
"username": user.username,
"role": user.role,
}
@app.post("/articles")
async def create_article(
user: Annotated[UserInDB, Depends(require_role("editor"))],
) -> dict:
return {
"created_by": user.username,
"status": "created",
}
启动:
fastapi dev
FastAPI CLI 的开发命令会启动 Uvicorn,并默认启用开发环境自动重载;生产环境应使用适合部署环境的启动方式,而不是把开发自动重载作为生产配置。(fastapi.tiangolo.com)
获取 Token:
curl -X POST http://127.0.0.1:8000/token \
-H 'Content-Type: application/x-www-form-urlencoded' \
-d 'username=alice&password=alice-password'
预期响应结构:
{
"access_token": "eyJ...",
"token_type": "bearer"
}
访问当前用户:
curl http://127.0.0.1:8000/me \
-H "Authorization: Bearer eyJ..."
alice 访问创建文章接口应得到:
{
"created_by": "alice",
"status": "created"
}
bob 虽然能够成功认证,但访问同一接口应得到 403,因为他的角色是 viewer。
这个例子有意把 JWT 中的 role 同时写入数据库用户和 Token。生产系统不能只信任请求中的 role 字段;如果角色可能在 Token 生命周期内变化,应查询当前用户状态,或者使用版本号、撤销列表等机制。
四、OAuth 2.0:授权框架,不是某一种 Token 格式
4.1 OAuth 2.0 解决什么问题
OAuth 2.0 解决的是委托访问问题:
用户允许一个客户端访问某些资源,但不把用户密码交给这个客户端。
OAuth 2.0 定义了四种角色:
- Resource Owner:资源所有者,通常是用户;
- Client:请求资源的应用;
- Authorization Server:认证用户并签发授权结果;
- Resource Server:保存资源并验证 Access Token。
OAuth 2.0 将用户密码与第三方客户端分离,客户端拿到的是具有范围、生命周期和其他限制的 Access Token。(rfc-editor.org)
因此:
OAuth 2.0 != JWT
OAuth 2.0 是协议和授权流程;JWT 只是可能被用作 Access Token 的一种令牌格式。Access Token 也可以是一个不透明随机字符串,由资源服务器通过 introspection 查询其状态。
4.2 Authorization Code + PKCE 流程
对于浏览器、移动端和其他无法安全保存客户端密钥的公开客户端,常见流程是 Authorization Code + PKCE。
sequenceDiagram
participant B as 浏览器
participant C as 客户端应用
participant AS as Authorization Server
participant RS as Resource Server
C->>C: 生成 code_verifier
C->>C: 计算 code_challenge
C->>AS: authorization request + state + code_challenge
AS->>B: 登录和授权页面
B->>AS: 用户认证并同意
AS->>B: redirect_uri?code=...&state=...
B->>C: 携带 authorization code
C->>AS: code + code_verifier
AS-->>C: access_token + refresh_token
C->>RS: Authorization: Bearer access_token
RS-->>C: 受保护资源
关键点是:
- 客户端生成随机
code_verifier; - 客户端把
code_verifier的摘要code_challenge发送给授权服务器; - 授权服务器回调时只返回一次性
code; - 客户端用原始
code_verifier换取 Token; - 授权服务器验证两者匹配。
攻击者即使截获授权码,也因为不知道 code_verifier,不能成功兑换 Token。PKCE 的目标正是降低授权码被截获后被滥用的风险。(rfc-editor.org)
4.3 state 和 redirect_uri
state 是客户端生成的不可预测值:
state = random()
客户端发起授权请求时保存它:
session["oauth_state"] = state
回调时比较:
if request.query_params["state"] != session["oauth_state"]:
raise HTTPException(400, "invalid oauth state")
如果不校验 state,攻击者可能构造一个指向回调地址的响应,把受害者浏览器绑定到攻击者发起的授权流程中。
redirect_uri 也不能使用任意用户输入直接拼接。授权服务器应对回调地址进行精确注册和匹配:
允许:
https://app.example.com/oauth/callback
不应泛化为:
https://app.example.com/*
开放重定向会造成授权码、Token 或登录结果被转发到攻击者控制的地址。
4.4 不要把 OAuth 2.0 当作“登录协议”
OAuth 2.0 本身表达的是:
这个客户端可以访问哪些资源
它不标准化“这个人是谁”的用户身份信息。
如果业务需要通过第三方身份登录,通常还需要 OpenID Connect(OIDC)。OIDC 在 OAuth 2.0 之上定义了身份层,并使用 ID Token 表达用户身份。
因此:
- API 委托访问:OAuth 2.0;
- 第三方登录:通常是 OIDC;
- API 访问令牌格式:可以是 JWT,也可以是不透明 Token。
FastAPI 的 OAuth2PasswordBearer 主要描述 API 如何从 Authorization: Bearer ... 中读取 Bearer Token,并不意味着应用已经实现完整的 OAuth 2.0 授权服务器。FastAPI 官方示例展示的是“使用用户名密码签发 Bearer JWT”的 API 模式,不应误解为推荐所有系统直接采用密码授权流程。(fastapi.tiangolo.com)
五、RBAC:把权限决策从业务代码中抽出来
5.1 RBAC 的基本定义
RBAC,即 Role-Based Access Control,基于角色的访问控制。
最基本的集合可以表示为:
- 用户集合
- 角色集合
- 权限集合
- 用户角色关系
- 角色权限关系
用户 是否拥有权限 ,可以推导为:
例如:
用户 alice
└── 角色 editor
├── article:read
└── article:write
如果请求是:
alice -> article:write
则授权成立。
如果请求是:
bob -> article:write
而 bob 只有 viewer 角色,则授权失败。
5.2 角色不是权限,权限也不是资源所有权
不要把下面几个概念混为一谈:
role = editor
permission = article:write
resource = article-42
ownership = article-42.owner_id == current_user.id
一个用户可能拥有:
article:write
但仍然不能修改别的租户的文章。
完整授权条件可能是:
例如:
def can_edit_article(user, article) -> bool:
return (
user.tenant_id == article.tenant_id
and (
"article:write:any" in user.permissions
or (
"article:write:own" in user.permissions
and article.owner_id == user.id
)
)
)
这已经超出了纯 RBAC,包含了资源属性和所有权判断。工程上经常需要 RBAC 与 ABAC、租户隔离、资源策略结合。
5.3 默认拒绝与权限继承
授权函数应采用默认拒绝:
def authorize(user, permission: str) -> None:
if permission not in user.permissions:
raise HTTPException(status_code=403)
而不是:
def authorize(user, permission: str) -> None:
if permission == "dangerous-operation":
raise HTTPException(status_code=403)
# 其他权限默认放行
第二种写法在新增接口或新增权限时容易产生漏洞。
角色继承需要明确方向:
admin 继承 editor
editor 继承 viewer
则:
permissions(admin)
= permissions(editor)
∪ permissions(viewer)
∪ permissions(admin_direct)
如果角色继承图包含环:
admin -> editor -> admin
权限展开可能无限递归。实现时应进行环检测:
def expand_roles(role: str, graph: dict[str, set[str]]) -> set[str]:
result: set[str] = set()
visiting: set[str] = set()
def visit(current: str) -> None:
if current in visiting:
raise ValueError("role inheritance cycle")
if current in result:
return
visiting.add(current)
result.add(current)
for parent in graph.get(current, set()):
visit(parent)
visiting.remove(current)
visit(role)
return result
5.4 Scope 与 RBAC 的关系
OAuth2 Scope 是令牌授权范围,例如:
article:read article:write
RBAC 是应用内部的权限组织方式。
两者可以组合:
Token scope: article:write
↓
映射为 API 权限
↓
再检查用户的角色、租户和资源所有权
不要因为 Token 中有:
{"scope": "admin"}
就直接让请求通过。Token Scope 证明的是授权服务器授予了某种范围,不一定包含当前业务资源的全部约束。
六、CSRF:防止浏览器自动带上凭据
6.1 CSRF 的成立条件
CSRF,即 Cross-Site Request Forgery,跨站请求伪造,通常依赖以下条件:
- 浏览器保存了目标站点的认证凭据;
- 浏览器访问攻击者页面时,会自动向目标站点发送请求;
- 目标站点没有验证请求是否由自己的页面发起;
- 请求会改变服务器状态。
攻击流程:
用户已登录 bank.example
↓
访问 evil.example
↓
evil.example 自动提交:
POST https://bank.example/transfer
Cookie: session_id=用户会话
↓
bank.example 误认为请求来自用户
关键不是攻击者知道 Cookie,而是浏览器会自动携带 Cookie。
6.2 Cookie Session 与 Bearer Token 的差异
如果认证凭据放在 Cookie 中:
Cookie: session_id=...
浏览器会自动携带它,因此必须考虑 CSRF。
如果认证凭据放在 JavaScript 手工设置的 Header 中:
Authorization: Bearer eyJ...
攻击者页面通常不能直接读取另一个站点的 Token,也不能随意设置跨域 Authorization Header;此时传统 CSRF 风险会显著降低。
但这不意味着 Bearer Token 自动安全:
- Token 可能被 XSS 窃取;
- Token 可能出现在日志、错误信息或 URL 中;
- CORS 配置错误可能暴露接口;
- 恶意浏览器扩展仍可能访问页面上下文;
- 如果 Token 也放入 Cookie,CSRF 风险重新出现。
6.3 Synchronizer Token 模式
服务端 Session 中保存 CSRF Secret:
session.csrf_secret = S
渲染表单时把 Token 写入页面:
<form method="post">
<input type="hidden" name="csrf_token" value="T">
</form>
其中:
T = HMAC(server_secret, session_id + csrf_secret)
提交时服务器校验:
expected = hmac.new(
key=SERVER_CSRF_KEY.encode(),
msg=f"{session_id}:{csrf_secret}".encode(),
digestmod=hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(received_token, expected):
raise HTTPException(status_code=403, detail="CSRF validation failed")
攻击者页面无法读取目标站点页面中的隐藏字段,因此无法构造正确的 Token。
6.4 Double Submit Cookie 模式
服务器设置两个值:
Set-Cookie: session_id=...; HttpOnly; Secure; SameSite=Lax
Set-Cookie: csrf_token=random-value; Secure; SameSite=Lax
前端把第二个值复制到 Header:
X-CSRF-Token: random-value
服务器比较:
Cookie csrf_token == Header X-CSRF-Token
攻击者虽然可能触发跨站请求,但通常不能读取目标站点 Cookie 并把它复制到自定义 Header。
这种模式依赖 Cookie 作用域和域名配置。不要把 Cookie 设置为过宽的父域:
Domain=example.com
如果同一父域下存在不可信子域,子域可能影响或覆盖 Cookie,从而破坏方案。
6.5 CSRF 检查哪些请求
通常只对可能改变状态的请求检查:
POST
PUT
PATCH
DELETE
但“只检查 HTTP 方法”不是充分条件。GET 不应改变服务器状态:
错误:
GET /delete-account
正确:
DELETE /account
如果 GET 会删除数据,即使加了 CSRF Token,也违背了安全方法的语义,并增加缓存、预取和爬虫误触发风险。
6.6 CORS 不是 CSRF 防护
CORS 控制的是浏览器是否允许脚本读取跨域响应,CSRF 关注的是请求是否已经发出。
即使攻击者页面无法读取响应,也可能成功发出:
POST /transfer
因此:
CORS != CSRF
CORS 应显式配置可信 Origin,不要把带凭据的请求配置成任意 Origin:
from fastapi.middleware.cors import CORSMiddleware
app.add_middleware(
CORSMiddleware,
allow_origins=["https://app.example.com"],
allow_credentials=True,
allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE"],
allow_headers=["Authorization", "Content-Type", "X-CSRF-Token"],
)
allow_origins=["*"] 与 allow_credentials=True 的组合尤其容易造成错误的安全预期。CORS 只改变浏览器跨域访问策略,不替代服务器端身份和权限校验。
七、Session、JWT、OAuth 2.0、RBAC 和 CSRF 如何组合
一个典型的浏览器应用可以采用:
浏览器
├── HttpOnly Session Cookie
├── SameSite Cookie 属性
└── CSRF Token
Web 应用
├── Session 验证 -> 得到用户身份
├── RBAC 检查 -> 判断角色权限
├── 资源策略 -> 判断租户和所有权
└── CSRF 校验 -> 判断请求来源证明
外部身份提供商
└── OAuth 2.0 / OIDC Authorization Code + PKCE
完整登录过程:
1. 用户访问应用
2. 应用生成 state、code_verifier
3. 浏览器跳转到身份提供商
4. 用户在身份提供商处认证
5. 应用收到授权码
6. 应用使用 code_verifier 换取 Token
7. 应用验证身份信息
8. 应用创建本地 Session
9. 后续浏览器请求使用 Session Cookie
10. 修改状态的请求还必须提交 CSRF Token
11. 资源接口执行 RBAC 和资源级策略
这里 OAuth 2.0 只负责外部授权流程,Session 负责本地 Web 会话,CSRF 负责防止 Cookie 被跨站滥用,RBAC 负责业务权限。
对于纯 API 客户端,也可以采用:
OAuth 2.0 Authorization Server
↓
Access Token
↓
API Resource Server
↓
JWT 签名验证或 Token Introspection
↓
Scope + RBAC + 资源策略
这五个层次没有互相替代关系。
八、常见错误及诊断方法
8.1 把 JWT 当成加密数据
失败表现:
Token 泄露后,日志中出现用户邮箱、角色和内部 ID
原因是 JWT Payload 默认可读。
诊断方法:
python - <<'PY'
import base64
import json
token = "把测试 JWT 放在这里"
payload = token.split(".")[1]
payload += "=" * (-len(payload) % 4)
print(json.dumps(
json.loads(base64.urlsafe_b64decode(payload)),
indent=2,
ensure_ascii=False,
))
PY
这个脚本只解析,不验证签名,因此不能用于认证决策。
8.2 只验证签名,不验证 aud 和 iss
如果多个服务共享签名密钥,服务 A 签发的 Token 可能被服务 B 接受。
错误条件:
SignatureValid = true
但:
iss != expected_issuer
aud 不包含当前服务
所以验证必须同时约束发行者和受众。
8.3 将角色直接信任为授权结果
错误代码:
if payload["role"] == "admin":
allow()
问题在于:
- 角色可能已经被数据库撤销;
- Token 可能来自另一个服务;
- 不同环境可能共享密钥;
- 角色无法表达资源所有权;
- 一个管理员角色可能不应拥有所有操作。
更可靠的授权路径是:
验证 Token
↓
确认主体存在且未禁用
↓
加载当前角色和权限
↓
检查租户、资源和动作
8.4 把 401 和 403 混用
诊断时可以使用下面的决策树:
请求是否包含凭据?
├── 否 -> 401
└── 是
├── 凭据能否验证? -> 否 -> 401
└── 是
├── 是否拥有权限? -> 否 -> 403
└── 是 -> 继续执行
如果 Token 过期,通常应返回 401,并携带:
WWW-Authenticate: Bearer
FastAPI 官方 JWT 示例也采用 401 和 WWW-Authenticate: Bearer 表达无法验证凭据的情况。(fastapi.tiangolo.com)
8.5 密码直接存储或使用普通哈希
不能存储:
password = "alice-password"
也不应直接使用:
hashlib.sha256(password.encode()).hexdigest()
普通快速哈希适合完整性校验,不适合密码存储。密码哈希必须具有:
- 随机盐;
- 较高计算成本;
- 可调参数;
- 独立的验证 API。
FastAPI 当前安全教程使用 pwdlib,并将 Argon2 作为推荐密码哈希算法之一;用户密码数据库中应保存哈希结果,而不是明文密码。(fastapi.tiangolo.com)
8.6 用户不存在时立即返回
如果不存在用户时直接返回,而存在用户时进行昂贵密码验证,攻击者可以通过响应时间枚举用户名。
改进方式是对不存在的用户也执行一次 dummy hash 验证:
if user is None:
password_hash.verify(password, DUMMY_HASH)
return None
这不能消除所有时间差,但能避免最明显的差异。FastAPI 的示例也采用 dummy hash 处理用户名不存在的情况。(fastapi.tiangolo.com)
8.7 把 Refresh Token 放进普通日志
以下内容不应记录:
Authorization: Bearer ...
refresh_token=...
Cookie: session_id=...
日志、APM、异常追踪、反向代理和浏览器历史都可能保存这些值。应该记录:
{
"request_id": "req-123",
"user_id": "u-123",
"auth_method": "bearer",
"token_jti_hash": "..."
}
如果需要关联 Token,应保存不可逆摘要或截断后的标识,而不是完整凭据。
九、如何选择方案
使用 Session 的场景
适合:
- 传统服务端渲染 Web;
- 同一应用管理登录和页面;
- 需要立即注销;
- 需要服务端集中控制会话;
- 不希望浏览器 JavaScript 接触认证凭据。
代价是需要共享 Session Store,以及处理 Cookie、CSRF 和多实例一致性。
使用 JWT 的场景
适合:
- 多个资源服务器验证同一类令牌;
- 服务之间需要无状态验证;
- 访问者包括移动端、脚本或其他 API 客户端;
- 能接受短期令牌和显式撤销设计。
代价是 Token 一旦泄露,在过期前可能继续有效;令牌中的声明也可能在签发后变旧。
使用 OAuth 2.0 的场景
适合:
- 第三方应用委托访问;
- 多个服务共享统一授权中心;
- 用户不应把密码交给客户端;
- 需要 Scope、Consent、Refresh Token 和授权码流程。
OAuth 2.0 不是“登录库”,也不是“JWT 库”。它需要授权服务器、客户端注册、回调地址、Token 生命周期和错误处理共同组成完整系统。
使用 RBAC 的场景
适合:
- 权限能自然归类到角色;
- 组织结构相对稳定;
- 管理员、编辑者、查看者等角色清晰。
如果权限大量依赖:
- 租户;
- 资源归属;
- 时间;
- IP;
- 数据标签;
- 组织层级;
则应在 RBAC 之上增加资源级策略或属性访问控制。
使用 CSRF 防护的场景
只要认证凭据会被浏览器自动携带,就应认真处理 CSRF,尤其是:
Cookie Session
Cookie JWT
自动携带的客户端证书
其他浏览器自动发送的认证状态
如果使用手工设置的 Authorization Header,传统 CSRF 风险较低,但 XSS、Token 泄露、CORS 和权限绕过问题仍然存在。
十、最终安全边界
一个安全的 Web API 请求,不应只满足:
Token 能解码
而应满足完整条件:
其中:
TLS保证传输过程不被直接窃听和篡改;CredentialValid验证 Session、JWT 或 OAuth2 Token;PrincipalActive确认用户仍存在且未被禁用;TokenContextValid验证算法、发行者、受众和时间;CSRFValid处理 Cookie 自动携带造成的跨站伪造;PermissionGranted执行 RBAC 或 Scope 检查;ResourcePolicyGranted检查租户、所有权和资源状态;InputValid保证请求数据符合接口契约。
Session、JWT 和 OAuth 2.0 决定“凭据如何传递和验证”;RBAC 决定“身份拥有哪些权限”;CSRF 决定“浏览器自动携带的凭据是否被恶意跨站请求滥用”。只有把这些层次分开,才能准确分析登录失败、权限拒绝、Token 泄露、会话撤销和跨站请求伪造等问题。
系列导航与关联阅读
- 系列入口:Python 完整学习路线:从语言模型、并发到 Web、数据、AI 与生产交付
- 上一篇:Python 实时通信:WebSocket、SSE、心跳、广播和背压
- 下一篇:Python 文件上传与后台处理:流式、校验、对象存储和任务状态
- 延伸:Python Web API 工程:契约、错误、分页、幂等、限流和版本
- 延伸:Python 安全工程:输入、注入、反序列化、依赖、Secret 和沙箱
官方资料
本文依据 Python 官方文档、相关 PEP 与生态项目官方文档重新梳理;正文、示例与工程清单由 WR BLOG 编写。

评论
0 条讨论