Невозможно создать интеграционный тест для потока сброса пароля Django - PullRequest
0 голосов
/ 18 мая 2018

Я пытаюсь реализовать интеграционный тест для потока сброса пароля, но я застрял в представлении " password_reset_confirm ".Я уже проверил поток вручную, и он отлично работает.К сожалению, клиент модульного тестирования Django, кажется, не может правильно выполнить перенаправления, требуемые в этом представлении.

urls config

from django.contrib.auth import views as auth_views


url(r"^accounts/password_change/$",
    auth_views.PasswordChangeView.as_view(),
    name="password_change"),
url(r"^accounts/password_change/done/$",
    auth_views.PasswordChangeDoneView.as_view(),
    name="password_change_done"),
url(r"^accounts/password_reset/$",
    auth_views.PasswordResetView.as_view(email_template_name="app/email/accounts/password_reset_email.html",
                                         success_url=reverse_lazy("app:password_reset_done"),
                                         subject_template_name="app/email/accounts/password_reset_subject.html"),
    name="password_reset"),
url(r"^accounts/password_reset/done/$",
    auth_views.PasswordResetDoneView.as_view(),
    name="password_reset_done"),
url(r"^accounts/reset/(?P<uidb64>[0-9A-Za-z_\-]+)/(?P<token>[0-9A-Za-z]{1,13}-[0-9A-Za-z]{1,20})/$",
    auth_views.PasswordResetConfirmView.as_view(
        success_url=reverse_lazy("app:password_reset_complete"),
        form_class=CustomSetPasswordForm),
    name="password_reset_confirm"),
url(r"^accounts/reset/complete/$",
    auth_views.PasswordResetCompleteView.as_view(),
    name="password_reset_complete"),

Тестовый код

import re
from django.urls import reverse, NoReverseMatch
from django.test import TestCase, Client
from django.core import mail
from django.test.utils import override_settings
from django.contrib.auth import authenticate

VALID_USER_NAME = "username"
USER_OLD_PSW = "oldpassword"
USER_NEW_PSW = "newpassword"
PASSWORD_RESET_URL = reverse("app:password_reset")

def PASSWORD_RESET_CONFIRM_URL(uidb64, token):
    try:
        return reverse("app:password_reset_confirm", args=(uidb64, token))
    except NoReverseMatch:
        return f"/accounts/reset/invaliduidb64/invalid-token/"


def utils_extract_reset_tokens(full_url):
    return re.findall(r"/([\w\-]+)",
                      re.search(r"^http\://.+$", full_url, flags=re.MULTILINE)[0])[3:5]


@override_settings(EMAIL_BACKEND="anymail.backends.test.EmailBackend")
class PasswordResetTestCase(TestCase):
    @classmethod
    def setUpClass(cls):
        super().setUpClass()
        cls.myclient = Client()

    def test_password_reset_ok(self):
        # ask for password reset
        response = self.myclient.post(PASSWORD_RESET_URL,
                                      {"email": VALID_USER_NAME},
                                      follow=True)

        # extract reset token from email
        self.assertEqual(len(mail.outbox), 1)
        msg = mail.outbox[0]
        uidb64, token = utils_extract_reset_tokens(msg.body)

        # change the password
        response = self.myclient.post(PASSWORD_RESET_CONFIRM_URL(uidb64, token),
                                      {"new_password1": USER_NEW_PSW,
                                       "new_password2": USER_NEW_PSW},
                                      follow=True)

        self.assertIsNone(authenticate(username=VALID_USER_NAME,password=USER_OLD_PSW))

ТеперьСбой assert: пользователь аутентифицирован со старым паролем.Из журнала я могу обнаружить, что изменение пароля не выполняется.

Несколько дополнительных полезных сведений:

  • post возвращает успешное HTTP 200;
  • response.redirect_chain - это [('/accounts/reset/token_removed/set-password/', 302)], и я думаю, что это неправильно, поскольку он должен иметь другой цикл (в ручном случае я вижу другой вызов метода отправки);
  • Я выполняютест с использованием инструментов модульного тестирования Django.

Есть идеи, как правильно протестировать этот сценарий?Мне нужно это, чтобы убедиться, что электронная почта и логирование выполняются должным образом (и никогда не удаляются).

Большое спасибо!

РЕДАКТИРОВАТЬ: решение

Как хорошо объяснено в принятом решении,вот рабочий код для теста:

def test_password_reset_ok(self):
        # ask for password reset
        response = self.myclient.post(PASSWORD_RESET_URL,
                                      {"email": VALID_USER_NAME},
                                      follow=True)

        # extract reset token from email
        self.assertEqual(len(mail.outbox), 1)
        msg = mail.outbox[0]
        uidb64, token = utils_extract_reset_tokens(msg.body)

        # change the password
        self.myclient.get(PASSWORD_RESET_CONFIRM_URL(uidb64, token), follow=True)
        response = self.myclient.post(PASSWORD_RESET_CONFIRM_URL(uidb64, "set-password"),
                                      {"new_password1": USER_NEW_PSW,
                                       "new_password2": USER_NEW_PSW},
                                      follow=True)

        self.assertIsNone(authenticate(username=VALID_USER_NAME,password=USER_OLD_PSW))

1 Ответ

0 голосов
/ 18 мая 2018

Это очень интересно;похоже, что Django реализовал функцию безопасности на странице сброса пароля, чтобы предотвратить утечку токена в заголовке HTTP Referrer .Подробнее об утечках заголовка Referrer здесь.

TL; DR

Django в основном берет токен чувствительный из URL и помещает его в сеанси выполнить внутреннее перенаправление (тот же домен), чтобы предотвратить переход на другой сайт и утечку токена через заголовок Referer.

Вот как:

  • Когда вы нажимаете /accounts/reset/uidb64/token/ (вы должны делать GET здесь, однако вы делаете POST в вашем тестовом случае) в первый раз, Django тянеттокен из URL и устанавливает его в сеансе и перенаправляет вас на /accounts/reset/uidb64/set-password/.
  • Теперь загружается страница /accounts/reset/uidb64/set-password/, где вы можете установить пароли и выполнить POST
  • Когда вы выполняете POST с этой страницы, то же представление обрабатывает ваш POST.запрос, поскольку параметр token URL может обрабатывать как токен, так и строку set-password.
  • На этот раз, однако, представление видит, что вы обратились к нему с set-password, а не токеном, поэтому он ожидает извлечь ваш текущий токен из сеанса, а затем изменить пароль.

Вот последовательность операций в виде диаграммы:

GET /reset/uidb64/token/ -> Установить токен в сеансе -> 302 Перенаправить на /reset/uidb64/set-token/ -> Пароль POST -> Получить токен изСессия -> Токен действителен?-> Сброс пароля

Вот код!

INTERNAL_RESET_URL_TOKEN = 'set-password'
INTERNAL_RESET_SESSION_TOKEN = '_password_reset_token'

@method_decorator(sensitive_post_parameters())
@method_decorator(never_cache)
def dispatch(self, *args, **kwargs):
    assert 'uidb64' in kwargs and 'token' in kwargs

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

    if self.user is not None:
        token = kwargs['token']
        if token == INTERNAL_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, INTERNAL_RESET_URL_TOKEN)
                return HttpResponseRedirect(redirect_url)

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

Обратите внимание на комментарий в коде, где происходит это волшебство:

Сохраните токен в сеансе и перенаправьте на форму сброса пароля по URL-адресу без токена.Это исключает возможность утечки токена в заголовке HTTP Referer.

Я думаю, это проясняет, как вы можете исправить свой модульный тест;сделайте GET на PASSWORD_RESET_URL, который даст вам URL-адрес перенаправления, затем вы можете отправить POST к этому redirect_url и выполнить сброс пароля!

...