4.2 串联身份与资源所有权检查
串联身份与资源所有权检查
“用户已经登录”和“用户可以修改这篇文章”是两条不同的规则。前者确认请求来自哪个已验证身份,后者比较这个身份与文章的所有者。把两条规则写在每个路由里,会让权限逻辑重复,也容易出现某个接口忘记检查所有权的问题。
FastAPI 依赖可以把它们串起来:valid_post_id 负责加载文章,get_current_user 负责从测试认证替身得到当前用户,valid_owned_post 组合两者并拒绝非所有者。最终路由只接收“当前用户拥有的文章”,表达的是操作意图,而不是一串认证细节。
先区分身份来源和请求体
本节使用 X-Demo-Token 请求头映射到两个固定的测试用户。它只是为了让示例不依赖真实账号系统的测试替身,不是可用于生产的认证方案。客户端请求体只有可编辑的文章字段,没有 author_id;所有者来自服务端加载的文章和已经验证的身份。
如果把 author_id 放进请求体并直接相信它,调用者就可以把自己的 ID 改成另一个人的 ID,绕过所有权检查。服务端不得信任客户端提供的身份字段;本例通过 extra="forbid" 直接拒绝它,另一种明确约定是忽略它并始终使用已验证身份。
让依赖形成一条清晰的链
依赖关系可以写成:
请求头 X-Demo-Token
│
▼
get_current_user ─────────────┐
▼
路径 {post_id} → valid_post_id → valid_owned_post → 修改路由
valid_owned_post 同时声明文章依赖和当前用户依赖。FastAPI 会先解析它们,再执行所有权比较;比较成功后返回文章对象。路由不需要再次读取请求头,也不需要从请求体读取作者 ID。
下面的 DEMO_TOKENS 是刻意显式的替身。真实系统应使用成熟的 OAuth2/JWT 方案,核验签名算法、令牌过期时间以及必要的 iss、aud 等声明,不能把本节的固定映射或硬编码密钥带到生产环境。
<!-- file: ch04_auth/ownership_dependency_demo.py -->
from __future__ import annotations
from typing import Annotated, Any
from fastapi import Depends, FastAPI, Header, HTTPException
from fastapi.testclient import TestClient
from pydantic import BaseModel, ConfigDict, Field
class User(BaseModel):
id: int
name: str
class PostResponse(BaseModel):
id: int
title: str
content: str
author_id: int
class PostUpdate(BaseModel):
model_config = ConfigDict(extra="forbid")
# author_id 不在输入模型中,由服务端根据文章原有关系维护。
title: str = Field(min_length=1, max_length=80)
USERS: dict[int, User] = {
10: User(id=10, name="Alice"),
20: User(id=20, name="Bob"),
}
POSTS: list[dict[str, Any]] = [
{
"id": 1,
"title": "Alice 的文章",
"content": "只有 Alice 可以修改这篇文章。",
"author_id": 10,
},
]
## 这是测试替身:token 到用户的映射是预先写死的,绝不能当作生产认证。
DEMO_TOKENS: dict[str, int] = {
"demo-alice": 10,
"demo-bob": 20,
}
async def get_current_user(
x_demo_token: str | None = Header(default=None),
) -> User:
"""从明确标记的测试请求头得到已验证身份。"""
if x_demo_token is None:
raise HTTPException(status_code=401, detail="请先认证")
user_id = DEMO_TOKENS.get(x_demo_token)
if user_id is None:
raise HTTPException(status_code=401, detail="认证信息无效")
return USERS[user_id]
async def valid_post_id(post_id: int) -> dict[str, Any]:
for post in POSTS:
if post["id"] == post_id:
return post
raise HTTPException(status_code=404, detail="文章不存在")
Post = Annotated[dict[str, Any], Depends(valid_post_id)]
CurrentUser = Annotated[User, Depends(get_current_user)]
async def valid_owned_post(
post: Post,
current_user: CurrentUser,
) -> dict[str, Any]:
if post["author_id"] != current_user.id:
raise HTTPException(status_code=403, detail="没有修改这篇文章的权限")
return post
OwnedPost = Annotated[dict[str, Any], Depends(valid_owned_post)]
app = FastAPI(title="身份与所有权依赖示例")
@app.patch("/posts/{post_id}", response_model=PostResponse)
async def update_post(
update: PostUpdate,
post: OwnedPost,
) -> dict[str, Any]:
post["title"] = update.title
return post
@app.get("/posts/{post_id}", response_model=PostResponse)
async def read_post(post: Post) -> dict[str, Any]:
return post
def self_check() -> None:
with TestClient(app) as client:
unauthenticated = client.patch(
"/posts/1", json={"title": "未认证的修改"}
)
assert unauthenticated.status_code == 401
not_owner = client.patch(
"/posts/1",
headers={"X-Demo-Token": "demo-bob"},
json={"title": "Bob 的修改"},
)
assert not_owner.status_code == 403
assert not_owner.json()["detail"] == "没有修改这篇文章的权限"
forged_owner = client.patch(
"/posts/1",
headers={"X-Demo-Token": "demo-alice"},
json={"title": "伪造作者", "author_id": 20},
)
assert forged_owner.status_code == 422
allowed = client.patch(
"/posts/1",
headers={"X-Demo-Token": "demo-alice"},
json={"title": "Alice 修改后的文章"},
)
assert allowed.status_code == 200
assert allowed.json() == {
"id": 1,
"title": "Alice 修改后的文章",
"content": "只有 Alice 可以修改这篇文章。",
"author_id": 10,
}
# 作者 ID 来自服务端数据;客户端提交该字段会被拒绝。
assert allowed.json()["author_id"] == 10
print("ownership_dependency_demo self-check passed")
if __name__ == "__main__":
self_check()
运行检查:
python ch04_auth/ownership_dependency_demo.py
三条失败或成功路径分别表示:
| 请求 | 结果 | 原因 |
|---|---|---|
不带 X-Demo-Token |
401 | 没有得到当前身份 |
X-Demo-Token: demo-bob 修改 Alice 的文章 |
403 | 身份有效,但不是文章所有者 |
Alice 在请求体中伪造 author_id |
422 | 输入模型不接受服务端维护字段 |
X-Demo-Token: demo-alice 修改 Alice 的文章 |
200 | 身份有效且所有权匹配 |
401 和 403 的区别要保持稳定:401 表示请求还没有通过认证,403 表示身份已经确认但没有执行该操作的权限。实际项目如果采用不同的安全策略,可以有额外的错误响应约定,但应在接口文档和测试中固定下来。
生产认证需要补齐哪些检查
认证替身只解决示例的可重复性。接入真实令牌时,不要把字符串映射替换成“自行拆分 JWT 后读取 sub”。应按所选认证库和密钥管理方式验证:签名是否正确、允许的算法是否明确、exp 是否未过期、iss 和 aud 是否符合本服务预期、sub 是否能映射到仍然存在且可用的用户。密钥从环境或受控密钥系统注入,不能写进源代码。
FastAPI 官方的 OAuth2 与 JWT 教程可以作为实现入口,但示例中的算法、声明字段和密钥配置仍需要结合项目的身份提供方核对。认证成功后,后续依赖只接收已经验证的用户对象;业务路由不应重新解析令牌。
依赖链的边界
valid_owned_post 适合复用在修改、删除或只允许作者查看的接口。如果管理员也可以修改,应把“所有者或管理员”的业务规则写成命名清晰的另一个依赖,并测试普通用户、管理员和未认证三条路径;不要在路由中堆叠多个布尔条件。
依赖只应做进入操作前必须满足的检查。发送通知、保存审计记录等副作用应放在明确的 service 或任务边界中,避免每次读取文章都意外触发写操作。身份检查也不等于对象级授权:登录用户可以访问自己的文章,并不代表他可以访问所有文章。
资料来源
- 主要参考:fastapi-best-practices 中文 README 的“链式依赖”部分。本节将原主题改写为带测试认证替身的文章所有权示例。
- 官方参考:FastAPI 安全工具、OAuth2 与 JWT。

免费 AI IDE


更多建议: