Fixed #14881 -- Modified password reset to work with a non-integer UserModel.pk.

uid is now base64 encoded in password reset URLs/views. A backwards compatible
password_reset_confirm view/URL will allow password reset links generated before
this change to continue to work. This view will be removed in Django 1.7.

Thanks jonash for the initial patch and claudep for the review.
This commit is contained in:
Tim Graham 2013-06-21 16:59:33 -04:00
parent b6a87f5c93
commit 1184d07789
13 changed files with 164 additions and 23 deletions

View File

@ -3,7 +3,7 @@
{% trans "Please go to the following page and choose a new password:" %} {% trans "Please go to the following page and choose a new password:" %}
{% block reset_link %} {% block reset_link %}
{{ protocol }}://{{ domain }}{% url 'password_reset_confirm' uidb36=uid token=token %} {{ protocol }}://{{ domain }}{% url 'password_reset_confirm' uidb64=uid token=token %}
{% endblock %} {% endblock %}
{% trans "Your username, in case you've forgotten:" %} {{ user.get_username }} {% trans "Your username, in case you've forgotten:" %} {{ user.get_username }}

View File

@ -6,8 +6,9 @@ from django import forms
from django.forms.util import flatatt from django.forms.util import flatatt
from django.template import loader from django.template import loader
from django.utils.datastructures import SortedDict from django.utils.datastructures import SortedDict
from django.utils.encoding import force_bytes
from django.utils.html import format_html, format_html_join from django.utils.html import format_html, format_html_join
from django.utils.http import int_to_base36 from django.utils.http import urlsafe_base64_encode
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from django.utils.text import capfirst from django.utils.text import capfirst
from django.utils.translation import ugettext, ugettext_lazy as _ from django.utils.translation import ugettext, ugettext_lazy as _
@ -243,7 +244,7 @@ class PasswordResetForm(forms.Form):
'email': user.email, 'email': user.email,
'domain': domain, 'domain': domain,
'site_name': site_name, 'site_name': site_name,
'uid': int_to_base36(user.pk), 'uid': urlsafe_base64_encode(force_bytes(user.pk)),
'user': user, 'user': user,
'token': token_generator.make_token(user), 'token': token_generator.make_token(user),
'protocol': 'https' if use_https else 'http', 'protocol': 'https' if use_https else 'http',

View File

@ -1 +1 @@
{{ protocol }}://{{ domain }}/reset/{{ uid }}-{{ token }}/ {{ protocol }}://{{ domain }}/reset/{{ uid }}/{{ token }}/

View File

@ -13,7 +13,7 @@ from django.core import mail
from django.core.urlresolvers import reverse, NoReverseMatch from django.core.urlresolvers import reverse, NoReverseMatch
from django.http import QueryDict, HttpRequest from django.http import QueryDict, HttpRequest
from django.utils.encoding import force_text from django.utils.encoding import force_text
from django.utils.http import urlquote from django.utils.http import int_to_base36, urlsafe_base64_decode, urlquote
from django.utils._os import upath from django.utils._os import upath
from django.test import TestCase from django.test import TestCase
from django.test.utils import override_settings, patch_logger from django.test.utils import override_settings, patch_logger
@ -91,7 +91,7 @@ class AuthViewNamedURLTests(AuthViewsTestCase):
('password_reset', [], {}), ('password_reset', [], {}),
('password_reset_done', [], {}), ('password_reset_done', [], {}),
('password_reset_confirm', [], { ('password_reset_confirm', [], {
'uidb36': 'aaaaaaa', 'uidb64': 'aaaaaaa',
'token': '1111-aaaaa', 'token': '1111-aaaaa',
}), }),
('password_reset_complete', [], {}), ('password_reset_complete', [], {}),
@ -193,6 +193,16 @@ class PasswordResetTest(AuthViewsTestCase):
# redirect to a 'complete' page: # redirect to a 'complete' page:
self.assertContains(response, "Please enter your new password") self.assertContains(response, "Please enter your new password")
def test_confirm_valid_base36(self):
# Remove in Django 1.7
url, path = self._test_confirm_start()
path_parts = path.strip("/").split("/")
# construct an old style (base36) URL by converting the base64 ID
path_parts[1] = int_to_base36(int(urlsafe_base64_decode(path_parts[1])))
response = self.client.get("/%s/%s-%s/" % tuple(path_parts))
# redirect to a 'complete' page:
self.assertContains(response, "Please enter your new password")
def test_confirm_invalid(self): def test_confirm_invalid(self):
url, path = self._test_confirm_start() url, path = self._test_confirm_start()
# Let's munge the token in the path, but keep the same length, # Let's munge the token in the path, but keep the same length,
@ -204,11 +214,21 @@ class PasswordResetTest(AuthViewsTestCase):
def test_confirm_invalid_user(self): def test_confirm_invalid_user(self):
# Ensure that we get a 200 response for a non-existant user, not a 404 # Ensure that we get a 200 response for a non-existant user, not a 404
response = self.client.get('/reset/123456/1-1/')
self.assertContains(response, "The password reset link was invalid")
def test_confirm_invalid_user_base36(self):
# Remove in Django 1.7
response = self.client.get('/reset/123456-1-1/') response = self.client.get('/reset/123456-1-1/')
self.assertContains(response, "The password reset link was invalid") self.assertContains(response, "The password reset link was invalid")
def test_confirm_overflow_user(self): def test_confirm_overflow_user(self):
# Ensure that we get a 200 response for a base36 user id that overflows int # 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_overflow_user_base36(self):
# Remove in Django 1.7
response = self.client.get('/reset/zzzzzzzzzzzzz-1-1/') response = self.client.get('/reset/zzzzzzzzzzzzz-1-1/')
self.assertContains(response, "The password reset link was invalid") self.assertContains(response, "The password reset link was invalid")

View File

@ -67,10 +67,10 @@ urlpatterns = urlpatterns + patterns('',
(r'^password_reset_from_email/$', 'django.contrib.auth.views.password_reset', dict(from_email='staffmember@example.com')), (r'^password_reset_from_email/$', 'django.contrib.auth.views.password_reset', dict(from_email='staffmember@example.com')),
(r'^password_reset/custom_redirect/$', 'django.contrib.auth.views.password_reset', dict(post_reset_redirect='/custom/')), (r'^password_reset/custom_redirect/$', 'django.contrib.auth.views.password_reset', dict(post_reset_redirect='/custom/')),
(r'^password_reset/custom_redirect/named/$', 'django.contrib.auth.views.password_reset', dict(post_reset_redirect='password_reset')), (r'^password_reset/custom_redirect/named/$', 'django.contrib.auth.views.password_reset', dict(post_reset_redirect='password_reset')),
(r'^reset/custom/(?P<uidb36>[0-9A-Za-z]{1,13})-(?P<token>[0-9A-Za-z]{1,13}-[0-9A-Za-z]{1,20})/$', (r'^reset/custom/(?P<uidb64>[0-9A-Za-z_\-]+)/(?P<token>[0-9A-Za-z]{1,13}-[0-9A-Za-z]{1,20})/$',
'django.contrib.auth.views.password_reset_confirm', 'django.contrib.auth.views.password_reset_confirm',
dict(post_reset_redirect='/custom/')), dict(post_reset_redirect='/custom/')),
(r'^reset/custom/named/(?P<uidb36>[0-9A-Za-z]{1,13})-(?P<token>[0-9A-Za-z]{1,13}-[0-9A-Za-z]{1,20})/$', (r'^reset/custom/named/(?P<uidb64>[0-9A-Za-z_\-]+)/(?P<token>[0-9A-Za-z]{1,13}-[0-9A-Za-z]{1,20})/$',
'django.contrib.auth.views.password_reset_confirm', 'django.contrib.auth.views.password_reset_confirm',
dict(post_reset_redirect='password_reset')), dict(post_reset_redirect='password_reset')),
(r'^password_change/custom/$', 'django.contrib.auth.views.password_change', dict(post_change_redirect='/custom/')), (r'^password_change/custom/$', 'django.contrib.auth.views.password_change', dict(post_change_redirect='/custom/')),
@ -88,4 +88,3 @@ urlpatterns = urlpatterns + patterns('',
(r'^custom_request_auth_login/$', custom_request_auth_login), (r'^custom_request_auth_login/$', custom_request_auth_login),
url(r'^userpage/(.+)/$', userpage, name="userpage"), url(r'^userpage/(.+)/$', userpage, name="userpage"),
) )

View File

@ -12,7 +12,10 @@ urlpatterns = patterns('',
url(r'^password_change/done/$', 'django.contrib.auth.views.password_change_done', name='password_change_done'), url(r'^password_change/done/$', 'django.contrib.auth.views.password_change_done', name='password_change_done'),
url(r'^password_reset/$', 'django.contrib.auth.views.password_reset', name='password_reset'), url(r'^password_reset/$', 'django.contrib.auth.views.password_reset', name='password_reset'),
url(r'^password_reset/done/$', 'django.contrib.auth.views.password_reset_done', name='password_reset_done'), url(r'^password_reset/done/$', 'django.contrib.auth.views.password_reset_done', name='password_reset_done'),
# Support old style base36 password reset links; remove in Django 1.7
url(r'^reset/(?P<uidb36>[0-9A-Za-z]{1,13})-(?P<token>[0-9A-Za-z]{1,13}-[0-9A-Za-z]{1,20})/$', url(r'^reset/(?P<uidb36>[0-9A-Za-z]{1,13})-(?P<token>[0-9A-Za-z]{1,13}-[0-9A-Za-z]{1,20})/$',
'django.contrib.auth.views.password_reset_confirm_uidb36'),
url(r'^reset/(?P<uidb64>[0-9A-Za-z_\-]+)/(?P<token>[0-9A-Za-z]{1,13}-[0-9A-Za-z]{1,20})/$',
'django.contrib.auth.views.password_reset_confirm', 'django.contrib.auth.views.password_reset_confirm',
name='password_reset_confirm'), name='password_reset_confirm'),
url(r'^reset/done/$', 'django.contrib.auth.views.password_reset_complete', name='password_reset_complete'), url(r'^reset/done/$', 'django.contrib.auth.views.password_reset_complete', name='password_reset_complete'),

View File

@ -7,9 +7,10 @@ from django.conf import settings
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from django.http import HttpResponseRedirect, QueryDict from django.http import HttpResponseRedirect, QueryDict
from django.template.response import TemplateResponse from django.template.response import TemplateResponse
from django.utils.http import base36_to_int, is_safe_url from django.utils.http import base36_to_int, is_safe_url, urlsafe_base64_decode, urlsafe_base64_encode
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
from django.shortcuts import resolve_url from django.shortcuts import resolve_url
from django.utils.encoding import force_bytes, force_text
from django.views.decorators.debug import sensitive_post_parameters from django.views.decorators.debug import sensitive_post_parameters
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
@ -184,7 +185,7 @@ def password_reset_done(request,
# Doesn't need csrf_protect since no-one can guess the URL # Doesn't need csrf_protect since no-one can guess the URL
@sensitive_post_parameters() @sensitive_post_parameters()
@never_cache @never_cache
def password_reset_confirm(request, uidb36=None, token=None, def password_reset_confirm(request, uidb64=None, token=None,
template_name='registration/password_reset_confirm.html', template_name='registration/password_reset_confirm.html',
token_generator=default_token_generator, token_generator=default_token_generator,
set_password_form=SetPasswordForm, set_password_form=SetPasswordForm,
@ -195,15 +196,15 @@ def password_reset_confirm(request, uidb36=None, token=None,
form for entering a new password. form for entering a new password.
""" """
UserModel = get_user_model() UserModel = get_user_model()
assert uidb36 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:
post_reset_redirect = reverse('password_reset_complete') post_reset_redirect = reverse('password_reset_complete')
else: else:
post_reset_redirect = resolve_url(post_reset_redirect) post_reset_redirect = resolve_url(post_reset_redirect)
try: try:
uid_int = base36_to_int(uidb36) uid = urlsafe_base64_decode(uidb64)
user = UserModel._default_manager.get(pk=uid_int) user = UserModel._default_manager.get(pk=uid)
except (ValueError, OverflowError, UserModel.DoesNotExist): except (TypeError, ValueError, OverflowError, UserModel.DoesNotExist):
user = None user = None
if user is not None and token_generator.check_token(user, token): if user is not None and token_generator.check_token(user, token):
@ -227,6 +228,14 @@ def password_reset_confirm(request, uidb36=None, token=None,
return TemplateResponse(request, template_name, context, return TemplateResponse(request, template_name, context,
current_app=current_app) current_app=current_app)
def password_reset_confirm_uidb36(request, uidb36=None, **kwargs):
# Support old password reset URLs that used base36 encoded user IDs.
# Remove in Django 1.7
try:
uidb64 = force_text(urlsafe_base64_encode(force_bytes(base36_to_int(uidb36))))
except ValueError:
uidb64 = '1' # dummy invalid ID (incorrect padding for base64)
return password_reset_confirm(request, uidb64=uidb64, **kwargs)
def password_reset_complete(request, def password_reset_complete(request,
template_name='registration/password_reset_complete.html', template_name='registration/password_reset_complete.html',

View File

@ -1,5 +1,6 @@
from __future__ import unicode_literals from __future__ import unicode_literals
import base64
import calendar import calendar
import datetime import datetime
import re import re
@ -11,7 +12,7 @@ except ImportError: # Python 2
import urlparse import urlparse
urllib_parse.urlparse = urlparse.urlparse urllib_parse.urlparse = urlparse.urlparse
from binascii import Error as BinasciiError
from email.utils import formatdate from email.utils import formatdate
from django.utils.datastructures import MultiValueDict from django.utils.datastructures import MultiValueDict
@ -202,6 +203,24 @@ def int_to_base36(i):
factor -= 1 factor -= 1
return ''.join(base36) return ''.join(base36)
def urlsafe_base64_encode(s):
"""
Encodes a bytestring in base64 for use in URLs, stripping any trailing
equal signs.
"""
return base64.urlsafe_b64encode(s).rstrip(b'\n=')
def urlsafe_base64_decode(s):
"""
Decodes a base64 encoded string, adding back any trailing equal signs that
might have been stripped.
"""
s = s.encode('utf-8') # base64encode should only return ASCII.
try:
return base64.urlsafe_b64decode(s.ljust(len(s) + len(s) % 4, b'='))
except (LookupError, BinasciiError) as e:
raise ValueError(e)
def parse_etags(etag_str): def parse_etags(etag_str):
""" """
Parses a string with one or several etags passed in If-None-Match and Parses a string with one or several etags passed in If-None-Match and

View File

@ -326,6 +326,14 @@ these changes.
remove calls to this method, and instead ensure that their auth related views remove calls to this method, and instead ensure that their auth related views
are CSRF protected, which ensures that cookies are enabled. are CSRF protected, which ensures that cookies are enabled.
* The version of :func:`django.contrib.auth.views.password_reset_confirm` that
supports base36 encoded user IDs
(``django.contrib.auth.views.password_reset_confirm_uidb36``) will be
removed. If your site has been running Django 1.6 for more than
:setting:`PASSWORD_RESET_TIMEOUT_DAYS`, this change will have no effect. If
not, then any password reset links generated before you upgrade to Django 1.7
won't work after the upgrade.
1.8 1.8
--- ---

View File

@ -2278,9 +2278,14 @@ your URLconf. Specifically, add these four patterns:
url(r'^admin/password_reset/$', 'django.contrib.auth.views.password_reset', name='admin_password_reset'), url(r'^admin/password_reset/$', 'django.contrib.auth.views.password_reset', name='admin_password_reset'),
url(r'^admin/password_reset/done/$', 'django.contrib.auth.views.password_reset_done', name='password_reset_done'), url(r'^admin/password_reset/done/$', 'django.contrib.auth.views.password_reset_done', name='password_reset_done'),
url(r'^reset/(?P<uidb36>[0-9A-Za-z]+)-(?P<token>.+)/$', 'django.contrib.auth.views.password_reset_confirm', name='password_reset_confirm'), url(r'^reset/(?P<uidb64>[0-9A-Za-z_\-]+)/(?P<token>.+)/$', 'django.contrib.auth.views.password_reset_confirm', name='password_reset_confirm'),
url(r'^reset/done/$', 'django.contrib.auth.views.password_reset_complete', name='password_reset_complete'), url(r'^reset/done/$', 'django.contrib.auth.views.password_reset_complete', name='password_reset_complete'),
.. versionchanged:: 1.6
The pattern for :func:`~django.contrib.auth.views.password_reset_confirm`
changed as the ``uid`` is now base 64 encoded.
(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
itself). itself).

View File

@ -649,6 +649,20 @@ escaping HTML.
Converts a positive integer to a base 36 string. On Python 2 ``i`` must be Converts a positive integer to a base 36 string. On Python 2 ``i`` must be
smaller than :data:`sys.maxint`. smaller than :data:`sys.maxint`.
.. function:: urlsafe_base64_encode(s)
.. versionadded:: 1.6
Encodes a bytestring in base64 for use in URLs, stripping any trailing
equal signs.
.. function:: urlsafe_base64_decode(s)
.. versionadded:: 1.6
Decodes a base64 encoded string, adding back any trailing equal signs that
might have been stripped.
``django.utils.module_loading`` ``django.utils.module_loading``
=============================== ===============================

View File

@ -656,6 +656,59 @@ rely on the previous URLs. If you want to revert to the original behavior you
can set the can set the
:attr:`~django.contrib.admin.ModelAdmin.preserve_filters` attribute to ``False``. :attr:`~django.contrib.admin.ModelAdmin.preserve_filters` attribute to ``False``.
``django.contrib.auth`` password reset uses base 64 encoding of ``User`` PK
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Past versions of Django used base 36 encoding of the ``User`` primary key in
the password reset views and URLs
(:func:`django.contrib.auth.views.password_reset_confirm`). Base 36 encoding is
sufficient if the user primary key is an integer, however, with the
introduction of custom user models in Django 1.5, that assumption may no longer
be true.
:func:`django.contrib.auth.views.password_reset_confirm` has been modified to
take a ``uidb64`` parameter instead of ``uidb36``. If you are reversing this
view, for example in a custom ``password_reset_email.html`` template, be sure
to update your code.
A temporary shim for :func:`django.contrib.auth.views.password_reset_confirm`
that will allow password reset links generated prior to Django 1.6 to continue
to work has been added to provide backwards compatibility; this will be removed
in Django 1.7. Thus, as long as your site has been running Django 1.6 for more
than :setting:`PASSWORD_RESET_TIMEOUT_DAYS`, this change will have no effect.
If not (for example, if you upgrade directly from Django 1.5 to Django 1.7),
then any password reset links generated before you upgrade to Django 1.7 or
later won't work after the upgrade.
In addition, if you have any custom password reset URLs, you will need to
update them by replacing ``uidb36`` with ``uidb64`` and the dash that follows
that pattern with a slash. Also add ``_\-`` to the list of characters that may
match the ``uidb64`` pattern.
For example::
url(r'^reset/(?P<uidb36>[0-9A-Za-z]+)-(?P<token>.+)/$',
'django.contrib.auth.views.password_reset_confirm',
name='password_reset_confirm'),
becomes::
url(r'^reset/(?P<uidb64>[0-9A-Za-z_\-]+)/(?P<token>.+)/$',
'django.contrib.auth.views.password_reset_confirm',
name='password_reset_confirm'),
You may also want to add the shim to support the old style reset links. Using
the example above, you would modify the existing url by replacing
``django.contrib.auth.views.password_reset_confirm`` with
``django.contrib.auth.views.password_reset_confirm_uidb36`` and also remove
the ``name`` argument so it doesn't conflict with the new url::
url(r'^reset/(?P<uidb36>[0-9A-Za-z]+)-(?P<token>.+)/$',
'django.contrib.auth.views.password_reset_confirm_uidb36'),
You can remove this url pattern after your app has been deployed with Django
1.6 for :setting:`PASSWORD_RESET_TIMEOUT_DAYS`.
Miscellaneous Miscellaneous
~~~~~~~~~~~~~ ~~~~~~~~~~~~~

View File

@ -817,7 +817,7 @@ patterns.
* ``protocol``: http or https * ``protocol``: http or https
* ``uid``: The user's id encoded in base 36. * ``uid``: The user's primary key encoded in base 64.
* ``token``: Token to check that the reset link is valid. * ``token``: Token to check that the reset link is valid.
@ -826,7 +826,12 @@ patterns.
.. code-block:: html+django .. code-block:: html+django
Someone asked for password reset for email {{ email }}. Follow the link below: Someone asked for password reset for email {{ email }}. Follow the link below:
{{ protocol}}://{{ domain }}{% url 'password_reset_confirm' uidb36=uid token=token %} {{ protocol}}://{{ domain }}{% url 'password_reset_confirm' uidb64=uid token=token %}
.. versionchanged:: 1.6
Reversing ``password_reset_confirm`` takes a ``uidb64`` argument instead
of ``uidb36``.
The same template context is used for subject template. Subject must be The same template context is used for subject template. Subject must be
single line plain text string. single line plain text string.
@ -846,7 +851,7 @@ patterns.
Defaults to :file:`registration/password_reset_done.html` if not Defaults to :file:`registration/password_reset_done.html` if not
supplied. supplied.
.. function:: password_reset_confirm(request[, uidb36, token, template_name, token_generator, set_password_form, post_reset_redirect]) .. function:: password_reset_confirm(request[, uidb64, token, template_name, token_generator, set_password_form, post_reset_redirect])
Presents a form for entering a new password. Presents a form for entering a new password.
@ -854,7 +859,12 @@ patterns.
**Optional arguments:** **Optional arguments:**
* ``uidb36``: The user's id encoded in base 36. Defaults to ``None``. * ``uidb64``: The user's id encoded in base 64. Defaults to ``None``.
.. versionchanged:: 1.6
The ``uidb64`` parameter was previously base 36 encoded and named
``uidb36``.
* ``token``: Token to check that the password is valid. Defaults to * ``token``: Token to check that the password is valid. Defaults to
``None``. ``None``.
@ -877,8 +887,8 @@ patterns.
* ``form``: The form (see ``set_password_form`` above) for setting the * ``form``: The form (see ``set_password_form`` above) for setting the
new user's password. new user's password.
* ``validlink``: Boolean, True if the link (combination of uidb36 and * ``validlink``: Boolean, True if the link (combination of ``uidb64`` and
token) is valid or unused yet. ``token``) is valid or unused yet.
.. function:: password_reset_complete(request[,template_name]) .. function:: password_reset_complete(request[,template_name])