Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with HTTPS or Subversion.

Download ZIP

Loading…

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

Closed
wants to merge 4 commits into from

1 participant

This page is out of date. Refresh to see the latest.
View
2  django/contrib/admin/templates/registration/password_reset_email.html
@@ -3,7 +3,7 @@
{% trans "Please go to the following page and choose a new password:" %}
{% block reset_link %}
-{{ protocol }}://{{ domain }}{% url 'password_reset_confirm' uidb36=uid token=token %}
+{{ protocol }}://{{ domain }}{% url 'password_reset_confirm' uidb64=uid token=token %}
{% endblock %}
{% trans "Your username, in case you've forgotten:" %} {{ user.get_username }}
View
5 django/contrib/auth/forms.py
@@ -6,8 +6,9 @@
from django.forms.util import flatatt
from django.template import loader
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.http import int_to_base36
+from django.utils.http import urlsafe_base64_encode
from django.utils.safestring import mark_safe
from django.utils.text import capfirst
from django.utils.translation import ugettext, ugettext_lazy as _
@@ -243,7 +244,7 @@ def save(self, domain_override=None,
'email': user.email,
'domain': domain,
'site_name': site_name,
- 'uid': int_to_base36(user.pk),
+ 'uid': urlsafe_base64_encode(force_bytes(user.pk)),
'user': user,
'token': token_generator.make_token(user),
'protocol': 'https' if use_https else 'http',
View
2  django/contrib/auth/tests/templates/registration/password_reset_email.html
@@ -1 +1 @@
-{{ protocol }}://{{ domain }}/reset/{{ uid }}-{{ token }}/
+{{ protocol }}://{{ domain }}/reset/{{ uid }}/{{ token }}/
View
24 django/contrib/auth/tests/test_views.py
@@ -13,7 +13,7 @@
from django.core.urlresolvers import reverse, NoReverseMatch
from django.http import QueryDict, HttpRequest
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.test import TestCase
from django.test.utils import override_settings, patch_logger
@@ -91,7 +91,7 @@ def test_named_urls(self):
('password_reset', [], {}),
('password_reset_done', [], {}),
('password_reset_confirm', [], {
- 'uidb36': 'aaaaaaa',
+ 'uidb64': 'aaaaaaa',
'token': '1111-aaaaa',
}),
('password_reset_complete', [], {}),
@@ -193,6 +193,16 @@ def test_confirm_valid(self):
# redirect to a 'complete' page:
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):
url, path = self._test_confirm_start()
# Let's munge the token in the path, but keep the same length,
@@ -204,11 +214,21 @@ def test_confirm_invalid(self):
def test_confirm_invalid_user(self):
# 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/')
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_overflow_user_base36(self):
+ # Remove in Django 1.7
response = self.client.get('/reset/zzzzzzzzzzzzz-1-1/')
self.assertContains(response, "The password reset link was invalid")
View
5 django/contrib/auth/tests/urls.py
@@ -67,10 +67,10 @@ def custom_request_auth_login(request):
(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/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',
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',
dict(post_reset_redirect='password_reset')),
(r'^password_change/custom/$', 'django.contrib.auth.views.password_change', dict(post_change_redirect='/custom/')),
@@ -88,4 +88,3 @@ def custom_request_auth_login(request):
(r'^custom_request_auth_login/$', custom_request_auth_login),
url(r'^userpage/(.+)/$', userpage, name="userpage"),
)
-
View
3  django/contrib/auth/urls.py
@@ -12,7 +12,10 @@
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/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})/$',
+ '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',
name='password_reset_confirm'),
url(r'^reset/done/$', 'django.contrib.auth.views.password_reset_complete', name='password_reset_complete'),
View
21 django/contrib/auth/views.py
@@ -7,9 +7,10 @@
from django.core.urlresolvers import reverse
from django.http import HttpResponseRedirect, QueryDict
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.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.cache import never_cache
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
@sensitive_post_parameters()
@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',
token_generator=default_token_generator,
set_password_form=SetPasswordForm,
@@ -195,15 +196,15 @@ def password_reset_confirm(request, uidb36=None, token=None,
form for entering a new password.
"""
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:
post_reset_redirect = reverse('password_reset_complete')
else:
post_reset_redirect = resolve_url(post_reset_redirect)
try:
- uid_int = base36_to_int(uidb36)
- user = UserModel._default_manager.get(pk=uid_int)
- except (ValueError, OverflowError, UserModel.DoesNotExist):
+ uid = urlsafe_base64_decode(uidb64)
+ user = UserModel._default_manager.get(pk=uid)
+ except (TypeError, ValueError, OverflowError, UserModel.DoesNotExist):
user = None
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,
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,
template_name='registration/password_reset_complete.html',
View
21 django/utils/http.py
@@ -1,5 +1,6 @@
from __future__ import unicode_literals
+import base64
import calendar
import datetime
import re
@@ -11,7 +12,7 @@
import urlparse
urllib_parse.urlparse = urlparse.urlparse
-
+from binascii import Error as BinasciiError
from email.utils import formatdate
from django.utils.datastructures import MultiValueDict
@@ -202,6 +203,24 @@ def int_to_base36(i):
factor -= 1
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):
"""
Parses a string with one or several etags passed in If-None-Match and
View
8 docs/internals/deprecation.txt
@@ -326,6 +326,14 @@ these changes.
remove calls to this method, and instead ensure that their auth related views
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
---
View
2  docs/ref/contrib/admin/index.txt
@@ -2278,7 +2278,7 @@ 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/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'),
(This assumes you've added the admin at ``admin/`` and requires that you put
View
14 docs/ref/utils.txt
@@ -649,6 +649,20 @@ escaping HTML.
Converts a positive integer to a base 36 string. On Python 2 ``i`` must be
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``
===============================
View
49 docs/releases/1.6.txt
@@ -656,6 +656,55 @@ rely on the previous URLs. If you want to revert to the original behavior you
can set the
: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``. 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 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.
+
+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 after your app has been deployed for
+:setting:`PASSWORD_RESET_TIMEOUT_DAYS`.
+
Miscellaneous
~~~~~~~~~~~~~
View
22 docs/topics/auth/default.txt
@@ -817,7 +817,7 @@ patterns.
* ``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.
@@ -826,7 +826,12 @@ patterns.
.. code-block:: html+django
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
single line plain text string.
@@ -846,7 +851,7 @@ patterns.
Defaults to :file:`registration/password_reset_done.html` if not
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.
@@ -854,7 +859,12 @@ patterns.
**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
``None``.
@@ -877,8 +887,8 @@ patterns.
* ``form``: The form (see ``set_password_form`` above) for setting the
new user's password.
- * ``validlink``: Boolean, True if the link (combination of uidb36 and
- token) is valid or unused yet.
+ * ``validlink``: Boolean, True if the link (combination of ``uidb64`` and
+ ``token``) is valid or unused yet.
.. function:: password_reset_complete(request[,template_name])
Something went wrong with that request. Please try again.