【Pyhon/Django】カスタムユーザとメールアドレスログイン1

djangoを使い始めて公式のチュートリアルなど一通り確認し終え、会員登録とログイン機能を実装してみたのですが
このままでは使いづらい部分もあり、機能をカスタマイズしていきます。
ただ、公式サイトや各種ブログを探しても断片的な情報だったり情報が古かったりしてかなり苦労しました。現在の最新バージョンにて、構築しておりますのでこちらに軌跡を整理いたします。
Python:3.9.4
Django:3.2

Djangoの情報調べたいのに、なかなか体系的な記述がなくめっちゃ苦労しました

みなさんの参考になれば!

まずは前提

ユーザ

サイトを利用する一般ユーザです。会員登録してからログインすることにより、サイトの機能が利用できるようになります。
ユーザのアカウントでは管理者にログインできません。
アカウントの情報以外にも、住所なども追加で登録することが多いです。(今回はそこまで作っておりません)

管理者

管理者
サイトを管理するためのアカウントです。管理画面にログインしてサイトのメンテナンスを行います。
管理者のアカウントでは、ユーザのログイン画面ではログインできません。

基本機能での課題・不満点

①ログインに「ユーザネーム」が必須

最近のサイトではユーザネームを使うサイトの方が少ないですね。理由の大部分はユーザ利便性です。
ログインにユーザネームが必要になると、もちろん会員登録の時に入力してもらう必要があります。
他にもメールアドレスも登録必須とすると、入力してもらう項目が増えてしまい離脱の原因になります。
今どきのログインは「メールアドレス」「パスワード」の2点でしょ
そもそもメールアドレスをわざわざ入力させずSNSログインさせることも選択肢としてありますが
今回は自サイトで完結する方法で検討します。

②管理画面とユーザログインでの認証方法が一緒

管理画面もユーザのログイン画面も、そのまま使うとユーザネームとパスワードでのログインになります。
そして、認証方法を変えようとしても同じ機能を使っているため
例えばユーザのログイン画面を「メールアドレス」と「パスワード」のみにしようとしても
そのままでは管理画面のログイン方法も「メールアドレス」と「パスワード」になってしまいます。
そこで、管理画面のログイン認証とユーザのログイン認証の機能を分ける必要が出てきます。
また、テーブルの登録先も一緒になりますので
admin区分などを正しく制御しておかないと、ユーザのアカウントで管理画面ログインできてしまうという脆弱性を埋め込むことになります。

目指す実装

ユーザと管理者について、このような実装にしていきます。
アカウント会員登録時の入力項目ログイン時の入力項目
ユーザメールアドレス
パスワード
メールアドレス
パスワード
管理者ユーザネーム
メールアドレス
パスワード
ユーザネーム
パスワード

ポイント

ユーザ:会員登録もログインも「メールアドレス」と「パスワード」のみ
管理者:「ユーザネーム」「メールアドレス」「パスワード」を登録し、ログイン時は「ユーザネーム」「パスワード」でログイン
管理者のログインは、セキュリティ観点でユーザネームを利用することにしています。
メールアドレスはパスワードを忘れた場合などの通知先に利用します。
全体のイメージは下記の図の通りです。
ユーザと管理者の認証を分ける場合のクラス図
このように、認証機構を分けてユーザと管理者で認証方法を分けられるようにします。
将来的にユーザ認証にOAuthなどを導入する場合も扱いやすくなると思います(たぶん)

色々やってわかったこと

本当は他にも気になる部分があり、カスタマイズを試みましたが
下記は実現難易度高すぎて諦めました。
  1. ユーザと管理者の管理テーブルを分ける
後述のAUTH_USER_MODELで指定できるユーザモデルが一つしかないため、そもそもユーザと管理者のモデルを分ける方法がわからず断念
  1. ユーザがDBに登録する項目はメールアドレスとパスワードのみにする
ユーザはusernameの項目を利用しないのですが、テーブル定義上NotNull制約が掛けられています。この制限を解除するのは容易ですが、設定を変えると他に色々影響しそうなので諦めました。
対応方法はこちらも後述

準備

templates

フロント画面画面として最低限準備するもの
  • 会員登録
  • ログイン
  • ログイン後のTOPページ(検証用)

view

formから値を受け取り、条件に従って処理を行います。
処理が完了したらtemplateにパラメータを引渡し遷移します。

form

会員登録とログイン用のフォームを準備しています。
管理者のアカウント作成はコマンドラインで実行するため、実装が必要なのはユーザ用の機能のみになります。

models

Django標準のAbstractBaseUserを継承して、カスタムユーザを定義していきます。
カスタムユーザの継承については公式サイトもご覧ください。
公式サイト

新しくプロジェクトを始める場合は、デフォルトの User で十分である場合でも、カスタムユーザーモデルを作成することを強く推奨します

とのことですので、当サイトでもカスタムユーザの構築を推奨しています。

auth

メールアドレス認証用バックエンドを定義しています。
Djangoの認証機能について、公式サイトで下記の通り紹介されています。

Django がデフォルトで提供する認証機能は、ほとんどの一般的なケースでは十分なものですが、デフォルトではニーズにマッチしない場合もあると思います。自分のプロジェクトで認証のカスタマイズを行うためには、Django が提供する認証システムをどの場所で拡張・置換できるかという知識が必要です。

tests

会員登録とログインのテストを定義

templates

フロントのデザインはbootstrapを使っています。

まずは会員登録ページです。
「メールアドレス」「パスワード」「パスワード(確認用)」の3つの入力項目を準備しています。
<body class="text-center">
<main class="form-signup">
  <form method="post">
    {% csrf_token %}
    <img class="mb-4" src="/docs/5.0/assets/brand/bootstrap-logo.svg" alt="" width="72" height="57">
    <h1 class="h3 mb-3 fw-normal">会員登録する</h1>
      {{ error }}
      {{ result }}
    <label>mail address</label>
    {{ form.email }}
    {{ form.email.errors }}
    <label>パスワード</label>
    {{ form.password1 }}
    {{ form.password1.errors }}
    <label>パスワード(確認)</label>
    {{ form.password2 }}
    {{ form.password2.errors }}
    <div class="checkbox mb-3">
      <label>
        <input type="checkbox" value="remember-me"> 記憶する
      </label>
    </div>
    <button class="w-100 btn btn-lg btn-primary" type="submit">会員登録</button>
    <p class="mt-5 mb-3 text-muted">&amp;amp;amp;copy; 2017-2021</p>
  </form>
</main>
</body>
次にログイン画面です。
ログイン画面は「メールアドレス」「パスワード」の2つの入力画面になります。
<body class="text-center">
    
<main class="form-signin">
  <form method="post">{% csrf_token %}
    <img class="mb-4" src="/docs/5.0/assets/brand/bootstrap-logo.svg" alt="" width="72" height="57">
    <h1 class="h3 mb-3 fw-normal">ログイン</h1>
      {{ context }}
    <label for="inputEmail" class="visually-hidden">メールアドレス</label>
    <input type="email" id="inputEmail" class="form-control" placeholder="メールアドレス" name = "email" required autofocus>
    <label for="inputPassword" class="visually-hidden">パスワード</label>
    <input type="password" id="inputPassword" class="form-control" placeholder="パスワード" name = "password" required>
    <div class="checkbox mb-3">
      <label>
        <input type="checkbox" value="remember-me"> 記憶する
      </label>
    </div>
    <button class="w-100 btn btn-lg btn-primary" type="submit">ログイン</button>
    <p class="mt-5 mb-3 text-muted">&amp;amp;amp;copy; 2017-2021</p>
  </form>
</main>
</body>
最後にログイン後のページになります。
こちらのページは検証用なのでなんでもいいです。記載しているものもhomeと表示するのみの画面になります。
<body class="text-center">
<main>
    home
</main>
</body>

View

作成したtemplateを呼び出す処理を追加します。
また、受け取ったパラメータを用いて会員登録やログイン処理を呼び出します。
import uuid

from django.contrib.auth import login
from django.db import IntegrityError
from django.shortcuts import render, redirect
from django.views import View
from django.views.generic import CreateView

from user.auth import EmailAuthBackend
from user.form import SignupForm, LoginForm
from user.models import User


def home(request):
    return render(request, 'home.html', {})


class SignupView(CreateView):
    def post(self, request, *args, **kwargs):
        template_name = 'signup.html'
        form = SignupForm(data=request.POST)
        if form.is_valid():
            # フォームから'email'を読み取る
            email = form.cleaned_data.get('email')
            # フォームから'password1'を読み取る
            password = form.cleaned_data.get('password1')
            uuid_object = uuid.uuid1()
            username = uuid_object.hex
            try:
                user = User.objects.create_user(username=username, email=email, password=password)
                result = '登録しました。'
                return render(request, template_name, {'form': form, 'result': result})
            except IntegrityError:
                error= '既に登録済みです'
                return render(request, template_name, {'form': form, 'error': error})
        return render(request, template_name, {'form': form})

    def get(self, request, *args, **kwargs):
        template_name = 'signup.html'
        form = SignupForm(request.POST)
        return render(request, template_name, {'form': form})


class LoginView(View):
    def post(self, request, *args, **kwargs):
        template_name = 'login.html'
        success = 'home'
        form = LoginForm(data=request.POST)
        if form.is_valid():
            # フォームから'email'を読み取る
            email = form.cleaned_data.get('email')
            # フォームから'password'を読み取る
            password = form.cleaned_data.get('password')
            auth_object = EmailAuthBackend()
            user = auth_object.authenticate(email=email, password=password)
            if user is not None:
                #認証処理
                login(request, user, backend='user.auth.EmailAuthBackend')
                return redirect(success)
            else:
                return render(request, template_name, {'context': 'ログインに失敗しました'})

    def get(self, request, *args, **kwargs):
        template_name = 'login.html'
        form = LoginForm(request.POST)
        return render(request, template_name, {'context': 'get'})
ログイン認証処理では、後で作成する「user.auth.EmailAuthBackend」を呼び出しています。
ユーザ側の認証を「メールアドレス」「パスワード」にするために
カスタマイズした認証バックエンドを利用します。
ユーザの会員登録時にusernameはuuidを入力しています。これは先述の通り、テーブル定義上usernameが必須になっていたりDjangoの認証機能で各所にusernameが利用されているため、一意の値を登録することにしています。

form

templateに表示する項目などを定義します。
会員登録フォームは「UserCreationForm」を継承しているため、個々のフォームはここでは定義しておりません。
ログインフォームの方は「forms.Form」を継承しているので、必要なフィールドを定義しています。

from django.contrib.auth import password_validation

from django.contrib.auth.forms import UserCreationForm
from django import forms
from django.utils.translation import gettext_lazy as _

from user.models import User


class SignupForm(UserCreationForm):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        # htmlの表示を変更可能にします

        self.fields['email'].widget.attrs['class'] = 'form-control'
        self.fields['password1'].widget.attrs['class'] = 'form-control'
        self.fields['password2'].widget.attrs['class'] = 'form-control'

    class Meta:
        model = User
        fields = ("email", "password1", "password2",)


class LoginForm(forms.Form):
    email = forms.EmailField(label='メールアドレス', max_length=100)
    password = forms.CharField(
        label=_("Password"),
        strip=False,
        widget=forms.PasswordInput(attrs={'autocomplete': 'new-password'}),
        help_text=password_validation.password_validators_help_text_html(),
    )

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        # htmlの表示を変更
        self.fields['email'].widget.attrs['class'] = 'form-control'
        self.fields['password'].widget.attrs['class'] = 'form-control'


model

最後にmodelになります。
「AbstractBaseUser」を継承したカスタムユーザの作成になります。
import dataclasses
from django.contrib.auth.base_user import BaseUserManager

from todo.entity.value_objects import username as vo_username
from django.contrib.auth.base_user import AbstractBaseUser
from django.contrib.auth.models import PermissionsMixin
from django.contrib.auth.validators import UnicodeUsernameValidator
from django.db import models
from django.utils.translation import gettext_lazy as _


class UserManager(BaseUserManager):
    def create_user(self, username=None, password=None, email=None):
        """
        会員登録
        """
        if not email:
            raise ValueError('Users must have an email address')

        user = self.model(
            username=username,
            email=email,
        )

        user.set_password(password)
        user.save(using=self._db)
        return user

    def create_superuser(self, username, password=None, email=None):
        """
        管理者の会員登録
        """
        user = self.create_user(
            username=username,
            email=email,
            password=password,
        )
        user.is_admin = True
        user.is_staff = True
        user.save(using=self._db)
        return user


@dataclasses.dataclass
class User(AbstractBaseUser, PermissionsMixin):

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)

    username_object = vo_username.UserName;
    username = models.CharField(
        verbose_name='username',
        max_length=150,
        unique=True,
    )
    email = models.EmailField(
        verbose_name='email',
        max_length=255,
        unique=True,
    )

    is_active = models.BooleanField(default=True)
    is_admin = models.BooleanField(default=False)

    objects = UserManager()

    USERNAME_FIELD = 'username'
    REQUIRED_FIELDS = ['email']

    def has_perm(self, perm, obj=None):
       "Does the user have a specific permission?"
        return True

    def has_module_perms(self, app_label):
       "Does the user have permissions to view the app `app_label`?"
         return True

    def __str__(self):
        return self.username

カスタムユーザモデルは公式サイトの記述をほぼそのまま利用しています。
生年月日の項目は不要だと思ったので消しました。

setting

カスタムユーザの利用と認証機能の追加を行っているため
settings.pyにも各々追記が必要になります。追記するのは下記です。
#カスタムユーザモデルの指定
AUTH_USER_MODEL = 'user.User'

#認証バックエンドの追加
AUTHENTICATION_BACKENDS = [
    'django.contrib.auth.backends.ModelBackend',
    'user.auth.EmailAuthBackend',
]

一応urls.pyも記載しておきます。
from django.contrib import admin
from django.urls import path, include

from user.views import SignupView, home, listfunc, LoginView

urlpatterns = [
    path('admin/', admin.site.urls),
    path('', home, name='home'),
    path('signup/', SignupView.as_view(), name='signup'),
    path('login/', LoginView.as_view(), name='login'),
]
本稿でのご説明は以上となります。
次回はテストの作成を予定しております。

その他の記事

Python
【Pyhon/Django】ログイン機能カスタマイズ
Python
Django
【Pyhon/Django】カスタムユーザとメールアドレスログイン1
Python
django
Python/Django 遭遇したエラー集と解決方法
Django
django
Djangoでadminにカスタムフィルタ追加

コメントを残す

メールアドレスが公開されることはありません。

CAPTCHA