首页
/ FastAPI HTTP Basic Auth 实战:用 HTTPBasic 依赖与 secrets.compare_digest 安全校验用户名和密码

FastAPI HTTP Basic Auth 实战:用 HTTPBasic 依赖与 secrets.compare_digest 安全校验用户名和密码

2026-09-06 14:53:07作者:裴锟轩Denise

本文以 FastAPI 官方文档《HTTP Basic Auth》为主线,讲解如何用 fastapi.security 中的 HTTPBasic 依赖实现最简 HTTP 基础认证,以及如何用 Python 标准库 secrets.compare_digest() 安全地校验用户名和密码,避免"时序攻击"(timing attack)。读完本文,你将能够:写出可运行的 Basic Auth 受保护接口,理解 401 与 WWW-Authenticate 响应头的触发机制,并在生产级代码中正确处理凭据比较与错误返回。

HTTP Basic Auth 的工作原理

在最简单的场景下,可以直接使用 HTTP Basic Auth(依据 RFC 7617)。其工作流程如下:

  1. 应用期望收到一个携带用户名和密码的 Authorization 请求头(浏览器按 Basic base64(username:password) 编码后发送);
  2. 如果应用没有收到该请求头,则返回 HTTP 401 "Unauthorized" 错误;
  3. 同时在响应中携带 WWW-Authenticate 头,值为 Basic,并可附带可选的 realm 参数;
  4. 浏览器看到该响应头后,会弹出系统集成的"用户名/密码"输入框;
  5. 用户输入凭据后,浏览器会自动在后续的 Authorization 请求头中携带这些信息。

FastAPI 对这个流程的封装非常薄:你只需要把 HTTPBasic 实例作为依赖注入到路径操作中,框架就会自动完成"读头、解码、401 拦截"这些细节,并把解码后的凭据对象交给你做业务校验。

最简单的 HTTP Basic Auth 示例

按照官方文档 http-basic-auth.md 的"Simple HTTP Basic Auth"一节,最小可用实现包含 4 个要点:

  • 导入 HTTPBasicHTTPBasicCredentials
  • 使用 HTTPBasic 创建一个 "security" 方案对象;
  • 在路径操作中通过依赖使用该 security 对象;
  • 依赖返回一个 HTTPBasicCredentials 对象,其中包含客户端发送的 usernamepassword 两个字段。

对应仓库中的示例代码 tutorial006_an_py310.py

from typing import Annotated

from fastapi import Depends, FastAPI
from fastapi.security import HTTPBasic, HTTPBasicCredentials

app = FastAPI()

security = HTTPBasic()


@app.get("/users/me")
def read_current_user(credentials: Annotated[HTTPBasicCredentials, Depends(security)]):
    return {"username": credentials.username, "password": credentials.password}

运行这个应用后,首次打开 GET /users/me 的 URL(或在 /docs 的 Swagger UI 中点击 "Execute" 按钮)时,浏览器会弹出用户名/密码输入框:

FastAPI Swagger UI 触发 HTTP Basic Auth 浏览器登录提示

这个示例故意把用户名和密码回显在响应里,方便你在本地验证凭据确实被框架解析出来了。在生产代码中应改为下面的校验模式。

校验用户名和密码

官方文档的"Check the username"一节给出了更完整的例子(tutorial007_an_py310.py):用独立的依赖函数检查用户名和密码是否正确,通过 Python 标准库 secrets 模块的 secrets.compare_digest() 进行比较。完整代码:

import secrets
from typing import Annotated

from fastapi import Depends, FastAPI, HTTPException, status
from fastapi.security import HTTPBasic, HTTPBasicCredentials

app = FastAPI()

security = HTTPBasic()


def get_current_username(
    credentials: Annotated[HTTPBasicCredentials, Depends(security)],
):
    current_username_bytes = credentials.username.encode("utf8")
    correct_username_bytes = b"stanleyjobson"
    is_correct_username = secrets.compare_digest(
        current_username_bytes, correct_username_bytes
    )
    current_password_bytes = credentials.password.encode("utf8")
    correct_password_bytes = b"swordfish"
    is_correct_password = secrets.compare_digest(
        current_password_bytes, correct_password_bytes
    )
    if not (is_correct_username and is_correct_password):
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Incorrect username or password",
            headers={"WWW-Authenticate": "Basic"},
        )
    return credentials.username


@app.get("/users/me")
def read_current_user(username: Annotated[str, Depends(get_current_username)]):
    return {"username": username}

几个关键点逐条说明:

  • 为什么先转 bytes 再比较secrets.compare_digest() 只接受 bytes,或仅包含 ASCII 字符的 str。也就是说它无法直接处理 á 这样的字符(例如用户名 Sebastián)。所以文档的做法是先把 credentials.usernamecredentials.password 用 UTF-8 编码成 bytes,再交给 compare_digest()
  • 校验逻辑:确认 credentials.username 等于 "stanleyjobson"、且 credentials.password 等于 "swordfish"。注意示例用 is_correct_username and is_correct_password 两个布尔值的与运算,而不是在 if 里直接串起两次 == 比较——这样即使用户名比对提前"短路",密码比对也一定会执行,进一步保证两条比较路径的时间行为一致;
  • 成功后返回:依赖返回 credentials.username,路径操作函数只需声明 username: Annotated[str, Depends(get_current_username)] 即可拿到已验证的用户名。

如果不用 secrets.compare_digest(),这段逻辑等价于:

if not (credentials.username == "stanleyjobson") or not (credentials.password == "swordfish"):
    # Return some error
    ...

功能上看起来没问题,但它对一类叫做"时序攻击"的攻击不安全。

什么是时序攻击(Timing Attack)

假设攻击者试图猜解用户名和密码。他先发送请求,使用用户名 johndoe、密码 love123。此时你的应用代码等价于:

if "johndoe" == "stanleyjobson" and "love123" == "swordfish":
    ...

Python 在比较 johndoe 的第一个字符 jstanleyjobson 的第一个字符 s 时就会立即返回 False——解释器"知道"这两个字符串不同,认为"没必要浪费更多计算去比较剩下的字母",于是应用很快就回复"Incorrect username or password"。

接着攻击者改试用用户名 stanleyjobsox、密码 love123

if "stanleyjobsox" == "stanleyjobson" and "love123" == "swordfish":
    ...

这一次 Python 必须逐字符比较完前面的 stanleyjobso,直到第 13 个字符才发现两个字符串不同,因此响应会多花几微秒才返回同样的"错误"提示。

响应时间的差异会帮助攻击者

攻击者注意到服务器第二次响应比第一次慢了若干微秒,就可以推断自己"猜对了一部分"——前缀更接近正确答案了。于是他下一次尝试会偏向 stanleyjobsox 而不是 johndoe,每次试探多锁定一个正确的字符。

"专业化"的攻击

当然,攻击者不会手工做这件事,而是写程序以每秒数千次甚至数百万次的速率批量试探,每次多猜对一个字符。按这个速度,只需要几分钟到几小时,他们就能在你的应用"帮忙"下,仅凭响应时间差异猜出正确的用户名和密码。

用 secrets.compare_digest() 修复

而文档示例中使用的是 secrets.compare_digest()。它的核心性质是:比较 stanleyjobsoxstanleyjobson 所花费的时间,和比较 johndoestanleyjobson 所花的时间相同,密码同理。比较耗时常数化,意味着响应时间不再泄露"前缀猜对了几位"这一信息,从而对整类时序攻击免疫。

返回 401 错误

检测到凭据不正确后,应当抛出 HTTPException,状态码 401(与未提供任何凭据时框架自动返回的状态码一致),并附带 WWW-Authenticate 响应头,让浏览器再次弹出登录输入框:

raise HTTPException(
    status_code=status.HTTP_401_UNAUTHORIZED,
    detail="Incorrect username or password",
    headers={"WWW-Authenticate": "Basic"},
)

注意这里与"未提供凭据"路径的响应格式保持一致:都返回 401 + WWW-Authenticate: Basic。这样浏览器在输入框里填错后重新弹窗,客户端体验与首次未认证完全一致。

源码级解析:HTTPBasic 依赖内部做了什么

上面示例中框架自动完成的那部分逻辑,全部位于 fastapi/security/http.py。结合源码可以确认以下实现事实:

1. HTTPBasicCredentials 就是一个 Pydantic 模型

fastapi/security/http.py 中定义:

class HTTPBasicCredentials(BaseModel):
    username: Annotated[str, Doc("The HTTP Basic username.")]
    password: Annotated[str, Doc("The HTTP Basic password.")]

只有 usernamepassword 两个字符串字段,与文档描述完全一致。它从 fastapi.security 包导出(见 fastapi/security/init.py)。

2. __call__ 的完整解析链路

HTTPBasic 是异步依赖,其 __call__ 方法(fastapi/security/http.py)按如下顺序工作:

  1. request.headers 取出 Authorization 头;
  2. 调用 get_authorization_scheme_param() 按第一个空格拆分出 schemeparam(该工具函数在 fastapi/security/utils.py 中,对 None 头直接返回 ("", ""));
  3. 若没有 Authorization 头,或 scheme 小写后不等于 "basic"
    • auto_error=True(默认)时,抛出 401 的 HTTPException
    • auto_error=False 时,返回 None(用于可选认证);
  4. param 执行 b64decode(param).decode("ascii"),解码失败(ValueError / UnicodeDecodeError / binascii.Error)同样按"未认证"处理;
  5. data.partition(":") 以第一个冒号切分,若不存在分隔符(没有冒号)则抛出 401 错误;
  6. 成功时返回 HTTPBasicCredentials(username=..., password=...)

3. 401 响应头的 realm 参数从哪来

父类 HTTPBase.make_not_authenticated_error()fastapi/security/http.py)构造 401 异常,detail 固定为 "Not authenticated",响应头由 make_authenticate_headers() 提供。HTTPBasic 重写了该方法(fastapi/security/http.py):

def make_authenticate_headers(self) -> dict[str, str]:
    if self.realm:
        return {"WWW-Authenticate": f'Basic realm="{self.realm}"'}
    return {"WWW-Authenticate": "Basic"}

这就是文档开头所说的"WWW-Authenticate 值为 Basic,可附带可选 realm 参数"的直接实现来源——realm 通常用于让浏览器在登录弹窗中区分不同站点/应用的凭据域。

4. HTTPBasic 构造参数一览

HTTPBasic.__init__fastapi/security/http.py)接受 4 个参数:

参数 类型 默认值 说明
scheme_name str | None None(回退为类名 HTTPBasic 安全方案名称,会写入生成的 OpenAPI(在 /docs 中可见)
realm str | None None Basic 认证的 realm,决定 401 响应头是 Basic 还是 Basic realm="..."
description str | None None 安全方案描述,同样进入 OpenAPI
auto_error bool True 未收到 Basic 认证头时是否自动抛出 401;设为 False 时依赖结果变为 None,适合"可选认证"或 Basic/Bearer 多方式可选认证的场景

5. 从源码结构看测试覆盖

仓库 tests/ 下存在 test_security_http_basic_realm.pytest_security_http_basic_realm_description.pytest_security_http_base.py 等用例,分别验证 realm 参数进入 WWW-Authenticate 头、realm 与 description 进入 OpenAPI schema 等行为,与上述源码分析相互印证。

小结

  • 最简单的 Basic Auth 只需要三行:security = HTTPBasic()、路径操作里声明 Annotated[HTTPBasicCredentials, Depends(security)],框架自动完成 401 拦截、Base64 解码和 WWW-Authenticate 响应头;
  • 校验凭据务必用 secrets.compare_digest() 比较 UTF-8 编码后的 bytes,不要用裸 ==,否则会暴露响应时间侧信道,被时序攻击逐字符猜解;
  • 凭据错误时应抛出 HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, headers={"WWW-Authenticate": "Basic"}),与框架默认的未认证响应保持同构,让浏览器正常重新弹窗;
  • HTTPBasic(realm=..., scheme_name=..., description=..., auto_error=...) 四个参数可分别定制 401 响应头、OpenAPI 展示与可选认证行为,具体实现见 fastapi/security/http.py
登录后查看全文
热门项目推荐
相关项目推荐