diff options
| author | Tim Graham <timograham@gmail.com> | 2013-06-21 16:59:33 -0400 |
|---|---|---|
| committer | Tim Graham <timograham@gmail.com> | 2013-06-26 13:11:47 -0400 |
| commit | 1184d077893ff1bc947e45b00a4d565f3df81776 (patch) | |
| tree | 1011df8828a780f762197352d2145e1735315dd7 /django | |
| parent | b6a87f5c93efa5192433be1e45fc4e79d54efdc7 (diff) | |
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.
Diffstat (limited to 'django')
| -rw-r--r-- | django/contrib/admin/templates/registration/password_reset_email.html | 2 | ||||
| -rw-r--r-- | django/contrib/auth/forms.py | 5 | ||||
| -rw-r--r-- | django/contrib/auth/tests/templates/registration/password_reset_email.html | 2 | ||||
| -rw-r--r-- | django/contrib/auth/tests/test_views.py | 24 | ||||
| -rw-r--r-- | django/contrib/auth/tests/urls.py | 5 | ||||
| -rw-r--r-- | django/contrib/auth/urls.py | 3 | ||||
| -rw-r--r-- | django/contrib/auth/views.py | 21 | ||||
| -rw-r--r-- | django/utils/http.py | 21 |
8 files changed, 67 insertions, 16 deletions
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<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 @@ 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<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'), 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 |
