Fixed #17209 -- Added password reset/change class-based views

Thanks Tim Graham for the review.
This commit is contained in:
Claude Paroz 2013-04-09 23:31:58 +02:00
parent 20d39325ca
commit 255fb99284
12 changed files with 896 additions and 146 deletions

View File

@ -289,30 +289,30 @@ class AdminSite(object):
Handles the "change password" task -- both form display and validation. Handles the "change password" task -- both form display and validation.
""" """
from django.contrib.admin.forms import AdminPasswordChangeForm 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) url = reverse('admin:password_change_done', current_app=self.name)
defaults = { defaults = {
'password_change_form': AdminPasswordChangeForm, 'form_class': AdminPasswordChangeForm,
'post_change_redirect': url, 'success_url': url,
'extra_context': dict(self.each_context(request), **(extra_context or {})), 'extra_context': dict(self.each_context(request), **(extra_context or {})),
} }
if self.password_change_template is not None: if self.password_change_template is not None:
defaults['template_name'] = self.password_change_template defaults['template_name'] = self.password_change_template
request.current_app = self.name 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): def password_change_done(self, request, extra_context=None):
""" """
Displays the "success" page after a password change. 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 = { defaults = {
'extra_context': dict(self.each_context(request), **(extra_context or {})), 'extra_context': dict(self.each_context(request), **(extra_context or {})),
} }
if self.password_change_done_template is not None: if self.password_change_done_template is not None:
defaults['template_name'] = self.password_change_done_template defaults['template_name'] = self.password_change_done_template
request.current_app = self.name 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): def i18n_javascript(self, request, extra_context=None):
""" """

View File

@ -9,11 +9,13 @@ from django.contrib.auth import views
urlpatterns = [ urlpatterns = [
url(r'^login/$', views.LoginView.as_view(), name='login'), url(r'^login/$', views.LoginView.as_view(), name='login'),
url(r'^logout/$', views.LogoutView.as_view(), name='logout'), 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_change/$', views.PasswordChangeView.as_view(), name='password_change'),
url(r'^password_reset/$', views.password_reset, name='password_reset'), url(r'^password_change/done/$', views.PasswordChangeDoneView.as_view(), name='password_change_done'),
url(r'^password_reset/done/$', views.password_reset_done, name='password_reset_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<uidb64>[0-9A-Za-z_\-]+)/(?P<token>[0-9A-Za-z]{1,13}-[0-9A-Za-z]{1,20})/$', url(r'^reset/(?P<uidb64>[0-9A-Za-z_\-]+)/(?P<token>[0-9A-Za-z]{1,13}-[0-9A-Za-z]{1,20})/$',
views.password_reset_confirm, name='password_reset_confirm'), views.PasswordResetConfirmView.as_view(), name='password_reset_confirm'),
url(r'^reset/done/$', views.password_reset_complete, name='password_reset_complete'), url(r'^reset/done/$', views.PasswordResetCompleteView.as_view(), name='password_reset_complete'),
] ]

View File

@ -16,7 +16,7 @@ from django.contrib.sites.shortcuts import get_current_site
from django.http import HttpResponseRedirect, QueryDict from django.http import HttpResponseRedirect, QueryDict
from django.shortcuts import resolve_url from django.shortcuts import resolve_url
from django.template.response import TemplateResponse 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.decorators import method_decorator
from django.utils.deprecation import ( from django.utils.deprecation import (
RemovedInDjango20Warning, RemovedInDjango21Warning, RemovedInDjango20Warning, RemovedInDjango21Warning,
@ -24,7 +24,7 @@ from django.utils.deprecation import (
from django.utils.encoding import force_text from django.utils.encoding import force_text
from django.utils.http import is_safe_url, urlsafe_base64_decode from django.utils.http import is_safe_url, urlsafe_base64_decode
from django.utils.six.moves.urllib.parse import urlparse, urlunparse 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.cache import never_cache
from django.views.decorators.csrf import csrf_protect from django.views.decorators.csrf import csrf_protect
from django.views.decorators.debug import sensitive_post_parameters from django.views.decorators.debug import sensitive_post_parameters
@ -224,6 +224,9 @@ def password_reset(request,
extra_context=None, extra_context=None,
html_email_template_name=None, html_email_template_name=None,
extra_email_context=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: if post_reset_redirect is None:
post_reset_redirect = reverse('password_reset_done') post_reset_redirect = reverse('password_reset_done')
else: else:
@ -259,6 +262,9 @@ def password_reset(request,
def password_reset_done(request, def password_reset_done(request,
template_name='registration/password_reset_done.html', template_name='registration/password_reset_done.html',
extra_context=None): extra_context=None):
warnings.warn("The password_reset_done() view is superseded by the "
"class-based PasswordResetDoneView().",
RemovedInDjango21Warning, stacklevel=2)
context = { context = {
'title': _('Password reset sent'), '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 View that checks the hash in a password reset link and presents a
form for entering a new password. 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() UserModel = get_user_model()
assert uidb64 is not None and token is not None # checked by URLconf assert uidb64 is not None and token is not None # checked by URLconf
if post_reset_redirect is None: if post_reset_redirect is None:
@ -324,6 +333,9 @@ def password_reset_confirm(request, uidb64=None, token=None,
def password_reset_complete(request, def password_reset_complete(request,
template_name='registration/password_reset_complete.html', template_name='registration/password_reset_complete.html',
extra_context=None): extra_context=None):
warnings.warn("The password_reset_complete() view is superseded by the "
"class-based PasswordResetCompleteView().",
RemovedInDjango21Warning, stacklevel=2)
context = { context = {
'login_url': resolve_url(settings.LOGIN_URL), 'login_url': resolve_url(settings.LOGIN_URL),
'title': _('Password reset complete'), 'title': _('Password reset complete'),
@ -334,6 +346,116 @@ def password_reset_complete(request,
return TemplateResponse(request, template_name, context) 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() @sensitive_post_parameters()
@csrf_protect @csrf_protect
@login_required @login_required
@ -343,6 +465,9 @@ def password_change(request,
post_change_redirect=None, post_change_redirect=None,
password_change_form=PasswordChangeForm, password_change_form=PasswordChangeForm,
extra_context=None): 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: if post_change_redirect is None:
post_change_redirect = reverse('password_change_done') post_change_redirect = reverse('password_change_done')
else: else:
@ -372,6 +497,9 @@ def password_change(request,
def password_change_done(request, def password_change_done(request,
template_name='registration/password_change_done.html', template_name='registration/password_change_done.html',
extra_context=None): extra_context=None):
warnings.warn("The password_change_done() view is superseded by the "
"class-based PasswordChangeDoneView().",
RemovedInDjango21Warning, stacklevel=2)
context = { context = {
'title': _('Password change successful'), 'title': _('Password change successful'),
} }
@ -379,3 +507,37 @@ def password_change_done(request,
context.update(extra_context) context.update(extra_context)
return TemplateResponse(request, template_name, 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)

View File

@ -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<deprecated-features-1.11>` for more See the :ref:`Django 1.11 release notes<deprecated-features-1.11>` for more
details on these changes. 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: .. _deprecation-removed-in-2.0:

View File

@ -2773,10 +2773,10 @@ your URLconf. Specifically, add these four patterns::
from django.contrib.auth import views as auth_views 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/$', auth_views.PasswordResetView.as_view(), name='admin_password_reset'),
url(r'^admin/password_reset/done/$', auth_views.password_reset_done, name='password_reset_done'), url(r'^admin/password_reset/done/$', auth_views.PasswordResetDoneView.as_view(), name='password_reset_done'),
url(r'^reset/(?P<uidb64>[0-9A-Za-z_\-]+)/(?P<token>.+)/$', auth_views.password_reset_confirm, name='password_reset_confirm'), url(r'^reset/(?P<uidb64>[0-9A-Za-z_\-]+)/(?P<token>.+)/$', PasswordResetConfirmView.as_view(), name='password_reset_confirm'),
url(r'^reset/done/$', auth_views.password_reset_complete, name='password_reset_complete'), 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 (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 the URLs starting with ``^admin/`` before the line that includes the admin app

View File

@ -2010,7 +2010,7 @@ The secret key is used for:
* All :doc:`messages </ref/contrib/messages>` if you are using * All :doc:`messages </ref/contrib/messages>` if you are using
:class:`~django.contrib.messages.storage.cookie.CookieStorage` or :class:`~django.contrib.messages.storage.cookie.CookieStorage` or
:class:`~django.contrib.messages.storage.fallback.FallbackStorage`. :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 </topics/signing>`, unless a * Any usage of :doc:`cryptographic signing </topics/signing>`, unless a
different key is provided. different key is provided.

View File

@ -70,6 +70,17 @@ Minor features
:class:`~django.contrib.auth.views.LogoutView` class-based views supersede the :class:`~django.contrib.auth.views.LogoutView` class-based views supersede the
deprecated ``login()`` and ``logout()`` function-based views. 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` :mod:`django.contrib.contenttypes`
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
@ -354,3 +365,14 @@ Miscellaneous
deprecated in favor of new class-based views deprecated in favor of new class-based views
:class:`~django.contrib.auth.views.LoginView` and :class:`~django.contrib.auth.views.LoginView` and
:class:`~django.contrib.auth.views.LogoutView`. :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`.

View File

@ -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. out all of their sessions by changing their password.
The default password change views included with Django, 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 ``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 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 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 from django.contrib.auth import views as auth_views
urlpatterns = [ 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 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 = [ urlpatterns = [
url( url(
'^change-password/$', '^change-password/$',
auth_views.password_change, auth_views.PasswordChangeView.as_view(template_name='change-password.html'),
{'template_name': 'change-password.html'}
), ),
] ]
All views return a :class:`~django.template.response.TemplateResponse` All views are :doc:`class-based </topics/class-based-views/index>`, which allows
instance, which allows you to easily customize the response data before you to easily customize them by subclassing.
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
</ref/template-response>`.
.. _all-authentication-views: .. _all-authentication-views:
@ -963,7 +951,7 @@ implementation details see :ref:`using-the-views`.
:class:`LoginView`. :class:`LoginView`.
The optional arguments of this view are similar to the class-based 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_app``: A hint indicating which application contains the
current view. See the :ref:`namespaced URL resolution strategy current view. See the :ref:`namespaced URL resolution strategy
@ -1111,7 +1099,7 @@ implementation details see :ref:`using-the-views`.
class-based :class:`LogoutView`. class-based :class:`LogoutView`.
The optional arguments of this view are similar to the class-based 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_app``: A hint indicating which application contains the
current view. See the :ref:`namespaced URL resolution strategy 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) .. 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
<topics-http-reversing-url-namespaces>` 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`` **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 * ``template_name``: The full name of a template to use for
displaying the password change form. Defaults to displaying the password change form. Defaults to
:file:`registration/password_change_form.html` if not supplied. :file:`registration/password_change_form.html` if not supplied.
* ``post_change_redirect``: The URL to redirect to after a successful * ``success_url``: The URL to redirect to after a successful password
password change. change.
* ``password_change_form``: A custom "change password" form which must * ``form_class``: A custom "change password" form which must accept a
accept a ``user`` keyword argument. The form is responsible for ``user`` keyword argument. The form is responsible for actually changing
actually changing the user's password. Defaults to the user's password. Defaults to
:class:`~django.contrib.auth.forms.PasswordChangeForm`. :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 * ``current_app``: A hint indicating which application contains the current
view. See the :ref:`namespaced URL resolution strategy view. See the :ref:`namespaced URL resolution strategy
<topics-http-reversing-url-namespaces>` for more information. <topics-http-reversing-url-namespaces>` 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 .. deprecated:: 1.9
The ``current_app`` parameter is deprecated and will be removed in The ``current_app`` parameter is deprecated and will be removed in
Django 2.0. Callers should set ``request.current_app`` instead. Django 2.0. Callers should set ``request.current_app`` instead.
**Template context:** .. class:: PasswordChangeDoneView
* ``form``: The password change form (see ``password_change_form`` above). .. versionadded:: 1.11
.. 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.
**URL name:** ``password_change_done`` **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. * ``template_name``: The full name of a template to use.
Defaults to :file:`registration/password_change_done.html` if not Defaults to :file:`registration/password_change_done.html` if not
supplied. 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 * ``current_app``: A hint indicating which application contains the current
view. See the :ref:`namespaced URL resolution strategy view. See the :ref:`namespaced URL resolution strategy
<topics-http-reversing-url-namespaces>` for more information. <topics-http-reversing-url-namespaces>` 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 .. deprecated:: 1.9
The ``current_app`` parameter is deprecated and will be removed in The ``current_app`` parameter is deprecated and will be removed in
Django 2.0. Callers should set ``request.current_app`` instead. 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 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 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 This prevents information leaking to potential attackers. If you want to
provide an error message in this case, you can subclass provide an error message in this case, you can subclass
:class:`~django.contrib.auth.forms.PasswordResetForm` and use the :class:`~django.contrib.auth.forms.PasswordResetForm` and use the
``password_reset_form`` argument. ``form_class`` attribute.
Users flagged with an unusable password (see Users flagged with an unusable password (see
:meth:`~django.contrib.auth.models.User.set_unusable_password()` aren't :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 error message since this would expose their account's existence but no
mail will be sent either. mail will be sent either.
**URL name:** ``password_reset`` **Attributes:**
**Optional arguments:**
* ``template_name``: The full name of a template to use for * ``template_name``: The full name of a template to use for
displaying the password reset form. Defaults to displaying the password reset form. Defaults to
:file:`registration/password_reset_form.html` if not supplied. :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 * ``email_template_name``: The full name of a template to use for
generating the email with the reset password link. Defaults to generating the email with the reset password link. Defaults to
:file:`registration/password_reset_email.html` if not supplied. :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 the subject of the email with the reset password link. Defaults
to :file:`registration/password_reset_subject.txt` if not supplied. 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. * ``token_generator``: Instance of the class to check the one time link.
This will default to ``default_token_generator``, it's an instance of This will default to ``default_token_generator``, it's an instance of
``django.contrib.auth.tokens.PasswordResetTokenGenerator``. ``django.contrib.auth.tokens.PasswordResetTokenGenerator``.
* ``post_reset_redirect``: The URL to redirect to after a successful * ``success_url``: The URL to redirect to after a successful password reset
password reset request. request.
* ``from_email``: A valid email address. By default Django uses * ``from_email``: A valid email address. By default Django uses
the :setting:`DEFAULT_FROM_EMAIL`. the :setting:`DEFAULT_FROM_EMAIL`.
* ``current_app``: A hint indicating which application contains the current
view. See the :ref:`namespaced URL resolution strategy
<topics-http-reversing-url-namespaces>` for more information.
* ``extra_context``: A dictionary of context data that will be added to the * ``extra_context``: A dictionary of context data that will be added to the
default context data passed to the template. 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 * ``extra_email_context``: A dictionary of context data that will available
in the email template. 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:** **Template context:**
* ``form``: The form (see ``password_reset_form`` above) for resetting * ``form``: The form (see ``form_class`` above) for resetting the user's
the user's password. password.
**Email template context:** **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) .. 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 .. deprecated:: 1.11
password. This view is called by default if the :func:`password_reset` view
doesn't have an explicit ``post_reset_redirect`` URL set. 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
<topics-http-reversing-url-namespaces>` 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`` **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:: .. note::
If the email address provided does not exist in the system, the user is 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 inactive, or has an unusable password, the user will still be
redirected to this view but no email will be sent. redirected to this view but no email will be sent.
**Optional arguments:** **Attributes:**
* ``template_name``: The full name of a template to use. * ``template_name``: The full name of a template to use.
Defaults to :file:`registration/password_reset_done.html` if not Defaults to :file:`registration/password_reset_done.html` if not
supplied. 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 * ``current_app``: A hint indicating which application contains the current
view. See the :ref:`namespaced URL resolution strategy view. See the :ref:`namespaced URL resolution strategy
<topics-http-reversing-url-namespaces>` for more information. <topics-http-reversing-url-namespaces>` 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 .. deprecated:: 1.9
The ``current_app`` parameter is deprecated and will be removed in The ``current_app`` parameter is deprecated and will be removed in
Django 2.0. Callers should set ``request.current_app`` instead. 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`` **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 * ``uidb64``: The user's id encoded in base 64.
``None``.
* ``token``: Token to check that the password is valid.
**Attributes:**
* ``template_name``: The full name of a template to display the confirm * ``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 * ``token_generator``: Instance of the class to check the password. This
will default to ``default_token_generator``, it's an instance of will default to ``default_token_generator``, it's an instance of
``django.contrib.auth.tokens.PasswordResetTokenGenerator``. ``django.contrib.auth.tokens.PasswordResetTokenGenerator``.
* ``set_password_form``: Form that will be used to set the password. * ``form_class``: Form that will be used to set the password. Defaults to
Defaults to :class:`~django.contrib.auth.forms.SetPasswordForm` :class:`~django.contrib.auth.forms.SetPasswordForm`.
* ``post_reset_redirect``: URL to redirect after the password reset * ``success_url``: URL to redirect after the password reset done. Defaults
done. Defaults to ``None``. to ``'password_reset_complete'``.
* ``current_app``: A hint indicating which application contains the current
view. See the :ref:`namespaced URL resolution strategy
<topics-http-reversing-url-namespaces>` for more information.
* ``extra_context``: A dictionary of context data that will be added to the * ``extra_context``: A dictionary of context data that will be added to the
default context data passed to the template. 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 * ``validlink``: Boolean, True if the link (combination of ``uidb64`` and
``token``) is valid or unused yet. ``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) .. 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 .. deprecated:: 1.11
successfully changed.
**URL name:** ``password_reset_complete`` The ``password_reset_complete`` function-based view should be replaced
by the class-based :class:`PasswordResetCompleteView`.
**Optional arguments:** The optional arguments of this view are similar to the class-based
``PasswordResetCompleteView`` attributes. In addition, it has:
* ``template_name``: The full name of a template to display the view.
Defaults to :file:`registration/password_reset_complete.html`.
* ``current_app``: A hint indicating which application contains the current * ``current_app``: A hint indicating which application contains the current
view. See the :ref:`namespaced URL resolution strategy view. See the :ref:`namespaced URL resolution strategy
<topics-http-reversing-url-namespaces>` for more information. <topics-http-reversing-url-namespaces>` 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 .. deprecated:: 1.9
The ``current_app`` parameter is deprecated and will be removed in The ``current_app`` parameter is deprecated and will be removed in
Django 2.0. Callers should set ``request.current_app`` instead. 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 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. defaults to ``None``, in which case a plain text email is sent.
By default, ``save()`` populates the ``context`` with the By default, ``save()`` populates the ``context`` with the
same variables that :func:`~django.contrib.auth.views.password_reset` same variables that
passes to its email context. :class:`~django.contrib.auth.views.PasswordResetView` passes to its
email context.
.. class:: SetPasswordForm .. class:: SetPasswordForm

View File

@ -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('<html>', message.get_payload(0).get_payload())
self.assertIn('<html>', 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/')

View File

@ -2,8 +2,8 @@ from django.contrib.auth import authenticate
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.contrib.auth.tokens import PasswordResetTokenGenerator from django.contrib.auth.tokens import PasswordResetTokenGenerator
from django.contrib.auth.views import ( from django.contrib.auth.views import (
password_change, password_change_done, password_reset, PasswordChangeDoneView, PasswordChangeView, PasswordResetCompleteView,
password_reset_complete, password_reset_confirm, password_reset_done, PasswordResetConfirmView, PasswordResetDoneView, PasswordResetView,
) )
from django.test import RequestFactory, TestCase, override_settings from django.test import RequestFactory, TestCase, override_settings
from django.utils.encoding import force_bytes, force_text from django.utils.encoding import force_bytes, force_text
@ -20,35 +20,35 @@ class AuthTemplateTests(TestCase):
request = rf.get('/somepath/') request = rf.get('/somepath/')
request.user = user request.user = user
response = password_reset(request, post_reset_redirect='dummy/') response = PasswordResetView.as_view(success_url='dummy/')(request)
self.assertContains(response, '<title>Password reset</title>') self.assertContains(response, '<title>Password reset</title>')
self.assertContains(response, '<h1>Password reset</h1>') self.assertContains(response, '<h1>Password reset</h1>')
response = password_reset_done(request) response = PasswordResetDoneView.as_view()(request)
self.assertContains(response, '<title>Password reset sent</title>') self.assertContains(response, '<title>Password reset sent</title>')
self.assertContains(response, '<h1>Password reset sent</h1>') self.assertContains(response, '<h1>Password reset sent</h1>')
# password_reset_confirm invalid token # PasswordResetConfirmView invalid token
response = password_reset_confirm(request, uidb64='Bad', token='Bad', post_reset_redirect='dummy/') response = PasswordResetConfirmView.as_view(success_url='dummy/')(request, uidb64='Bad', token='Bad')
self.assertContains(response, '<title>Password reset unsuccessful</title>') self.assertContains(response, '<title>Password reset unsuccessful</title>')
self.assertContains(response, '<h1>Password reset unsuccessful</h1>') self.assertContains(response, '<h1>Password reset unsuccessful</h1>')
# password_reset_confirm valid token # PasswordResetConfirmView valid token
default_token_generator = PasswordResetTokenGenerator() default_token_generator = PasswordResetTokenGenerator()
token = default_token_generator.make_token(user) token = default_token_generator.make_token(user)
uidb64 = force_text(urlsafe_base64_encode(force_bytes(user.pk))) 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, '<title>Enter new password</title>') self.assertContains(response, '<title>Enter new password</title>')
self.assertContains(response, '<h1>Enter new password</h1>') self.assertContains(response, '<h1>Enter new password</h1>')
response = password_reset_complete(request) response = PasswordResetCompleteView.as_view()(request)
self.assertContains(response, '<title>Password reset complete</title>') self.assertContains(response, '<title>Password reset complete</title>')
self.assertContains(response, '<h1>Password reset complete</h1>') self.assertContains(response, '<h1>Password reset complete</h1>')
response = password_change(request, post_change_redirect='dummy/') response = PasswordChangeView.as_view(success_url='dummy/')(request)
self.assertContains(response, '<title>Password change</title>') self.assertContains(response, '<title>Password change</title>')
self.assertContains(response, '<h1>Password change</h1>') self.assertContains(response, '<h1>Password change</h1>')
response = password_change_done(request) response = PasswordChangeDoneView.as_view()(request)
self.assertContains(response, '<title>Password change successful</title>') self.assertContains(response, '<title>Password change successful</title>')
self.assertContains(response, '<h1>Password change successful</h1>') self.assertContains(response, '<h1>Password change successful</h1>')

View File

@ -8,6 +8,7 @@ from django.contrib.messages.api import info
from django.http import HttpRequest, HttpResponse from django.http import HttpRequest, HttpResponse
from django.shortcuts import render from django.shortcuts import render
from django.template import RequestContext, Template from django.template import RequestContext, Template
from django.urls import reverse_lazy
from django.views.decorators.cache import never_cache 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/$', views.LogoutView.as_view(next_page='/somewhere/')),
url(r'^logout/next_page/named/$', views.LogoutView.as_view(next_page='password_reset')), url(r'^logout/next_page/named/$', views.LogoutView.as_view(next_page='password_reset')),
url(r'^remote_user/$', remote_user_auth_view), 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, url(r'^password_reset_from_email/$',
dict(extra_email_context=dict(greeting='Hello!'))), views.PasswordResetView.as_view(from_email='staffmember@example.com')),
url(r'^password_reset/custom_redirect/$', views.password_reset, dict(post_reset_redirect='/custom/')), url(r'^password_reset_extra_email_context/$',
url(r'^password_reset/custom_redirect/named/$', views.password_reset, dict(post_reset_redirect='password_reset')), views.PasswordResetView.as_view(extra_email_context=dict(greeting='Hello!'))),
url(r'^password_reset/html_email_template/$', views.password_reset, url(r'^password_reset/custom_redirect/$',
dict(html_email_template_name='registration/html_password_reset_email.html')), 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<uidb64>[0-9A-Za-z_\-]+)/(?P<token>[0-9A-Za-z]{1,13}-[0-9A-Za-z]{1,20})/$', url(r'^reset/custom/(?P<uidb64>[0-9A-Za-z_\-]+)/(?P<token>[0-9A-Za-z]{1,13}-[0-9A-Za-z]{1,20})/$',
views.password_reset_confirm, views.PasswordResetConfirmView.as_view(success_url='/custom/')),
dict(post_reset_redirect='/custom/')),
url(r'^reset/custom/named/(?P<uidb64>[0-9A-Za-z_\-]+)/(?P<token>[0-9A-Za-z]{1,13}-[0-9A-Za-z]{1,20})/$', url(r'^reset/custom/named/(?P<uidb64>[0-9A-Za-z_\-]+)/(?P<token>[0-9A-Za-z]{1,13}-[0-9A-Za-z]{1,20})/$',
views.password_reset_confirm, views.PasswordResetConfirmView.as_view(success_url=reverse_lazy('password_reset'))),
dict(post_reset_redirect='password_reset')), url(r'^password_change/custom/$',
url(r'^password_change/custom/$', views.password_change, dict(post_change_redirect='/custom/')), views.PasswordChangeView.as_view(success_url='/custom/')),
url(r'^password_change/custom/named/$', views.password_change, dict(post_change_redirect='password_reset')), url(r'^password_change/custom/named/$',
url(r'^login_required/$', login_required(views.password_reset)), views.PasswordChangeView.as_view(success_url=reverse_lazy('password_reset'))),
url(r'^login_required_login_url/$', login_required(views.password_reset, login_url='/somewhere/')), 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_no_attr_access/$', auth_processor_no_attr_access),
url(r'^auth_processor_attr_access/$', auth_processor_attr_access), url(r'^auth_processor_attr_access/$', auth_processor_attr_access),

View File

@ -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<uidb64>[0-9A-Za-z_\-]+)/(?P<token>[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<uidb64>[0-9A-Za-z_\-]+)/(?P<token>[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<uidb64>[0-9A-Za-z_\-]+)/(?P<token>[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),
]