はじめに
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