4.2 串联身份与资源所有权检查

2026-09-05 15:06 更新

串联身份与资源所有权检查

“用户已经登录”和“用户可以修改这篇文章”是两条不同的规则。前者确认请求来自哪个已验证身份,后者比较这个身份与文章的所有者。把两条规则写在每个路由里,会让权限逻辑重复,也容易出现某个接口忘记检查所有权的问题。

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 方案,核验签名算法、令牌过期时间以及必要的 issaud 等声明,不能把本节的固定映射或硬编码密钥带到生产环境。

<!-- 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 是否未过期、issaud 是否符合本服务预期、sub 是否能映射到仍然存在且可用的用户。密钥从环境或受控密钥系统注入,不能写进源代码。

FastAPI 官方的 OAuth2 与 JWT 教程可以作为实现入口,但示例中的算法、声明字段和密钥配置仍需要结合项目的身份提供方核对。认证成功后,后续依赖只接收已经验证的用户对象;业务路由不应重新解析令牌。

依赖链的边界

valid_owned_post 适合复用在修改、删除或只允许作者查看的接口。如果管理员也可以修改,应把“所有者或管理员”的业务规则写成命名清晰的另一个依赖,并测试普通用户、管理员和未认证三条路径;不要在路由中堆叠多个布尔条件。

依赖只应做进入操作前必须满足的检查。发送通知、保存审计记录等副作用应放在明确的 service 或任务边界中,避免每次读取文章都意外触发写操作。身份检查也不等于对象级授权:登录用户可以访问自己的文章,并不代表他可以访问所有文章。

资料来源

以上内容是否对您有帮助:
在线笔记
App下载
App下载

扫描二维码

下载编程狮App

公众号
微信公众号

编程狮公众号