无法为 Django 的重置密码流程创建集成测试

Posted

技术标签:

【中文标题】无法为 Django 的重置密码流程创建集成测试【英文标题】:Unable to create an integration test for Django's reset password flow 【发布时间】:2018-10-29 03:41:18 【问题描述】:

我正在尝试为密码重置流程实施集成测试,但我被困在“password_reset_confirm”视图中。我已经手动测试了流程,它工作正常。不幸的是,Django 单元测试客户端似乎无法正确遵循此视图中所需的重定向。

网址配置

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))

现在,断言失败:用户使用旧密码进行身份验证。从日志中我可以检测到更改密码没有被执行。

一些额外的有用信息:

post 返回一个成功的HTTP 200response.redirect_chain[('/accounts/reset/token_removed/set-password/', 302)],我认为这是错误的,因为它应该有另一个循环(在手动情况下,我看到另一个调用 dispatch 方法); 我正在使用 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】:

这很有趣;所以看起来Django在密码重置页面中实现了一个安全功能,以防止令牌在HTTP Referrer header中泄露。阅读更多关于 Referrer Header Leaks here.

TL;DR

Django 基本上是从 URL 中获取 sensitive 令牌并将其放置在 Session 中并执行内部重定向(相同的域),以防止您点击到不同的站点并通过参考标头。

方法如下:

当你第一次点击/accounts/reset/uidb64/token/(你应该在这里做一个GET,但是你在你的测试用例中做一个POST)时,Django从URL中提取令牌并将它设置在会话中并将你重定向到@ 987654324@。 这会加载/accounts/reset/uidb64/set-password/ 页面,您可以在其中设置密码并执行POST 当您从该页面 POST 时,同一个 View 会处理您的 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 标头。

我认为这清楚地说明了如何修复单元测试;在PASSWORD_RESET_URL 上执行 GET,它将为您提供重定向 URL,然后您可以 POST 到此 redirect_url 并执行密码重置!

【讨论】:

以上是关于无法为 Django 的重置密码流程创建集成测试的主要内容,如果未能解决你的问题,请参考以下文章

Django Social Auth - 我无法重置通过Facebook签名的用户的密码

Django Rest Framework + Django-Allauth 密码重置/恢复

尝试重置密码时出现 Djongo + Django + MongoDB Atlas DatabaseError

Django如何重设Admin密码(转)

使用gmail和Django注册不会重置密码电子邮件

如何在 Django 密码重置中为密码重置添加用户名和电子邮件选项?