diff --git a/django/contrib/admin/templates/registration/password_reset_email.html b/django/contrib/admin/templates/registration/password_reset_email.html index 44ae5850b1..01b3bccbbc 100644 --- a/django/contrib/admin/templates/registration/password_reset_email.html +++ b/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 }} diff --git a/django/contrib/auth/forms.py b/django/contrib/auth/forms.py index a9ecba45c2..43f5303b63 100644 --- a/django/contrib/auth/forms.py +++ b/django/contrib/auth/forms.py @@ -6,8 +6,9 @@ from django import forms 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 @@ class PasswordResetForm(forms.Form): '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', diff --git a/django/contrib/auth/tests/templates/registration/password_reset_email.html b/django/contrib/auth/tests/templates/registration/password_reset_email.html index 1b9a48255a..baac2fc2dd 100644 --- a/django/contrib/auth/tests/templates/registration/password_reset_email.html +++ b/django/contrib/auth/tests/templates/registration/password_reset_email.html @@ -1 +1 @@ -{{ protocol }}://{{ domain }}/reset/{{ uid }}-{{ token }}/ \ No newline at end of file +{{ protocol }}://{{ domain }}/reset/{{ uid }}/{{ token }}/ diff --git a/django/contrib/auth/tests/test_views.py b/django/contrib/auth/tests/test_views.py index 3a1be5bb7b..ba06a6af4d 100644 --- a/django/contrib/auth/tests/test_views.py +++ b/django/contrib/auth/tests/test_views.py @@ -13,7 +13,7 @@ from django.core import mail 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 @@ class AuthViewNamedURLTests(AuthViewsTestCase): ('password_reset', [], {}), ('password_reset_done', [], {}), ('password_reset_confirm', [], { - 'uidb36': 'aaaaaaa', + 'uidb64': 'aaaaaaa', 'token': '1111-aaaaa', }), ('password_reset_complete', [], {}), @@ -193,6 +193,16 @@ class PasswordResetTest(AuthViewsTestCase): # 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 @@ class PasswordResetTest(AuthViewsTestCase): 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") diff --git a/django/contrib/auth/tests/urls.py b/django/contrib/auth/tests/urls.py index 835ff41de7..502fc659d4 100644 --- a/django/contrib/auth/tests/urls.py +++ b/django/contrib/auth/tests/urls.py @@ -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/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[0-9A-Za-z]{1,13})-(?P[0-9A-Za-z]{1,13}-[0-9A-Za-z]{1,20})/$', + (r'^reset/custom/(?P[0-9A-Za-z_\-]+)/(?P[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[0-9A-Za-z]{1,13})-(?P[0-9A-Za-z]{1,13}-[0-9A-Za-z]{1,20})/$', + (r'^reset/custom/named/(?P[0-9A-Za-z_\-]+)/(?P[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 @@ urlpatterns = urlpatterns + patterns('', (r'^custom_request_auth_login/$', custom_request_auth_login), url(r'^userpage/(.+)/$', userpage, name="userpage"), ) - diff --git a/django/contrib/auth/urls.py b/django/contrib/auth/urls.py index c5e87ed2eb..801d133437 100644 --- a/django/contrib/auth/urls.py +++ b/django/contrib/auth/urls.py @@ -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_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[0-9A-Za-z]{1,13})-(?P[0-9A-Za-z]{1,13}-[0-9A-Za-z]{1,20})/$', + 'django.contrib.auth.views.password_reset_confirm_uidb36'), + url(r'^reset/(?P[0-9A-Za-z_\-]+)/(?P[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'), diff --git a/django/contrib/auth/views.py b/django/contrib/auth/views.py index fe21683323..e9affb33cd 100644 --- a/django/contrib/auth/views.py +++ b/django/contrib/auth/views.py @@ -7,9 +7,10 @@ from django.conf import settings 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', diff --git a/django/utils/http.py b/django/utils/http.py index f4911b4ec0..4647d89847 100644 --- a/django/utils/http.py +++ b/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 @@ except ImportError: # Python 2 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 diff --git a/docs/internals/deprecation.txt b/docs/internals/deprecation.txt index 45f82b49e6..9672746717 100644 --- a/docs/internals/deprecation.txt +++ b/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 --- diff --git a/docs/ref/contrib/admin/index.txt b/docs/ref/contrib/admin/index.txt index 90ef68837a..318ce297a2 100644 --- a/docs/ref/contrib/admin/index.txt +++ b/docs/ref/contrib/admin/index.txt @@ -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/done/$', 'django.contrib.auth.views.password_reset_done', name='password_reset_done'), - url(r'^reset/(?P[0-9A-Za-z]+)-(?P.+)/$', 'django.contrib.auth.views.password_reset_confirm', name='password_reset_confirm'), + url(r'^reset/(?P[0-9A-Za-z_\-]+)/(?P.+)/$', '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'), +.. 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 the URLs starting with ``^admin/`` before the line that includes the admin app itself). diff --git a/docs/ref/utils.txt b/docs/ref/utils.txt index 45d7781403..8d722829fb 100644 --- a/docs/ref/utils.txt +++ b/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`` =============================== diff --git a/docs/releases/1.6.txt b/docs/releases/1.6.txt index 3d59ce771b..2c1fffd8cd 100644 --- a/docs/releases/1.6.txt +++ b/docs/releases/1.6.txt @@ -656,6 +656,59 @@ 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``. 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[0-9A-Za-z]+)-(?P.+)/$', + 'django.contrib.auth.views.password_reset_confirm', + name='password_reset_confirm'), + +becomes:: + + url(r'^reset/(?P[0-9A-Za-z_\-]+)/(?P.+)/$', + '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[0-9A-Za-z]+)-(?P.+)/$', + '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 ~~~~~~~~~~~~~ diff --git a/docs/topics/auth/default.txt b/docs/topics/auth/default.txt index 8849520b11..e2fa0c287e 100644 --- a/docs/topics/auth/default.txt +++ b/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])