From 255fb992845e987ef36e3d721a77747a0b2df620 Mon Sep 17 00:00:00 2001 From: Claude Paroz Date: Tue, 9 Apr 2013 23:31:58 +0200 Subject: [PATCH] Fixed #17209 -- Added password reset/change class-based views Thanks Tim Graham for the review. --- django/contrib/admin/sites.py | 12 +- django/contrib/auth/urls.py | 14 +- django/contrib/auth/views.py | 166 +++++++- docs/internals/deprecation.txt | 5 +- docs/ref/contrib/admin/index.txt | 8 +- docs/ref/settings.txt | 2 +- docs/releases/1.11.txt | 22 ++ docs/topics/auth/default.txt | 269 ++++++++----- tests/auth_tests/test_deprecated_views.py | 446 ++++++++++++++++++++++ tests/auth_tests/test_templates.py | 22 +- tests/auth_tests/urls.py | 37 +- tests/auth_tests/urls_deprecated.py | 39 ++ 12 files changed, 896 insertions(+), 146 deletions(-) create mode 100644 tests/auth_tests/test_deprecated_views.py create mode 100644 tests/auth_tests/urls_deprecated.py diff --git a/django/contrib/admin/sites.py b/django/contrib/admin/sites.py index ea64d6e296..ca87311cd9 100644 --- a/django/contrib/admin/sites.py +++ b/django/contrib/admin/sites.py @@ -289,30 +289,30 @@ class AdminSite(object): Handles the "change password" task -- both form display and validation. """ from django.contrib.admin.forms import AdminPasswordChangeForm - from django.contrib.auth.views import password_change + from django.contrib.auth.views import PasswordChangeView url = reverse('admin:password_change_done', current_app=self.name) defaults = { - 'password_change_form': AdminPasswordChangeForm, - 'post_change_redirect': url, + 'form_class': AdminPasswordChangeForm, + 'success_url': url, 'extra_context': dict(self.each_context(request), **(extra_context or {})), } if self.password_change_template is not None: defaults['template_name'] = self.password_change_template request.current_app = self.name - return password_change(request, **defaults) + return PasswordChangeView.as_view(**defaults)(request) def password_change_done(self, request, extra_context=None): """ Displays the "success" page after a password change. """ - from django.contrib.auth.views import password_change_done + from django.contrib.auth.views import PasswordChangeDoneView defaults = { 'extra_context': dict(self.each_context(request), **(extra_context or {})), } if self.password_change_done_template is not None: defaults['template_name'] = self.password_change_done_template request.current_app = self.name - return password_change_done(request, **defaults) + return PasswordChangeDoneView.as_view(**defaults)(request) def i18n_javascript(self, request, extra_context=None): """ diff --git a/django/contrib/auth/urls.py b/django/contrib/auth/urls.py index 008ed092a6..233eef8fec 100644 --- a/django/contrib/auth/urls.py +++ b/django/contrib/auth/urls.py @@ -9,11 +9,13 @@ from django.contrib.auth import views urlpatterns = [ url(r'^login/$', views.LoginView.as_view(), name='login'), url(r'^logout/$', views.LogoutView.as_view(), name='logout'), - url(r'^password_change/$', views.password_change, name='password_change'), - url(r'^password_change/done/$', views.password_change_done, name='password_change_done'), - url(r'^password_reset/$', views.password_reset, name='password_reset'), - url(r'^password_reset/done/$', views.password_reset_done, name='password_reset_done'), + + url(r'^password_change/$', views.PasswordChangeView.as_view(), name='password_change'), + url(r'^password_change/done/$', views.PasswordChangeDoneView.as_view(), name='password_change_done'), + + url(r'^password_reset/$', views.PasswordResetView.as_view(), name='password_reset'), + url(r'^password_reset/done/$', views.PasswordResetDoneView.as_view(), name='password_reset_done'), url(r'^reset/(?P[0-9A-Za-z_\-]+)/(?P[0-9A-Za-z]{1,13}-[0-9A-Za-z]{1,20})/$', - views.password_reset_confirm, name='password_reset_confirm'), - url(r'^reset/done/$', views.password_reset_complete, name='password_reset_complete'), + views.PasswordResetConfirmView.as_view(), name='password_reset_confirm'), + url(r'^reset/done/$', views.PasswordResetCompleteView.as_view(), name='password_reset_complete'), ] diff --git a/django/contrib/auth/views.py b/django/contrib/auth/views.py index 38a0f658c1..8b4c1d6fdb 100644 --- a/django/contrib/auth/views.py +++ b/django/contrib/auth/views.py @@ -16,7 +16,7 @@ from django.contrib.sites.shortcuts import get_current_site from django.http import HttpResponseRedirect, QueryDict from django.shortcuts import resolve_url from django.template.response import TemplateResponse -from django.urls import reverse +from django.urls import reverse, reverse_lazy from django.utils.decorators import method_decorator from django.utils.deprecation import ( RemovedInDjango20Warning, RemovedInDjango21Warning, @@ -24,7 +24,7 @@ from django.utils.deprecation import ( from django.utils.encoding import force_text from django.utils.http import is_safe_url, urlsafe_base64_decode from django.utils.six.moves.urllib.parse import urlparse, urlunparse -from django.utils.translation import ugettext as _ +from django.utils.translation import ugettext_lazy as _ from django.views.decorators.cache import never_cache from django.views.decorators.csrf import csrf_protect from django.views.decorators.debug import sensitive_post_parameters @@ -224,6 +224,9 @@ def password_reset(request, extra_context=None, html_email_template_name=None, extra_email_context=None): + warnings.warn("The password_reset() view is superseded by the " + "class-based PasswordResetView().", + RemovedInDjango21Warning, stacklevel=2) if post_reset_redirect is None: post_reset_redirect = reverse('password_reset_done') else: @@ -259,6 +262,9 @@ def password_reset(request, def password_reset_done(request, template_name='registration/password_reset_done.html', extra_context=None): + warnings.warn("The password_reset_done() view is superseded by the " + "class-based PasswordResetDoneView().", + RemovedInDjango21Warning, stacklevel=2) context = { 'title': _('Password reset sent'), } @@ -282,6 +288,9 @@ def password_reset_confirm(request, uidb64=None, token=None, View that checks the hash in a password reset link and presents a form for entering a new password. """ + warnings.warn("The password_reset_confirm() view is superseded by the " + "class-based PasswordResetConfirmView().", + RemovedInDjango21Warning, stacklevel=2) UserModel = get_user_model() assert uidb64 is not None and token is not None # checked by URLconf if post_reset_redirect is None: @@ -324,6 +333,9 @@ def password_reset_confirm(request, uidb64=None, token=None, def password_reset_complete(request, template_name='registration/password_reset_complete.html', extra_context=None): + warnings.warn("The password_reset_complete() view is superseded by the " + "class-based PasswordResetCompleteView().", + RemovedInDjango21Warning, stacklevel=2) context = { 'login_url': resolve_url(settings.LOGIN_URL), 'title': _('Password reset complete'), @@ -334,6 +346,116 @@ def password_reset_complete(request, return TemplateResponse(request, template_name, context) +# Class-based password reset views +# - PasswordResetView sends the mail +# - PasswordResetDoneView shows a success message for the above +# - PasswordResetConfirmView checks the link the user clicked and +# prompts for a new password +# - PasswordResetCompleteView shows a success message for the above + +class PasswordContextMixin(object): + extra_context = None + + def get_context_data(self, **kwargs): + context = super(PasswordContextMixin, self).get_context_data(**kwargs) + context['title'] = self.title + if self.extra_context is not None: + context.update(self.extra_context) + return context + + +class PasswordResetView(PasswordContextMixin, FormView): + email_template_name = 'registration/password_reset_email.html' + extra_email_context = None + form_class = PasswordResetForm + from_email = None + html_email_template_name = None + subject_template_name = 'registration/password_reset_subject.txt' + success_url = reverse_lazy('password_reset_done') + template_name = 'registration/password_reset_form.html' + title = _('Password reset') + token_generator = default_token_generator + + @method_decorator(csrf_protect) + def dispatch(self, *args, **kwargs): + return super(PasswordResetView, self).dispatch(*args, **kwargs) + + def form_valid(self, form): + opts = { + 'use_https': self.request.is_secure(), + 'token_generator': self.token_generator, + 'from_email': self.from_email, + 'email_template_name': self.email_template_name, + 'subject_template_name': self.subject_template_name, + 'request': self.request, + 'html_email_template_name': self.html_email_template_name, + 'extra_email_context': self.extra_email_context, + } + form.save(**opts) + return super(PasswordResetView, self).form_valid(form) + + +class PasswordResetDoneView(PasswordContextMixin, TemplateView): + template_name = 'registration/password_reset_done.html' + title = _('Password reset sent') + + +class PasswordResetConfirmView(PasswordContextMixin, FormView): + form_class = SetPasswordForm + success_url = reverse_lazy('password_reset_complete') + template_name = 'registration/password_reset_confirm.html' + title = _('Enter new password') + token_generator = default_token_generator + + @method_decorator(sensitive_post_parameters()) + @method_decorator(never_cache) + def dispatch(self, *args, **kwargs): + assert 'uidb64' in kwargs and 'token' in kwargs + return super(PasswordResetConfirmView, self).dispatch(*args, **kwargs) + + def get_user(self, uidb64): + UserModel = get_user_model() + try: + # urlsafe_base64_decode() decodes to bytestring on Python 3 + uid = force_text(urlsafe_base64_decode(uidb64)) + user = UserModel._default_manager.get(pk=uid) + except (TypeError, ValueError, OverflowError, UserModel.DoesNotExist): + user = None + return user + + def get_form_kwargs(self): + kwargs = super(PasswordResetConfirmView, self).get_form_kwargs() + kwargs['user'] = self.get_user(self.kwargs['uidb64']) + return kwargs + + def form_valid(self, form): + form.save() + return super(PasswordResetConfirmView, self).form_valid(form) + + def get_context_data(self, **kwargs): + context = super(PasswordResetConfirmView, self).get_context_data(**kwargs) + user = context['form'].user + if user is not None and self.token_generator.check_token(user, self.kwargs['token']): + context['validlink'] = True + else: + context.update({ + 'form': None, + 'title': _('Password reset unsuccessful'), + 'validlink': False, + }) + return context + + +class PasswordResetCompleteView(PasswordContextMixin, TemplateView): + template_name = 'registration/password_reset_complete.html' + title = _('Password reset complete') + + def get_context_data(self, **kwargs): + context = super(PasswordResetCompleteView, self).get_context_data(**kwargs) + context['login_url'] = resolve_url(settings.LOGIN_URL) + return context + + @sensitive_post_parameters() @csrf_protect @login_required @@ -343,6 +465,9 @@ def password_change(request, post_change_redirect=None, password_change_form=PasswordChangeForm, extra_context=None): + warnings.warn("The password_change() view is superseded by the " + "class-based PasswordChangeView().", + RemovedInDjango21Warning, stacklevel=2) if post_change_redirect is None: post_change_redirect = reverse('password_change_done') else: @@ -372,6 +497,9 @@ def password_change(request, def password_change_done(request, template_name='registration/password_change_done.html', extra_context=None): + warnings.warn("The password_change_done() view is superseded by the " + "class-based PasswordChangeDoneView().", + RemovedInDjango21Warning, stacklevel=2) context = { 'title': _('Password change successful'), } @@ -379,3 +507,37 @@ def password_change_done(request, context.update(extra_context) return TemplateResponse(request, template_name, context) + + +class PasswordChangeView(PasswordContextMixin, FormView): + form_class = PasswordChangeForm + success_url = reverse_lazy('password_change_done') + template_name = 'registration/password_change_form.html' + title = _('Password change') + + @method_decorator(sensitive_post_parameters()) + @method_decorator(csrf_protect) + @method_decorator(login_required) + def dispatch(self, *args, **kwargs): + return super(PasswordChangeView, self).dispatch(*args, **kwargs) + + def get_form_kwargs(self): + kwargs = super(PasswordChangeView, self).get_form_kwargs() + kwargs['user'] = self.request.user + return kwargs + + def form_valid(self, form): + form.save() + # Updating the password logs out all other sessions for the user + # except the current one. + update_session_auth_hash(self.request, form.user) + return super(PasswordChangeView, self).form_valid(form) + + +class PasswordChangeDoneView(PasswordContextMixin, TemplateView): + template_name = 'registration/password_change_done.html' + title = _('Password change successful') + + @method_decorator(login_required) + def dispatch(self, *args, **kwargs): + return super(PasswordChangeDoneView, self).dispatch(*args, **kwargs) diff --git a/docs/internals/deprecation.txt b/docs/internals/deprecation.txt index 48c56af785..23b7a2d0b2 100644 --- a/docs/internals/deprecation.txt +++ b/docs/internals/deprecation.txt @@ -15,7 +15,10 @@ about each item can often be found in the release notes of two versions prior. See the :ref:`Django 1.11 release notes` for more details on these changes. -* ``contrib.auth.views.login()`` and ``logout()`` will be removed. +* ``contrib.auth.views.login()``, ``logout()``, ``password_change()``, + ``password_change_done()``, ``password_reset()``, ``password_reset_done()``, + ``password_reset_confirm()``, and ``password_reset_complete()`` will be + removed. .. _deprecation-removed-in-2.0: diff --git a/docs/ref/contrib/admin/index.txt b/docs/ref/contrib/admin/index.txt index d588964a70..f01a845493 100644 --- a/docs/ref/contrib/admin/index.txt +++ b/docs/ref/contrib/admin/index.txt @@ -2773,10 +2773,10 @@ your URLconf. Specifically, add these four patterns:: from django.contrib.auth import views as auth_views - url(r'^admin/password_reset/$', auth_views.password_reset, name='admin_password_reset'), - url(r'^admin/password_reset/done/$', auth_views.password_reset_done, name='password_reset_done'), - url(r'^reset/(?P[0-9A-Za-z_\-]+)/(?P.+)/$', auth_views.password_reset_confirm, name='password_reset_confirm'), - url(r'^reset/done/$', auth_views.password_reset_complete, name='password_reset_complete'), + url(r'^admin/password_reset/$', auth_views.PasswordResetView.as_view(), name='admin_password_reset'), + url(r'^admin/password_reset/done/$', auth_views.PasswordResetDoneView.as_view(), name='password_reset_done'), + url(r'^reset/(?P[0-9A-Za-z_\-]+)/(?P.+)/$', PasswordResetConfirmView.as_view(), name='password_reset_confirm'), + url(r'^reset/done/$', auth_views.PasswordResetCompleteView.as_view(), name='password_reset_complete'), (This assumes you've added the admin at ``admin/`` and requires that you put the URLs starting with ``^admin/`` before the line that includes the admin app diff --git a/docs/ref/settings.txt b/docs/ref/settings.txt index 7bbc3a20aa..225c22a84f 100644 --- a/docs/ref/settings.txt +++ b/docs/ref/settings.txt @@ -2010,7 +2010,7 @@ The secret key is used for: * All :doc:`messages ` if you are using :class:`~django.contrib.messages.storage.cookie.CookieStorage` or :class:`~django.contrib.messages.storage.fallback.FallbackStorage`. -* All :func:`~django.contrib.auth.views.password_reset` tokens. +* All :class:`~django.contrib.auth.views.PasswordResetView` tokens. * Any usage of :doc:`cryptographic signing `, unless a different key is provided. diff --git a/docs/releases/1.11.txt b/docs/releases/1.11.txt index 2056e54921..061f9a4fd1 100644 --- a/docs/releases/1.11.txt +++ b/docs/releases/1.11.txt @@ -70,6 +70,17 @@ Minor features :class:`~django.contrib.auth.views.LogoutView` class-based views supersede the deprecated ``login()`` and ``logout()`` function-based views. +* The :class:`~django.contrib.auth.views.PasswordChangeView`, + :class:`~django.contrib.auth.views.PasswordChangeDoneView`, + :class:`~django.contrib.auth.views.PasswordResetView`, + :class:`~django.contrib.auth.views.PasswordResetDoneView`, + :class:`~django.contrib.auth.views.PasswordResetConfirmView`, and + :class:`~django.contrib.auth.views.PasswordResetCompleteView` class-based + views supersede the deprecated ``password_change()``, + ``password_change_done()``, ``password_reset()``, ``password_reset_done()``, + ``password_reset_confirm()``, and ``password_reset_complete()`` function-based + views. + :mod:`django.contrib.contenttypes` ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -354,3 +365,14 @@ Miscellaneous deprecated in favor of new class-based views :class:`~django.contrib.auth.views.LoginView` and :class:`~django.contrib.auth.views.LogoutView`. + +* ``contrib.auth``’s ``password_change()``, ``password_change_done()``, + ``password_reset()``, ``password_reset_done()``, ``password_reset_confirm()``, + and ``password_reset_complete()`` function-based views are deprecated in favor + of new class-based views + :class:`~django.contrib.auth.views.PasswordChangeView`, + :class:`~django.contrib.auth.views.PasswordChangeDoneView`, + :class:`~django.contrib.auth.views.PasswordResetView`, + :class:`~django.contrib.auth.views.PasswordResetDoneView`, + :class:`~django.contrib.auth.views.PasswordResetConfirmView`, and + :class:`~django.contrib.auth.views.PasswordResetCompleteView`. diff --git a/docs/topics/auth/default.txt b/docs/topics/auth/default.txt index de2d1fecaa..5fd8ab45bd 100644 --- a/docs/topics/auth/default.txt +++ b/docs/topics/auth/default.txt @@ -839,7 +839,7 @@ request matches the one that's computed server-side. This allows a user to log out all of their sessions by changing their password. The default password change views included with Django, -:func:`django.contrib.auth.views.password_change` and the +:class:`django.contrib.auth.views.PasswordChangeView` and the ``user_change_password`` view in the :mod:`django.contrib.auth` admin, update the session with the new password hash so that a user changing their own password won't log themselves out. If you have a custom password change view @@ -917,7 +917,7 @@ your URLconf:: from django.contrib.auth import views as auth_views urlpatterns = [ - url('^change-password/$', auth_views.password_change), + url('^change-password/$', auth_views.PasswordChangeView.as_view()), ] The views have optional arguments you can use to alter the behavior of the @@ -928,24 +928,12 @@ arguments in the URLconf, these will be passed on to the view. For example:: urlpatterns = [ url( '^change-password/$', - auth_views.password_change, - {'template_name': 'change-password.html'} + auth_views.PasswordChangeView.as_view(template_name='change-password.html'), ), ] -All views return a :class:`~django.template.response.TemplateResponse` -instance, which allows you to easily customize the response data before -rendering. A way to do this is to wrap a view in your own view:: - - from django.contrib.auth import views - - def change_password(request): - template_response = views.password_change(request) - # Do something with `template_response` - return template_response - -For more details, see the :doc:`TemplateResponse documentation -`. +All views are :doc:`class-based `, which allows +you to easily customize them by subclassing. .. _all-authentication-views: @@ -963,7 +951,7 @@ implementation details see :ref:`using-the-views`. :class:`LoginView`. The optional arguments of this view are similar to the class-based - ``LoginView`` optional attributes. In addition, it has: + ``LoginView`` attributes. In addition, it has: * ``current_app``: A hint indicating which application contains the current view. See the :ref:`namespaced URL resolution strategy @@ -1111,7 +1099,7 @@ implementation details see :ref:`using-the-views`. class-based :class:`LogoutView`. The optional arguments of this view are similar to the class-based - ``LogoutView`` optional attributes. In addition, it has: + ``LogoutView`` attributes. In addition, it has: * ``current_app``: A hint indicating which application contains the current view. See the :ref:`namespaced URL resolution strategy @@ -1193,65 +1181,116 @@ implementation details see :ref:`using-the-views`. .. function:: password_change(request, template_name='registration/password_change_form.html', post_change_redirect=None, password_change_form=PasswordChangeForm, current_app=None, extra_context=None) - Allows a user to change their password. + .. deprecated:: 1.11 + + The ``password_change`` function-based view should be replaced by the + class-based :class:`PasswordChangeView`. + + The optional arguments of this view are similar to the class-based + ``PasswordChangeView`` attributes, except the ``post_change_redirect`` and + ``password_change_form`` arguments which map to the ``success_url`` and + ``form_class`` attributes of the class-based view. In addition, it has: + + * ``current_app``: A hint indicating which application contains the current + view. See the :ref:`namespaced URL resolution strategy + ` for more information. + + .. deprecated:: 1.9 + + The ``current_app`` parameter is deprecated and will be removed in + Django 2.0. Callers should set ``request.current_app`` instead. + +.. class:: PasswordChangeView + + .. versionadded:: 1.11 **URL name:** ``password_change`` - **Optional arguments:** + Allows a user to change their password. + + **Attributes:** * ``template_name``: The full name of a template to use for displaying the password change form. Defaults to :file:`registration/password_change_form.html` if not supplied. - * ``post_change_redirect``: The URL to redirect to after a successful - password change. + * ``success_url``: The URL to redirect to after a successful password + change. - * ``password_change_form``: A custom "change password" form which must - accept a ``user`` keyword argument. The form is responsible for - actually changing the user's password. Defaults to + * ``form_class``: A custom "change password" form which must accept a + ``user`` keyword argument. The form is responsible for actually changing + the user's password. Defaults to :class:`~django.contrib.auth.forms.PasswordChangeForm`. + * ``extra_context``: A dictionary of context data that will be added to the + default context data passed to the template. + + **Template context:** + + * ``form``: The password change form (see ``form_class`` above). + +.. function:: password_change_done(request, template_name='registration/password_change_done.html', current_app=None, extra_context=None) + + .. deprecated:: 1.11 + + The ``password_change_done`` function-based view should be replaced by + the class-based :class:`PasswordChangeDoneView`. + + The optional arguments of this view are similar to the class-based + ``PasswordChangeDoneView`` attributes. In addition, it has: + * ``current_app``: A hint indicating which application contains the current view. See the :ref:`namespaced URL resolution strategy ` for more information. - * ``extra_context``: A dictionary of context data that will be added to the - default context data passed to the template. - .. deprecated:: 1.9 The ``current_app`` parameter is deprecated and will be removed in Django 2.0. Callers should set ``request.current_app`` instead. - **Template context:** +.. class:: PasswordChangeDoneView - * ``form``: The password change form (see ``password_change_form`` above). - -.. function:: password_change_done(request, template_name='registration/password_change_done.html', current_app=None, extra_context=None) - - The page shown after a user has changed their password. + .. versionadded:: 1.11 **URL name:** ``password_change_done`` - **Optional arguments:** + The page shown after a user has changed their password. + + **Attributes:** * ``template_name``: The full name of a template to use. Defaults to :file:`registration/password_change_done.html` if not supplied. + * ``extra_context``: A dictionary of context data that will be added to the + default context data passed to the template. + +.. function:: password_reset(request, template_name='registration/password_reset_form.html', email_template_name='registration/password_reset_email.html', subject_template_name='registration/password_reset_subject.txt', password_reset_form=PasswordResetForm, token_generator=default_token_generator, post_reset_redirect=None, from_email=None, current_app=None, extra_context=None, html_email_template_name=None, extra_email_context=None) + + .. deprecated:: 1.11 + + The ``password_reset`` function-based view should be replaced by the + class-based :class:`PasswordResetView`. + + The optional arguments of this view are similar to the class-based + ``PasswordResetView`` attributes, except the ``post_reset_redirect`` and + ``password_reset_form`` arguments which map to the ``success_url`` and + ``form_class`` attributes of the class-based view. In addition, it has: + * ``current_app``: A hint indicating which application contains the current view. See the :ref:`namespaced URL resolution strategy ` for more information. - * ``extra_context``: A dictionary of context data that will be added to the - default context data passed to the template. - .. deprecated:: 1.9 The ``current_app`` parameter is deprecated and will be removed in Django 2.0. Callers should set ``request.current_app`` instead. -.. function:: password_reset(request, template_name='registration/password_reset_form.html', email_template_name='registration/password_reset_email.html', subject_template_name='registration/password_reset_subject.txt', password_reset_form=PasswordResetForm, token_generator=default_token_generator, post_reset_redirect=None, from_email=None, current_app=None, extra_context=None, html_email_template_name=None, extra_email_context=None) +.. class:: PasswordResetView + + .. versionadded:: 1.11 + + **URL name:** ``password_reset`` Allows a user to reset their password by generating a one-time use link that can be used to reset the password, and sending that link to the @@ -1262,7 +1301,7 @@ implementation details see :ref:`using-the-views`. This prevents information leaking to potential attackers. If you want to provide an error message in this case, you can subclass :class:`~django.contrib.auth.forms.PasswordResetForm` and use the - ``password_reset_form`` argument. + ``form_class`` attribute. Users flagged with an unusable password (see :meth:`~django.contrib.auth.models.User.set_unusable_password()` aren't @@ -1271,14 +1310,16 @@ implementation details see :ref:`using-the-views`. error message since this would expose their account's existence but no mail will be sent either. - **URL name:** ``password_reset`` - - **Optional arguments:** + **Attributes:** * ``template_name``: The full name of a template to use for displaying the password reset form. Defaults to :file:`registration/password_reset_form.html` if not supplied. + * ``form_class``: Form that will be used to get the email of + the user to reset the password for. Defaults to + :class:`~django.contrib.auth.forms.PasswordResetForm`. + * ``email_template_name``: The full name of a template to use for generating the email with the reset password link. Defaults to :file:`registration/password_reset_email.html` if not supplied. @@ -1287,24 +1328,16 @@ implementation details see :ref:`using-the-views`. the subject of the email with the reset password link. Defaults to :file:`registration/password_reset_subject.txt` if not supplied. - * ``password_reset_form``: Form that will be used to get the email of - the user to reset the password for. Defaults to - :class:`~django.contrib.auth.forms.PasswordResetForm`. - * ``token_generator``: Instance of the class to check the one time link. This will default to ``default_token_generator``, it's an instance of ``django.contrib.auth.tokens.PasswordResetTokenGenerator``. - * ``post_reset_redirect``: The URL to redirect to after a successful - password reset request. + * ``success_url``: The URL to redirect to after a successful password reset + request. * ``from_email``: A valid email address. By default Django uses the :setting:`DEFAULT_FROM_EMAIL`. - * ``current_app``: A hint indicating which application contains the current - view. See the :ref:`namespaced URL resolution strategy - ` for more information. - * ``extra_context``: A dictionary of context data that will be added to the default context data passed to the template. @@ -1315,15 +1348,10 @@ implementation details see :ref:`using-the-views`. * ``extra_email_context``: A dictionary of context data that will available in the email template. - .. deprecated:: 1.9 - - The ``current_app`` parameter is deprecated and will be removed in - Django 2.0. Callers should set ``request.current_app`` instead. - **Template context:** - * ``form``: The form (see ``password_reset_form`` above) for resetting - the user's password. + * ``form``: The form (see ``form_class`` above) for resetting the user's + password. **Email template context:** @@ -1360,65 +1388,98 @@ implementation details see :ref:`using-the-views`. .. function:: password_reset_done(request, template_name='registration/password_reset_done.html', current_app=None, extra_context=None) - The page shown after a user has been emailed a link to reset their - password. This view is called by default if the :func:`password_reset` view - doesn't have an explicit ``post_reset_redirect`` URL set. + .. deprecated:: 1.11 + + The ``password_reset_done`` function-based view should be replaced by + the class-based :class:`PasswordResetDoneView`. + + The optional arguments of this view are similar to the class-based + ``PasswordResetDoneView`` attributes. In addition, it has: + + * ``current_app``: A hint indicating which application contains the current + view. See the :ref:`namespaced URL resolution strategy + ` for more information. + + .. deprecated:: 1.9 + + The ``current_app`` parameter is deprecated and will be removed in + Django 2.0. Callers should set ``request.current_app`` instead. + +.. class:: PasswordResetDoneView + + .. versionadded:: 1.11 **URL name:** ``password_reset_done`` + The page shown after a user has been emailed a link to reset their + password. This view is called by default if the :class:`PasswordResetView` + doesn't have an explicit ``success_url`` URL set. + .. note:: If the email address provided does not exist in the system, the user is inactive, or has an unusable password, the user will still be redirected to this view but no email will be sent. - **Optional arguments:** + **Attributes:** * ``template_name``: The full name of a template to use. Defaults to :file:`registration/password_reset_done.html` if not supplied. + * ``extra_context``: A dictionary of context data that will be added to the + default context data passed to the template. + +.. function:: password_reset_confirm(request, uidb64=None, token=None, template_name='registration/password_reset_confirm.html', token_generator=default_token_generator, set_password_form=SetPasswordForm, post_reset_redirect=None, current_app=None, extra_context=None) + + .. deprecated:: 1.11 + + The ``password_reset_confirm`` function-based view should be replaced by + the class-based :class:`PasswordResetConfirmView`. + + The optional arguments of this view are similar to the class-based + ``PasswordResetConfirmView`` attributes, except the ``post_reset_redirect`` + and ``set_password_form`` arguments which map to the ``success_url`` and + ``form_class`` attributes of the class-based view. In addition, it has: + * ``current_app``: A hint indicating which application contains the current view. See the :ref:`namespaced URL resolution strategy ` for more information. - * ``extra_context``: A dictionary of context data that will be added to the - default context data passed to the template. - .. deprecated:: 1.9 The ``current_app`` parameter is deprecated and will be removed in Django 2.0. Callers should set ``request.current_app`` instead. -.. function:: password_reset_confirm(request, uidb64=None, token=None, template_name='registration/password_reset_confirm.html', token_generator=default_token_generator, set_password_form=SetPasswordForm, post_reset_redirect=None, current_app=None, extra_context=None) +.. class:: PasswordResetConfirmView - Presents a form for entering a new password. + .. versionadded:: 1.11 **URL name:** ``password_reset_confirm`` - **Optional arguments:** + Presents a form for entering a new password. - * ``uidb64``: The user's id encoded in base 64. Defaults to ``None``. + **Keyword arguments from the URL:** - * ``token``: Token to check that the password is valid. Defaults to - ``None``. + * ``uidb64``: The user's id encoded in base 64. + + * ``token``: Token to check that the password is valid. + + **Attributes:** * ``template_name``: The full name of a template to display the confirm - password view. Default value is :file:`registration/password_reset_confirm.html`. + password view. Default value is + :file:`registration/password_reset_confirm.html`. * ``token_generator``: Instance of the class to check the password. This will default to ``default_token_generator``, it's an instance of ``django.contrib.auth.tokens.PasswordResetTokenGenerator``. - * ``set_password_form``: Form that will be used to set the password. - Defaults to :class:`~django.contrib.auth.forms.SetPasswordForm` + * ``form_class``: Form that will be used to set the password. Defaults to + :class:`~django.contrib.auth.forms.SetPasswordForm`. - * ``post_reset_redirect``: URL to redirect after the password reset - done. Defaults to ``None``. - - * ``current_app``: A hint indicating which application contains the current - view. See the :ref:`namespaced URL resolution strategy - ` for more information. + * ``success_url``: URL to redirect after the password reset done. Defaults + to ``'password_reset_complete'``. * ``extra_context``: A dictionary of context data that will be added to the default context data passed to the template. @@ -1431,35 +1492,42 @@ implementation details see :ref:`using-the-views`. * ``validlink``: Boolean, True if the link (combination of ``uidb64`` and ``token``) is valid or unused yet. - .. deprecated:: 1.9 - - The ``current_app`` parameter is deprecated and will be removed in - Django 2.0. Callers should set ``request.current_app`` instead. - .. function:: password_reset_complete(request, template_name='registration/password_reset_complete.html', current_app=None, extra_context=None) - Presents a view which informs the user that the password has been - successfully changed. + .. deprecated:: 1.11 - **URL name:** ``password_reset_complete`` + The ``password_reset_complete`` function-based view should be replaced + by the class-based :class:`PasswordResetCompleteView`. - **Optional arguments:** - - * ``template_name``: The full name of a template to display the view. - Defaults to :file:`registration/password_reset_complete.html`. + The optional arguments of this view are similar to the class-based + ``PasswordResetCompleteView`` attributes. In addition, it has: * ``current_app``: A hint indicating which application contains the current view. See the :ref:`namespaced URL resolution strategy ` for more information. - * ``extra_context``: A dictionary of context data that will be added to the - default context data passed to the template. - .. deprecated:: 1.9 The ``current_app`` parameter is deprecated and will be removed in Django 2.0. Callers should set ``request.current_app`` instead. +.. class:: PasswordResetCompleteView + + .. versionadded:: 1.11 + + **URL name:** ``password_reset_complete`` + + Presents a view which informs the user that the password has been + successfully changed. + + **Attributes:** + + * ``template_name``: The full name of a template to display the view. + Defaults to :file:`registration/password_reset_complete.html`. + + * ``extra_context``: A dictionary of context data that will be added to the + default context data passed to the template. + Helper functions ---------------- @@ -1574,8 +1642,9 @@ provides several built-in forms located in :mod:`django.contrib.auth.forms`: defaults to ``None``, in which case a plain text email is sent. By default, ``save()`` populates the ``context`` with the - same variables that :func:`~django.contrib.auth.views.password_reset` - passes to its email context. + same variables that + :class:`~django.contrib.auth.views.PasswordResetView` passes to its + email context. .. class:: SetPasswordForm diff --git a/tests/auth_tests/test_deprecated_views.py b/tests/auth_tests/test_deprecated_views.py new file mode 100644 index 0000000000..6034fd11de --- /dev/null +++ b/tests/auth_tests/test_deprecated_views.py @@ -0,0 +1,446 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +import datetime +import itertools +import re + +from django.conf import settings +from django.contrib.auth import SESSION_KEY +from django.contrib.auth.forms import ( + AuthenticationForm, PasswordChangeForm, SetPasswordForm, +) +from django.contrib.auth.models import User +from django.core import mail +from django.http import QueryDict +from django.test import TestCase, override_settings +from django.test.utils import ignore_warnings, patch_logger +from django.utils.deprecation import RemovedInDjango21Warning +from django.utils.encoding import force_text +from django.utils.six.moves.urllib.parse import ParseResult, urlparse + +from .models import CustomUser, UUIDUser +from .settings import AUTH_TEMPLATES + + +@override_settings( + LANGUAGES=[('en', 'English')], + LANGUAGE_CODE='en', + TEMPLATES=AUTH_TEMPLATES, + ROOT_URLCONF='auth_tests.urls_deprecated', +) +class AuthViewsTestCase(TestCase): + """ + Helper base class for all the follow test cases. + """ + + @classmethod + def setUpTestData(cls): + cls.u1 = User.objects.create_user(username='testclient', password='password', email='testclient@example.com') + cls.u3 = User.objects.create_user(username='staff', password='password', email='staffmember@example.com') + + def login(self, username='testclient', password='password'): + response = self.client.post('/login/', { + 'username': username, + 'password': password, + }) + self.assertIn(SESSION_KEY, self.client.session) + return response + + def logout(self): + response = self.client.get('/admin/logout/') + self.assertEqual(response.status_code, 200) + self.assertNotIn(SESSION_KEY, self.client.session) + + def assertFormError(self, response, error): + """Assert that error is found in response.context['form'] errors""" + form_errors = list(itertools.chain(*response.context['form'].errors.values())) + self.assertIn(force_text(error), form_errors) + + def assertURLEqual(self, url, expected, parse_qs=False): + """ + Given two URLs, make sure all their components (the ones given by + urlparse) are equal, only comparing components that are present in both + URLs. + If `parse_qs` is True, then the querystrings are parsed with QueryDict. + This is useful if you don't want the order of parameters to matter. + Otherwise, the query strings are compared as-is. + """ + fields = ParseResult._fields + + for attr, x, y in zip(fields, urlparse(url), urlparse(expected)): + if parse_qs and attr == 'query': + x, y = QueryDict(x), QueryDict(y) + if x and y and x != y: + self.fail("%r != %r (%s doesn't match)" % (url, expected, attr)) + + +@ignore_warnings(category=RemovedInDjango21Warning) +class PasswordResetTest(AuthViewsTestCase): + + def test_email_not_found(self): + """If the provided email is not registered, don't raise any error but + also don't send any email.""" + response = self.client.get('/password_reset/') + self.assertEqual(response.status_code, 200) + response = self.client.post('/password_reset/', {'email': 'not_a_real_email@email.com'}) + self.assertEqual(response.status_code, 302) + self.assertEqual(len(mail.outbox), 0) + + def test_email_found(self): + "Email is sent if a valid email address is provided for password reset" + response = self.client.post('/password_reset/', {'email': 'staffmember@example.com'}) + self.assertEqual(response.status_code, 302) + self.assertEqual(len(mail.outbox), 1) + self.assertIn("http://", mail.outbox[0].body) + self.assertEqual(settings.DEFAULT_FROM_EMAIL, mail.outbox[0].from_email) + # optional multipart text/html email has been added. Make sure original, + # default functionality is 100% the same + self.assertFalse(mail.outbox[0].message().is_multipart()) + + def test_extra_email_context(self): + """ + extra_email_context should be available in the email template context. + """ + response = self.client.post( + '/password_reset_extra_email_context/', + {'email': 'staffmember@example.com'}, + ) + self.assertEqual(response.status_code, 302) + self.assertEqual(len(mail.outbox), 1) + self.assertIn('Email email context: "Hello!"', mail.outbox[0].body) + + def test_html_mail_template(self): + """ + A multipart email with text/plain and text/html is sent + if the html_email_template parameter is passed to the view + """ + response = self.client.post('/password_reset/html_email_template/', {'email': 'staffmember@example.com'}) + self.assertEqual(response.status_code, 302) + self.assertEqual(len(mail.outbox), 1) + message = mail.outbox[0].message() + self.assertEqual(len(message.get_payload()), 2) + self.assertTrue(message.is_multipart()) + self.assertEqual(message.get_payload(0).get_content_type(), 'text/plain') + self.assertEqual(message.get_payload(1).get_content_type(), 'text/html') + self.assertNotIn('', message.get_payload(0).get_payload()) + self.assertIn('', message.get_payload(1).get_payload()) + + def test_email_found_custom_from(self): + "Email is sent if a valid email address is provided for password reset when a custom from_email is provided." + response = self.client.post('/password_reset_from_email/', {'email': 'staffmember@example.com'}) + self.assertEqual(response.status_code, 302) + self.assertEqual(len(mail.outbox), 1) + self.assertEqual("staffmember@example.com", mail.outbox[0].from_email) + + # Skip any 500 handler action (like sending more mail...) + @override_settings(DEBUG_PROPAGATE_EXCEPTIONS=True) + def test_poisoned_http_host(self): + "Poisoned HTTP_HOST headers can't be used for reset emails" + # This attack is based on the way browsers handle URLs. The colon + # should be used to separate the port, but if the URL contains an @, + # the colon is interpreted as part of a username for login purposes, + # making 'evil.com' the request domain. Since HTTP_HOST is used to + # produce a meaningful reset URL, we need to be certain that the + # HTTP_HOST header isn't poisoned. This is done as a check when get_host() + # is invoked, but we check here as a practical consequence. + with patch_logger('django.security.DisallowedHost', 'error') as logger_calls: + response = self.client.post( + '/password_reset/', + {'email': 'staffmember@example.com'}, + HTTP_HOST='www.example:dr.frankenstein@evil.tld' + ) + self.assertEqual(response.status_code, 400) + self.assertEqual(len(mail.outbox), 0) + self.assertEqual(len(logger_calls), 1) + + # Skip any 500 handler action (like sending more mail...) + @override_settings(DEBUG_PROPAGATE_EXCEPTIONS=True) + def test_poisoned_http_host_admin_site(self): + "Poisoned HTTP_HOST headers can't be used for reset emails on admin views" + with patch_logger('django.security.DisallowedHost', 'error') as logger_calls: + response = self.client.post( + '/admin_password_reset/', + {'email': 'staffmember@example.com'}, + HTTP_HOST='www.example:dr.frankenstein@evil.tld' + ) + self.assertEqual(response.status_code, 400) + self.assertEqual(len(mail.outbox), 0) + self.assertEqual(len(logger_calls), 1) + + def _test_confirm_start(self): + # Start by creating the email + self.client.post('/password_reset/', {'email': 'staffmember@example.com'}) + self.assertEqual(len(mail.outbox), 1) + return self._read_signup_email(mail.outbox[0]) + + def _read_signup_email(self, email): + urlmatch = re.search(r"https?://[^/]*(/.*reset/\S*)", email.body) + self.assertIsNotNone(urlmatch, "No URL found in sent email") + return urlmatch.group(), urlmatch.groups()[0] + + def test_confirm_valid(self): + url, path = self._test_confirm_start() + response = self.client.get(path) + # redirect to a 'complete' page: + self.assertContains(response, "Please enter your new password") + + def test_confirm_invalid(self): + url, path = self._test_confirm_start() + # Let's munge the token in the path, but keep the same length, + # in case the URLconf will reject a different length. + path = path[:-5] + ("0" * 4) + path[-1] + + response = self.client.get(path) + self.assertContains(response, "The password reset link was invalid") + + def test_confirm_invalid_user(self): + # Ensure that we get a 200 response for a non-existent user, not a 404 + response = self.client.get('/reset/123456/1-1/') + self.assertContains(response, "The password reset link was invalid") + + def test_confirm_overflow_user(self): + # Ensure that we get a 200 response for a base36 user id that overflows int + response = self.client.get('/reset/zzzzzzzzzzzzz/1-1/') + self.assertContains(response, "The password reset link was invalid") + + def test_confirm_invalid_post(self): + # Same as test_confirm_invalid, but trying + # to do a POST instead. + url, path = self._test_confirm_start() + path = path[:-5] + ("0" * 4) + path[-1] + + self.client.post(path, { + 'new_password1': 'anewpassword', + 'new_password2': ' anewpassword', + }) + # Check the password has not been changed + u = User.objects.get(email='staffmember@example.com') + self.assertTrue(not u.check_password("anewpassword")) + + def test_confirm_complete(self): + url, path = self._test_confirm_start() + response = self.client.post(path, {'new_password1': 'anewpassword', 'new_password2': 'anewpassword'}) + # Check the password has been changed + u = User.objects.get(email='staffmember@example.com') + self.assertTrue(u.check_password("anewpassword")) + + # Check we can't use the link again + response = self.client.get(path) + self.assertContains(response, "The password reset link was invalid") + + def test_confirm_different_passwords(self): + url, path = self._test_confirm_start() + response = self.client.post(path, {'new_password1': 'anewpassword', 'new_password2': 'x'}) + self.assertFormError(response, SetPasswordForm.error_messages['password_mismatch']) + + def test_reset_redirect_default(self): + response = self.client.post('/password_reset/', {'email': 'staffmember@example.com'}) + self.assertEqual(response.status_code, 302) + self.assertURLEqual(response.url, '/password_reset/done/') + + def test_reset_custom_redirect(self): + response = self.client.post('/password_reset/custom_redirect/', {'email': 'staffmember@example.com'}) + self.assertEqual(response.status_code, 302) + self.assertURLEqual(response.url, '/custom/') + + def test_reset_custom_redirect_named(self): + response = self.client.post('/password_reset/custom_redirect/named/', {'email': 'staffmember@example.com'}) + self.assertEqual(response.status_code, 302) + self.assertURLEqual(response.url, '/password_reset/') + + def test_confirm_redirect_default(self): + url, path = self._test_confirm_start() + response = self.client.post(path, {'new_password1': 'anewpassword', 'new_password2': 'anewpassword'}) + self.assertEqual(response.status_code, 302) + self.assertURLEqual(response.url, '/reset/done/') + + def test_confirm_redirect_custom(self): + url, path = self._test_confirm_start() + path = path.replace('/reset/', '/reset/custom/') + response = self.client.post(path, {'new_password1': 'anewpassword', 'new_password2': 'anewpassword'}) + self.assertEqual(response.status_code, 302) + self.assertURLEqual(response.url, '/custom/') + + def test_confirm_redirect_custom_named(self): + url, path = self._test_confirm_start() + path = path.replace('/reset/', '/reset/custom/named/') + response = self.client.post(path, {'new_password1': 'anewpassword', 'new_password2': 'anewpassword'}) + self.assertEqual(response.status_code, 302) + self.assertURLEqual(response.url, '/password_reset/') + + def test_confirm_display_user_from_form(self): + url, path = self._test_confirm_start() + response = self.client.get(path) + + # #16919 -- The ``password_reset_confirm`` view should pass the user + # object to the ``SetPasswordForm``, even on GET requests. + # For this test, we render ``{{ form.user }}`` in the template + # ``registration/password_reset_confirm.html`` so that we can test this. + username = User.objects.get(email='staffmember@example.com').username + self.assertContains(response, "Hello, %s." % username) + + # However, the view should NOT pass any user object on a form if the + # password reset link was invalid. + response = self.client.get('/reset/zzzzzzzzzzzzz/1-1/') + self.assertContains(response, "Hello, .") + + +@ignore_warnings(category=RemovedInDjango21Warning) +@override_settings(AUTH_USER_MODEL='auth_tests.CustomUser') +class CustomUserPasswordResetTest(AuthViewsTestCase): + user_email = 'staffmember@example.com' + + @classmethod + def setUpTestData(cls): + cls.u1 = CustomUser.custom_objects.create( + email='staffmember@example.com', + date_of_birth=datetime.date(1976, 11, 8), + ) + cls.u1.set_password('password') + cls.u1.save() + + def _test_confirm_start(self): + # Start by creating the email + response = self.client.post('/password_reset/', {'email': self.user_email}) + self.assertEqual(response.status_code, 302) + self.assertEqual(len(mail.outbox), 1) + return self._read_signup_email(mail.outbox[0]) + + def _read_signup_email(self, email): + urlmatch = re.search(r"https?://[^/]*(/.*reset/\S*)", email.body) + self.assertIsNotNone(urlmatch, "No URL found in sent email") + return urlmatch.group(), urlmatch.groups()[0] + + def test_confirm_valid_custom_user(self): + url, path = self._test_confirm_start() + response = self.client.get(path) + # redirect to a 'complete' page: + self.assertContains(response, "Please enter your new password") + # then submit a new password + response = self.client.post(path, { + 'new_password1': 'anewpassword', + 'new_password2': 'anewpassword', + }) + self.assertRedirects(response, '/reset/done/') + + +@ignore_warnings(category=RemovedInDjango21Warning) +@override_settings(AUTH_USER_MODEL='auth_tests.UUIDUser') +class UUIDUserPasswordResetTest(CustomUserPasswordResetTest): + + def _test_confirm_start(self): + # instead of fixture + UUIDUser.objects.create_user( + email=self.user_email, + username='foo', + password='foo', + ) + return super(UUIDUserPasswordResetTest, self)._test_confirm_start() + + +@ignore_warnings(category=RemovedInDjango21Warning) +class ChangePasswordTest(AuthViewsTestCase): + + def fail_login(self, password='password'): + response = self.client.post('/login/', { + 'username': 'testclient', + 'password': password, + }) + self.assertFormError(response, AuthenticationForm.error_messages['invalid_login'] % { + 'username': User._meta.get_field('username').verbose_name + }) + + def logout(self): + self.client.get('/logout/') + + def test_password_change_fails_with_invalid_old_password(self): + self.login() + response = self.client.post('/password_change/', { + 'old_password': 'donuts', + 'new_password1': 'password1', + 'new_password2': 'password1', + }) + self.assertFormError(response, PasswordChangeForm.error_messages['password_incorrect']) + + def test_password_change_fails_with_mismatched_passwords(self): + self.login() + response = self.client.post('/password_change/', { + 'old_password': 'password', + 'new_password1': 'password1', + 'new_password2': 'donuts', + }) + self.assertFormError(response, SetPasswordForm.error_messages['password_mismatch']) + + def test_password_change_succeeds(self): + self.login() + self.client.post('/password_change/', { + 'old_password': 'password', + 'new_password1': 'password1', + 'new_password2': 'password1', + }) + self.fail_login() + self.login(password='password1') + + def test_password_change_done_succeeds(self): + self.login() + response = self.client.post('/password_change/', { + 'old_password': 'password', + 'new_password1': 'password1', + 'new_password2': 'password1', + }) + self.assertEqual(response.status_code, 302) + self.assertURLEqual(response.url, '/password_change/done/') + + @override_settings(LOGIN_URL='/login/') + def test_password_change_done_fails(self): + response = self.client.get('/password_change/done/') + self.assertEqual(response.status_code, 302) + self.assertURLEqual(response.url, '/login/?next=/password_change/done/') + + def test_password_change_redirect_default(self): + self.login() + response = self.client.post('/password_change/', { + 'old_password': 'password', + 'new_password1': 'password1', + 'new_password2': 'password1', + }) + self.assertEqual(response.status_code, 302) + self.assertURLEqual(response.url, '/password_change/done/') + + def test_password_change_redirect_custom(self): + self.login() + response = self.client.post('/password_change/custom/', { + 'old_password': 'password', + 'new_password1': 'password1', + 'new_password2': 'password1', + }) + self.assertEqual(response.status_code, 302) + self.assertURLEqual(response.url, '/custom/') + + def test_password_change_redirect_custom_named(self): + self.login() + response = self.client.post('/password_change/custom/named/', { + 'old_password': 'password', + 'new_password1': 'password1', + 'new_password2': 'password1', + }) + self.assertEqual(response.status_code, 302) + self.assertURLEqual(response.url, '/password_reset/') + + +@ignore_warnings(category=RemovedInDjango21Warning) +class SessionAuthenticationTests(AuthViewsTestCase): + def test_user_password_change_updates_session(self): + """ + #21649 - Ensure contrib.auth.views.password_change updates the user's + session auth hash after a password change so the session isn't logged out. + """ + self.login() + response = self.client.post('/password_change/', { + 'old_password': 'password', + 'new_password1': 'password1', + 'new_password2': 'password1', + }) + # if the hash isn't updated, retrieving the redirection page will fail. + self.assertRedirects(response, '/password_change/done/') diff --git a/tests/auth_tests/test_templates.py b/tests/auth_tests/test_templates.py index 781ffcb735..5bcc5e3672 100644 --- a/tests/auth_tests/test_templates.py +++ b/tests/auth_tests/test_templates.py @@ -2,8 +2,8 @@ from django.contrib.auth import authenticate from django.contrib.auth.models import User from django.contrib.auth.tokens import PasswordResetTokenGenerator from django.contrib.auth.views import ( - password_change, password_change_done, password_reset, - password_reset_complete, password_reset_confirm, password_reset_done, + PasswordChangeDoneView, PasswordChangeView, PasswordResetCompleteView, + PasswordResetConfirmView, PasswordResetDoneView, PasswordResetView, ) from django.test import RequestFactory, TestCase, override_settings from django.utils.encoding import force_bytes, force_text @@ -20,35 +20,35 @@ class AuthTemplateTests(TestCase): request = rf.get('/somepath/') request.user = user - response = password_reset(request, post_reset_redirect='dummy/') + response = PasswordResetView.as_view(success_url='dummy/')(request) self.assertContains(response, 'Password reset') self.assertContains(response, '

Password reset

') - response = password_reset_done(request) + response = PasswordResetDoneView.as_view()(request) self.assertContains(response, 'Password reset sent') self.assertContains(response, '

Password reset sent

') - # password_reset_confirm invalid token - response = password_reset_confirm(request, uidb64='Bad', token='Bad', post_reset_redirect='dummy/') + # PasswordResetConfirmView invalid token + response = PasswordResetConfirmView.as_view(success_url='dummy/')(request, uidb64='Bad', token='Bad') self.assertContains(response, 'Password reset unsuccessful') self.assertContains(response, '

Password reset unsuccessful

') - # password_reset_confirm valid token + # PasswordResetConfirmView valid token default_token_generator = PasswordResetTokenGenerator() token = default_token_generator.make_token(user) uidb64 = force_text(urlsafe_base64_encode(force_bytes(user.pk))) - response = password_reset_confirm(request, uidb64, token, post_reset_redirect='dummy/') + response = PasswordResetConfirmView.as_view(success_url='dummy/')(request, uidb64=uidb64, token=token) self.assertContains(response, 'Enter new password') self.assertContains(response, '

Enter new password

') - response = password_reset_complete(request) + response = PasswordResetCompleteView.as_view()(request) self.assertContains(response, 'Password reset complete') self.assertContains(response, '

Password reset complete

') - response = password_change(request, post_change_redirect='dummy/') + response = PasswordChangeView.as_view(success_url='dummy/')(request) self.assertContains(response, 'Password change') self.assertContains(response, '

Password change

') - response = password_change_done(request) + response = PasswordChangeDoneView.as_view()(request) self.assertContains(response, 'Password change successful') self.assertContains(response, '

Password change successful

') diff --git a/tests/auth_tests/urls.py b/tests/auth_tests/urls.py index c6dd632e0a..6a7574b8cd 100644 --- a/tests/auth_tests/urls.py +++ b/tests/auth_tests/urls.py @@ -8,6 +8,7 @@ from django.contrib.messages.api import info from django.http import HttpRequest, HttpResponse from django.shortcuts import render from django.template import RequestContext, Template +from django.urls import reverse_lazy from django.views.decorators.cache import never_cache @@ -67,23 +68,29 @@ urlpatterns = auth_urlpatterns + [ url(r'^logout/next_page/$', views.LogoutView.as_view(next_page='/somewhere/')), url(r'^logout/next_page/named/$', views.LogoutView.as_view(next_page='password_reset')), url(r'^remote_user/$', remote_user_auth_view), - url(r'^password_reset_from_email/$', views.password_reset, dict(from_email='staffmember@example.com')), - url(r'^password_reset_extra_email_context/$', views.password_reset, - dict(extra_email_context=dict(greeting='Hello!'))), - url(r'^password_reset/custom_redirect/$', views.password_reset, dict(post_reset_redirect='/custom/')), - url(r'^password_reset/custom_redirect/named/$', views.password_reset, dict(post_reset_redirect='password_reset')), - url(r'^password_reset/html_email_template/$', views.password_reset, - dict(html_email_template_name='registration/html_password_reset_email.html')), + + url(r'^password_reset_from_email/$', + views.PasswordResetView.as_view(from_email='staffmember@example.com')), + url(r'^password_reset_extra_email_context/$', + views.PasswordResetView.as_view(extra_email_context=dict(greeting='Hello!'))), + url(r'^password_reset/custom_redirect/$', + views.PasswordResetView.as_view(success_url='/custom/')), + url(r'^password_reset/custom_redirect/named/$', + views.PasswordResetView.as_view(success_url=reverse_lazy('password_reset'))), + url(r'^password_reset/html_email_template/$', + views.PasswordResetView.as_view( + html_email_template_name='registration/html_password_reset_email.html' + )), url(r'^reset/custom/(?P[0-9A-Za-z_\-]+)/(?P[0-9A-Za-z]{1,13}-[0-9A-Za-z]{1,20})/$', - views.password_reset_confirm, - dict(post_reset_redirect='/custom/')), + views.PasswordResetConfirmView.as_view(success_url='/custom/')), url(r'^reset/custom/named/(?P[0-9A-Za-z_\-]+)/(?P[0-9A-Za-z]{1,13}-[0-9A-Za-z]{1,20})/$', - views.password_reset_confirm, - dict(post_reset_redirect='password_reset')), - url(r'^password_change/custom/$', views.password_change, dict(post_change_redirect='/custom/')), - url(r'^password_change/custom/named/$', views.password_change, dict(post_change_redirect='password_reset')), - url(r'^login_required/$', login_required(views.password_reset)), - url(r'^login_required_login_url/$', login_required(views.password_reset, login_url='/somewhere/')), + views.PasswordResetConfirmView.as_view(success_url=reverse_lazy('password_reset'))), + url(r'^password_change/custom/$', + views.PasswordChangeView.as_view(success_url='/custom/')), + url(r'^password_change/custom/named/$', + views.PasswordChangeView.as_view(success_url=reverse_lazy('password_reset'))), + url(r'^login_required/$', login_required(views.PasswordResetView.as_view())), + url(r'^login_required_login_url/$', login_required(views.PasswordResetView.as_view(), login_url='/somewhere/')), url(r'^auth_processor_no_attr_access/$', auth_processor_no_attr_access), url(r'^auth_processor_attr_access/$', auth_processor_attr_access), diff --git a/tests/auth_tests/urls_deprecated.py b/tests/auth_tests/urls_deprecated.py new file mode 100644 index 0000000000..2881e7d9d5 --- /dev/null +++ b/tests/auth_tests/urls_deprecated.py @@ -0,0 +1,39 @@ +from django.conf.urls import url +from django.contrib import admin +from django.contrib.auth import views +from django.contrib.auth.decorators import login_required + + +# special urls for deprecated function-based views +urlpatterns = [ + url(r'^login/$', views.login, name='login'), + url(r'^logout/$', views.logout, name='logout'), + url(r'^password_change/$', views.password_change, name='password_change'), + url(r'^password_change/done/$', views.password_change_done, name='password_change_done'), + url(r'^password_reset/$', views.password_reset, name='password_reset'), + url(r'^password_reset/done/$', views.password_reset_done, name='password_reset_done'), + url(r'^reset/(?P[0-9A-Za-z_\-]+)/(?P[0-9A-Za-z]{1,13}-[0-9A-Za-z]{1,20})/$', + views.password_reset_confirm, name='password_reset_confirm'), + url(r'^reset/done/$', views.password_reset_complete, name='password_reset_complete'), + + url(r'^password_reset_from_email/$', views.password_reset, dict(from_email='staffmember@example.com')), + url(r'^password_reset_extra_email_context/$', views.password_reset, + dict(extra_email_context=dict(greeting='Hello!'))), + url(r'^password_reset/custom_redirect/$', views.password_reset, dict(post_reset_redirect='/custom/')), + url(r'^password_reset/custom_redirect/named/$', views.password_reset, dict(post_reset_redirect='password_reset')), + url(r'^password_reset/html_email_template/$', views.password_reset, + dict(html_email_template_name='registration/html_password_reset_email.html')), + url(r'^reset/custom/(?P[0-9A-Za-z_\-]+)/(?P[0-9A-Za-z]{1,13}-[0-9A-Za-z]{1,20})/$', + views.password_reset_confirm, + dict(post_reset_redirect='/custom/')), + url(r'^reset/custom/named/(?P[0-9A-Za-z_\-]+)/(?P[0-9A-Za-z]{1,13}-[0-9A-Za-z]{1,20})/$', + views.password_reset_confirm, + dict(post_reset_redirect='password_reset')), + url(r'^password_change/custom/$', views.password_change, dict(post_change_redirect='/custom/')), + url(r'^password_change/custom/named/$', views.password_change, dict(post_change_redirect='password_reset')), + url(r'^login_required/$', login_required(views.password_reset)), + url(r'^login_required_login_url/$', login_required(views.password_reset, login_url='/somewhere/')), + + # This line is only required to render the password reset with is_admin=True + url(r'^admin/', admin.site.urls), +]