HTTP Basic認証¶
最も単純なケースでは、HTTP Basic認証を使用できます。
HTTP Basic認証では、アプリケーションはユーザー名とパスワードを含むヘッダーを期待します。
それを受け取らない場合、HTTP 401 "Unauthorized" エラーを返します。
そして、WWW-Authenticate
ヘッダーを Basic
の値とオプションの realm
パラメーターとともに返します。
これにより、ブラウザはユーザー名とパスワードの統合プロンプトを表示します。
そして、そのユーザー名とパスワードを入力すると、ブラウザはそれらをヘッダーに自動的に送信します。
シンプルなHTTP Basic認証¶
HTTPBasic
とHTTPBasicCredentials
をインポートします。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}
🤓 その他のバージョンとバリアント
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
のような á
のような文字では機能しないことを意味します。
これを処理するために、まず username
と password
を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
の最初の j
と stanleyjobson
の最初の s
を比較する瞬間に、すでにこれらの2つの文字列が同じではないことを知っているため、False
を返します。「残りの文字を比較する計算を無駄にする必要はない」と考えているからです。そして、アプリケーションは「ユーザー名またはパスワードが間違っています」と表示します。
しかし、攻撃者はユーザー名 stanleyjobsox
とパスワード love123
で試します。
そして、アプリケーションコードは次のように動作します。
if "stanleyjobsox" == "stanleyjobson" and "love123" == "swordfish":
...
Pythonは、stanleyjobsox
と stanleyjobson
の両方で、文字列が同じではないと認識する前に、全体の stanleyjobso
を比較する必要があります。そのため、「ユーザー名またはパスワードが間違っています」と返答するのに数マイクロ秒余分にかかります。
応答時間が攻撃者に有利に働く¶
その時点で、サーバーが「ユーザー名またはパスワードが間違っています」という応答を送信するのに数マイクロ秒長くかかったことに気付くことで、攻撃者は何かが正しかったこと、つまり最初の文字の一部が正しかったことを知るでしょう。
そして、johndoe
よりも stanleyjobsox
に似たものである可能性が高いことを知って、再度試すことができます。
「プロフェッショナルな」攻撃¶
もちろん、攻撃者はこれを手作業で試すことはなく、プログラムを作成して、おそらく毎秒数千または数百万回のテストを実行します。そして、一度に1つずつ正しい文字を追加で取得するでしょう。
しかし、これを実行することで、数分または数時間で攻撃者は、私たちのアプリケーションの「助け」を借りて、応答にかかる時間だけを使用して、正しいユーザー名とパスワードを推測してしまうでしょう。
secrets.compare_digest()
で修正する¶
しかし、私たちのコードでは実際に secrets.compare_digest()
を使用しています。
簡単に言えば、stanleyjobsox
と stanleyjobson
を比較するのにかかる時間は、johndoe
と stanleyjobson
を比較するのにかかる時間と同じです。パスワードについても同様です。
このように、アプリケーションコードで 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}