Python Django 入門トップページ


カスタムユーザ認証

  1. プロジェクトの概要
  2. プロジェクトの作成と初期設定
  3. Users アプリケーションの作成と有効化
  4. 認証にカスタムユーザモデルを利用する
  5. モデルの作成
  6. マイグレーション
  7. ユーザの登録
  8. 管理ユーザの登録
  9. 管理サイトの作成
  10. Comments アプリケーションの作成
  11. ページ雛形の作成
  12. ログイン・ログアウトの実装
  13. Navbar の設置
  14. Comments アプリケーションのユーザ認証
  15. ユーザ一覧ページ
  16. ユーザ詳細情報の表示
  17. ユーザ情報の更新
  18. パスワードの変更
  19. Gmail 2段階認証の設定とアプリパスワードの取得
  20. メールの設定と送信
  21. パスワードのリセット
  22. ユーザ登録機能の実装
  23. ユーザ登録時に氏名も登録
  24. ユーザ登録時にメールアドレスも登録
  25. ユーザ登録してもログインできないように
  26. ユーザ登録後にメールを送信
  27. メール検証によるアカウントの有効化
  28. トークン有効期限の変更
  29. ログアウト後に top へリダイレクト
  30. 検証メールの再送信
  31. 未検証ユーザのログインエラーメッセージ
  32. メールに有効期限を表示
  33. フラッシュメッセージの変更
  34. 未検証ユーザのパスワードリセット

カスタムユーザ認証

ログイン・ログアウトの実装

ログインフォームを作成しますが,Django のソース ( C:\Users\lecture\anaconda3\envs\py39\Lib\site-packages\django\contrib\auth\forms.py ) から必要部分(1つの関数 _unicode_ci_compare,および2つのクラス UsernameFieldAuthenticationForm)をコピーし,一部書き換えます.

users/forms.pyimport unicodedata

from django import forms
from django.contrib.auth import authenticate, get_user_model, password_validation
from django.contrib.auth.hashers import UNUSABLE_PASSWORD_PREFIX, identify_hasher
from django.contrib.auth.models import User
from django.contrib.auth.tokens import default_token_generator
from django.contrib.sites.shortcuts import get_current_site
from django.core.exceptions import ValidationError
from django.core.mail import EmailMultiAlternatives
from django.template import loader
from django.utils.encoding import force_bytes
from django.utils.http import urlsafe_base64_encode
from django.utils.text import capfirst
from django.utils.translation import gettext
from django.utils.translation import gettext_lazy as _

UserModel = get_user_model()


def _unicode_ci_compare(s1, s2):
    """
    Perform case-insensitive comparison of two identifiers, using the
    recommended algorithm from Unicode Technical Report 36, section
    2.11.2(B)(2).
    """
    return (
        unicodedata.normalize("NFKC", s1).casefold()
        == unicodedata.normalize("NFKC", s2).casefold()
    )

class UsernameField(forms.CharField):
    def to_python(self, value):
        return unicodedata.normalize("NFKC", super().to_python(value))

    def widget_attrs(self, widget):
        return {
            **super().widget_attrs(widget),
            "autocapitalize": "none",
            "autocomplete": "username",
            'class': 'form-control',
        }


class AuthenticationForm(forms.Form):
    """
    Base class for authenticating users. Extend this to get a form that accepts
    username/password logins.
    """

    username = UsernameField(widget=forms.TextInput(attrs={"autofocus": True}))
    password = forms.CharField(
        label=_("Password"),
        strip=False,
        widget=forms.PasswordInput(attrs={"autocomplete": "current-password", 'class': 'form-control'}),
    )

    error_messages = {
        "invalid_login": _(
            "Please enter a correct %(username)s and password. Note that both "
            "fields may be case-sensitive."
        ),
        "inactive": _("This account is inactive."),
    }

    def __init__(self, request=None, *args, **kwargs):
        """
        The 'request' parameter is set for custom auth use by subclasses.
        The form data comes in via the standard 'data' kwarg.
        """
        self.request = request
        self.user_cache = None
        super().__init__(*args, **kwargs)

        # Set the max length and label for the "username" field.
        self.username_field = UserModel._meta.get_field(UserModel.USERNAME_FIELD)
        username_max_length = self.username_field.max_length or 254
        self.fields["username"].max_length = username_max_length
        self.fields["username"].widget.attrs["maxlength"] = username_max_length
        if self.fields["username"].label is None:
            self.fields["username"].label = capfirst(self.username_field.verbose_name)

    def clean(self):
        username = self.cleaned_data.get("username")
        password = self.cleaned_data.get("password")

        if username is not None and password:
            self.user_cache = authenticate(
                self.request, username=username, password=password
            )
            if self.user_cache is None:
                raise self.get_invalid_login_error()
            else:
                self.confirm_login_allowed(self.user_cache)

        return self.cleaned_data

    def confirm_login_allowed(self, user):
        """
        Controls whether the given User may log in. This is a policy setting,
        independent of end-user authentication. This default behavior is to
        allow login by active users, and reject login by inactive users.

        If the given user cannot log in, this method should raise a
        ``ValidationError``.

        If the given user may log in, this method should return None.
        """
        if not user.is_active:
            raise ValidationError(
                self.error_messages["inactive"],
                code="inactive",
            )

    def get_user(self):
        return self.user_cache

    def get_invalid_login_error(self):
        return ValidationError(
            self.error_messages["invalid_login"],
            code="invalid_login",
            params={"username": self.username_field.verbose_name},
        )

views.py についても Django のソースから次の3つのクラスをコピーします.

  • class SuccessURLAllowedHostsMixin:
  • class LoginView(SuccessURLAllowedHostsMixin, FormView):
  • class LogoutView(SuccessURLAllowedHostsMixin, TemplateView):
このとき,必要なパケージもインポートします.さらに,ユーザ一覧ページの表示にはユーザ認証が必要となるような処理も追加しておきます.

users/views.pyfrom urllib.parse import urlparse, urlunparse

from django.conf import settings

# Avoid shadowing the login() and logout() views below.
from django.contrib.auth import REDIRECT_FIELD_NAME, get_user_model
from django.contrib.auth import login as auth_login
from django.contrib.auth import logout as auth_logout
from django.contrib.auth import update_session_auth_hash
from django.contrib.auth.decorators import login_required
# from django.contrib.auth.forms import (
#    AuthenticationForm,
#    PasswordChangeForm,
#    PasswordResetForm,
#    SetPasswordForm,
# )
from django.contrib.auth.tokens import default_token_generator
from django.contrib.sites.shortcuts import get_current_site
from django.core.exceptions import ImproperlyConfigured, ValidationError
from django.http import HttpResponseRedirect, QueryDict
from django.shortcuts import resolve_url
from django.urls import reverse_lazy
from django.utils.decorators import method_decorator
from django.utils.http import url_has_allowed_host_and_scheme, urlsafe_base64_decode
from django.utils.translation import gettext_lazy as _
from django.views.decorators.cache import never_cache
from django.views.decorators.csrf import csrf_protect
from django.views.decorators.debug import sensitive_post_parameters
from django.views.generic.base import TemplateView
from django.views.generic.edit import FormView

from django.shortcuts import render
from django.shortcuts import redirect
from django.urls import reverse
from .forms import AuthenticationForm

UserModel = get_user_model()


# Create your views here.

def users_index(request):
    if not request.user.is_authenticated:
        return redirect('%s?next=%s' % (reverse('users:login'), request.path))
    context = {}
    return render(request, 'users/index.html', context)


class SuccessURLAllowedHostsMixin:
    success_url_allowed_hosts = set()

    def get_success_url_allowed_hosts(self):
        return {self.request.get_host(), *self.success_url_allowed_hosts}


class LoginView(SuccessURLAllowedHostsMixin, FormView):
    """
    Display the login form and handle the login action.
    """

    form_class = AuthenticationForm
    authentication_form = None
    next_page = None
    redirect_field_name = REDIRECT_FIELD_NAME
    # template_name = "registration/login.html"
    template_name = "users/login.html"
    redirect_authenticated_user = False
    extra_context = None

    @method_decorator(sensitive_post_parameters())
    @method_decorator(csrf_protect)
    @method_decorator(never_cache)
    def dispatch(self, request, *args, **kwargs):
        if self.redirect_authenticated_user and self.request.user.is_authenticated:
            redirect_to = self.get_success_url()
            if redirect_to == self.request.path:
                raise ValueError(
                    "Redirection loop for authenticated user detected. Check that "
                    "your LOGIN_REDIRECT_URL doesn't point to a login page."
                )
            return HttpResponseRedirect(redirect_to)
        return super().dispatch(request, *args, **kwargs)

    def get_success_url(self):
        return self.get_redirect_url() or self.get_default_redirect_url()

    def get_redirect_url(self):
        """Return the user-originating redirect URL if it's safe."""
        redirect_to = self.request.POST.get(
            self.redirect_field_name, self.request.GET.get(self.redirect_field_name, "")
        )
        url_is_safe = url_has_allowed_host_and_scheme(
            url=redirect_to,
            allowed_hosts=self.get_success_url_allowed_hosts(),
            require_https=self.request.is_secure(),
        )
        return redirect_to if url_is_safe else ""

    def get_default_redirect_url(self):
        """Return the default redirect URL."""
        return resolve_url(self.next_page or settings.LOGIN_REDIRECT_URL)

    def get_form_class(self):
        return self.authentication_form or self.form_class

    def get_form_kwargs(self):
        kwargs = super().get_form_kwargs()
        kwargs["request"] = self.request
        return kwargs

    def form_valid(self, form):
        """Security check complete. Log the user in."""
        auth_login(self.request, form.get_user())
        return HttpResponseRedirect(self.get_success_url())

    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
        current_site = get_current_site(self.request)
        context.update(
            {
                self.redirect_field_name: self.get_redirect_url(),
                "site": current_site,
                "site_name": current_site.name,
                **(self.extra_context or {}),
            }
        )
        return context


class LogoutView(SuccessURLAllowedHostsMixin, TemplateView):
    """
    Log out the user and display the 'You are logged out' message.
    """

    next_page = None
    redirect_field_name = REDIRECT_FIELD_NAME
    # template_name = "registration/logged_out.html"
    template_name = "users/logged_out.html"
    extra_context = None

    @method_decorator(never_cache)
    def dispatch(self, request, *args, **kwargs):
        auth_logout(request)
        next_page = self.get_next_page()
        if next_page:
            # Redirect to this page until the session has been cleared.
            return HttpResponseRedirect(next_page)
        return super().dispatch(request, *args, **kwargs)

    def post(self, request, *args, **kwargs):
        """Logout may be done via POST."""
        return self.get(request, *args, **kwargs)

    def get_next_page(self):
        if self.next_page is not None:
            next_page = resolve_url(self.next_page)
        elif settings.LOGOUT_REDIRECT_URL:
            next_page = resolve_url(settings.LOGOUT_REDIRECT_URL)
        else:
            next_page = self.next_page

        if (
            self.redirect_field_name in self.request.POST
            or self.redirect_field_name in self.request.GET
        ):
            next_page = self.request.POST.get(
                self.redirect_field_name, self.request.GET.get(self.redirect_field_name)
            )
            url_is_safe = url_has_allowed_host_and_scheme(
                url=next_page,
                allowed_hosts=self.get_success_url_allowed_hosts(),
                require_https=self.request.is_secure(),
            )
            # Security check -- Ensure the user-originating redirection URL is
            # safe.
            if not url_is_safe:
                next_page = self.request.path
        return next_page

    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
        current_site = get_current_site(self.request)
        context.update(
            {
                "site": current_site,
                "site_name": current_site.name,
                "title": _("Logged out"),
                **(self.extra_context or {}),
            }
        )
        return context

ログインページを作成します.

users/templates/users/login.html{% extends "base.html" %}

{% block title %}
ログイン
{% endblock %}

{% block content %}
<div class="col-md-12">
<h1 class="my-5">コメントアプリケーション</h1>
<div class="card">
    <div class="card-header">Login</div>

    <div class="card-body">

        <form method="post" action="{% url 'users:login' %}">
            {% csrf_token %}
            {{ form.as_p }}

            <button type="submit" class="btn btn-primary btn-block">
                Login
            </button>

            <input type="hidden" name="next" value="{{ next }}">
        </form>
    </div>
    <div class="card-footer">
        <p>
        </p>
    </div>
</div>
{% endblock %}

ログアウト完了時に表示されるページを作成します.

users/templates/users/logged_out.html{% extends "base.html" %}

{% block title %}
ログアウトしました
{% endblock %}

{% block content %}
<h1 class="my-5">ログアウトしました</h1>
<ul>
    <li>
        <a href="/">トップページへ</a>
    </li>
</ul>
{% endblock content %}

ユーザ一覧ページにログアウトのリンクを作成します.

users/templates/users/index.html{% extends "base.html" %}

{% block title %}
ユーザ一覧
{% endblock %}

{% block content %}
<h1 class="my-5">ユーザ一覧</h1>
<ul>
    <li>
        <a href="{% url 'index' %}">トップへ戻る</a>
    </li>
    <li>
        <a href="{% url 'users:logout' %}">ログアウト</a>
    </li>
</ul>
{% endblock content %}

ユーザ一覧ページにアクセスしようとするとログイン画面が表示されるはずです.すでに作成したユーザアカウントを使ってログインとログアウトができることを確認します.

django2022-00309

django2022-00310

django2022-00311

目次に戻る