From fcd837cd0f9b2c706bc49af509628778d442bb3f Mon Sep 17 00:00:00 2001 From: Luke Plant Date: Thu, 31 Jul 2008 20:47:53 +0000 Subject: [PATCH] Fixed #7723 - implemented a secure password reset form that uses a token and prompts user for new password. git-svn-id: http://code.djangoproject.com/svn/django/trunk@8162 bcc190cf-cafb-0310-a4f2-bffc1f526a37 --- django/conf/global_settings.py | 3 + .../registration/password_reset_complete.html | 14 +++ .../registration/password_reset_confirm.html | 32 +++++++ .../registration/password_reset_done.html | 2 +- .../registration/password_reset_email.html | 14 +-- .../registration/password_reset_form.html | 2 +- django/contrib/auth/forms.py | 66 ++++++++------ django/contrib/auth/tests/__init__.py | 5 +- django/contrib/auth/tests/basic.py | 21 ----- django/contrib/auth/tests/forms.py | 33 ++++++- django/contrib/auth/tests/tokens.py | 29 ++++++ django/contrib/auth/tests/views.py | 88 +++++++++++++++++++ django/contrib/auth/tokens.py | 66 ++++++++++++++ django/contrib/auth/urls.py | 5 +- django/contrib/auth/views.py | 62 +++++++++++-- django/utils/http.py | 29 ++++++ 16 files changed, 401 insertions(+), 70 deletions(-) create mode 100644 django/contrib/admin/templates/registration/password_reset_complete.html create mode 100644 django/contrib/admin/templates/registration/password_reset_confirm.html create mode 100644 django/contrib/auth/tests/tokens.py create mode 100644 django/contrib/auth/tests/views.py create mode 100644 django/contrib/auth/tokens.py diff --git a/django/conf/global_settings.py b/django/conf/global_settings.py index 797841d297b..811feed3495 100644 --- a/django/conf/global_settings.py +++ b/django/conf/global_settings.py @@ -366,6 +366,9 @@ LOGOUT_URL = '/accounts/logout/' LOGIN_REDIRECT_URL = '/accounts/profile/' +# The number of days a password reset link is valid for +PASSWORD_RESET_TIMEOUT_DAYS = 3 + ########### # TESTING # ########### diff --git a/django/contrib/admin/templates/registration/password_reset_complete.html b/django/contrib/admin/templates/registration/password_reset_complete.html new file mode 100644 index 00000000000..446717d38ae --- /dev/null +++ b/django/contrib/admin/templates/registration/password_reset_complete.html @@ -0,0 +1,14 @@ +{% extends "admin/base_site.html" %} +{% load i18n %} + +{% block breadcrumbs %}{% endblock %} + +{% block title %}{% trans 'Password reset complete' %}{% endblock %} + +{% block content %} + +

{% trans 'Password reset complete' %}

+ +

{% trans "Your password has been set. You may go ahead and log in now." %}

+ +{% endblock %} diff --git a/django/contrib/admin/templates/registration/password_reset_confirm.html b/django/contrib/admin/templates/registration/password_reset_confirm.html new file mode 100644 index 00000000000..9ba0e5af271 --- /dev/null +++ b/django/contrib/admin/templates/registration/password_reset_confirm.html @@ -0,0 +1,32 @@ +{% extends "admin/base_site.html" %} +{% load i18n %} + +{% block breadcrumbs %}{% endblock %} + +{% block title %}{% trans 'Password reset' %}{% endblock %} + +{% block content %} + +{% if validlink %} + +

{% trans 'Enter new password' %}

+ +

{% trans "Please enter your new password twice so we can verify you typed it in correctly." %}

+ +
+{% if form.new_password1.errors %}{{ form.new_password1.errors }}{% endif %} +

{{ form.new_password1 }}

+{% if form.new_password2.errors %}{{ form.new_password2.errors }}{% endif %} +

{{ form.new_password2 }}

+

+
+ +{% else %} + +

{% trans 'Password reset unsuccessful' %}

+ +

{% trans "The password reset link was invalid, possibly because it has already been used. Please request a new password reset." %} + +{% endif %} + +{% endblock %} diff --git a/django/contrib/admin/templates/registration/password_reset_done.html b/django/contrib/admin/templates/registration/password_reset_done.html index f97b5688c2f..e223bdb9dee 100644 --- a/django/contrib/admin/templates/registration/password_reset_done.html +++ b/django/contrib/admin/templates/registration/password_reset_done.html @@ -9,6 +9,6 @@

{% trans 'Password reset successful' %}

-

{% trans "We've e-mailed a new password to the e-mail address you submitted. You should be receiving it shortly." %}

+

{% trans "We've e-mailed you instructions for setting your password to the e-mail address you submitted. You should be receiving it shortly." %}

{% endblock %} diff --git a/django/contrib/admin/templates/registration/password_reset_email.html b/django/contrib/admin/templates/registration/password_reset_email.html index f765dd0670b..3b2d5b0f34d 100644 --- a/django/contrib/admin/templates/registration/password_reset_email.html +++ b/django/contrib/admin/templates/registration/password_reset_email.html @@ -1,15 +1,15 @@ -{% load i18n %} +{% load i18n %}{% autoescape off %} {% trans "You're receiving this e-mail because you requested a password reset" %} {% blocktrans %}for your user account at {{ site_name }}{% endblocktrans %}. -{% blocktrans %}Your new password is: {{ new_password }}{% endblocktrans %} - -{% trans "Feel free to change this password by going to this page:" %} - -http://{{ domain }}/password_change/ - +{% trans "Please go to the following page and choose a new password:" %} +{% block reset_link %} +{{ protocol }}://{{ domain }}/reset/{{ uid }}-{{ token }}/ +{% endblock %} {% trans "Your username, in case you've forgotten:" %} {{ user.username }} {% trans "Thanks for using our site!" %} {% blocktrans %}The {{ site_name }} team{% endblocktrans %} + +{% endautoescape %} diff --git a/django/contrib/admin/templates/registration/password_reset_form.html b/django/contrib/admin/templates/registration/password_reset_form.html index d8c7d03f930..4ecebc77a17 100644 --- a/django/contrib/admin/templates/registration/password_reset_form.html +++ b/django/contrib/admin/templates/registration/password_reset_form.html @@ -9,7 +9,7 @@

{% trans "Password reset" %}

-

{% trans "Forgotten your password? Enter your e-mail address below, and we'll reset your password and e-mail the new one to you." %}

+

{% trans "Forgotten your password? Enter your e-mail address below, and we'll e-mail instructions for setting a new one." %}

{% if form.email.errors %}{{ form.email.errors }}{% endif %} diff --git a/django/contrib/auth/forms.py b/django/contrib/auth/forms.py index a9e3257d47a..730744567c4 100644 --- a/django/contrib/auth/forms.py +++ b/django/contrib/auth/forms.py @@ -1,9 +1,11 @@ from django.contrib.auth.models import User from django.contrib.auth import authenticate +from django.contrib.auth.tokens import default_token_generator from django.contrib.sites.models import Site from django.template import Context, loader from django import forms from django.utils.translation import ugettext_lazy as _ +from django.utils.http import int_to_base36 class UserCreationForm(forms.ModelForm): """ @@ -97,16 +99,14 @@ class PasswordResetForm(forms.Form): self.users_cache = User.objects.filter(email__iexact=email) if len(self.users_cache) == 0: raise forms.ValidationError(_("That e-mail address doesn't have an associated user account. Are you sure you've registered?")) - - def save(self, domain_override=None, email_template_name='registration/password_reset_email.html'): + + def save(self, domain_override=None, email_template_name='registration/password_reset_email.html', + use_https=False, token_generator=default_token_generator): """ - Calculates a new password randomly and sends it to the user. + Generates a one-use only link for restting password and sends to the user """ from django.core.mail import send_mail for user in self.users_cache: - new_pass = User.objects.make_random_password() - user.set_password(new_pass) - user.save() if not domain_override: current_site = Site.objects.get_current() site_name = current_site.name @@ -115,26 +115,49 @@ class PasswordResetForm(forms.Form): site_name = domain = domain_override t = loader.get_template(email_template_name) c = { - 'new_password': new_pass, 'email': user.email, 'domain': domain, 'site_name': site_name, + 'uid': int_to_base36(user.id), 'user': user, + 'token': token_generator.make_token(user), + 'protocol': use_https and 'https' or 'http', } send_mail(_("Password reset on %s") % site_name, t.render(Context(c)), None, [user.email]) -class PasswordChangeForm(forms.Form): +class SetPasswordForm(forms.Form): """ - A form that lets a user change his/her password. + A form that lets a user change set his/her password without + entering the old password """ - old_password = forms.CharField(label=_("Old password"), max_length=30, widget=forms.PasswordInput) - new_password1 = forms.CharField(label=_("New password"), max_length=30, widget=forms.PasswordInput) - new_password2 = forms.CharField(label=_("New password confirmation"), max_length=30, widget=forms.PasswordInput) - + new_password1 = forms.CharField(label=_("New password"), max_length=60, widget=forms.PasswordInput) + new_password2 = forms.CharField(label=_("New password confirmation"), max_length=60, widget=forms.PasswordInput) + def __init__(self, user, *args, **kwargs): self.user = user - super(PasswordChangeForm, self).__init__(*args, **kwargs) + super(SetPasswordForm, self).__init__(*args, **kwargs) + + def clean_new_password2(self): + password1 = self.cleaned_data.get('new_password1') + password2 = self.cleaned_data.get('new_password2') + if password1 and password2: + if password1 != password2: + raise forms.ValidationError(_("The two password fields didn't match.")) + return password2 + + def save(self, commit=True): + self.user.set_password(self.cleaned_data['new_password1']) + if commit: + self.user.save() + return self.user + +class PasswordChangeForm(SetPasswordForm): + """ + A form that lets a user change his/her password by entering + their old password. + """ + old_password = forms.CharField(label=_("Old password"), max_length=60, widget=forms.PasswordInput) def clean_old_password(self): """ @@ -144,21 +167,8 @@ class PasswordChangeForm(forms.Form): if not self.user.check_password(old_password): raise forms.ValidationError(_("Your old password was entered incorrectly. Please enter it again.")) return old_password +PasswordChangeForm.base_fields.keyOrder = ['old_password', 'new_password1', 'new_password2'] - def clean_new_password2(self): - password1 = self.cleaned_data.get('new_password1') - password2 = self.cleaned_data.get('new_password2') - if password1 and password2: - if password1 != password2: - raise forms.ValidationError(_("The two password fields didn't match.")) - return password2 - - def save(self, commit=True): - self.user.set_password(self.cleaned_data['new_password1']) - if commit: - self.user.save() - return self.user - class AdminPasswordChangeForm(forms.Form): """ A form used to change the password of a user in the admin interface. diff --git a/django/contrib/auth/tests/__init__.py b/django/contrib/auth/tests/__init__.py index 6242303f461..2458800b226 100644 --- a/django/contrib/auth/tests/__init__.py +++ b/django/contrib/auth/tests/__init__.py @@ -1,8 +1,11 @@ -from django.contrib.auth.tests.basic import BASIC_TESTS, PasswordResetTest +from django.contrib.auth.tests.basic import BASIC_TESTS +from django.contrib.auth.tests.views import PasswordResetTest from django.contrib.auth.tests.forms import FORM_TESTS +from django.contrib.auth.tests.tokens import TOKEN_GENERATOR_TESTS __test__ = { 'BASIC_TESTS': BASIC_TESTS, 'PASSWORDRESET_TESTS': PasswordResetTest, 'FORM_TESTS': FORM_TESTS, + 'TOKEN_GENERATOR_TESTS': TOKEN_GENERATOR_TESTS } diff --git a/django/contrib/auth/tests/basic.py b/django/contrib/auth/tests/basic.py index 76dbdc9cb96..2071710279e 100644 --- a/django/contrib/auth/tests/basic.py +++ b/django/contrib/auth/tests/basic.py @@ -54,24 +54,3 @@ u'joe@somewhere.org' >>> u.password u'!' """ - -from django.test import TestCase -from django.core import mail - -class PasswordResetTest(TestCase): - fixtures = ['authtestdata.json'] - urls = 'django.contrib.auth.urls' - - def test_email_not_found(self): - "Error is raised if the provided email address isn't currently registered" - response = self.client.get('/password_reset/') - self.assertEquals(response.status_code, 200) - response = self.client.post('/password_reset/', {'email': 'not_a_real_email@email.com'}) - self.assertContains(response, "That e-mail address doesn't have an associated user account") - self.assertEquals(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.assertEquals(response.status_code, 302) - self.assertEquals(len(mail.outbox), 1) diff --git a/django/contrib/auth/tests/forms.py b/django/contrib/auth/tests/forms.py index 1e1e0a95d43..01f4995bb78 100644 --- a/django/contrib/auth/tests/forms.py +++ b/django/contrib/auth/tests/forms.py @@ -2,7 +2,7 @@ FORM_TESTS = """ >>> from django.contrib.auth.models import User >>> from django.contrib.auth.forms import UserCreationForm, AuthenticationForm ->>> from django.contrib.auth.forms import PasswordChangeForm +>>> from django.contrib.auth.forms import PasswordChangeForm, SetPasswordForm The user already exists. @@ -95,6 +95,32 @@ True >>> form.non_field_errors() [] +SetPasswordForm: + +The two new passwords do not match. + +>>> data = { +... 'new_password1': 'abc123', +... 'new_password2': 'abc', +... } +>>> form = SetPasswordForm(user, data) +>>> form.is_valid() +False +>>> form["new_password2"].errors +[u"The two password fields didn't match."] + +The success case. + +>>> data = { +... 'new_password1': 'abc123', +... 'new_password2': 'abc123', +... } +>>> form = SetPasswordForm(user, data) +>>> form.is_valid() +True + +PasswordChangeForm: + The old password is incorrect. >>> data = { @@ -132,4 +158,9 @@ The success case. >>> form.is_valid() True +Regression test - check the order of fields: + +>>> PasswordChangeForm(user, {}).fields.keys() +['old_password', 'new_password1', 'new_password2'] + """ diff --git a/django/contrib/auth/tests/tokens.py b/django/contrib/auth/tests/tokens.py new file mode 100644 index 00000000000..6d3a964fe75 --- /dev/null +++ b/django/contrib/auth/tests/tokens.py @@ -0,0 +1,29 @@ +TOKEN_GENERATOR_TESTS = """ +>>> from django.contrib.auth.models import User, AnonymousUser +>>> from django.contrib.auth.tokens import PasswordResetTokenGenerator +>>> from django.conf import settings +>>> u = User.objects.create_user('tokentestuser', 'test2@example.com', 'testpw') +>>> p0 = PasswordResetTokenGenerator() +>>> tk1 = p0.make_token(u) +>>> p0.check_token(u, tk1) +True + +Tests to ensure we can use the token after n days, but no greater. +Use a mocked version of PasswordResetTokenGenerator so we can change +the value of 'today' + +>>> class Mocked(PasswordResetTokenGenerator): +... def __init__(self, today): +... self._today_val = today +... def _today(self): +... return self._today_val + +>>> from datetime import date, timedelta +>>> p1 = Mocked(date.today() + timedelta(settings.PASSWORD_RESET_TIMEOUT_DAYS)) +>>> p1.check_token(u, tk1) +True +>>> p2 = Mocked(date.today() + timedelta(settings.PASSWORD_RESET_TIMEOUT_DAYS + 1)) +>>> p2.check_token(u, tk1) +False + +""" diff --git a/django/contrib/auth/tests/views.py b/django/contrib/auth/tests/views.py new file mode 100644 index 00000000000..9abdc3baaf0 --- /dev/null +++ b/django/contrib/auth/tests/views.py @@ -0,0 +1,88 @@ + +import re +from django.contrib.auth.models import User +from django.test import TestCase +from django.core import mail + +class PasswordResetTest(TestCase): + fixtures = ['authtestdata.json'] + urls = 'django.contrib.auth.urls' + + def test_email_not_found(self): + "Error is raised if the provided email address isn't currently registered" + response = self.client.get('/password_reset/') + self.assertEquals(response.status_code, 200) + response = self.client.post('/password_reset/', {'email': 'not_a_real_email@email.com'}) + self.assertContains(response, "That e-mail address doesn't have an associated user account") + self.assertEquals(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.assertEquals(response.status_code, 302) + self.assertEquals(len(mail.outbox), 1) + self.assert_("http://" in mail.outbox[0].body) + + def _test_confirm_start(self): + # Start by creating the email + response = self.client.post('/password_reset/', {'email': 'staffmember@example.com'}) + self.assertEquals(response.status_code, 302) + self.assertEquals(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.assert_(urlmatch is not None, "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.assertEquals(response.status_code, 200) + self.assert_("Please enter your new password" in response.content) + + def test_confirm_invalid(self): + url, path = self._test_confirm_start() + # Lets munge the token in the path, but keep the same length, + # in case the URL conf will reject a different length + path = path[:-5] + ("0"*4) + path[-1] + + response = self.client.get(path) + self.assertEquals(response.status_code, 200) + self.assert_("The password reset link was invalid" in response.content) + + 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] + + response = 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.assert_(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'}) + # It redirects us to a 'complete' page: + self.assertEquals(response.status_code, 302) + # Check the password has been changed + u = User.objects.get(email='staffmember@example.com') + self.assert_(u.check_password("anewpassword")) + + # Check we can't use the link again + response = self.client.get(path) + self.assertEquals(response.status_code, 200) + self.assert_("The password reset link was invalid" in response.content) + + def test_confirm_different_passwords(self): + url, path = self._test_confirm_start() + response = self.client.post(path, {'new_password1': 'anewpassword', + 'new_password2':' x'}) + self.assertEquals(response.status_code, 200) + self.assert_("The two password fields didn't match" in response.content) + diff --git a/django/contrib/auth/tokens.py b/django/contrib/auth/tokens.py new file mode 100644 index 00000000000..acfbc3bb9c4 --- /dev/null +++ b/django/contrib/auth/tokens.py @@ -0,0 +1,66 @@ +from datetime import date +from django.conf import settings +from django.utils.http import int_to_base36, base36_to_int + +class PasswordResetTokenGenerator(object): + """ + Stratgy object used to generate and check tokens for the password + reset mechanism. + """ + def make_token(self, user): + """ + Returns a token that can be used once to do a password reset + for the given user. + """ + return self._make_token_with_timestamp(user, self._num_days(self._today())) + + def check_token(self, user, token): + """ + Check that a password reset token is correct for a given user. + """ + # Parse the tokem + try: + ts_b36, hash = token.split("-") + except ValueError: + return False + + try: + ts = base36_to_int(ts_b36) + except ValueError: + return False + + # Check that the timestamp/uid has not been tampered with + if self._make_token_with_timestamp(user, ts) != token: + return False + + # Check the timestamp is within limit + if (self._num_days(self._today()) - ts) > settings.PASSWORD_RESET_TIMEOUT_DAYS: + return False + + return True + + def _make_token_with_timestamp(self, user, timestamp): + # timestamp is number of days since 2001-1-1. Converted to + # base 36, this gives us a 3 digit string until about 2121 + ts_b36 = int_to_base36(timestamp) + + # By hashing on the internal state of the user and using state + # that is sure to change (the password salt will change as soon as + # the password is set, at least for current Django auth, and + # last_login will also change), we produce a hash that will be + # invalid as soon as it is used. + # We limit the hash to 20 chars to keep URL short + import sha + hash = sha.new(settings.SECRET_KEY + unicode(user.id) + + user.password + unicode(user.last_login) + + unicode(timestamp)).hexdigest()[::2] + return "%s-%s" % (ts_b36, hash) + + def _num_days(self, dt): + return (dt - date(2001,1,1)).days + + def _today(self): + # Used for mocking in tests + return date.today() + +default_token_generator = PasswordResetTokenGenerator() diff --git a/django/contrib/auth/urls.py b/django/contrib/auth/urls.py index 5ddfcf15e83..30f92b42c24 100644 --- a/django/contrib/auth/urls.py +++ b/django/contrib/auth/urls.py @@ -8,6 +8,9 @@ urlpatterns = patterns('', ('^logout/$', 'django.contrib.auth.views.logout'), ('^password_change/$', 'django.contrib.auth.views.password_change'), ('^password_change/done/$', 'django.contrib.auth.views.password_change_done'), - ('^password_reset/$', 'django.contrib.auth.views.password_reset') + ('^password_reset/$', 'django.contrib.auth.views.password_reset'), + ('^password_reset/done/$', 'django.contrib.auth.views.password_reset_done'), + ('^reset/(?P[0-9A-Za-z]+)-(?P.+)/$', 'django.contrib.auth.views.password_reset_confirm'), + ('^reset/done/$', 'django.contrib.auth.views.password_reset_complete'), ) diff --git a/django/contrib/auth/views.py b/django/contrib/auth/views.py index 0a52240631b..180103ed050 100644 --- a/django/contrib/auth/views.py +++ b/django/contrib/auth/views.py @@ -1,13 +1,14 @@ from django.contrib.auth import REDIRECT_FIELD_NAME from django.contrib.auth.decorators import login_required from django.contrib.auth.forms import AuthenticationForm -from django.contrib.auth.forms import PasswordResetForm, PasswordChangeForm, AdminPasswordChangeForm +from django.contrib.auth.forms import PasswordResetForm, SetPasswordForm, PasswordChangeForm, AdminPasswordChangeForm +from django.contrib.auth.tokens import default_token_generator from django.core.exceptions import PermissionDenied from django.shortcuts import render_to_response, get_object_or_404 from django.contrib.sites.models import Site, RequestSite -from django.http import HttpResponseRedirect +from django.http import HttpResponseRedirect, Http404 from django.template import RequestContext -from django.utils.http import urlquote +from django.utils.http import urlquote, base36_to_int from django.utils.html import escape from django.utils.translation import ugettext as _ from django.contrib.auth.models import User @@ -65,19 +66,29 @@ def redirect_to_login(next, login_url=None, redirect_field_name=REDIRECT_FIELD_N login_url = settings.LOGIN_URL return HttpResponseRedirect('%s?%s=%s' % (login_url, urlquote(redirect_field_name), urlquote(next))) +# 4 views for password reset: +# - password_reset sends the mail +# - password_reset_done shows a success message for the above +# - password_reset_confirm checks the link the user clicked and +# prompts for a new password +# - password_reset_complete shows a success message for the above + def password_reset(request, is_admin_site=False, template_name='registration/password_reset_form.html', email_template_name='registration/password_reset_email.html', - password_reset_form=PasswordResetForm): + password_reset_form=PasswordResetForm, token_generator=default_token_generator): if request.method == "POST": form = password_reset_form(request.POST) if form.is_valid(): + opts = {} + opts['use_https'] = request.is_secure() + opts['token_generator'] = token_generator if is_admin_site: - form.save(domain_override=request.META['HTTP_HOST']) + opts['domain_override'] = request.META['HTTP_HOST'] else: - if Site._meta.installed: - form.save(email_template_name=email_template_name) - else: - form.save(domain_override=RequestSite(request).domain, email_template_name=email_template_name) + opts['email_template_name'] = email_template_name + if not Site._meta.installed: + opts['domain_override'] = RequestSite(request).domain + form.save(**opts) return HttpResponseRedirect('%sdone/' % request.path) else: form = password_reset_form() @@ -88,6 +99,39 @@ def password_reset(request, is_admin_site=False, template_name='registration/pas def password_reset_done(request, template_name='registration/password_reset_done.html'): return render_to_response(template_name, context_instance=RequestContext(request)) +def password_reset_confirm(request, uidb36=None, token=None, template_name='registration/password_reset_confirm.html', + token_generator=default_token_generator, set_password_form=SetPasswordForm): + """ + View that checks the hash in a password reset link and presents a + form for entering a new password. + """ + assert uidb36 is not None and token is not None # checked by URLconf + try: + uid_int = base36_to_int(uidb36) + except ValueError: + raise Http404 + + user = get_object_or_404(User, id=uid_int) + context_instance = RequestContext(request) + + if token_generator.check_token(user, token): + context_instance['validlink'] = True + if request.method == 'POST': + form = set_password_form(user, request.POST) + if form.is_valid(): + form.save() + return HttpResponseRedirect("../done/") + else: + form = set_password_form(None) + else: + context_instance['validlink'] = False + form = None + context_instance['form'] = form + return render_to_response(template_name, context_instance=context_instance) + +def password_reset_complete(request, template_name='registration/password_reset_complete.html'): + return render_to_response(template_name, context_instance=RequestContext(request)) + def password_change(request, template_name='registration/password_change_form.html'): if request.method == "POST": form = PasswordChangeForm(request.user, request.POST) diff --git a/django/utils/http.py b/django/utils/http.py index 5ec6e92d28a..7d2af95c47f 100644 --- a/django/utils/http.py +++ b/django/utils/http.py @@ -65,3 +65,32 @@ def http_date(epoch_seconds=None): """ rfcdate = formatdate(epoch_seconds) return '%s GMT' % rfcdate[:25] + +# Base 36 functions: useful for generating compact URLs + +def base36_to_int(s): + """ + Convertd a base 36 string to an integer + """ + return int(s, 36) + +def int_to_base36(i): + """ + Converts an integer to a base36 string + """ + digits = "0123456789abcdefghijklmnopqrstuvwxyz" + factor = 0 + # Find starting factor + while True: + factor += 1 + if i < 36 ** factor: + factor -= 1 + break + base36 = [] + # Construct base36 representation + while factor >= 0: + j = 36 ** factor + base36.append(digits[i / j]) + i = i % j + factor -= 1 + return ''.join(base36)