もた日記

くだらないことを真面目にやる

Djangoメモ(23) : ユーザー認証を実装する〜パスワードリセットとメール送信

Python 3.6.4 Django 2.0.2

A Complete Beginner's Guide to Djangoのチュートリアルを参考にパスワードリセット機能を実装する。


パスワードリセットの流れ

ログイン、ログアウトと同様にパスワードリセットのビューもDjangoで提供されているが以下のようにメール送信など手順が多い。

  1. パスワードリセットを開始するフォームでEメールアドレスを入力するとパスワードリセットリンクが記載されたメールが送信される(reset/
  2. メールが送信されたことをユーザに伝える画面が表示される(reset/done/
  3. パスワードリセットリンクにアクセスして表示されるフォームで新パスワードを入力(reset/<uidb64>/<token>/
  4. パスワードリセットが完了したことをユーザに伝える画面が表示される(reset/complete/

上記処理に対するURLConfをmyproject/urls.pyに追加する。ドキュメントを見るとわかるようにテンプレートファイル名はデフォルトで指定されているがチュートリアルでは上書きしている。

    path(
        'reset/',
        auth_views.PasswordResetView.as_view(
            template_name='password_reset.html',
            email_template_name='password_reset_email.html',
            subject_template_name='password_reset_subject.txt'),
        name='password_reset'),
    path(
        'reset/done/',
        auth_views.PasswordResetDoneView.as_view(
            template_name='password_reset_done.html'),
        name='password_reset_done'),
    path(
        'reset/<uidb64>/<token>/',
        auth_views.PasswordResetConfirmView.as_view(
            template_name='password_reset_confirm.html'),
        name='password_reset_confirm'),
    path(
        'reset/complete/',
        auth_views.PasswordResetCompleteView.as_view(
            template_name='password_reset_complete.html'),
        name='password_reset_complete'),


メール送信設定

パスワードリセットではメールを送信する手順があるが、実際にメールを送信するのは設定が大変なので開発向けの機能を利用する。
myproject/settings.pyに以下の行を追加。

EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'

この設定はメールのバックエンドとしてコンソールバックエンドを指定しており、実際にメールを送信する代わりに標準出力に送信されるメールを書き込むことができるようになる。
バックエンドとしては下記が利用可能だが詳細はドキュメントを参照。

  • SMTPバックエンド
  • コンソールバックエンド
  • ファイルバックエンド
  • インメモリーバックエンド
  • ダミーバックエンド


メールドアレス入力(password_reset.html)

最初にパスワードリセットを開始するためにメールアドレスを入力するtemplates/password_reset.htmlテンプレートを作成する。

{% extends 'base_accounts.html' %}

{% block title %}Reset your password{% endblock %}

{% block content %}
  <div class="row justify-content-center">
    <div class="col-lg-4 col-md-6 col-sm-8">
      <div class="card">
        <div class="card-body">
          <h3 class="card-title">Reset your password</h3>
          <p>Enter your email address and we will send you a link to reset your password.</p>
          <form method="post" novalidate>
            {% csrf_token %}
            {% include 'includes/form.html' %}
            <button type="submit" class="btn btn-primary btn-block">Send password reset email</button>
          </form>
        </div>
      </div>
    </div>
  </div>
{% endblock %}

これでreset/にアクセスするとメールアドレス入力フォームが表示される。

f:id:wonder-wall:20180321221148p:plain

次にパスワードリセットメールの件名と本文のテンプレートを作成する。
件名はtemplates/password_reset_subject.txtテンプレートを、

[Django Boards] Please reset your password

本文はtemplates/password_reset_email.htmテンプレートを作成する。

Hi there,

Someone asked for a password reset for the email address {{ email }}.
Follow the link below:
{{ protocol }}://{{ domain }}{% url 'password_reset_confirm' uidb64=uid token=token %}

In case you forgot your Django Boards username: {{ user.username }}

If clicking the link above doesn't work, please copy and paste the URL
in a new browser window instead.

If you've received this mail in error, it's likely that another user entered
your email address by mistake while trying to reset a password. If you didn't
initiate the request, you don't need to take any further action and can safely
disregard this email.

Thanks,

The Django Boards Team

ドキュメントによるとテンプレートで使えるコンテキストは以下の通り。

  • email: user.email の別名 (エイリアス) です。
  • user: 現在の User で、email フォームフィールドから取得されます。アクティブなユーザ (User.is_active が True) だけがパスワードをリセットすることができます。
  • site_name: An alias for site.name. If you don't have the site framework installed, this will be set to the value of request.META['SERVER_NAME']. For more on sites, see The "sites" framework.
  • domain: site.domain の別名 (エイリアス) です。サイトのフレームワークをインストールしていない場合、request.get_host() の値がセットされます。
  • protocol: http か https です。
  • uid: Base 64 でエンコードされたユーザのプライマリキーです。
  • token: リセットリンクを検証するためのトークンです。


メールの件名、本文のテンプレートを作成したのでフォームにメールアドレス(ユーザ登録済みのメールアドレス)を入力して送信してみる。
動作に問題がなければrunserverを起動しているコンソールにパスワードリセットリンクが記載されているメールが表示される。ユーザ登録済みのメールアドレス以外だと何も表示されないので注意。

f:id:wonder-wall:20180323231723p:plain


メール送信完了(password_reset_done.html)

続いて、メールの送信が完了したことをユーザに伝えるtemplates/password_reset_done.htmlテンプレートを作成する。

{% extends 'base_accounts.html' %}

{% block title %}Reset your password{% endblock %}

{% block content %}
  <div class="row justify-content-center">
    <div class="col-lg-4 col-md-6 col-sm-8">
      <div class="card">
        <div class="card-body">
          <h3 class="card-title">Reset your password</h3>
          <p>Check your email for a link to reset your password. If it doesn't appear within a few minutes, check your spam folder.</p>
          <a href="{% url 'login' %}" class="btn btn-secondary btn-block">Return to log in</a>
        </div>
      </div>
    </div>
  </div>
{% endblock %}

これでユーザにメールが送信されるとともに下の画面が表示されるようになる。

f:id:wonder-wall:20180321222157p:plain


新パスワード登録(password_reset_confirm.html)

メールに記載してあるパスワードリセット用リンクにアクセスしたときに表示される新パスワード登録用のtemplates/password_reset_confirm.htmlテンプレートを作成する。

{% extends 'base_accounts.html' %}

{% block title %}
  {% if validlink %}
    Change password for {{ form.user.username }}
  {% else %}
    Reset your password
  {% endif %}
{% endblock %}

{% block content %}
  <div class="row justify-content-center">
    <div class="col-lg-6 col-md-8 col-sm-10">
      <div class="card">
        <div class="card-body">
          {% if validlink %}
            <h3 class="card-title">Change password for @{{ form.user.username }}</h3>
            <form method="post" novalidate>
              {% csrf_token %}
              {% include 'includes/form.html' %}
              <button type="submit" class="btn btn-success btn-block">Change password</button>
            </form>
          {% else %}
            <h3 class="card-title">Reset your password</h3>
            <div class="alert alert-danger" role="alert">
              It looks like you clicked on an invalid password reset link. Please try again.
            </div>
            <a href="{% url 'password_reset' %}" class="btn btn-secondary btn-block">Request a new password reset link</a>
          {% endif %}
        </div>
      </div>
    </div>
  </div>
{% endblock %}

{% if validlink %}はリンクが正しく、まだ使われていない場合にTrueになる。
その場合は以下のように新パスワードを入力できるフォームが表示される。

f:id:wonder-wall:20180323231736p:plain

既に使われている場合などは下の画面が表示される。

f:id:wonder-wall:20180323231940p:plain


パスワードリセット完了(password_reset_complete.html)

最後に、新パスワード登録後にパスワードリセットが完了したことをユーザに伝えるtemplates/password_reset_complete.htmlテンプレートを作成する。
何も問題がなければ新しいパスワードでログインできるようになっているはず。

{% extends 'base_accounts.html' %}

{% block title %}Password changed!{% endblock %}

{% block content %}
  <div class="row justify-content-center">
    <div class="col-lg-6 col-md-8 col-sm-10">
      <div class="card">
        <div class="card-body">
          <h3 class="card-title">Password changed!</h3>
          <div class="alert alert-success" role="alert">
            You have successfully changed your password! You may now proceed to log in.
          </div>
          <a href="{% url 'login' %}" class="btn btn-secondary btn-block">Return to log in</a>
        </div>
      </div>
    </div>
  </div>
{% endblock %}

f:id:wonder-wall:20180323232434p:plain


パスワードリセットのテスト

パスワードリセット関連のテストは以下の通り。

accounts/tests/test_mail_password_reset.py(クリックで展開)

from django.core import mail
from django.contrib.auth.models import User
from django.urls import reverse
from django.test import TestCase

class PasswordResetMailTests(TestCase):
    def setUp(self):
        User.objects.create_user(username='john', email='john@doe.com', password='123')
        self.response = self.client.post(reverse('password_reset'), { 'email': 'john@doe.com' })
        self.email = mail.outbox[0]

    def test_email_subject(self):
        self.assertEqual('[Django Boards] Please reset your password', self.email.subject)

    def test_email_body(self):
        context = self.response.context
        token = context.get('token')
        uid = context.get('uid')
        password_reset_token_url = reverse('password_reset_confirm', kwargs={
            'uidb64': uid,
            'token': token
        })
        self.assertIn(password_reset_token_url, self.email.body)
        self.assertIn('john', self.email.body)
        self.assertIn('john@doe.com', self.email.body)

    def test_email_to(self):
        self.assertEqual(['john@doe.com',], self.email.to)

accounts/tests/test_view_password_reset.pyのテスト(クリックで展開)

from django.contrib.auth.tokens import default_token_generator
from django.utils.encoding import force_bytes
from django.utils.http import urlsafe_base64_encode
from django.contrib.auth import views as auth_views
from django.contrib.auth.forms import PasswordResetForm, SetPasswordForm
from django.contrib.auth.models import User
from django.core import mail
from django.urls import reverse, resolve
from django.test import TestCase


class PasswordResetTests(TestCase):
    def setUp(self):
        url = reverse('password_reset')
        self.response = self.client.get(url)

    def test_status_code(self):
        self.assertEquals(self.response.status_code, 200)

    def test_view_function(self):
        view = resolve('/reset/')
        self.assertEquals(view.func.view_class, auth_views.PasswordResetView)

    def test_csrf(self):
        self.assertContains(self.response, 'csrfmiddlewaretoken')

    def test_contains_form(self):
        form = self.response.context.get('form')
        self.assertIsInstance(form, PasswordResetForm)

    def test_form_inputs(self):
        '''
        The view must contain two inputs: csrf and email
        '''
        self.assertContains(self.response, '<input', 2)
        self.assertContains(self.response, 'type="email"', 1)


class SuccessfulPasswordResetTests(TestCase):
    def setUp(self):
        email = 'john@doe.com'
        User.objects.create_user(username='john', email=email, password='123abcdef')
        url = reverse('password_reset')
        self.response = self.client.post(url, {'email': email})

    def test_redirection(self):
        '''
        A valid form submission should redirect the user to `password_reset_done` view
        '''
        url = reverse('password_reset_done')
        self.assertRedirects(self.response, url)

    def test_send_password_reset_email(self):
        self.assertEqual(1, len(mail.outbox))


class InvalidPasswordResetTests(TestCase):
    def setUp(self):
        url = reverse('password_reset')
        self.response = self.client.post(url, {'email': 'donotexist@email.com'})

    def test_redirection(self):
        '''
        Even invalid emails in the database should
        redirect the user to `password_reset_done` view
        '''
        url = reverse('password_reset_done')
        self.assertRedirects(self.response, url)

    def test_no_reset_email_sent(self):
        self.assertEqual(0, len(mail.outbox))


class PasswordResetDoneTests(TestCase):
    def setUp(self):
        url = reverse('password_reset_done')
        self.response = self.client.get(url)

    def test_status_code(self):
        self.assertEquals(self.response.status_code, 200)

    def test_view_function(self):
        view = resolve('/reset/done/')
        self.assertEquals(view.func.view_class, auth_views.PasswordResetDoneView)


class PasswordResetConfirmTests(TestCase):
    def setUp(self):
        user = User.objects.create_user(username='john', email='john@doe.com', password='123abcdef')

        '''
        create a valid password reset token
        based on how django creates the token internally:
        https://github.com/django/django/blob/1.11.5/django/contrib/auth/forms.py#L280
        '''
        self.uid = urlsafe_base64_encode(force_bytes(user.pk)).decode()
        self.token = default_token_generator.make_token(user)

        url = reverse('password_reset_confirm', kwargs={'uidb64': self.uid, 'token': self.token})
        self.response = self.client.get(url, follow=True)

    def test_status_code(self):
        self.assertEquals(self.response.status_code, 200)

    def test_view_function(self):
        view = resolve('/reset/{uidb64}/{token}/'.format(uidb64=self.uid, token=self.token))
        self.assertEquals(view.func.view_class, auth_views.PasswordResetConfirmView)

    def test_csrf(self):
        self.assertContains(self.response, 'csrfmiddlewaretoken')

    def test_contains_form(self):
        form = self.response.context.get('form')
        self.assertIsInstance(form, SetPasswordForm)

    def test_form_inputs(self):
        '''
        The view must contain two inputs: csrf and two password fields
        '''
        self.assertContains(self.response, '<input', 3)
        self.assertContains(self.response, 'type="password"', 2)


class InvalidPasswordResetConfirmTests(TestCase):
    def setUp(self):
        user = User.objects.create_user(username='john', email='john@doe.com', password='123abcdef')
        uid = urlsafe_base64_encode(force_bytes(user.pk)).decode()
        token = default_token_generator.make_token(user)

        '''
        invalidate the token by changing the password
        '''
        user.set_password('abcdef123')
        user.save()

        url = reverse('password_reset_confirm', kwargs={'uidb64': uid, 'token': token})
        self.response = self.client.get(url)

    def test_status_code(self):
        self.assertEquals(self.response.status_code, 200)

    def test_html(self):
        password_reset_url = reverse('password_reset')
        self.assertContains(self.response, 'invalid password reset link')
        self.assertContains(self.response, 'href="{0}"'.format(password_reset_url))


class PasswordResetCompleteTests(TestCase):
    def setUp(self):
        url = reverse('password_reset_complete')
        self.response = self.client.get(url)

    def test_status_code(self):
        self.assertEquals(self.response.status_code, 200)

    def test_view_function(self):
        view = resolve('/reset/complete/')
        self.assertEquals(view.func.view_class, auth_views.PasswordResetCompleteView)

テストが通ることを確認しておく。

$ python manage.py test
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
.......................................................
----------------------------------------------------------------------
Ran 55 tests in 4.187s

OK
Destroying test database for alias 'default'...


まとめ

  • PasswordResetViewはリセットリンクを入力されたEメールアドレスに送信
  • PasswordResetDoneViewはEメールが送信されたことをユーザに表示
  • PasswordResetConfirmViewは入力された新パスワードでパスワードをリセット
  • PasswordResetCompleteViewはリセットが完了したことをユーザに表示
  • 開発向けにはEMAIL_BACKENDにコンソールバックエンドを指定