コンテンツへスキップ

HTTP Basic認証

最も単純なケースでは、HTTP Basic認証を使用できます。

HTTP Basic認証では、アプリケーションはユーザー名とパスワードを含むヘッダーを期待します。

それを受け取らない場合、HTTP 401 "Unauthorized" エラーを返します。

そして、WWW-Authenticate ヘッダーを Basic の値とオプションの realm パラメーターとともに返します。

これにより、ブラウザはユーザー名とパスワードの統合プロンプトを表示します。

そして、そのユーザー名とパスワードを入力すると、ブラウザはそれらをヘッダーに自動的に送信します。

シンプルなHTTP Basic認証

  • 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}

初めてURLを開こうとするとき(またはドキュメントの「実行」ボタンをクリックするとき)、ブラウザはユーザー名とパスワードを要求します。

ユーザー名のチェック

より完全な例を以下に示します。

依存関係を使用して、ユーザー名とパスワードが正しいかチェックします。

これには、Python標準モジュール secrets を使用してユーザー名とパスワードをチェックします。

secrets.compare_digest() は、bytes またはASCII文字 (英語の文字) のみを含む str を取る必要があります。これは、Sebastián のような á のような文字では機能しないことを意味します。

これを処理するために、まず usernamepassword をUTF-8でエンコードして 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 を比較する瞬間に、すでにこれらの2つの文字列が同じではないことを知っているため、False を返します。「残りの文字を比較する計算を無駄にする必要はない」と考えているからです。そして、アプリケーションは「ユーザー名またはパスワードが間違っています」と表示します。

しかし、攻撃者はユーザー名 stanleyjobsox とパスワード love123 で試します。

そして、アプリケーションコードは次のように動作します。

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

Pythonは、stanleyjobsoxstanleyjobson の両方で、文字列が同じではないと認識する前に、全体の stanleyjobso を比較する必要があります。そのため、「ユーザー名またはパスワードが間違っています」と返答するのに数マイクロ秒余分にかかります。

応答時間が攻撃者に有利に働く

その時点で、サーバーが「ユーザー名またはパスワードが間違っています」という応答を送信するのに数マイクロ秒長くかかったことに気付くことで、攻撃者は何かが正しかったこと、つまり最初の文字の一部が正しかったことを知るでしょう。

そして、johndoe よりも stanleyjobsox に似たものである可能性が高いことを知って、再度試すことができます。

「プロフェッショナルな」攻撃

もちろん、攻撃者はこれを手作業で試すことはなく、プログラムを作成して、おそらく毎秒数千または数百万回のテストを実行します。そして、一度に1つずつ正しい文字を追加で取得するでしょう。

しかし、これを実行することで、数分または数時間で攻撃者は、私たちのアプリケーションの「助け」を借りて、応答にかかる時間だけを使用して、正しいユーザー名とパスワードを推測してしまうでしょう。

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}