コンテンツにスキップ

セッション認証#

2024 iThome鉄人レース

これはDjango Ninjaシリーズチュートリアルの第28回です。

第7章へようこそ!本章は以下の2つの内容で構成されています:

  • 第28回:認証——セッション認証とグローバル設定
  • 第29回:単体テスト——テストクライアントとpytestを使用したAPIのテスト

これらのテーマのコア機能は、Django Ninjaによって実装されたものではありませんが、フレームワークは依然としてある程度の統合を提供しています。さらに、これらの機能はどのDjangoプロジェクトにとっても非常に重要です。

この記事では、ほぼすべてのAPIプロジェクトで必要とされる認証Authentication)を紹介します。

Django NinjaでDjango組み込みのsession-based認証を利用して完全なログイン検証機能を実現する方法を探り、さらにコードの重複を減らすためのグローバル認証の設定方法について説明します。

本文中のすべてのコード変更は、こちらのPRを参照してください。

GitHubサンプルプロジェクト#

👉 Django-Ninja-Tutorial


認証の2つのレベル#

実装に入る前に、いわゆる認証が実際に何を意味するのかを理解する必要があります。

「アカウントとパスワード + session認証」を例にすると、認証の範囲は主に2つの段階をカバーしています。

まず、ユーザーがアカウントとパスワードを使用してログインする際、システムはこれらの内容をチェックし、身分が正当であることを確認します。ログインに成功すると、システムはユーザー情報(例えばユーザーid)をsessionに保存し、ログイン状態を維持します。

これがログイン時の認証であり、私たちが最もよく言う認証です。(狭義の認証

次に、ユーザーが「認証保護」されたAPIにアクセスしようとすると、システムはsessionをチェックして身分を確認し、各APIリクエストが正当にログインしたユーザーからのものであることを保証します。

要するに:

  • 第1段階:初回ログイン時の身分確認。
  • 第2段階:後続のリクエスト時の身分確認。

これら2つのレベルは相互に補完し合い、表裏一体となって、サービスがユーザーのログインから後続の操作において適切なセキュリティ保障を提供できるようにします。


「ユーザーログイン」APIの実装#

上記の2つのレベルを理解したところで、まずは「狭義」の認証、つまりログイン検証自体を実装しましょう。

「ユーザーログイン」APIを作成し、Djangoのauthenticatelogin関数を通じてアカウントとパスワードの検証ログイン状態を直接処理します——非常に便利です!

authenticateは、ユーザーが入力したアカウント(username)とパスワードが正しいかを検証するために使用され、loginはユーザーのログイン状態をsessionに保存します。

コードの実装#

まず、ログインリクエストのスキーマを追加します:

# user/schemas.py
class LoginRequest(Schema):
    username: str = Field(examples=['Alice'])
    password: str = Field(examples=['password123'])

そしてview関数です:

from django.contrib.auth import authenticate, login

from user.schemas import CreateUserRequest, LoginRequest
...

@router.post('/users/login/', summary='ユーザーログイン')
def login_user(
    request: HttpRequest, payload: LoginRequest
) -> dict[str, str]:
    """
    ユーザーログイン
    """
    user = authenticate(
        request,
        username=payload.username,
        password=payload.password
    )
    if user is not None:
        login(request, user)  # ユーザーのログイン状態をsessionに保存
        return {'message': 'ログイン成功'}
    else:
        raise HttpError(401, 'アカウントまたはパスワードが間違っています')

非常に簡単です!

ちなみに、私はプログラムに「不必要な」elseが含まれるのがあまり好きではありません。この時点での書き方はまだ理想的ではありません——なぜならelse完全に省略できるからです。

最新のコードでは、次のように変更していることがわかります:

user = authenticate(...)
if user is None:
    raise HttpError(401, 'アカウントまたはパスワードが間違っています')

login(request, user)  # ユーザーのログイン状態をsessionに保存
return {'message': 'ログイン成功'}

このようなやり方はいわゆるGuard ClauseまたはEarly Return(ここではraiseですが)と呼ばれます。

簡単な評価と重要な補足#

authenticateloginの使用法はほぼ固定されており、理解しやすいです:

  • authenticateは検証成功時に対応するUserオブジェクトをreturnし、失敗時はNoneを返します。
  • loginはreturnしませんが、requestuserが必須のパラメータです。

ログインに成功すると、200のレスポンスが得られ、2組のcookieが取得されます:

これはAPI client(例えばPostman)のユーザーにとって重要です。ブラウザは自動的に保存してくれますが、これらのツールはそうしてくれません——いや、私が間違っていました。少なくとも私が使用しているRapidAPIは自動的に保存し、送信してくれます!

(APIをテストしている時、認証保護が無効になっているのかと不思議に思いました🤣)

ツールが代わりに行ってくれない場合は、リクエストのheadersに自分で追加することを忘れないでください:

POST /users/2/avatar/ HTTP/1.1
...
Cookie: csrftoken=...; sessionid=...
X-CSRFToken: ...

authenticateはデフォルトでAbstractUserusernameフィールドとパスワードを認証の基準として使用します。もしemailなど他のフィールドを使用したい場合は、Djangoの認証バックエンドを自分でオーバーライドする必要があります。


APIに「認証保護」を追加する#

ログイン機能が完成したら、次に「ログインが必要な」APIにそれぞれ認証保護を追加します。これにはDjango Ninjaが提供するdjango_authを使用します。これはDjango組み込みのsession認証専用です。

「アバターのアップロード」APIを例にします:

from ninja.security import django_auth
...

@router.post(
    path='/users/{int:user_id}/avatar/',
    summary='アバターのアップロード',
    auth=django_auth  # このパラメータを追加
)

この例では、auth=django_authにより、「ログイン済みのユーザー」のみがこのAPIにアクセスできるようになります。そうでない場合は401または403のレスポンスが得られます。


Django Ninjaのrequest.auth#

しかし、こう思うかもしれません:

「ログイン済み」であることを検証するだけでは不十分では?

「アバターのアップロード」は「自分」のためにしかアップロードできないはずです。「他の人」のプロフィール写真をアップロードすることはできませんよね!

その通りです。だからview関数の内部で、もう一層の検証を行う必要があります。

Djangoのrequest.user#

従来のDjangoプロジェクトでは、関数の最初のパラメータであるrequestを通じて、request.userを使用して現在のユーザー情報を取得します。例えば:(ドキュメント参考)

if request.user.is_authenticated:
    # Do something for authenticated users.
    ...
else:
    # Do something for anonymous users.
    ...
具体的には: - ユーザーがログインしている場合、request.userUserインスタンスであり、現在ログインしているユーザーを表します。 - 未ログインの場合、request.userAnonymousUserインスタンスであり、未ログインユーザーを表します。

ユーザーがログインしている場合、request.userの属性、例えばrequest.user.idをチェックして、「本人」であるかどうかを確認できます。

Django Ninjaのrequest.auth#

しかし、Django Ninjaで書く場合は、それが提供するrequest.authを使用する必要があります。実装結果は以下の通りです:

...
def upload_avatar(...) -> dict[str, str]:
    """
    アバターのアップロード
    """
    # ログインしているユーザーが「本人」であるかをチェック
    if request.auth.id != user_id:
        raise HttpError(403, '他のユーザーのアバターをアップロードする権限がありません')
    ...

テストしてみましょう。ログイン後にURLパスに他の人のidを入力してこのAPIを呼び出します:

// 403 Forbidden
{
    "detail": "他のユーザーのアバターをアップロードする権限がありません"
}

素晴らしい!


request.authの解析#

ここではrequest.userの代わりにrequest.authを使用していますが、実際には両者の意味合いは大きく異なります

Django Ninjaにおいて、request.auth認証プロセスのreturn結果を表します。さらに、Django Ninjaはカスタム認証メソッドを許可しているため、request.authの内容は固定されていません

少し深く掘り下げてみましょう。

認証結果#

request.authには現在の認証メソッドが返した値が含まれています。 - この値は、認証ロジックの実装方法に応じて、任意の型にできます。 - これは開発者に大きな柔軟性を与えます。それはUserオブジェクト、文字列、Pythonの辞書などになります。

認証メソッドと一般的なユースケース#

  • Djangoのsession認証を使用する場合、request.authはDjangoのUserオブジェクトです。
  • APIキー認証の場合、request.authはおそらくAPIキー自体、またはそれに関連する情報です。
  • JWT認証では、request.authにはデコードされたトークン情報が含まれる可能性があります。

とにかく、view関数内でさらに認証情報を取得したい場合は、request.authを通じる必要があることだけ覚えておいてください。


これで認証の実装は完了しましたが、物事をもう少し「簡単」にすることができます。

グローバル認証の設定と例外#

各APIに対して一つ一つ認証保護を設定するのは、特にAPIが多い場合は少し面倒に感じます。

これに対して、Django Ninjaはグローバル認証をサポートしており、すべてのAPIがデフォルトで保護されるようになります。開発者は特定のルートで例外処理を行い、適用したくないAPIを除外するだけで済みます。

実装は非常に簡単で、Django Ninjaはグローバルなsession-based認証を処理するためのSessionAuth認証クラスを直接提供しています。

グローバル認証の実装:SessionAuthの使用#

プロジェクトのapi.pyに以下の内容を追加します:

# NinjaForum/api.py
from ninja.security import SessionAuth
...

api = NinjaAPI(
    auth=SessionAuth(),  # グローバル認証を設定
    ...
)

このようにすれば、すべてのAPIがデフォルトで認証保護を持つことになります。特定のAPI、例えば「ユーザーログイン」で除外することができます:

@router.post(path='/users/login/', summary='ユーザーログイン', auth=None)

ルートデコレータで、authNoneとして定義し、認証保護を解除します。


認証保護のテスト#

認証保護がある」APIをテストしてみましょう。未ログインの状態で、異なるHTTPメソッドのAPIを試すと、異なるエラーレスポンスが返ってくることがわかります:

  • GET:401 Unauthorized
  • POST:403 Forbidden

だからこそ、先ほど「401または403」のレスポンスが得られると言ったのです。

「すべてのユーザーを取得する」APIのテスト#

私たちのプロジェクトの設計では、ログインしたユーザーのみが「すべてのユーザーを取得する」APIにアクセスできます。

未ログインの場合、401レスポンスが得られます:

// 401 Unauthorized
{
    "detail": "Unauthorized"
}

「記事の追加」APIのテスト#

未ログインでは「記事の追加」APIにもアクセスできません——これは明らかに非常に理にかなっています。そうでなければ、記事に作者がいなくなってしまいます😅

403レスポンスが得られます:

// 403 Forbidden
{
    "detail": "CSRF check Failed"
}

「おかしいな?なぜCSRF check Failedなんだろう?」と思うかもしれません。

これはDjangoのCSRF保護メカニズムです。私たちのAPIはPOSTメソッドであるため、Djangoは自動的にCSRFトークンをチェックしますが、私たちがCSRFトークンを提供していないため、このエラーが発生します。


まとめと次のステップ#

この記事では、Djangoのsession認証とDjango Ninjaの統合を探り、「ユーザーログイン」APIを実装し、他のAPIに認証保護を追加しました。最後に、グローバル認証を実現し、プロセス全体をより簡単にする方法も実演しました。

このシリーズの最後の実践として、プロジェクトのためにテストを書くことにします!

次回は、test clientとpytestを使用して、私たちのDjango APIの単体テストを記述する方法を探ります。これは既存の機能を検証するのに役立つだけでなく、将来の開発やリファクタリングにおいてもう一層の保証を提供します。