もた日記

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

Djangoメモ(24) : ユーザー認証を実装する〜パスワード変更

Python 3.6.4 Django 2.0.2

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


パスワード変更

サインアップ、ログアウト、ログイン、パスワードリセット機能を実装してきたが、最後にパスワード変更機能を実装する。
Djangoではパスワード変更のビューも提供されているのでmyproject/urls.pyに以下の2行を追加。

    path(
        'settings/password/',
        auth_views.PasswordChangeView.as_view(
            template_name='password_change.html'),
        name='password_change'),
    path('settings/password/done/',
        auth_views.PasswordChangeDoneView.as_view(
            template_name='password_change_done.html'),
        name='password_change_done'),

パスワード変更機能はログインしているユーザーのみが使用できる機能だが、ログインが必要な処理かどうかは@login_requiredデコレータ(次のチュートリアルで説明)により実現されている。
もしログインしていないユーザーがこのページにアクセスしようとした場合はログインページにリダイレクトされる。
ログインページの定義はmyproject/settings.pyに下記行を追加する。

LOGIN_URL = 'login'


設定が完了したので、新しいパスワード入力フォームのtemplates/password_change.htmlテンプレートを作成する。

{% extends 'base.html' %}

{% block title %}Change password{% endblock %}

{% block breadcrumb %}
  <li class="breadcrumb-item active">Change password</li>
{% endblock %}

{% block content %}
  <div class="row">
    <div class="col-lg-6 col-md-8 col-sm-10">
      <form method="post" novalidate>
        {% csrf_token %}
        {% include 'includes/form.html' %}
        <button type="submit" class="btn btn-success">Change password</button>
      </form>
    </div>
  </div>
{% endblock %}

これでsettings/password/にアクセスすると下のページが表示されるようになる。

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

バリデーションエラーメッセージも問題なく表示される。

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

もしログインしていない状態でsettings/password/にアクセスするとLOGIN_URL = 'login'で設定したページにリダイレクトされる。


次にパスワード変更が完了したことをユーザーに伝えるtemplates/password_change_done.htmlテンプレートを作成する。

{% extends 'base.html' %}

{% block title %}Change password successful{% endblock %}

{% block breadcrumb %}
  <li class="breadcrumb-item"><a href="{% url 'password_change' %}">Change password</a></li>
  <li class="breadcrumb-item active">Success</li>
{% endblock %}

{% block content %}
  <div class="alert alert-success" role="alert">
    <strong>Success!</strong> Your password has been changed!
  </div>
  <a href="{% url 'home' %}" class="btn btn-secondary">Return to home page</a>
{% endblock %}

パスワード変更が完了した際には下のページが表示される。

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


パスワード変更のテスト

パスワード変更のテストは下記コードを参照。

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

from django.contrib.auth.forms import PasswordChangeForm
from django.contrib.auth.models import User
from django.contrib.auth import views as auth_views
from django.urls import reverse, resolve
from django.test import TestCase


class PasswordChangeTests(TestCase):
    def setUp(self):
        username = 'john'
        password = 'secret123'
        user = User.objects.create_user(username=username, email='john@doe.com', password=password)
        url = reverse('password_change')
        self.client.login(username=username, password=password)
        self.response = self.client.get(url)

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

    def test_url_resolves_correct_view(self):
        view = resolve('/settings/password/')
        self.assertEquals(view.func.view_class, auth_views.PasswordChangeView)

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

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

    def test_form_inputs(self):
        '''
        The view must contain four inputs: csrf, old_password, new_password1, new_password2
        '''
        self.assertContains(self.response, '<input', 4)
        self.assertContains(self.response, 'type="password"', 3)


class LoginRequiredPasswordChangeTests(TestCase):
    def test_redirection(self):
        url = reverse('password_change')
        login_url = reverse('login')
        response = self.client.get(url)
        self.assertRedirects(response, f'{login_url}?next={url}')


class PasswordChangeTestCase(TestCase):
    '''
    Base test case for form processing
    accepts a `data` dict to POST to the view.
    '''
    def setUp(self, data={}):
        self.user = User.objects.create_user(username='john', email='john@doe.com', password='old_password')
        self.url = reverse('password_change')
        self.client.login(username='john', password='old_password')
        self.response = self.client.post(self.url, data)


class SuccessfulPasswordChangeTests(PasswordChangeTestCase):
    def setUp(self):
        super().setUp({
            'old_password': 'old_password',
            'new_password1': 'new_password',
            'new_password2': 'new_password',
        })

    def test_redirection(self):
        '''
        A valid form submission should redirect the user
        '''
        self.assertRedirects(self.response, reverse('password_change_done'))

    def test_password_changed(self):
        '''
        refresh the user instance from database to get the new password
        hash updated by the change password view.
        '''
        self.user.refresh_from_db()
        self.assertTrue(self.user.check_password('new_password'))

    def test_user_authentication(self):
        '''
        Create a new request to an arbitrary page.
        The resulting response should now have an `user` to its context, after a successful sign up.
        '''
        response = self.client.get(reverse('home'))
        user = response.context.get('user')
        self.assertTrue(user.is_authenticated)


class InvalidPasswordChangeTests(PasswordChangeTestCase):
    def test_status_code(self):
        '''
        An invalid form submission should return to the same page
        '''
        self.assertEquals(self.response.status_code, 200)

    def test_form_errors(self):
        form = self.response.context.get('form')
        self.assertTrue(form.errors)

    def test_didnt_change_password(self):
        '''
        refresh the user instance from the database to make
        sure we have the latest data.
        '''
        self.user.refresh_from_db()
        self.assertTrue(self.user.check_password('old_password'))

テストコードのself.user.refresh_from_db()はデータベースから最新の値を再読込する処理。
最後にテストが通ることを確認しておく。

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

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


まとめ

  • PasswordChangeViewはパスワード変更処理
  • PasswordChangeDoneViewはパスワード変更が完了したことを表示
  • パスワード変更はログインしているユーザーのみが利用可能
  • ログインしていないユーザーはログインページにリダイレクトされる
  • LOGIN_URL = 'login'でログインページを指定