跳至內容

HTTP 基本驗證

對於最簡單的情況,您可以使用 HTTP 基本驗證。

在 HTTP 基本驗證中,應用程式預期一個包含使用者名稱和密碼的標頭。

如果沒有收到,它會返回 HTTP 401「未授權」錯誤。

並返回一個 WWW-Authenticate 標頭,其值為 Basic,以及一個可選的 realm 參數。

這會告知瀏覽器顯示整合的使用者名稱和密碼提示。

然後,當您輸入該使用者名稱和密碼時,瀏覽器會自動在標頭中發送它們。

簡單的 HTTP 基本驗證

  • 匯入 HTTPBasicHTTPBasicCredentials
  • 使用 HTTPBasic 建立一個「security 模式」。
  • 在您的*路徑操作*中,將該 security 與依賴項一起使用。
  • 它會返回一個 HTTPBasicCredentials 類型的物件。
    • 它包含發送的 usernamepassword
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}
from fastapi import Depends, FastAPI
from fastapi.security import HTTPBasic, HTTPBasicCredentials
from typing_extensions import Annotated

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}

當您第一次嘗試開啟網址(或點擊文件中的「執行」按鈕)時,瀏覽器會要求您輸入使用者名稱和密碼。

檢查使用者名稱

這是一個更完整的範例。

使用依賴項來檢查使用者名稱和密碼是否正確。

為此,請使用 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}
import secrets

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

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,然後才能意識到這兩個字串不同。因此,回覆「使用者名稱或密碼不正確」將需要額外的幾微秒。

回應時間幫助了攻擊者

此時,通過注意到伺服器花了額外的幾微秒才發送「使用者名稱或密碼不正確」的回應,攻擊者就會知道他們猜對了*一些*東西,一些初始字母是正確的。

這樣他們就能再次嘗試,因為他們知道正確答案可能更接近 stanleyjobsox 而不是 johndoe

「專業」攻擊

當然,攻擊者不會手動嘗試所有這些組合,他們會編寫程式來執行,可能每秒進行數千或數百萬次測試。而且他們每次只會多猜對一個字母。

但這樣一來,攻擊者只需幾分鐘或幾小時就能猜到正確的使用者名稱和密碼,而這全靠我們的應用程式在回應時間上「提供協助」。

使用 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}
import secrets

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

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}