跳到内容

HTTP 基本认证

对于最简单的场景,你可以使用 HTTP 基本认证(HTTP Basic Auth)。

在 HTTP 基本认证中,应用程序期望请求头中包含用户名和密码。

如果没有收到,它会返回一个 HTTP 401“未授权”错误。

并返回一个 WWW-Authenticate 请求头,其值为 Basic,以及一个可选的 realm 参数。

这会告诉浏览器显示内置的用户名和密码输入提示框。

然后,当你输入用户名和密码时,浏览器会自动在请求头中发送它们。

简单 HTTP 基本认证

  • 导入 HTTPBasicHTTPBasicCredentials
  • 使用 HTTPBasic 创建一个“security 方案”。
  • 在你的路径操作中使用该 security 作为依赖项。
  • 它会返回一个 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}
🤓 其他版本和变体

提示

如果可能,请优先使用 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: HTTPBasicCredentials = Depends(security)):
    return {"username": credentials.username, "password": credentials.password}

当你第一次尝试打开该 URL(或点击文档中的“执行”按钮)时,浏览器会要求你输入用户名和密码。

检查用户名

这是一个更完整的示例。

使用依赖项来检查用户名和密码是否正确。

为此,请使用 Python 标准模块 secrets 来检查用户名和密码。

secrets.compare_digest() 需要接收 bytes 或仅包含 ASCII 字符(英文常用字符)的 str。这意味着它无法直接处理像 Sebastián 中的 á 这样的字符。

为了处理这种情况,我们首先通过 UTF-8 编码将 usernamepassword 转换为 bytes

然后我们就可以使用 secrets.compare_digest() 来确保 credentials.username"stanleyjobson",且 credentials.password"swordfish"

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}
🤓 其他版本和变体

提示

如果可能,请优先使用 Annotated 版本。

import secrets

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

app = FastAPI()

security = HTTPBasic()


def get_current_username(credentials: 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: str = Depends(get_current_username)):
    return {"username": username}

这类似于

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

但通过使用 secrets.compare_digest(),它将能够防御一种称为“时序攻击”的攻击方式。

时序攻击

那么什么是“时序攻击”呢?

假设一些攻击者正在尝试猜测用户名和密码。

他们发送了一个用户名为 johndoe、密码为 love123 的请求。

那么你应用程序中的 Python 代码将类似于

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

但就在 Python 将 johndoe 中的第一个字符 jstanleyjobson 中的第一个字符 s 进行比较的瞬间,它就会返回 False,因为它已经知道这两个字符串不相等,并认为“没必要再浪费计算资源去比较剩下的字母”。于是你的应用程序会回答“用户名或密码错误”。

但是,攻击者接着尝试用户名为 stanleyjobsox,密码为 love123

你的应用程序代码做了类似下面的操作

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

Python 必须比较完 stanleyjobsoxstanleyjobson 中的整个 stanleyjobso,才会意识到这两个字符串不相等。因此,回复“用户名或密码错误”需要多花费一些微秒的时间。

响应时间会为攻击者提供信息

此时,通过注意到服务器发送“用户名或密码错误”的响应多花了微秒级的时间,攻击者就会知道他们猜对了一些东西,即开头的某些字母是正确的。

然后他们就可以再次尝试,因为他们知道答案可能比 johndoe 更接近 stanleyjobsox

“专业”攻击

当然,攻击者不会手动尝试这些,他们会编写程序来做这件事,每秒可能进行成千上万次测试。他们每次都能多猜对一个字母。

通过这种方式,在几分钟或几小时内,攻击者就能在我们应用程序“帮助”下(仅仅利用了响应时间),猜出正确的用户名和密码。

使用 secrets.compare_digest() 进行修复

但在我们的代码中,我们实际上使用了 secrets.compare_digest()

简而言之,比较 stanleyjobsoxstanleyjobson 所花费的时间,与比较 johndoestanleyjobson 所花费的时间是相同的。密码也是如此。

这样一来,在你的应用程序代码中使用 secrets.compare_digest(),就能有效防御这类安全攻击。

返回错误

在检测到凭据不正确后,返回一个状态码为 401 的 HTTPException(与未提供凭据时返回的一样),并添加 WWW-Authenticate 请求头,使浏览器再次显示登录提示框。

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}
🤓 其他版本和变体

提示

如果可能,请优先使用 Annotated 版本。

import secrets

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

app = FastAPI()

security = HTTPBasic()


def get_current_username(credentials: 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: str = Depends(get_current_username)):
    return {"username": username}