パスワードを忘れてしまってログインできないユーザに対して,電子メールを使ったパスワードリセットができるよう,パスワードリセット機能を実装しよう.まずは前のページのとおりメールの送信ができるようにしておいてください.
パスワードリセットに関連する 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」リンクを開きます.
先程変更登録した受信可能なメールアドレスを入力してボタンをクリックします.
入力されたメールアドレスが users_user
テーブルに存在すれば,メールが送信されます.
登録されていないメールアドレスを入力した場合はエラーメッセージが表示されます.
メールを受信して,リンクをクリックします.
パスワードリセットページが表示されるので,新しいパスワードを設定します.
パスワードのリセットができました.
リセット完了後にもう一度リセットしようと同じ URL を開いてもエラーが表示されます.あるいは,メール送信後72時間以上経過した場合も同じエラーになります.なお,メールの有効期間の変更はあとで説明します.