FastAPI 安全实战:HTTP Basic Auth 认证接入与基于 `secrets` 的防时序攻击凭据校验
HTTP Basic Auth 是 Web 认证中历史最悠久、最简单的方案之一。本篇基于 FastAPI 官方文档(韩文版)HTTP Basic Auth 展开,结合仓库中 fastapi.security 的源码实现与测试用例,带你掌握两件事:一是用 HTTPBasic 快速为接口加上 Basic 认证;二是如何在依赖里用 Python 标准库 secrets.compare_digest() 做抗时序攻击(Timing Attack)的用户名/密码校验。读完你即可在 FastAPI 项目中复现一份既可用、又经得起推敲的 Basic Auth 完整方案。
HTTP Basic Auth 的基本交互流程
在最简单的场景下,HTTP Basic Auth 的协议约定非常直观:
- 应用期望请求携带一个包含用户名和密码的请求头(即
Authorization头); - 若没有收到该头,应用返回 HTTP 401 "Unauthorized" 错误;
- 同时返回值为
Basic(可附带可选的realm参数)的WWW-Authenticate响应头; - 浏览器收到该头后,会弹出内置的用户名/密码输入框;
- 用户输入并确认后,浏览器会在后续请求中自动把凭据写进
Authorization头并随请求发送。
也就是说,Basic Auth 下"让浏览器弹框"这一体验并不是前端代码实现的,而是后端通过 401 + WWW-Authenticate 触发的标准浏览器行为。FastAPI 正是利用这一点,把整套协议封装成了开箱即用的安全组件。
最简单的 HTTP Basic Auth 实现
在 FastAPI 中接入 Basic Auth 只需要四步(完整示例见 docs_src/security/tutorial006_an_py310.py):
- 从
fastapi.security导入HTTPBasic与HTTPBasicCredentials; - 用
HTTPBasic()创建一个 "security scheme" 实例; - 在路径操作中通过依赖注入(
Depends)使用该实例; - 依赖返回一个
HTTPBasicCredentials类型对象,其中包含请求携带的username与password。
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}
首次在浏览器打开接口地址(或点击 Swagger 文档中的 "Execute")时,浏览器便会弹出上图那样的登录框;只有输入正确的用户名与密码后,请求才会真正发出。这一现象对应本仓库 tests/test_security_http_basic_realm.py 中的断言:带 auth=("john", "secret") 的请求返回 200,且响应体为 {"username": "john", "password": "secret"},证明框架已自动完成 Authorization 头的解析。
底层是如何解析凭据的
之所以返回的依赖对象直接就是 username/password 两个字段,是因为 fastapi/security/http.py 中定义了:
class HTTPBasicCredentials(BaseModel):
username: Annotated[str, Doc("The HTTP Basic username.")]
password: Annotated[str, Doc("The HTTP Basic password.")]
而真正的解析发生在 HTTPBasic.__call__ 中(见 fastapi/security/http.py),其核心步骤是:
- 读取请求的
Authorization头,用 fastapi/security/utils.py 中的get_authorization_scheme_param按第一个空格切分出 scheme 与参数部分; - 若缺少该头、或 scheme 不是
basic(大小写不敏感),在默认配置下直接抛 401"Not authenticated"异常并附带WWW-Authenticate头; - 对参数部分执行
base64解码为 ASCII 字符串; - 用
partition(":")在首个:处拆分出username与password,若没有:分隔符同样判定为未认证; - 解码失败(如传入的并非合法 Base64)时同样返回 401。
值得注意的是:整个流程中用户名/密码只是做了 base64 编码,并非加密,这一点从源码的 b64decode(param) 可直接看出,因此在生产环境必须依赖 HTTPS 保证传输安全。
realm 参数与 OpenAPI 呈现
HTTPBasic 的构造函数支持 scheme_name、realm、description 与 auto_error 四个可选参数。其中 realm 会拼入挑战头中,例如 HTTPBasic(realm="simple") 时,未认证响应的头为 WWW-Authenticate: Basic realm="simple"(见 fastapi/security/http.py),该行为也被 tests/test_security_http_basic_realm.py 明确验证。同时,FastAPI 会把该 scheme 注册进自动生成的 OpenAPI:从同一测试的 test_openapi_schema 可以看到 components.securitySchemes 下出现 {"HTTPBasic": {"type": "http", "scheme": "basic"}},因此 Swagger UI 会自动为接口加上锁形 Authorize 按钮。
用依赖校验用户名与密码:升级版示例
只做"解析"而不做"校验"显然没有意义。更完整的做法是把校验逻辑封装进一个依赖函数(完整示例见 docs_src/security/tutorial007_an_py310.py):
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}
其中几个细节值得展开:
- 依赖函数接收
HTTPBasicCredentials,随后与硬编码的期望值"stanleyjobson"/"swordfish"比对;真实项目中通常会把期望值换成查询数据库或配置项的结果。 secrets.compare_digest()要求输入为bytes,或者仅包含 ASCII 字符的str(即英文范围内的字符)。像Sebastián中的á这类非 ASCII 字符会导致调用失败,因此示例先调用.encode("utf8")把输入统一转成字节串再比较。- 校验失败时抛出状态码 401 的
HTTPException(与未提供凭据时一致),并附带WWW-Authenticate: Basic头,让浏览器再次弹出登录框。
表面上看,这段逻辑等价于朴素的字符串比较:
if not (credentials.username == "stanleyjobson") or not (credentials.password == "swordfish"):
# 返回某个错误
...
但两者在安全语义上完全不同,差别正是文档接下来重点阐述的"时序攻击"。
什么是时序攻击(Timing Attack)
设想攻击者在逐步猜测用户名与密码:先发送用户名 johndoe、密码 love123,此时应用代码的执行近似于:
if "johndoe" == "stanleyjobson" and "love123" == "swordfish":
...
Python 比较字符串时按字符逐位进行,一旦发现首字符 j 与 s 不同就立刻短路返回 False——它认为"没必要再浪费计算去比较剩余字符",随后应用返回 Incorrect username or password。
当攻击者改用用户名 stanleyjobsox、密码不变时,应用代码近似于:
if "stanleyjobsox" == "stanleyjobson" and "love123" == "swordfish":
...
这一次,Python 必须把 stanleyjobsox 与 stanleyjobson 共同的 stanleyjobso 全部比较完,才会在最后一个字符处判定不相等,因此返回同样的错误提示会多花几个微秒。
响应耗时成为攻击者的"信息通道"
关键在于:当攻击者观察到服务端这次回复 Incorrect username or password 多用了几个微秒,就能推断出前几个字符猜对了——自己的输入与正确答案的相似度,远高于 johndoe。于是下一次他们会尝试更接近 stanleyjobsox 的候选值。
"职业级"攻击方式
现实中的攻击者当然不会手工逐字符试错,而是编写自动化程序,以每秒数千甚至数百万次的速率发起请求,每次只"套取"一个正确的字符。这样持续几分钟到几小时,仅凭响应耗时这一侧信道,攻击者就能在应用代码"配合"下逐步还原出正确的用户名与密码。
secrets.compare_digest() 如何修复
由于示例代码实际使用的是 secrets.compare_digest(),情况就完全不同了:无论比较 stanleyjobsox 与 stanleyjobson,还是比较 johndoe 与 stanleyjobson,其耗时都保持一致,密码字段同理。耗时与内容的匹配程度解耦后,攻击者便无法再从响应时间中提取任何有用信息——整个这类基于时间推断的攻击面随之被消除。
凭据错误时如何返回 401
校验发现凭据不正确后,应返回状态码 401 的 HTTPException,并且必须带上 WWW-Authenticate 头,否则浏览器不会再次弹出登录框,用户只能停留在错误页面。这与未提供凭据时 FastAPI 自动生成的 401 使用同一状态码,保证了两种失败场景对客户端的语义一致;区别在于业务校验失败时使用更明确的 detail="Incorrect username or password",避免泄露"到底是用户名错还是密码错"。
可选认证与自动报错开关:auto_error
HTTPBasic 默认在未收到合法凭据时直接中断请求并返回 401(即 auto_error=True,来自 fastapi/security/http.py 的实现说明)。当希望认证是可选的(例如"允许匿名访问,但带凭据则识别身份",或与 HTTP Bearer 等多认证方式并存)时,可构造 HTTPBasic(auto_error=False),此时拿不到凭据时依赖的返回值为 None 而非抛异常,由业务代码自行决定后续逻辑。仓库中的 tests/test_security_http_basic_optional.py 即为该可选行为的回归测试,可作为参考。
用仓库测试验证完整行为
除了上面的原理分析,FastAPI 仓库自带的测试也为整套行为提供了可复现的验证依据。tests/test_security_http_basic_realm.py 中至少覆盖了四条关键路径:
- 携带合法 Basic 凭据(
auth=("john", "secret"))→ 200,返回解析出的用户名与密码; - 不携带凭据 → 401,响应体
{"detail": "Not authenticated"},WWW-Authenticate头为Basic realm="simple"; - 携带非法的 Base64 值 → 401 且附带挑战头;
- Base64 内容中没有
:分隔符(johnsecret)→ 同样 401; /openapi.json中按预期生成type: http、scheme: basic的HTTPBasicsecurity scheme。
这组测试与 docs/ko/docs/advanced/security/http-basic-auth.md(英文原版见 docs/en/docs/advanced/security/http-basic-auth.md)中描述的行为一一对应。若你想脱离浏览器自行验证,也可以手工构造头:先把 用户名:密码 做 Base64 编码后作为 Authorization: Basic <编码结果> 发送,FastAPI 端会按上文所述流程完成解码与拆分。
小结与安全提醒
把整套思路串起来,一个健壮的 FastAPI Basic Auth 方案应包含三层:
- 接入层:用
HTTPBasic(可配realm)作为依赖,让框架替你完成 401 挑战头与Authorization解析; - 校验层:在依赖函数中用
secrets.compare_digest()对字节串做常量时间比较,杜绝时序攻击; - 失败层:校验失败时抛 401
HTTPException并显式返回WWW-Authenticate头,维持浏览器弹框体验。
最后务必牢记两点:其一,HTTP Basic 的凭据仅经 Base64 编码,是明文等价物,必须运行在 HTTPS 之上;其二,Basic Auth 适用于内部工具、简单网关等低敏场景,涉及高价值资源时应优先考虑 OAuth2、Bearer Token 等更完善的认证体系——但它们交互与实现上的复杂度,也正好反衬出本文这套方案在"够用且安全"这一平衡点上的价值。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0629
MiniCPM5-2BMiniCPM5-2B 是一款面向端侧、本地部署和资源受限场景的 2B 稠密 Transformer,能够达到同尺寸开源模型 SOTA 水平。Markdown00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
HivisionIDPhotos⚡️HivisionIDPhotos: a lightweight and efficient AI ID photos tools. 一个轻量级的AI证件照制作算法。Python07
DragonOSDragonOS is an operating system developed from scratch using Rust, with Linux compatibility. It is designed for **Serverless** scenarios. 使用Rust从0自研内核,具有Linux兼容性的操作系统,面向云计算Serverless场景而设计。Rust00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
