🔐

FastAPIでJWTをHttpOnly Cookieに保存する

2024/07/18に公開

FastAPIのチュートリアルを行った人は、Json Web Token(JWT)を発行するエンドポイントの実装はすでに行ったかと思います。ただし、そのエンドポイントは、JWTの文字列を返すのみです。

しかし、JWTの保存場所として、HttpOnly Cookieを利用しているパターンが多くあるように感じます。HttpOnly Cookieに保存するためには、FastAPIで設定する必要があります。

そこで、本記事では、FastAPIで以下の実装を行います。

  • JWTをHttpOnly Cookieに保存させるエンドポイント
  • JWTをHttpOnly Cookieから削除するエンドポイント
  • JWTをHttpOnly Cookieから取得する関数

前提条件

本記事は、FastAPIチュートリアルのパスワード(およびハッシュ化)によるOAuth2、JWTトークンによるBearerまでを理解していることを前提としています。

https://fastapi.tiangolo.com/ja/tutorial/security/oauth2-jwt/

本記事で使用したpythonとFastAPIのバージョンは以下の通りです。

python = 3.12
fastapi = 0.111.0

HttpOnly Cookieに保存

Cookieへの保存の仕方は、公式サイトにもあります。

https://fastapi.tiangolo.com/ja/advanced/response-cookies/

Cookieへの保存は、公式サイトにある通りで、パスオペレーション関数の引数にResponseを追加し、その変数のset_cookieメソッドを使うだけです。HttpOnly Cookieに保存する場合は、httponly=Trueとしましょう。レスポンスボディは、設定しなくてもよいので、パスオペレーション関数の返値は、Noneでも良いです。

HttpOnly Cookieへ保存させる例
from fastapi import FastAPI, Response

app = FastAPI()


@app.post("/cookie-and-object/")
def create_cookie(response: Response):
    response.set_cookie(
        key="fakesession",
        value="fake-cookie-session-value",
        httponly=True, # HttpOnly Cookieに保存
    )
    return None

それでは、JWTをHttpOnly Cookieに保存させるエンドポイントの実装例を紹介します。

from fastapi import FastAPI, Response
from fastapi.security import OAuth2PasswordRequestForm

app = FastAPI()


@app.post("/login", response_model=None)
def login(
    response: Response,
    form_data: OAuth2PasswordRequestForm = Depends(),
):
    # ユーザー認証
    user = authenticate_user(fake_users_db, form_data.username, form_data.password)
    if not user:
        raise HTTPException(
            status_code=400, detail="ユーザー名かパスワードが異なります。"
        )
    # トークンの発行
    payload = schemas.TokenPayload(sub=user.username)
    # note: Cookieに有効期限を付けるので、有効期限も返してもらう
    access_token, expire = create_access_token(payload)
    
    # Cookieの設定
    response.set_cookie(
        key="access_token",
        value=f"Bearer {access_token}",
        httponly=True, # HttpOnly
        expires=expire, # 有効期限の設定
    )
    return None

Cookieを削除

Cookieから値を削除する方法も同じように、パスオペレーション関数の引数にResponseを追加し、その変数のdelete_cookieメソッドを使うだけです。引数には、cookieのkeyを指定します。cookieがHttpOnlyであるかどうかは、ここでは関係ありません。

Cookieから削除させる例
from fastapi import FastAPI, Response

app = FastAPI()


@app.post("/delete-cookie/")
def delete_cookie(response: Response):
    response.delete_cookie("fakesession")
    return None

それでは、JWTをHttpOnly Cookieから削除するエンドポイントの実装例です。key="access_token"にJWTを保存した場合の例です。

from fastapi import FastAPI, Response

app = FastAPI()


@app.post("/logout", response_model=None)
def logout(response: Response):
    response.delete_cookie("access_token")
    return None

Cookieから取得

Cookieから値を取得する方法は、公式サイトにもあります。

https://fastapi.tiangolo.com/ja/tutorial/cookie-params/

公式サイトの案内のように、パスオペレーション関数の引数にkey_name: str = Cookie()を追加するだけです。引数の名前は、Cookieのkeyに一致するものを設定しなければなりません。

Cookieから取得する例
from fastapi import Cookie, FastAPI

app = FastAPI()


@app.get("/items/")
def read_items(
    ads_id: str | None = Cookie(default=None), # key=ads_idの値を取得する
):
    return {"ads_id": ads_id}

JWTをHttpOnly Cookieから取得する関数の実装例を紹介します。ここでは、アクセストークンが取得できなかった場合、401エラーとします。

from fastapi import Cookie, HTTPException


def get_token_from_cookie(access_token: str | None = Cookie(default=None)):
    if(access_token is None):
        raise HTTPException(
                status_code=401,
                detail="Not authenticated",
                headers={"WWW-Authenticate": "Bearer"},
            )
    return access_token

エンドポイントでJWTを使用する場合、Dependsを使って以下のように加えましょう。

from fastapi import FastAPI, Depends

app = FastAPI()


@app.get("/hoge/")
def hoge(jwt: str = Depends(get_token_from_cookie)):
    pass

ここで、FastAPIのチュートリアルであったget_current_userをCookieからJWTを取得できるように修正します。oauth2_schemeは、ヘッダーからJWTを取得しているので、これをget_token_from_cookieに変更しましょう。

def get_current_user(
-     token: str = Depends(oauth2_scheme)
+     token: str = Depends(get_token_from_cookie)
):
    pass

(おまけ)Swagger UIの挙動を戻す

最後におまけとして、Swagger UIのログイン・ログアウトの挙動を戻す方法を紹介します。

CookieにJWTを保存する場合、FastAPIが自動生成するドキュメントSwagger UIでのログイン、ログアウトができなくなります。それは、Swagger UIでの認証がAuthorizationヘッダーを使うものを想定してあるからです。

そのため、トークンをレスポンスとして返すエンドポイントを実装し、AuthorizationヘッダーからもJWTを取得できるようにする必要があります。

まず、トークンをレスポンスとして返すエンドポイントですが、FastAPIのチュートリアルにある通りの実装です。

トークンをレスポンスとして返すエンドポイント

@app.post("/token")
def login(
    form_data: OAuth2PasswordRequestForm = Depends()
):
    # ユーザー認証
    user = authenticate_user(fake_users_db, form_data.username, form_data.password)
    if not user:
        raise HTTPException(
            status_code=400, detail="ユーザー名かパスワードが異なります。"
        )
    # トークン発行
    payload = schemas.TokenPayload(sub=user.username)
    access_token = create_access_token(payload)
    return {"access_token": access_token, "token_type": "bearer"}

次にトークンを取得する方法についてですが、AuthorizationヘッダーとCookieのどちらからもJWTを取得できるようにします。この実装は、FastAPI公式のIssuesにあったCookie based JWT tokensを参考にしています。

公式のチュートリアルでは、oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")としていましたが、このOAuth2PasswordBearerクラスをAuthorizationヘッダーからもCookieからもアクセストークンを受け取るように改造します。

AuthorizationヘッダーまたはCookieからJWTを受け取るクラス

from typing import Optional

from fastapi import Request, HTTPException
from fastapi.security import OAuth2
from fastapi.security.utils import get_authorization_scheme_param
from fastapi.openapi.models import OAuthFlows as OAuthFlowsModel

class OAuth2PasswordBearerWithCookie(OAuth2):
    def __init__(
        self,
        tokenUrl: str,
        scheme_name: str = None,
        scopes: dict = None,
        auto_error: bool = True,
    ):
        if not scopes:
            scopes = {}
        flows = OAuthFlowsModel(password={"tokenUrl": tokenUrl, "scopes": scopes})
        super().__init__(flows=flows, scheme_name=scheme_name, auto_error=auto_error)

    async def __call__(self, request: Request) -> Optional[str]:
        authorization: str = request.headers.get("Authorization")
        if not authorization:
            authorization: str = request.cookies.get("access_token")

        scheme, param = get_authorization_scheme_param(authorization)
        if not authorization or scheme.lower() != "bearer":
            if self.auto_error:
                raise HTTPException(
                    status_code=401,
                    detail="Not authenticated",
                    headers={"WWW-Authenticate": "Bearer"},
                )
            else:
                return None

        return param

このクラスの使い方ですが、OAuth2PasswordBearerと変わりません。

# tokenUrlにトークンをレスポンスとして返すエンドポイントのURLを設定
oauth2_scheme = OAuth2PasswordBearerWithCookie(tokenUrl="token")

# Dependsを使って引数にインスタンスを追加
def get_current_user(
    token: str = Depends(oauth2_scheme)
):
    pass

まとめ

本記事では、FastAPIで以下の実装例を紹介しました。

  • JWTをHttpOnly Cookieに保存させるエンドポイント
  • JWTをHttpOnly Cookieから削除するエンドポイント
  • JWTをHttpOnly Cookieから取得する関数

FastAPIのチュートリアルは、充実していますが、Cookieへの保存・削除についてはあまり紹介されていなかったので、記事にさせていただきました。

JWTをCookieに保存するとフロントエンド(javascript)では、axioswithCredentialstrueに設定するだけで済みますし、なにかと便利になることも多いのではないでしょうか。ぜひこの方法を使ってみてください。

Discussion