3
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

Django Rest Frameworkで認証機能をカスタムしてみた話(カスタムユーザー使用)

Last updated at Posted at 2020-09-08

はじめに

はじめに申し上げておきますと、私は5月ごろにプログラミングをはじめた初心者であり、もちろん同様にdjangoも初心者です。
ですので、他のプログラマーの方から見れば変なところで詰まっていると思われます。
しかし、ネットにあまり近しい事例が載っていないと感じたので、一部始終と解決策を書いていきたいと思います(チラリとdjango-rest-frameworkの日本語記事が少ないというのも見かけたので、貢献できたらいいなとも考えてはいます)。
P.S.この丸一週間の苦しみは忘れないでしょう。

環境

Django = 3.1.1
django-allauth = 0.42.0
django-rest-auth = 0.9.5
djangorestframework = 3.11.1

流れと解決策

1.自分が定義したUserモデルが、登録及び認証では使えない。

解決法
djangoが提供するUserをカスタムすることにより、自分のプロジェクトに近いUserを作成

2.django-rest-auth提供の登録画面でpassword1password2を打っても、「この項目は必須です。」、つまり、空欄に扱われる。

解決法
RegisterSerializerを編集して、必須項目を変更した。

3.登録画面を通して作成されたUserが、ログインでは認証されない(non FieldError:与えらえた情報では認証できませんとのエラーメッセージが出る)。

解決法
RegisterSerializer内の、Userをcreateする部分(メソッド)を書き換えた。

これが概要となります。
最後までご覧いただければ嬉しい限りです。

1. Userモデルが認証・登録で使えない

エラー概要

この問題が発生した原因は、__私が中途半端にログイン機能の実装方法を知っていたから__に他なりません。

  • 私が知っていた実装法

  • User(models.Model)から、独自にUserモデルを作成

  • sessionを用いて、session内にuser_id(ログイン時にDB内でフィルターをしてassignされる)があるかで、画面遷移を行う(エセ認証機能といったところでしょうか)

  • 故に、apiで閲覧のみ許可といった、permissionが実装できない

  • また、パスワードリセットといった方法がDB操作のみでしかできない

  • 本来の実装法(一般的な実装法という方が近いかもしれません)

  • フレームワークが提供するUserモデルを使用して認証を行う

  • トークンを用いてログイン済みユーザーか判定

  • ログインしているかしていないかで、apiの操作の制限を行える

  • パスワードリセットといった操作がメール送信等で実装可能

上記のように、Userモデルの定義の仕方というスタートラインから私は勘違いをしていました。フレームワークがUserを提供しているというのは微かに知っていましたが、__認証機能もそのフレームワークで提供されたUserではないと実装できない__というのが私の見解との大きな差であり、苦戦した原因でした。

おそらく、models.Modelで定義したUserは認証に使えないので、認証機能を実装するのであれば、少々面倒ですがdjango提供のUserをカスタムする形でプロジェクトの最初から使ってください。

後に知りましたが、フレームワークを使うならフレームワーク組み込みのモデル・メソッドを使う必要があるので、その点は事前にチェックをしておいた方が良さそうです。

解決方法

実際に解決した流れを書いていきます。
参考にした記事はこちらです。
公式ドキュメント
[Django] カスタムユーザーを利用する

まず、カスタムユーザーを定義するにあたり必要なものは、BaseUserManager, AbstractUser, AbstractBaseUserです。

  • __BaseUserManagerは、User作成周りの設定ができる__クラスです。管理ユーザー(この場合はauthの方ではなくadminの方だと思います)作成の項目をusernameからemailに変更したい場合等に使います(正直特にこのBaseUserManagerに関しては理解が浅いです、申し訳ないです)。

  • __AbstractUser__は__デフォルトのUserが持ってる機能全て__を持っています。電話番号等少しだけ変更を加えたい場合に向いています。

  • __AbstractBaseUser__は__最低限の機能(パスワードのハッシュ化等)__を持っていて、大胆に変更をしたい場合に向いています。

私はフィールドを多く追加する予定でしたが、後のエラーを恐れ今回は__AbstractUser__を使いました。尚、これら3つのクラスのソースコードはこちらとなります。

app.models.py(appはdjangoプロジェクト内のアプリ名)
class CustomUserManager(UserManager):

    use_in_migrations = True

    def _create_user(self, email, password, **extra_fields):
        if not email:
            raise ValueError('メールアドレスは必須項目です。')

        email = self.normalize_email(email)
        user = self.model(email=email, **extra_fields)
        user.set_password(password)
        user.save(using=self._db)
        return user

    def create_user(self, email, password=None, **extra_fields):

        extra_fields.setdefault('is_staff', False)
        extra_fields.setdefault('is_superuser', False)
        return self._create_user(email, password, **extra_fields)

    def create_superuser(self, email, password, **extra_fields):
        extra_fields.setdefault('is_staff', True)
        extra_fields.setdefault('is_superuser', True)
        if extra_fields.get('is_staff') is not True:
            raise ValueError('Superuser must have is_staff=True.')
        if extra_fields.get('is_superuser') is not True:
            raise ValueError('Superuser must have is_superuser=True.')
        return self._create_user(email, password, **extra_fields)

基本的にほとんど元のコードのコピーをmodels.pyに貼り付けました。管理ユーザー作成に求められる項目をusernameからemailに変えただけです。

app.models.py
class User(AbstractUser):
    # AbstractUserでpasswordは定義されているため、ここではpasswordをしません(DBにはちゃんと保存されます。)
    # 自分の要望にあわせて既に存在するフィールドも上書きをしています。
    username = models.CharField(max_length=150, unique=True)
    email = models.EmailField(max_length=100, unique=True)
    profile = models.TextField(max_length=800, blank=True, null=True)
    icon = models.ImageField(blank=True, null=True)
    background = models.ImageField(blank=True, null=True)
    # AbstractUserはfirst_name,last_nameを保持していますが、私には不要なので無効にしています
    first_name = None
    last_name = None

    is_staff = models.BooleanField(
        ('staff status'),
        default=True,
        help_text=(
            '管理サイトへのアクセス権の有無'),
    )

    is_active = models.BooleanField(
        ('active'),
        default=True,
        help_text=(
            'ユーザーのアクティブ判定'
        ),
    )

    is_superuser = models.BooleanField(
        ('superuser status'),
        default=True,
        help_text=(
            'Designates that this user has all permissions without '
            'explicitly assigning them.'
        ),
    )
    # 後になって気づきましたが、date_joinedというのが既に`AbstractUser`では入っているのでcreated_atは必要ありませんでした。
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

  #ここに上記のBaseUserManagerのクラス名を書きます
    objects = CustomUserManager()

    EMAIL_FIELD = 'email'
    USERNAME_FIELD = 'username'
    REQUIRED_FIELD = ["username", "email"]

    class Meta:
        db_table = "users"

これで、私が望んでいたUserモデルを作成することができました。

しかし、ここで忘れてはならないことがあります。

project.settings.py(projectはdjangoプロジェクトのことです)
AUTH_USER_MODEL = 'アプリ名.User'

これをしないと、作成したUserモデルが認証ユーザーモデルとして設定されないので注意が必要です。

また、豆知識として、作成したUserを1対多とかで関連付けたい場合は少しトリッキーで表記方法が変わります。実際の私のModelを具体例として出すとこの様になります。

app.models.py
class Comment(models.Model):
    comment = models.CharField(max_length=400)
    owner = models.ForeignKey(
#
# Userはこの場合リレーションに使えません。
#
        settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="comment")
    item = models.ForeignKey(Give_Item, on_delete=models.CASCADE, null=True)
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

他のModelと同じようにモデル名を素直に入れるわけではなく、__settings.AUTH_USER_MODEL__と書かなければいけません。

これにて、自分の望み通りのUserモデルをつかえて、それが認証にも使えるようになったわけです。次に認証周りに進みます。

2.django-rest-authでフィールドが認識されない

エラー概要

正直、これの原因については遂に解明できませんでした。しかし、1のカスタムユーザーを用いないで認証を実装する方法をいろいろ試していた段階で気付かぬうちに変に設定をいじっていたんだろうなと思います。
検証用で、全く異なる新規プロジェクトを作成したら同様のエラーが起きなかったので、ファイルのどこかに問題があったのでしょう(問題解明をしたかったです...)。

django-rest-auth, django-all-authといった認証周りについては本記事ではほとんど説明しません。その代わり、私が参考にした記事を記載しておきます。
django-rest-auth を用いたユーザー認証の導入

私が遭遇したエラーは、ユーザー登録時においてpassword1,password2を確実に、しかも全く同じ文字を入力しているはずなのに、_「この項目は必須です」_と返ってくるというものです。
Screen Shot 2020-09-06 at 13.40.00.png
以下が実際の画面です(尚、POSTMANでも同様のコードをPOSTリクエストで送信しましたが同じレスポンスが返ってきました)。
このエラーに関しては、1のカスタムユーザーの作成に取り掛かる前から遭遇していたので、かなり苦しめられたものになります笑(説明しにくく、検索しても類似例がヒットしないというのが余計に難しくさせました)。

解決方法

実際の解決法について記載をしていきます。

ステータスコードが400なので、クライエント側に問題があったのは明白でした。
そこで、私が考え実行した解決策は__登録時の項目を変更する__というものです。
自分で設定ができれば上記の問題をコントロールできると考えたからです。また、パスワード確認はバックエンドで行わず、フロントエンド(私の場合はReactです)のValidationのみでもできると判断をしたためです。

django-rest-authにて登録機能を担っているのはRegisterSerializerです(django-rest-authが認証のメインですが、実装にはdjango-allauthが必要です)。
公式ドキュメントソースコードのリンクを貼っておくので興味がある方はご覧ください。

rest_auth.registraion.serializers.py
class RegisterSerializer(serializers.Serializer):
    username = serializers.CharField(
        max_length=get_username_max_length(),
        min_length=allauth_settings.USERNAME_MIN_LENGTH,
        required=allauth_settings.USERNAME_REQUIRED
    )
    email = serializers.EmailField(required=allauth_settings.EMAIL_REQUIRED)
#
# password1とpassword2を削除してpasswordフィールド一つのみにしています。
#
    password = serializers.CharField(write_only=True)

    def validate_username(self, username):
        username = get_adapter().clean_username(username)
        return username

    def validate_email(self, email):
        email = get_adapter().clean_email(email)
        if allauth_settings.UNIQUE_EMAIL:
            if email and email_address_exists(email):
                raise serializers.ValidationError(
                    _("A user is already registered with this e-mail address."))
        return email

    def validate_password(self, password):
        return get_adapter().clean_password(password)

    def custom_signup(self, request, user):
        pass

    def get_cleaned_data(self):
        return {
            'username': self.validated_data.get('username', ''),
            'password': self.validated_data.get('password', ''),
            'email': self.validated_data.get('email', '')
        }

    def save(self, request):
        adapter = get_adapter()
        user = adapter.new_user(request)
        self.cleaned_data = self.get_cleaned_data()
        adapter.save_user(request, user, self)
        self.custom_signup(request, user)
        setup_user_email(request, user, [])
        return user

変更した箇所はシンプルで、passwordを一つのフィールドのみにすることです。基本的に、必要な箇所のみを編集した形です。尚、ログインの方も私は設定したかったので、rest_auth.serializers.py(ソースコード)からLoginSerializerを編集しました(変更内容はログイン項目をusernameのみに変更するというものです)。

当初わたしは、このRegisterSerializerを自分のアプリ内のapp.serializers.pyで上書きをしていました。しかし、RegisterSerializerが色々なメソッドと結びついておりimportのパス等で中々上手く変更が適用できなかったので、パッケージ内に直接変更を行いました。直感的に、パッケージへは直接変更をしない方がいいとは思うのですが、時間がかなりかかりそうなので今回はこの方法を採りました。皆さんは真似をしないでください。

もし、上書きをするのであれば、以下の様なコードをsettings.pyに書くことが必要です。

settings.py

REST_AUTH_SERIALIZERS = {
    'LOGIN_SERIALIZER': 'Serializerへのパス.LoginSerializer',
    ...
}

また、同時にSerializerの名前をCustomLoginSerializer等に変える場合、Viewを変えるのもお忘れなく。具体的に書くとするなら、以下の様になると思います。

views.py
class LoginView(GenericAPIView):
    """
    Check the credentials and return the REST Token
    if the credentials are valid and authenticated.
    Calls Django Auth login method to register User ID
    in Django session framework
    Accept the following POST parameters: username, password
    Return the REST Framework Token Object's key.
    """
    permission_classes = (AllowAny,)
#
# Serializerを変更後の名前に変更
#
    serializer_class = CustomLoginSerializer
    token_model = TokenModel

上記の変更を加えて、無事エラーは解決しユーザー登録をすることができました。
最後のエラーが待ち受けることを知らずに、私は一瞬ながら喜んでおりました。

3.登録画面を通したUserがログイン時に認証されない

エラー概要

このエラーの原因は、私がRegisterSerializerのフィールドを編集し、他のメソッドが参照できなかったために発生しました。

サブタイトルだけでは分かりづらいと思うので、詳しくエラーを説明していきたいと思います。

当然といえば当然ですが、django_rest_frameworkはユーザー登録時にUserクエリも作成してくれます。ユーザー登録へのURLはlocalhost:8000/rest-auth/registration/なので、このURLへ必要項目と一緒にPOSTリクエストを送信すればユーザーも作成され、また、登録されるというわけです。

しかし、必要項目と一緒にlocalhost:8000/rest-auth/login/へPOSTリクエストを送信しても_non FieldError提供された情報では認証を行えません_と返ってきてログインが認証されません。

Userクエリを作成する方法はもう一つあり、それは認証ではなく、APIのエンドポイントを使うことです。すなわち、localhost:8000/api/user/へPOSTリクエストを送信し、単純にcreateメソッドを用いてUserを作成するということです。

後者の方法で、Userを作成しログインを試みたところ、Userは認証されトークンが発行されました。

これを受けて、私は以下の様に考えました。

ユーザー登録とAPIではUserの作成方法が異なるのではないか
>APIによるcreateはUser.objects.create(...)を用いたもので、この方法なら認証はされる
>>ユーザー登録時において、UserUser.objects.create以外で作成されている可能性がある

これを具体的にいうと、
__RegisterSerializerUser.objects.create以外の方法でUserクエリを作っている可能性がある__ということです。

解決方法

実際に解決した流れを書いていきます。
まずは、RegisterSerializerのユーザー作成に関わるメソッドを見ていきます。

rest_auth.registration.serializers.py

def save(self, request):
        adapter = get_adapter()
        user = adapter.new_user(request)
        self.cleaned_data = self.get_cleaned_data()
        adapter.save_user(request, user, self)
        self.custom_signup(request, user)
        setup_user_email(request, user, [])
        return user

この部分が登録時のユーザー作成を担っています。
adapterについては、公式ドキュメントから説明を読むことができます。 基本的に、登録周りの使用はdjango-allauthの方のドキュメントを参照しなければいけません。

new_user(self, request): Instantiates a new, empty User.
save_user(self, request, user, form): Populates and saves the User instance using information provided in the signup form.

save_userの方に、ロジックがありそうなのでsave_userのメソッドを参照しにいきます。ソースコードはこちらです。

allauth.account.adapter.py

def save_user(self, request, user, form, commit=True):
        """
        Saves a new `User` instance using information provided in the
        signup form.
        """
        from .utils import user_email, user_field, user_username

        data = form.cleaned_data
        first_name = data.get('first_name')
        last_name = data.get('last_name')
        email = data.get('email')
        username = data.get('username')
        user_email(user, email)
        user_username(user, username)
        if first_name:
            user_field(user, 'first_name', first_name)
        if last_name:
            user_field(user, 'last_name', last_name)
#
# ここがpassword1となっているのが原因でした
#
        if 'password1' in data:
            user.set_password(data["password1"])
        else:
            user.set_unusable_password()
        self.populate_username(request, user)
        if commit:
            # Ability not to commit makes it easier to derive from
            # this adapter by adding
            user.save()
        return user

原因は、save_userにおいてはパスワード生成はpassword1が元となっていたからでした。
つまり、私がRegisterSerializerのフィールドをpasswordに変更していたから発生したエラーです。

上記の箇所をpasswordに変更したところユーザー登録時に作成されたUserも無事ログイン認証に成功しました。

以上が今回遭遇したエラーの全てです。

感想

今回の解決に時間がかかったのは、間違いなく最初からカスタムユーザーを使わなかったことです。一週間の最初の2,3日は、独自作成したUserでの実装方法を模索していたからです。最初から使っていれば、変にこねくり回すことなく2のエラーも起きなかったかもしれません(知識不足だったのもありますが)。

しかし、今回で学んだことは私自身としては大きかったです。

- 公式ドキュメントは細かいところまで目を通すこと
- パッケージのソースコードを場合によっては参照すること
- git reset --hard HEAD^を乱用しない笑

知識不足もありましたが、たった一つの機能で、しかもそこまで難易度が高くないだろうものにここまで時間がかかるとは思っていませんでした。

ポートフォリオ作成に関しては、django-rest-frameworkをこのまま使用していくので、日本語記事を増やすためにも気づいたことがあればアウトプット・共有をしていきたいと思います。

拙く、ひどく読みづらい、長い文章だったとは思いますが目を止めていただきありがとうございました。
間違っている箇所や、アドバイス等コメントをいただければ幸いです。

3
3
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?