DjangoのAbstractBaseUserを使ったカスタムユーザーモデルでメールアドレスとパスワードによるログイン機能を実装する

Django

はじめに

DjnagoのAbstractBaseUserを使ったカスタムユーザーモデルを作り、メールアドレスとパスワードによるログイン機能を実装します。

作業手順

Djangoのプロジェクトを作って、

mkdir ProjectName
cd ProjectName
django-admin startproject config .

ログを記録するなら、下記のsettings.pyに設定したようにディレクトリを作成して、

mkdir log

CustomUserを作成して、

python manage.py startapp users

今回、掲載した複数のファイルを修正して、makemigrations、migrateを実行して、

python manage.py makemigrations

python manage.py migrate

管理ユーザを作成して、

python manage.py createsuperuser

Djangoの管理画面で、Usersテーブルに新規ユーザを作成してからテストする。

python manage.py runserver 192.168.10.100

構成

実際はpipenvで作った仮想環境で行っています。

Debian GNU Linux(Bookworm)

Python 3.10.13

Django 5.1.2

pipenv 2023.10.3

ディレクトリ構成

今回、関係ありそうなファイルは以下の通りです。
Bootstrapはダウンロード・解凍して、設置しています。
jQueryもダウンロードして設置します。
「all.min.css」と「webfonts」はFont Awesomeで、ダウンロードしてローカルで利用します。
これはパスワード変更ページで、パスワードの可視、不可視を切り替えるアイコンを表示するために使います。

project/
  - config/
      settings.py

  - templates/
      - 404.html
      - home.html
      - base.html
      - allauth/
          - socialaccount/
              login_cancelled.html
      - users
          login.html
          logout.html

  - users/
      admin.py
      forms.py
      models.py
      urls.py
      views.py

  - static
      - webfonts
      - css
          bootstrap.min.css
          bootstrap.min.css.map
          all.min.css
      - js
          bootstrap.min.js
          bootstrap.min.js.map
          jquery.min.js
          toggle_password.js
      - favicon
          favicon.ico

settings.py

Cookieの有効期限、ログの設定も行います。

INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'users.apps.UsersConfig',  # CustomUser
    省略
]

# カスタムユーザーモデル
AUTH_USER_MODEL = 'users.CustomUser'

# home.html
# login.html
LOGIN_URL           = 'login'
LOGIN_REDIRECT_URL  = 'home'
LOGOUT_REDIRECT_URL = 'login'

'''
Cookieのセッション管理
優先順位
1. SESSION_EXPIRE_AT_BROWSER_CLOSE
2. SESSION_COOKIE_AGE
3. SESSION_SAVE_EVERY_REQUEST
'''
# Cookie有効期間(秒)
SESSION_COOKIE_AGE = 24 * 60 * 60 * 7
# 設定された期限に関係なく、ブラウザを閉じたらセッションを終了
#SESSION_EXPIRE_AT_BROWSER_CLOSE = True
# 全てのリクエストでセッションを保存する。
#SESSION_SAVE_EVERY_REQUEST = True

'''
infoとerrorで出力ファイルを分別する。

CRITICAL > ERROR > WARNING > INFO > DEBUG > NOTSET
'''
LOGGING = {
    'version': 1,
    'disable_existing_loggers': False,
    # 時刻を自動的に挿入する。
    # 例:
    # 2024-06-25 15:19:48,384 : users.views : INFO : Login emailaddress
    'formatters': {
        'verbose': {
            'format': '%(asctime)s : %(name)s : %(levelname)s : %(message)s'
        },
    },
    'handlers': {
        'info_handlers': {
            #'level': 'INFO',
            'level': 'WARNING',
            'class': 'logging.handlers.RotatingFileHandler',
            'filename': BASE_DIR / 'log/info.log',
            'maxBytes': 1024*1024*5,  # 5 MB
            'backupCount': 5,
            'formatter': 'verbose',
        },
        'error_handlers': {
            'level': 'ERROR',
            'class': 'logging.handlers.RotatingFileHandler',
            'filename': BASE_DIR / 'log/error.log',
            'maxBytes': 1024*1024*5,  # 5 MB
            'backupCount': 5,
            'formatter': 'verbose',
        },
    },
    'loggers': {
        '': {
            'level': 'INFO',
            #'handlers': ['info_handlers', 'error_handlers'],
            'handlers': ['info_handlers',],
        },
    },
}

models.py

AbstractBaseUserを使ってCustomUserを作ります。
emailをIDとして使うので、usenameは使いません。

from django.db import models
from django.contrib import auth
from django.contrib.auth.base_user import AbstractBaseUser, BaseUserManager
from django.core.mail import send_mail
from django.contrib.auth.hashers import make_password
from django.utils.translation import gettext_lazy as _
from django.contrib.auth.models import PermissionsMixin
import uuid

'''
カスタムユーザ関連の関数
'''
class UserManager(BaseUserManager):
    use_in_migrations = True

    def _create_user(self, email, password, **extra_fields):
        if not email:
            raise ValueError('The given email must be set')
        email         = self.normalize_email(email)
        user          = self.model(email=email, **extra_fields)
        user.password = make_password(password)
        user.save(using=self._db)
        return user

    def create_user(self, email=None, 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=None, password=None, **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)

    def with_perm(self, perm, is_active=True, include_superusers=True, backend=None, obj=None):
        if backend is None:
            backends = auth._get_backends(return_tuples=True)
            if len(backends) == 1:
                backend, _ = backends[0]
            else:
                raise ValueError(
                    'You have multiple authentication backends configured and '
                    'therefore must provide the `backend` argument.'
                )
        elif not isinstance(backend, str):
            raise TypeError(
                'backend must be a dotted import path string (got %r).' % backend
            )
        else:
            backend = auth.load_backend(backend)
        if hasattr(backend, 'with_perm'):
            return backend.with_perm(
                perm,
                is_active=is_active,
                include_superusers=include_superusers,
                obj=obj,
            )
        return self.none()

'''
カスタムユーザ定義
'''
class CustomUser(AbstractBaseUser, PermissionsMixin):
    # Primary KeyをUUIDに変更
    id    = models.UUIDField(default=uuid.uuid4, primary_key=True, editable=False)
    email = models.EmailField(verbose_name='Email address',  unique=True)

    is_staff = models.BooleanField(
        _('staff status'),
        default=False,
        help_text=_('Designates whether the user can log into this admin site.'),
    )

    is_active = models.BooleanField(
        _("active"),
        default=True,
        help_text=_(
            "Designates whether this user should be treated as active. "
            "Unselect this instead of deleting accounts."
        ),
    )

    # 追加フィールド
    created_time = models.DateTimeField(verbose_name='作成日時', auto_now_add=True) 
    updated_time = models.DateTimeField(verbose_name='更新日時', auto_now=True)
    name         = models.CharField(verbose_name='氏名', max_length=512)
    section      = models.CharField(verbose_name='所属', max_length=1024)
    remarks      = models.TextField(verbose_name='備考', blank=True, null=True)

    objects = UserManager()

    EMAIL_FIELD     = 'email'
    USERNAME_FIELD  = 'email'
    REQUIRED_FIELDS = []

    class Meta:
        verbose_name        = 'ユーザー'
        verbose_name_plural = 'ユーザー'

    def clean(self):
        super().clean()
        self.email = self.__class__.objects.normalize_email(self.email)

views.py

ログイン、ログアウト、パスワード変更、サインアップの処理を記載します。
Djangoの管理画面で登録した人のみを許可したい場合は、サインアップを削除します。

from django.contrib.auth.views import LoginView
from django.contrib.auth.views import LogoutView
from django.contrib.auth.views import PasswordChangeView
from django.contrib.auth.views import PasswordChangeDoneView
from django.contrib.auth.mixins import LoginRequiredMixin
from django.views.generic import CreateView
from django.urls import reverse_lazy
from django.dispatch import receiver
from django.contrib.auth import get_user_model
from django.contrib.auth import update_session_auth_hash
from django.contrib.auth.signals import user_logged_in
from django.conf import settings

import logging
logger = logging.getLogger(__name__)

from .forms import SignupForm
from .forms import LoginForm
from .forms import CustomPasswordChangeForm

UserModel = get_user_model()

'''
サインアップ
'''
class UserSignupView(CreateView):
    template_name = 'users/signup.html'
    form_class    = SignupForm
    success_url   = reverse_lazy('login')
    
    def form_valid(self, form):
        response = super().form_valid(form)
        user     = self.object
        login(self.request, user)
        return response

'''
ログイン
'''
class UserLoginView(LoginView):
    template_name               = 'users/login.html'
    form_class                  = LoginForm
    redirect_field_name         = 'redirect'
    redirect_authenticated_user = True

# ログアウト
# LogoutViewはPOSTのみ受け付けるので、テンプレートをPOSTで記述する。
# https://forum.djangoproject.com/t/deprecation-of-get-method-for-logoutview/25533
class UserLogoutView(LoginRequiredMixin, LogoutView):
    template_name = 'users/logout.html'

    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
        return context

'''
パスワード変更
'''
class CustomPasswordChangeView(LoginRequiredMixin, PasswordChangeView):
    model         = UserModel
    form_class    = CustomPasswordChangeForm
    success_url   = reverse_lazy('password_change_done')
    template_name = 'users/password_change.html'

    '''
    パスワード変更後もログイン状態を維持する。
    この処理が無い場合、ログアウトしてログインページが表示される。
    '''
    def form_valid(self, form):
        user = self.request.user
        logger.info(f'Changed password {user.email}')

        response = super().form_valid(form)
        update_session_auth_hash(self.request, self.request.user)
        return response

'''
パスワード変更の完了
'''
class CustomPasswordChangeDoneView(LoginRequiredMixin, PasswordChangeDoneView):
    template_name = 'users/password_change_done.html'

forms.py

パスワード変更は、2回入力させます。

from django import forms
from django.contrib.auth import get_user_model
from django.contrib.auth.forms import UserCreationForm
from django.contrib.auth.forms import AuthenticationForm
from django.contrib.auth.forms import PasswordChangeForm

UserModel = get_user_model()

'''
ユーザの自動登録
'''
class SignupForm(UserCreationForm):
    class Meta:
        model = get_user_model()
        fields = ['email', 'password1', 'password2']

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        '''
        Bootstrap
        class名の先頭に「form-control」を付加する。
        該当するclass名は、HTMLのソースを参照して探す。
        '''
        for field in self.fields.values():
            field.widget.attrs['class'] = 'form-control'

'''
ユーザのログイン
'''
class LoginForm(AuthenticationForm):
    pass

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        #  Bootstrap
        # class名の先頭に「form-control」を付加する。
        # 該当するclass名は、HTMLのソースを参照して探す。
        for field in self.fields.values():
            field.widget.attrs['class'] = 'form-control'

'''
パスワード変更
'''
class CustomPasswordChangeForm(PasswordChangeForm):
    old_password = forms.CharField(
        label="Old password",
        strip=False,
        widget=forms.PasswordInput(attrs={'autocomplete': 'current-password'}),
    )
    new_password1 = forms.CharField(
        label="New password",
        strip=False,
        widget=forms.PasswordInput(attrs={'autocomplete': 'new-password'}),
    )
    new_password2 = forms.CharField(
        label="Confirm new password",
        strip=False,
        widget=forms.PasswordInput(attrs={'autocomplete': 'new-password'}),
    )

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        for field in self.fields.values():
            field.widget.attrs['class'] = 'form-control'

urls.py

ログイン、ログアウト、パスワード変更、パスワード変更の完了、サインアップのルーティングを記載します。

from django.urls import path
from . import views

urlpatterns = [
    path('signup/', views.UserSignupView.as_view(), name='signup'),
    path('login/', views.UserLoginView.as_view(), name='login'),
    path('logout/', views.UserLogoutView.as_view(), name='logout'),
    path('password_change/', views.CustomPasswordChangeView.as_view(), name='password_change'),
    path('password_change/done/', views.CustomPasswordChangeDoneView.as_view(), name='password_change_done'),
]

admin.py

from django.contrib import admin
from django.contrib.auth import get_user_model
from django.contrib.auth.admin import UserAdmin
from django.utils.translation import gettext_lazy as _

from .models import CustomUser

'''
カスタムユーザー
'''
CustomUser = get_user_model()

class CustomUserAdmin(UserAdmin):
    save_on_top = True
    save_as     = True

    # ユーザーの詳細画面、編集画面で表示するフィールド
    fieldsets   = [
        (None, {'fields': ('email', 'password')}),
        (_('Personal info'), {'fields':('name', 'section', 'remarks')}),
        (_('Permissions'), {'fields':('is_active', 'is_staff', 'is_superuser', 'groups','user_permissions')}),
        (_('Important dates'), {'fields':('last_login',)}),
    ]

    # ユーザー追加フォームで入力するフィールド
    add_fieldsets = (
        (None,
        {
            'classes': ('wide',),
            'fields': (
                'email',
                'name',
                'section',
                'remarks',
                'password1',
                'password2',
            ),
        },),
    )

    list_display = (
        'email',
        'name',
        'section',
        'is_staff',
        'is_superuser',
        'is_active',
        'created_time',
        'updated_time',
    )

    list_filter   = ('is_staff', 'is_superuser', 'groups')
    search_fields = ('email', 'name', 'section')
    ordering      = ('is_active',)

    filter_horizontal = (
        'groups',
        'user_permissions',
    )

admin.site.register(CustomUser, CustomUserAdmin)

login.html

メールアドレスとパスワードを入力してログインします。

{% extends 'base.html' %}
{% load static %}

{% block title %}
Login
{% endblock title %}

{% block body %}

<form action="{% url 'login' %}" method="post">
    {% csrf_token %}

    <div class="mx-auto" style="width: 400px;">
    <table class="table table-bordered align-middle text-center">
    {% for field in form %}
        <tr class="border-danger-subtle">
        <th class="border-danger-subtle bg-danger-subtle text-danger-emphasis">{{ field.label }}</th>
        <td class="border-danger-subtle">{{ field }}</td>
        </tr>
    {% endfor %}
    </table>

    <p>
    <button class="form-control border-danger-subtle bg-danger-subtle text-danger-emphasis" type="submit">Login</button>
    <input type="hidden" name="redirect" value="{{ redirect }}">
    </p>
    </div>
</form>

{% endblock body %}

logout.html

{% extends 'base.html' %}
{% load static %}

{% block title %}
Logout
{% endblock title %}

{% block h1_title %}
Logout
{% endblock h1_title %}

{% block body %}

<p>You are logged out.</p>
<p><a href="{% url 'login' %}">Login</a></p>

{% endblock body %}

password_change.html

jquery.min.jsとtoggle_password.jsを使って、入力したパスワードの可視、不可視を切り替えます。

{% extends 'base.html' %}
{% load static %}

{% block title %}
Change Password
{% endblock title %}

{% block h1_title %}
Change Password
{% endblock h1_title %}


{% block body %}
<script src="{% static 'js/jquery.min.js' %}"></script>
<script src="{% static 'js/toggle_password.js' %}"></script>

<form action="" method="post">
{% csrf_token %}

<div class="mx-auto" style="width: 600px;">

<table class="table table-bordered align-middle text-center">

<tr class="border-secondary-subtle">
<th class="border-secondary-subtle bg-secondary-subtle text-secondary-emphasis">{{ form.old_password.label }}</th>
<td class="border-secondary-subtle d-flex align-items-center">
{{ form.old_password }}
<span class="toggle-password d-inline-block" data-toggle="#id_old_password"><i class="fas fa-eye-slash"></i></span>
</td>
</tr>

<tr class="border-secondary-subtle">
<th class="border-secondary-subtle bg-secondary-subtle text-secondary-emphasis">{{ form.new_password1.label }}</th>
<td class="border-secondary-subtle d-flex align-items-center">
{{ form.new_password1 }}
<span class="toggle-password d-inline-block" data-toggle="#id_new_password1"><i class="fas fa-eye-slash"></i></span>
</td>
</tr>

<tr class="border-secondary-subtle">
<th class="border-secondary-subtle bg-secondary-subtle text-secondary-emphasis">{{ form.new_password2.label }}</th>
<td class="border-secondary-subtle d-flex align-items-center">
{{ form.new_password2 }}
<span class="toggle-password d-inline-block" data-toggle="#id_new_password2"><i class="fas fa-eye-slash"></i></span>
</td>
</tr>

{% comment %}
{% for field in form %}
<tr class="border-secondary-subtle">
<th class="border-secondary-subtle bg-secondary-subtle text-secondary-emphasis">{{ field.label }}</th>
<td class="border-secondary-subtle">{{ field }}</td>
</tr>
{% endfor %}
{% endcomment %}

</table>
    
<p class="text-center">
<input type="submit" class="btn btn-primary" value="Change Password">
<a href="{% url 'home' %}" class="btn btn-outline-danger">Menu</a>
</p>

</div>

</form>

{% endblock body %}

toggle_password.js

入力したパスワードの可視、不可視を切り替えるために使います。

$(function() {
  $('.toggle-password').click(function() {
    var passwordField = $($(this).attr('data-toggle'));
    var icon = $(this).find('i');

    if (passwordField.attr('type') === 'password') {
      passwordField.attr('type', 'text');
      icon.removeClass('fa-eye-slash').addClass('fa-eye');
    } else {
      passwordField.attr('type', 'password');
      icon.removeClass('fa-eye').addClass('fa-eye-slash');
    }
  });
});

password_change_done.html

パスワード変更が完了したときに表示するページです。

{% extends 'base.html' %}
{% load static %}

{% block title %}
Password Change Successful
{% endblock title %}

{% block h1_title %}
Password Change Successful
{% endblock h1_title %}

{% block body %}
<div class="mx-auto" style="width: 600px;">
    <p class="h2 text-center">
        Password Change Successful</h2>
    </p>
    <p class="text-center">
        Your password has been changed.
    </p>

    <p class="text-center">
        <a href="{% url 'home' %}" class="btn btn-outline-danger">Menu</a>
    </p>
</div>
{% endblock body %}

signup.html

サインアップのページです。
今回は仕様上、使っていないので検証していません。

{% extends 'base.html' %}
{% load static %}

{% block title %}
Sign up
{% endblock title %}

{% block h1_title %}
Sign up
{% endblock h1_title %}

{% block body %}
<form action="{% url 'signup' %}" method="post">
    {% csrf_token %}
    <table>{{ form.as_table }}</table>
    <p><input type="submit" value="Sign up"></p>
</form>
{% endblock body %}

base.html

各ページの最初に読み込みます。
Bootstrapは、static/cssに置いています。

{% load i18n static %}
<!doctype html>
<html lang="ja" data-bs-theme="dark">
<head>
<meta charset="utf-8">

<link rel="shortcut icon" type="image/png" href="{% static 'favicon/favicon.ico' %}">
<link rel="stylesheet" href="{% static 'css/bootstrap.min.css' %}"

{% comment %}
Font Awesome
Web Font files used with CSS
{% endcomment %}
<link rel="stylesheet" href="{% static 'css/all.min.css' %}">

<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Caveat:wght@700&display=swap" rel="stylesheet">

<title>
{% block title %}
{% endblock %}
</title>

</head>

<body>

{# container: 表示幅を可変にする。#}
<div class="container-fluid">

{# formのエラー表示 #}
{% if form.errors and request.method == 'POST' %}
    {% for field, errors in form.errors.items %}
        {% for error in errors %}
            <div class="alert alert-danger" role="alert">
                <p class="mb-0">{{ field }} {{ error }}</p>
            </div>
        {% endfor %}
    {% endfor %}
{% endif %}


{# 「form.errors」以外のエラー表示(カスタムバリデーションなど)  #}
{% if non_field_errors %}
    <div class="alert alert-danger" role="alert">
        {% for error in form.non_field_errors %}
            <p{% if forloop.last %} class="mb-0"{% endif %}>{{ error }}</p>
        {% endfor %}
    </div>
{% endif %}

{# SuccessMessageMixinのメッセージ表示 #}
{% if messages %}
<div class="text-center messages alert alert-primary" role="alert">
    {% for message in messages %}
        {{ message }}
    {% endfor %}
</div>
{% endif %}

{% block error_404 %}
{% endblock %}

{% block body %}
{% endblock %}

{# フッタ #}
<footer class="container-fluid">
<small>
&copy;ほげ
</small>
</footer>

</div>

</body>
</html>

home.html

ログインが成功した場合に表示します。
各種メニューを列挙します。

{% extends "base.html" %}
{% load static %}

{% block title %}
Menu
{% endblock title %}

{% block h1_title %}
Menu
{% endblock h1_title %}

{% block body %}

{% if user.is_authenticated %}
    <div class="row justify-content-center">
        <p>
        <div class="col-auto">
            <a class="btn btn-success" href="{% url 'ほげ' %}">ほげほげ</a> 
        </div>
        </p>
    </div>

    <div class="row justify-content-center">
        <p>
        <div class="col-auto">
            <form method="post" action="{% url 'account_logout' %}">
            {% csrf_token %}
            <button class="btn btn-outline-secondary" type="submit">Logout</button>
            </form>
        </div>
        </p>
    </div>
{% else %}
    <p class="text-center"><a class="btn btn-outline-success" href="{% url 'account_login' %}">Login</a></p>
{% endif %}

{% endblock body %}

Comments