はじめに
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> ©ほげ </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