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. 未検証ユーザのパスワードリセット

カスタムユーザ認証

パスワードのリセット

パスワードを忘れてしまってログインできないユーザに対して,電子メールを使ったパスワードリセットができるよう,パスワードリセット機能を実装しよう.まずは前のページのとおりメールの送信ができるようにしておいてください.

パスワードリセットに関連する 4 つの URL を定義します.

users/urls.py
from django.urls import path

from . import views

app_name = 'users'
urlpatterns = [
    path('', views.users_index, name='index'),
    path('<int:user_id>/', views.users_profile, name='profile'),
    path('<int:user_id>/update/', views.users_update, name='update'),
    path('login/', views.LoginView.as_view(), name='login'),
    path('logout/', views.LogoutView.as_view(), name='logout'),
    path('password/', views.PasswordChangeView.as_view(), name='password_change_form'),
    path('password_change_done/', views.PasswordChangeDoneView.as_view(), name='password_change_done'),

    # パスワードリセットのメール送信画面
    path('password_reset/', views.PasswordResetView.as_view(), name='password_reset'),
    # メール送信後の画面
    path('password_reset/done/', views.PasswordResetDoneView.as_view(), name='password_reset_done'),
    # メールリンクから呼び出される
    path('reset/<uidb64>/<token>/', views.PasswordResetConfirmView.as_view(), name='password_reset_confirm'),
    path('reset/done/', views.PasswordResetCompleteView.as_view(), name='password_reset_complete'),
]

次に,Django contrib の forms.py ソースファイルから,class PasswordResetForm(forms.Form): をコピーして,編集を加えます.

users\forms.py
class PasswordResetForm(forms.Form):
    email = forms.EmailField(
        label=_("Email"),
        max_length=254,
        widget=forms.EmailInput(attrs={"autocomplete": "email", 'class': 'form-control'}),
    )

    def send_mail(
        self,
        subject_template_name,
        email_template_name,
        context,
        from_email,
        to_email,
        html_email_template_name=None,
    ):
        """
        Send a django.core.mail.EmailMultiAlternatives to `to_email`.
        """
        subject = loader.render_to_string(subject_template_name, context)
        # Email subject *must not* contain newlines
        subject = "".join(subject.splitlines())
        body = loader.render_to_string(email_template_name, context)

        email_message = EmailMultiAlternatives(subject, body, from_email, [to_email])
        if html_email_template_name is not None:
            html_email = loader.render_to_string(html_email_template_name, context)
            email_message.attach_alternative(html_email, "text/html")

        email_message.send()

    def get_users(self, email):
        """Given an email, return matching user(s) who should receive a reset.

        This allows subclasses to more easily customize the default policies
        that prevent inactive users and users with unusable passwords from
        resetting their password.
        """
        email_field_name = UserModel.get_email_field_name()
        active_users = UserModel._default_manager.filter(
            **{
                "%s__iexact" % email_field_name: email,
                "is_active": True,
            }
        )
        return (
            u
            for u in active_users
            if u.has_usable_password()
            and _unicode_ci_compare(email, getattr(u, email_field_name))
        )

    def save(
        self,
        domain_override=None,
        subject_template_name="users/password_reset_subject.txt",
        email_template_name="users/password_reset_email.html",
        use_https=False,
        token_generator=default_token_generator,
        from_email=None,
        request=None,
        html_email_template_name=None,
        extra_email_context=None,
    ):
        """
        Generate a one-use only link for resetting password and send it to the
        user.
        """
        email = self.cleaned_data["email"]
        if not domain_override:
            current_site = get_current_site(request)
            site_name = current_site.name
            domain = current_site.domain
        else:
            site_name = domain = domain_override
        email_field_name = UserModel.get_email_field_name()
        for user in self.get_users(email):
            user_email = getattr(user, email_field_name)
            context = {
                "email": user_email,
                "domain": domain,
                "site_name": site_name,
                "uid": urlsafe_base64_encode(force_bytes(user.pk)),
                "user": user,
                "token": token_generator.make_token(user),
                "protocol": "https" if use_https else "http",
                **(extra_email_context or {}),
            }
            self.send_mail(
                subject_template_name,
                email_template_name,
                context,
                from_email,
                user_email,
                html_email_template_name=html_email_template_name,
            )

電子メールの件名を生成するためのテキストファイルを作成します.

users/templates/users/password_reset_subject.txt
{% load i18n %}{% autoescape off %}
【{{ site_name }}】パスワードリセット
{% endautoescape %}

電子メールの本文を生成するための HTML ファイルを作成します.

users/templates/users/password_reset_email.html
{% autoescape off %}
パスワードリセットが {{ site_name }} で要求されました.

次のリンク先で新しいパスワードを設定してください.
{{ protocol }}://{{ domain }}{% url 'users:password_reset_confirm' uidb64=uid token=token %}

{% endautoescape %}

Django contrib の views.py ソースファイルから次の4つのクラスを views.py にコピーします.

  • PasswordResetView
  • PasswordResetDoneView
  • PasswordResetConfirmView
  • PasswordResetCompleteView
users/views.py
from 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 get_object_or_404
from django.shortcuts import redirect
from django.urls import reverse
from django.contrib import messages

# from django.shortcuts import redirect
# from django.urls import reverse
from .forms import UserForm
from .forms import AuthenticationForm
from .forms import PasswordChangeForm
from .forms import SetPasswordForm
from .forms import PasswordResetForm

UserModel = get_user_model()


...(中略)...


class PasswordResetView(PasswordContextMixin, FormView):
    email_template_name = "users/password_reset_email.html"
    extra_email_context = None
    form_class = PasswordResetForm
    from_email = settings.EMAIL_FROM
    html_email_template_name = None
    subject_template_name = "users/password_reset_subject.txt"
    success_url = reverse_lazy("users:password_reset_done")
    template_name = "users/password_reset_form.html"
    title = _("Password reset")
    token_generator = default_token_generator

    @method_decorator(csrf_protect)
    def dispatch(self, *args, **kwargs):
        return super().dispatch(*args, **kwargs)

    def form_valid(self, form):
        # メールアドレスのチェック:登録されていないアドレスには送信しない
        users = UserModel.objects.filter(email=form.cleaned_data['email'])
        if len(users) == 0:
            messages.success(self.request, '登録されていないメールアドレスです')
            return redirect(reverse('index'))
        messages.success(self.request, 'メールを送信しました')
        opts = {
            "use_https": self.request.is_secure(),
            "token_generator": self.token_generator,
            "from_email": self.from_email,
            "email_template_name": self.email_template_name,
            "subject_template_name": self.subject_template_name,
            "request": self.request,
            "html_email_template_name": self.html_email_template_name,
            "extra_email_context": self.extra_email_context,
        }
        form.save(**opts)
        return super().form_valid(form)


INTERNAL_RESET_SESSION_TOKEN = "_password_reset_token"


class PasswordResetDoneView(PasswordContextMixin, TemplateView):
    template_name = "users/password_reset_done.html"
    title = _("Password reset sent")


class PasswordResetConfirmView(PasswordContextMixin, FormView):
    form_class = SetPasswordForm
    post_reset_login = False
    post_reset_login_backend = None
    reset_url_token = "set-password"
    success_url = reverse_lazy("users:password_reset_complete")
    template_name = "users/password_reset_confirm.html"
    title = _("Enter new password")
    token_generator = default_token_generator

    @method_decorator(sensitive_post_parameters())
    @method_decorator(never_cache)
    def dispatch(self, *args, **kwargs):
        if "uidb64" not in kwargs or "token" not in kwargs:
            raise ImproperlyConfigured(
                "The URL path must contain 'uidb64' and 'token' parameters."
            )

        self.validlink = False
        self.user = self.get_user(kwargs["uidb64"])

        if self.user is not None:
            token = kwargs["token"]
            if token == self.reset_url_token:
                session_token = self.request.session.get(INTERNAL_RESET_SESSION_TOKEN)
                if self.token_generator.check_token(self.user, session_token):
                    # If the token is valid, display the password reset form.
                    self.validlink = True
                    return super().dispatch(*args, **kwargs)
            else:
                if self.token_generator.check_token(self.user, token):
                    # Store the token in the session and redirect to the
                    # password reset form at a URL without the token. That
                    # avoids the possibility of leaking the token in the
                    # HTTP Referer header.
                    self.request.session[INTERNAL_RESET_SESSION_TOKEN] = token
                    redirect_url = self.request.path.replace(
                        token, self.reset_url_token
                    )
                    return HttpResponseRedirect(redirect_url)

        # Display the "Password reset unsuccessful" page.
        return self.render_to_response(self.get_context_data())

    def get_user(self, uidb64):
        try:
            # urlsafe_base64_decode() decodes to bytestring
            uid = urlsafe_base64_decode(uidb64).decode()
            user = UserModel._default_manager.get(pk=uid)
        except (
            TypeError,
            ValueError,
            OverflowError,
            UserModel.DoesNotExist,
            ValidationError,
        ):
            user = None
        return user

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

    def form_valid(self, form):
        user = form.save()
        del self.request.session[INTERNAL_RESET_SESSION_TOKEN]
        if self.post_reset_login:
            auth_login(self.request, user, self.post_reset_login_backend)
        return super().form_valid(form)

    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
        if self.validlink:
            context["validlink"] = True
        else:
            context.update(
                {
                    "form": None,
                    "title": _("Password reset unsuccessful"),
                    "validlink": False,
                }
            )
        return context


class PasswordResetCompleteView(PasswordContextMixin, TemplateView):
    template_name = "users/password_reset_complete.html"
    title = _("Password reset complete")

    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
        context["login_url"] = resolve_url(settings.LOGIN_URL)
        return context

パスワードリセットの最初のステップでメールアドレスを入力するための HTML ファイルを作成します.

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

{% block title %}
パスワードリセット
{% endblock %}

{% block content %}
<h1 class="my-5">コメントアプリケーション</h1>
<div class="card">
  <div class="card-header">パスワードリセット</div>

  <div class="card-body">
      <form method="post">
          {% csrf_token %}
          {{ form.as_p }}

          <button type="submit" class="btn btn-primary btn-block">
            パスワードをリセットするためのメールを送信
          </button>
      </form>
  </div>
  <div class="card-footer">
    <p>
        メールアドレスを入力するとパスワードリセットのためのリンクが送信されます.
    </p>
  </div>
</div>


<ul>
    <li>
        <a href="{% url 'index' %}">トップへ</a>
    </li>
</ul>
{% endblock content %}

リセットメールを送信したあとに表示されるページを作成します.

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

{% block title %}
パスワードリセット
{% endblock %}

{% block content %}
<h1 class="my-5">コメントアプリケーション</h1>

<p>
  パスワードリセットのためのリンクをメールアドレスにお送りしました.
</p>

<ul>
    <li>
        <a href="{% url 'index' %}">トップへ</a>
    </li>
</ul>
{% endblock content %}

リセットメールのリンクを開いたときに表示されるページを作成します.

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

{% block title %}
パスワードリセット
{% endblock %}

{% block content %}
<h1 class="my-5">コメントアプリケーション</h1>
{% if validlink %}
<div class="card">
  <div class="card-header">パスワードリセット</div>
  <div class="card-body">
      <form method="post">
          {% csrf_token %}
          {{ form.new_password1.errors }}
          {{ form.new_password2.errors }}
          {{ form.as_p }}

          <button type="submit" class="btn btn-primary btn-block">
            パスワードをリセットする
          </button>
      </form>
  </div>
  <div class="card-footer">
      <p>
      </p>
  </div>
</div>
{% else %}
<p>
  不正なパスワードリセットリンクです.そのリンクはすでに利用されている可能性があります.もう一度パスワードリセットをリクエストしてください.
</p>
{% endif %}
<ul>
    <li>
        <a href="{% url 'index' %}">トップへ</a>
    </li>
</ul>
{% endblock content %}

パスワードのリセットが完了した後に表示されるページを作成します.

users/templates/users/password_reset_complete.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/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>
            <a href="{% url 'users:password_reset' %}">Reset Password</a>
        </p>
    </div>
</div>
{% endblock %}

現時点で登録されている電子メールアドレスがダミーのアドレスであるので,SHELL を使って受信可能なメールアドレスに変更します.

(py39) C:\Users\lecture\Documents\django\custom_auth_project>python manage.py shell ⏎
Python 3.9.12 (main, Apr  4 2022, 05:22:27) [MSC v.1916 64 bit (AMD64)]
Type 'copyright', 'credits' or 'license' for more information
IPython 8.3.0 -- An enhanced Interactive Python. Type '?' for help.

In [1]: from django.contrib.auth import get_user_model ⏎

In [2]: UserModel = get_user_model() ⏎

In [3]: user = UserModel.objects.get(id=1) ⏎

In [4]: user.email = "xxxxxxx@aa.bbbbbbbbbb.ac.jp" ⏎  # 受信可能なメールアドレスを指定

In [5]: user.save() ⏎

In [6]: exit() ⏎

(py39) C:\Users\lecture\Documents\django\custom_auth_project>

ログインページにある「Reset Password」リンクを開きます.

django2022-00331

先程変更登録した受信可能なメールアドレスを入力してボタンをクリックします.

django2022-00332

入力されたメールアドレスが users_user テーブルに存在すれば,メールが送信されます.

django2022-00334

登録されていないメールアドレスを入力した場合はエラーメッセージが表示されます.

django2022-00333

メールを受信して,リンクをクリックします.

django2022-00335

パスワードリセットページが表示されるので,新しいパスワードを設定します.

django2022-00336

パスワードのリセットができました.

django2022-00337

リセット完了後にもう一度リセットしようと同じ URL を開いてもエラーが表示されます.あるいは,メール送信後72時間以上経過した場合も同じエラーになります.なお,メールの有効期間の変更はあとで説明します.

django2022-00338

目次に戻る