【Django】ユーザ認証システムの注意点

Djangoのユーザ認証システムの注意点と、その解決方法を紹介します。具体的な注意点は、loginメソッド(django.contrib.auth.login)の引数に、DBから取得したUserを与えるとエラーが発生してしまうことです。

loginメソッドの挙動について

成功するケース

authenticateメソッド(django.contrib.auth.authenticate)メソッドを呼び、その戻り値のユーザ情報をloginメソッドの引数に与えると、問題なくログインが完了します。

以下に、成功する例のテストケースを示します。

テストケース

import 略...


class TestLogin(TestCase):
    def setUp(self):
        super().setUp()
        # ユーザを作成します。
        self.username = 'USERNAME'
        self.password = 'PASSWORD'
        self.user = User.objects.create_user(
            username=self.username, password=self.password)

        # HttpRequestのインスタンスを生成します。
        self.request = HttpRequest()
        engine = import_module(settings.SESSION_ENGINE)
        self.request.session = engine.SessionStore()

    def test_success(self):
        """ログインに成功するケースです。"""
        # ユーザ名とパスワードで認証します。
        user = authenticate(username=self.username, password=self.password)
        self.assertIsNotNone(user)

        # 認証されたユーザを使ってログインします。
        login(self.request, user)

失敗するケース

上記「成功するケース」では、authenticateメソッドの戻り値を使って、loginメソッドを呼んでました。つまり、authenticateメソッドとloginメソッドは同一リクエスト上で処理しています。

ただ、仕様の関係上、authenticateメソッドとloginメソッドを別々のリクエストで呼ばなければならない場合があります。例えば、ログインを2段階認証にした場合です。

この場合、最初のリクエストでユーザ名とパスワードを使ってauthenticateメソッドを呼び、次のリクエストでコードの一致をもってloginメソッドを呼ぶフローが考えられます。

※1度のリクエストでユーザ名・パスワード・2段階認証のコードを送信する導線の場合、1回のリクエストでauthenticateメソッドとloginメソッドを呼べるので、この問題は発生しません。

テストケース

下記テストケースを実行すると、

    def test_failed(self):
        """ログインに失敗するケースです。"""
        # DBからユーザを取得します。
        user = User.objects.get(pk=self.user.pk)

        # DBから取得したユーザを使ってloginを呼ぶと、エラーが発生します。
        login(self.request, user)

以下のエラーが発生します。

AttributeError: 'User' object has no attribute 'backend'

原因と解決策

原因

loginメソッドでは、authenticateメソッドで認証に成功したbackend(AUTHENTICATION_BACKENDSの中の1つ)をsessionに格納しています。そのbackendは、ユーザに設定されているものを使います。ただ、DBから取得したユーザにはbackendが設定されていないので、backendがないというエラーが発生しています。

AttributeError: 'User' object has no attribute 'backend'

解決策

loginメソッドを呼んだ時にエラーが発生する問題の解決策を2つ紹介します。解決策は2個以上ありますが、この辺りが妥当かなというものを選んでます。

解決策 - backendを代入

authenticateメソッドを使い、認証に成功するとbackendが設定されたユーザが返ってきます。そのbackendは、認証に成功したbackendがdjango.contrib.auth.backends.ModelBackendのように、文字列で格納されています。

この仕組みを利用して、DBから取得したユーザのbackendに、認証に成功したbackendを設定すればloginメソッドでエラーが発生しなくなります。

テストケース
    def test_solution1(self):
        """backendを自分で設定してあげる例です。"""
        # ユーザ名とパスワードで認証します。
        user = authenticate(username=self.username, password=self.password)

        # backendを取得します。
        # ※実際にはsessionに入れておきます。
        backend = user.backend

        # ここから下は別リクエストと考えます。
        # つまり、authenticateで取得したuserをそのまま使えません。
        del user

        user = User.objects.get(pk=self.user.pk)

        # backendを設定します。
        # ※実際にはsessionから取得します。
        user.backend = backend

        # ログインします。
        login(self.request, user)

解決策 - 再度authenticateを呼ぶ

個人的には、こちらの解決策の方が好ましいと考えています。

再度authenticateメソッドを呼んでユーザを取得し、そのユーザを使ってloginメソッドを呼べばエラーは発生しなくなります。ただし、デフォルトのauthenticateメソッドを呼ぶには、ユーザ名とパスワードが必要です。

ユーザ名とパスワードをsessionに格納してくのは好ましくないので、backendを自作します。

自作したbackend

自作したbackendは、Djangoの標準のbackend(django.contrib.auth.backends.ModelBackend)を継承して作成します。そして、authenticateメソッドをオーバーライドし、DBから取得したユーザを使って認証できるようにします。

# coding: utf-8
from django.contrib.auth.backends import ModelBackend


class MyAuthBackend(ModelBackend):
    """ユーザモデルから認証実施するバックエンドです。"""
    def authenticate(self, user=None, **kwds):
        if user is None:
            return
        if not user.is_authenticated() or not user.is_active:
            return
        return user

なお、自作したbackendはドキュメントの通り、authenticateメソッドとget_userメソッドが必要です。ただ、get_userは継承元(ModelBackend)のを使えばいいので、オーバーライドする必要はありません。

settings.py

backendを自作したら、settings.pyのAUTHENTICATION_BACKENDSに設定します。ここは、Django標準のdjango.contrib.auth.backends.ModelBackendの下に自作したbackendを追加します。

AUTHENTICATION_BACKENDS = (
    'django.contrib.auth.backends.ModelBackend',  # デフォルトのbackend
    'myapp.backends.MyAuthBackend',  # 自作したbackendを追加
)
テストケース
    def test_solution2(self):
        """backendを自作した例です。"""
        # ユーザ名とパスワードで認証します。
        user = authenticate(user=self.user)

        # ここから下は別リクエストと考えます。
        # つまり、authenticateで取得したuserをそのまま使えません。
        del user

        user = User.objects.get(pk=self.user.pk)

        # ユーザを使って認証します。
        user = authenticate(user=user)

        # ログインします。
        login(self.request, user)

テストケース全体

今回使用した、テストケースの全体です。

# coding: utf-8
from importlib import import_module
from django.conf import settings
from django.contrib.auth import authenticate, login
from django.contrib.auth.models import User
from django.http import HttpRequest
from django.test import TestCase


class TestLogin(TestCase):
    def setUp(self):
        super().setUp()
        # ユーザを作成します。
        self.username = 'USERNAME'
        self.password = 'PASSWORD'
        self.user = User.objects.create_user(
            username=self.username, password=self.password)

        # HttpRequestのインスタンスを生成します。
        self.request = HttpRequest()
        engine = import_module(settings.SESSION_ENGINE)
        self.request.session = engine.SessionStore()

    def test_success(self):
        """ログインに成功するケースです。"""
        # ユーザ名とパスワードで認証します。
        user = authenticate(username=self.username, password=self.password)
        self.assertIsNotNone(user)

        # 認証されたユーザを使ってログインします。
        login(self.request, user)

    def test_failed(self):
        """ログインに失敗するケースです。"""
        # DBからユーザを取得します。
        user = User.objects.get(pk=self.user.pk)

        # DBから取得したユーザを使ってloginを呼ぶと、エラーが発生します。
        login(self.request, user)

    def test_solution1(self):
        """backendを自分で設定してあげる例です。"""
        # ユーザ名とパスワードで認証します。
        user = authenticate(username=self.username, password=self.password)

        # backendを取得します。
        # ※実際にはsessionに入れておきます。
        backend = user.backend

        # ここから下は別リクエストと考えます。
        # つまり、authenticateで取得したuserをそのまま使えません。
        del user

        user = User.objects.get(pk=self.user.pk)

        # backendを設定します。
        # ※実際にはsessionから取得します。
        user.backend = backend

        # ログインします。
        login(self.request, user)

    def test_solution2(self):
        """backendを自作した例です。"""
        # ユーザ名とパスワードで認証します。
        user = authenticate(user=self.user)

        # ここから下は別リクエストと考えます。
        # つまり、authenticateで取得したuserをそのまま使えません。
        del user

        user = User.objects.get(pk=self.user.pk)

        # ユーザを使って認証します。
        user = authenticate(user=user)

        # ログインします。
        login(self.request, user)

この記事が役に立った場合、シェアしていただけると励みになります!!