Skip to content

第 15 章 · 安全进阶:Scopes 与 API Key

本章目标:用 OAuth2 scopes 实现"登录后还要有对应权限"的细粒度授权,并掌握服务间调用常用的三种 API Key 方案。

15.1 OAuth2 scopes:权限的最小单元

第 14 章解决了"你是谁",本章解决"你能干什么"。OAuth2 把权限定义为空格分隔的字符串列表(scope),例如 meitemsusers:read。GitHub、Google 等第三方登录用的正是这套机制。FastAPI 把它无缝集成进了 OpenAPI 文档:登录时可以勾选要授予的 scopes。

声明可用 scopes 只需在 OAuth2PasswordBearer 上加 scopes 参数:

python
from fastapi.security import OAuth2PasswordBearer

# key 是 scope 名,value 是描述(会显示在文档里)
oauth2_scheme = OAuth2PasswordBearer(
    tokenUrl="token",
    scopes={"me": "读取当前用户信息", "items": "读取物品列表"},
)

15.2 Security() 与 SecurityScopes:在依赖树中校验权限

Security 用法与 Depends 完全一致,只是多了 scopes 参数,用于声明"这条依赖链需要哪些权限":

python
from typing import Annotated

from fastapi import Depends, FastAPI, HTTPException, Security
from fastapi.security import OAuth2PasswordBearer, SecurityScopes
import jwt

oauth2_scheme = OAuth2PasswordBearer(
    tokenUrl="token",
    scopes={"me": "读取当前用户信息", "items": "读取物品"},
)

app = FastAPI()


async def get_current_user(
    security_scopes: SecurityScopes,          # 自动收集依赖树上所有 Security 声明的 scopes
    token: Annotated[str, Security(oauth2_scheme)],
):
    if security_scopes.scopes:
        authenticate_value = f'Bearer scope="{security_scopes.scope_str}"'
    else:
        authenticate_value = "Bearer"
    credentials_exception = HTTPException(
        status_code=401,
        detail="Could not validate credentials",
        headers={"WWW-Authenticate": authenticate_value},
    )
    payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
    token_scopes = payload.get("scopes", [])
    user = get_user_from_db(payload.get("sub"))
    if user is None:
        raise credentials_exception
    # 逐个检查 token 里的 scope 是否覆盖依赖链要求的所有 scope
    for scope in security_scopes.scopes:
        if scope not in token_scopes:
            raise HTTPException(
                status_code=403,               # 注意:权限不足是 403,不是 401
                detail="Not enough permissions",
                headers={"WWW-Authenticate": authenticate_value},
            )
    return user


async def get_current_active_user(
    current_user: Annotated[dict, Security(get_current_user, scopes=["me"])],
):
    return current_user


@app.get("/users/me/items/")
async def read_own_items(
    # 依赖树会累积要求:me(来自上一级)+ items(这里声明)
    current_user: Annotated[dict, Security(get_current_active_user, scopes=["items"])],
):
    return {"item_id": "Foo", "owner": current_user["username"]}

理解三个关键点:

  • SecurityScopes 参数会自动汇总整条依赖链上所有 Security(..., scopes=[...]) 声明的 scopes,无需手动传递;
  • scope 校验失败返回 403(已认证但权限不足),与 token 无效的 401 区分开;
  • 签发 token 时把该用户实际拥有的 scopes 写进 payload:create_access_token(data={"sub": username, "scopes": form_data.scopes})。生产中必须校验请求的 scopes 是否在该用户的允许范围内,而不是照单全收。

登录表单 OAuth2PasswordRequestForm 自带 scopes 属性(就是文档 Authorize 弹窗里勾选的那些)。

15.3 API Key 三种位置

服务间调用(没有"用户登录"概念)通常用 API Key。fastapi.security 提供三个类,分别从不同位置提取 key,且都会写进 OpenAPI 文档:

python
from fastapi import Security
from fastapi.security import APIKeyCookie, APIKeyHeader, APIKeyQuery

# 从查询参数 ?api_key=xxx 提取
api_key_query = APIKeyQuery(name="api_key", auto_error=False)
# 从请求头 X-API-Key: xxx 提取(最常用)
api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)
# 从 Cookie 提取
api_key_cookie = APIKeyCookie(name="api_key", auto_error=False)


async def get_api_key(
    header_key: Annotated[str | None, Security(api_key_header)],
    query_key: Annotated[str | None, Security(api_key_query)],
) -> str:
    key = header_key or query_key
    if key and key in VALID_KEYS:      # 换成查数据库/密钥管理服务
        return key
    raise HTTPException(status_code=401, detail="Invalid or missing API Key")

auto_error=False 的意义:设为 True(默认)时,缺 key 会立刻抛 401,你无法自定义错误格式,也无法实现"多渠道取 key"的回退逻辑。设为 False 后拿到的可能是 None,由你决定如何报错。

15.4 封装成可复用的依赖

把校验逻辑收敛到一个依赖工厂,避免每个端点重复写:

python
from typing import Annotated
from fastapi import Depends, FastAPI, Security

app = FastAPI()

ApiKeyDep = Annotated[str, Depends(get_api_key)]

@app.get("/reports/")
async def get_reports(api_key: ApiKeyDep):
    return {"reports": [...], "key_used": api_key[:4] + "****"}

与 OAuth2 共存的典型场景:面向用户的端点走 JWT + scopes,面向内部服务的端点走 API Key。两者可以放在同一个应用里,各自用各自的依赖声明即可,互不干扰;也可以写一个"双通道"依赖(JWT 或 API Key 任一通过即可),适合网关场景。

API Key 的安全边界

API Key 是身份标识而非强凭证:它通常长期有效、无过期、权限粗。泄露风险高于短命 JWT。生产中应:走 HTTPS、只放请求头(避免进访问日志的 query)、支持轮换、按 key 记录审计日志。

15.5 本章小结

  • scope 是空格分隔的权限字符串,通过 OAuth2PasswordBearer(scopes=...) 声明、Security(dep, scopes=[...]) 消费;
  • SecurityScopes 依赖自动收集依赖树上的全部 scopes 要求;scope 不足返回 403;
  • 签发 token 时只应写入用户实际拥有的 scopes;
  • APIKeyHeader/APIKeyQuery/APIKeyCookie 覆盖三种提取位置,auto_error=False 换取自定义错误与回退能力;
  • 用户侧用 JWT、服务间用 API Key,可在同一应用中共存。

🧪 随堂测验

点击你认为正确的选项。答错时会展示正确答案与原因解析。

1. token 有效但缺少依赖要求的 scope 时,FastAPI 应用应返回哪个状态码?

2. 依赖函数里声明参数 security_scopes: SecurityScopes 的作用是?

3. APIKeyHeader(auto_error=False) 与默认行为相比,主要好处是?

4. 关于 API Key 的安全实践,下列哪项是错误的?

🛠️ 动手实践

  1. 在第 14 章的代码上加入 scopes:登录时勾选 me/items,实现 /users/me/(要求 me)和 /users/me/items/(要求 me+items),用只勾 me 的 token 访问后者,确认返回 403。
  2. 实现一个"JWT 或 API Key 二选一"的依赖:先尝试解析 Bearer JWT,失败再查 X-API-Key 头,两种都无效返回 401。
  3. 把 API Key 校验改造成从 SQLite 表读取(key、所属服务、启用状态),写一个测试用例验证"被禁用的 key 返回 401"。

并发与延迟处理是下一个必修课——请进入下一章:异步并发与后台任务