无法为 Django 的重置密码流程创建集成测试
Unable to create an integration test for Django's reset password flow
我正在尝试为密码重置流程实施集成测试,但我停留在“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
returns一次成功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))
这很有趣;所以看起来 Django 在密码重置页面中实现了一个安全功能,以防止令牌在 HTTP Referrer header. Read more about Referrer Header Leaks here.
中泄露
TL;DR
Django 基本上是从 URL 中获取 sensitive 令牌并将其放入 Session 并执行内部重定向(相同域)以防止您从点击离开到另一个站点并通过 Referer header 泄漏令牌。
方法如下:
- 当你第一次点击
/accounts/reset/uidb64/token/
时(你应该在这里做一个 GET,但是你在你的测试用例中做的是 POST),Django 从 URL 并将其设置为 session 并将您重定向到 /accounts/reset/uidb64/set-password/
。
- 现在加载
/accounts/reset/uidb64/set-password/
页面,您可以在其中设置密码并执行 POST
- 当您从此页面 POST 时,同一个视图会处理您的 POST 请求,因为
token
URL 参数可以处理令牌和字符串 set-password
。
- 不过这一次,视图发现您是使用
set-password
而不是令牌访问它的,因此它希望从 session 中提取您的实际令牌,然后更改密码。
下面是流程图:
GET /reset/uidb64/token/
--> Set token in session --> 302 Redirect to /reset/uidb64/set-token/
--> POST Password --> Get token from Session --> Token Valid? --> Reset password
这是代码!
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())
注意代码中发生这种魔法的注释:
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.
我认为这清楚地说明了如何修复单元测试;在 PASSWORD_RESET_URL
上执行 GET,这将为您提供重定向 URL,然后您可以 POST 到此 redirect_url 并执行密码重置!
我正在尝试为密码重置流程实施集成测试,但我停留在“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
returns一次成功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))
这很有趣;所以看起来 Django 在密码重置页面中实现了一个安全功能,以防止令牌在 HTTP Referrer header. Read more about Referrer Header Leaks here.
中泄露TL;DR
Django 基本上是从 URL 中获取 sensitive 令牌并将其放入 Session 并执行内部重定向(相同域)以防止您从点击离开到另一个站点并通过 Referer header 泄漏令牌。
方法如下:
- 当你第一次点击
/accounts/reset/uidb64/token/
时(你应该在这里做一个 GET,但是你在你的测试用例中做的是 POST),Django 从 URL 并将其设置为 session 并将您重定向到/accounts/reset/uidb64/set-password/
。 - 现在加载
/accounts/reset/uidb64/set-password/
页面,您可以在其中设置密码并执行 POST - 当您从此页面 POST 时,同一个视图会处理您的 POST 请求,因为
token
URL 参数可以处理令牌和字符串set-password
。 - 不过这一次,视图发现您是使用
set-password
而不是令牌访问它的,因此它希望从 session 中提取您的实际令牌,然后更改密码。
下面是流程图:
GET
/reset/uidb64/token/
--> Set token in session --> 302 Redirect to/reset/uidb64/set-token/
--> POST Password --> Get token from Session --> Token Valid? --> Reset password
这是代码!
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())
注意代码中发生这种魔法的注释:
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.
我认为这清楚地说明了如何修复单元测试;在 PASSWORD_RESET_URL
上执行 GET,这将为您提供重定向 URL,然后您可以 POST 到此 redirect_url 并执行密码重置!