Django

Code

Changeset 8162

Show
Ignore:
Timestamp:
07/31/08 15:47:53 (1 year ago)
Author:
lukeplant
Message:

Fixed #7723 - implemented a secure password reset form that uses a token and prompts user for new password.

Files:

Legend:

Unmodified
Added
Removed
Modified
Copied
Moved
  • django/trunk/django/conf/global_settings.py

    r8015 r8162  
    367367LOGIN_REDIRECT_URL = '/accounts/profile/' 
    368368 
     369# The number of days a password reset link is valid for 
     370PASSWORD_RESET_TIMEOUT_DAYS = 3 
     371 
    369372########### 
    370373# TESTING # 
  • django/trunk/django/contrib/admin/templates/registration/password_reset_done.html

    r7294 r8162  
    1010<h1>{% trans 'Password reset successful' %}</h1> 
    1111 
    12 <p>{% trans "We've e-mailed a new password to the e-mail address you submitted. You should be receiving it shortly." %}</p> 
     12<p>{% trans "We've e-mailed you instructions for setting your password to the e-mail address you submitted. You should be receiving it shortly." %}</p> 
    1313 
    1414{% endblock %} 
  • django/trunk/django/contrib/admin/templates/registration/password_reset_email.html

    r7294 r8162  
    1 {% load i18n %} 
     1{% load i18n %}{% autoescape off %} 
    22{% trans "You're receiving this e-mail because you requested a password reset" %} 
    33{% blocktrans %}for your user account at {{ site_name }}{% endblocktrans %}. 
    44 
    5 {% blocktrans %}Your new password is: {{ new_password }}{% endblocktrans %} 
    6  
    7 {% trans "Feel free to change this password by going to this page:" %} 
    8  
    9 http://{{ domain }}/password_change/ 
    10  
     5{% trans "Please go to the following page and choose a new password:" %} 
     6{% block reset_link %} 
     7{{ protocol }}://{{ domain }}/reset/{{ uid }}-{{ token }}/ 
     8{% endblock %} 
    119{% trans "Your username, in case you've forgotten:" %} {{ user.username }} 
    1210 
     
    1412 
    1513{% blocktrans %}The {{ site_name }} team{% endblocktrans %} 
     14 
     15{% endautoescape %} 
  • django/trunk/django/contrib/admin/templates/registration/password_reset_form.html

    r7967 r8162  
    1010<h1>{% trans "Password reset" %}</h1> 
    1111 
    12 <p>{% trans "Forgotten your password? Enter your e-mail address below, and we'll reset your password and e-mail the new one to you." %}</p> 
     12<p>{% trans "Forgotten your password? Enter your e-mail address below, and we'll e-mail instructions for setting a new one." %}</p> 
    1313 
    1414<form action="" method="post"> 
  • django/trunk/django/contrib/auth/forms.py

    r8159 r8162  
    11from django.contrib.auth.models import User 
    22from django.contrib.auth import authenticate 
     3from django.contrib.auth.tokens import default_token_generator 
    34from django.contrib.sites.models import Site 
    45from django.template import Context, loader 
    56from django import forms 
    67from django.utils.translation import ugettext_lazy as _ 
     8from django.utils.http import int_to_base36 
    79 
    810class UserCreationForm(forms.ModelForm): 
     
    98100        if len(self.users_cache) == 0: 
    99101            raise forms.ValidationError(_("That e-mail address doesn't have an associated user account. Are you sure you've registered?")) 
    100      
    101     def save(self, domain_override=None, email_template_name='registration/password_reset_email.html'): 
     102 
     103    def save(self, domain_override=None, email_template_name='registration/password_reset_email.html', 
     104             use_https=False, token_generator=default_token_generator): 
    102105        """ 
    103         Calculates a new password randomly and sends it to the user. 
     106        Generates a one-use only link for restting password and sends to the user 
    104107        """ 
    105108        from django.core.mail import send_mail 
    106109        for user in self.users_cache: 
    107             new_pass = User.objects.make_random_password() 
    108             user.set_password(new_pass) 
    109             user.save() 
    110110            if not domain_override: 
    111111                current_site = Site.objects.get_current() 
     
    116116            t = loader.get_template(email_template_name) 
    117117            c = { 
    118                 'new_password': new_pass, 
    119118                'email': user.email, 
    120119                'domain': domain, 
    121120                'site_name': site_name, 
     121                'uid': int_to_base36(user.id), 
    122122                'user': user, 
     123                'token': token_generator.make_token(user), 
     124                'protocol': use_https and 'https' or 'http', 
    123125            } 
    124126            send_mail(_("Password reset on %s") % site_name, 
    125127                t.render(Context(c)), None, [user.email]) 
    126128 
    127 class PasswordChangeForm(forms.Form): 
     129class SetPasswordForm(forms.Form): 
    128130    """ 
    129     A form that lets a user change his/her password. 
     131    A form that lets a user change set his/her password without 
     132    entering the old password 
    130133    """ 
    131     old_password = forms.CharField(label=_("Old password"), max_length=30, widget=forms.PasswordInput) 
    132     new_password1 = forms.CharField(label=_("New password"), max_length=30, widget=forms.PasswordInput) 
    133     new_password2 = forms.CharField(label=_("New password confirmation"), max_length=30, widget=forms.PasswordInput) 
    134      
     134    new_password1 = forms.CharField(label=_("New password"), max_length=60, widget=forms.PasswordInput) 
     135    new_password2 = forms.CharField(label=_("New password confirmation"), max_length=60, widget=forms.PasswordInput) 
     136 
    135137    def __init__(self, user, *args, **kwargs): 
    136138        self.user = user 
    137         super(PasswordChangeForm, self).__init__(*args, **kwargs) 
     139        super(SetPasswordForm, self).__init__(*args, **kwargs) 
     140 
     141    def clean_new_password2(self): 
     142        password1 = self.cleaned_data.get('new_password1') 
     143        password2 = self.cleaned_data.get('new_password2') 
     144        if password1 and password2: 
     145            if password1 != password2: 
     146                raise forms.ValidationError(_("The two password fields didn't match.")) 
     147        return password2 
     148 
     149    def save(self, commit=True): 
     150        self.user.set_password(self.cleaned_data['new_password1']) 
     151        if commit: 
     152            self.user.save() 
     153        return self.user 
     154     
     155class PasswordChangeForm(SetPasswordForm): 
     156    """ 
     157    A form that lets a user change his/her password by entering 
     158    their old password. 
     159    """ 
     160    old_password = forms.CharField(label=_("Old password"), max_length=60, widget=forms.PasswordInput) 
    138161     
    139162    def clean_old_password(self): 
     
    145168            raise forms.ValidationError(_("Your old password was entered incorrectly. Please enter it again.")) 
    146169        return old_password 
     170PasswordChangeForm.base_fields.keyOrder = ['old_password', 'new_password1', 'new_password2'] 
    147171     
    148     def clean_new_password2(self): 
    149         password1 = self.cleaned_data.get('new_password1') 
    150         password2 = self.cleaned_data.get('new_password2') 
    151         if password1 and password2: 
    152             if password1 != password2: 
    153                 raise forms.ValidationError(_("The two password fields didn't match.")) 
    154         return password2 
    155      
    156     def save(self, commit=True): 
    157         self.user.set_password(self.cleaned_data['new_password1']) 
    158         if commit: 
    159             self.user.save() 
    160         return self.user 
    161  
    162172class AdminPasswordChangeForm(forms.Form): 
    163173    """ 
  • django/trunk/django/contrib/auth/tests/basic.py

    r7967 r8162  
    5555u'!' 
    5656""" 
    57  
    58 from django.test import TestCase 
    59 from django.core import mail 
    60  
    61 class PasswordResetTest(TestCase): 
    62     fixtures = ['authtestdata.json'] 
    63     urls = 'django.contrib.auth.urls' 
    64      
    65     def test_email_not_found(self): 
    66         "Error is raised if the provided email address isn't currently registered" 
    67         response = self.client.get('/password_reset/') 
    68         self.assertEquals(response.status_code, 200) 
    69         response = self.client.post('/password_reset/', {'email': 'not_a_real_email@email.com'}) 
    70         self.assertContains(response, "That e-mail address doesn't have an associated user account") 
    71         self.assertEquals(len(mail.outbox), 0) 
    72      
    73     def test_email_found(self): 
    74         "Email is sent if a valid email address is provided for password reset" 
    75         response = self.client.post('/password_reset/', {'email': 'staffmember@example.com'}) 
    76         self.assertEquals(response.status_code, 302) 
    77         self.assertEquals(len(mail.outbox), 1) 
  • django/trunk/django/contrib/auth/tests/forms.py

    r7967 r8162  
    33>>> from django.contrib.auth.models import User 
    44>>> from django.contrib.auth.forms import UserCreationForm, AuthenticationForm 
    5 >>> from django.contrib.auth.forms import PasswordChangeForm 
     5>>> from django.contrib.auth.forms import PasswordChangeForm, SetPasswordForm 
    66 
    77The user already exists. 
     
    9696[] 
    9797 
     98SetPasswordForm: 
     99 
     100The two new passwords do not match. 
     101 
     102>>> data = { 
     103...     'new_password1': 'abc123', 
     104...     'new_password2': 'abc', 
     105... } 
     106>>> form = SetPasswordForm(user, data) 
     107>>> form.is_valid() 
     108False 
     109>>> form["new_password2"].errors 
     110[u"The two password fields didn't match."] 
     111 
     112The success case. 
     113 
     114>>> data = { 
     115...     'new_password1': 'abc123', 
     116...     'new_password2': 'abc123', 
     117... } 
     118>>> form = SetPasswordForm(user, data) 
     119>>> form.is_valid() 
     120True 
     121 
     122PasswordChangeForm: 
     123 
    98124The old password is incorrect. 
    99125 
     
    133159True 
    134160 
     161Regression test - check the order of fields: 
     162 
     163>>> PasswordChangeForm(user, {}).fields.keys() 
     164['old_password', 'new_password1', 'new_password2'] 
     165 
    135166""" 
  • django/trunk/django/contrib/auth/tests/__init__.py

    r7967 r8162  
    1 from django.contrib.auth.tests.basic import BASIC_TESTS, PasswordResetTest 
     1from django.contrib.auth.tests.basic import BASIC_TESTS 
     2from django.contrib.auth.tests.views import PasswordResetTest 
    23from django.contrib.auth.tests.forms import FORM_TESTS 
     4from django.contrib.auth.tests.tokens import TOKEN_GENERATOR_TESTS 
    35 
    46__test__ = { 
     
    68    'PASSWORDRESET_TESTS': PasswordResetTest, 
    79    'FORM_TESTS': FORM_TESTS, 
     10    'TOKEN_GENERATOR_TESTS': TOKEN_GENERATOR_TESTS 
    811} 
  • django/trunk/django/contrib/auth/urls.py

    r7808 r8162  
    99    ('^password_change/$', 'django.contrib.auth.views.password_change'), 
    1010    ('^password_change/done/$', 'django.contrib.auth.views.password_change_done'), 
    11     ('^password_reset/$', 'django.contrib.auth.views.password_reset') 
     11    ('^password_reset/$', 'django.contrib.auth.views.password_reset'), 
     12    ('^password_reset/done/$', 'django.contrib.auth.views.password_reset_done'), 
     13    ('^reset/(?P<uidb36>[0-9A-Za-z]+)-(?P<token>.+)/$', 'django.contrib.auth.views.password_reset_confirm'), 
     14    ('^reset/done/$', 'django.contrib.auth.views.password_reset_complete'), 
    1215) 
    1316 
  • django/trunk/django/contrib/auth/views.py

    r7967 r8162  
    22from django.contrib.auth.decorators import login_required 
    33from django.contrib.auth.forms import AuthenticationForm 
    4 from django.contrib.auth.forms import PasswordResetForm, PasswordChangeForm, AdminPasswordChangeForm 
     4from django.contrib.auth.forms import PasswordResetForm, SetPasswordForm, PasswordChangeForm, AdminPasswordChangeForm 
     5from django.contrib.auth.tokens import default_token_generator 
    56from django.core.exceptions import PermissionDenied 
    67from django.shortcuts import render_to_response, get_object_or_404 
    78from django.contrib.sites.models import Site, RequestSite 
    8 from django.http import HttpResponseRedirect 
     9from django.http import HttpResponseRedirect, Http404 
    910from django.template import RequestContext 
    10 from django.utils.http import urlquote 
     11from django.utils.http import urlquote, base36_to_int 
    1112from django.utils.html import escape 
    1213from django.utils.translation import ugettext as _ 
     
    6667    return HttpResponseRedirect('%s?%s=%s' % (login_url, urlquote(redirect_field_name), urlquote(next))) 
    6768 
     69# 4 views for password reset: 
     70# - password_reset sends the mail 
     71# - password_reset_done shows a success message for the above 
     72# - password_reset_confirm checks the link the user clicked and  
     73#   prompts for a new password 
     74# - password_reset_complete shows a success message for the above 
     75 
    6876def password_reset(request, is_admin_site=False, template_name='registration/password_reset_form.html', 
    6977        email_template_name='registration/password_reset_email.html', 
    70         password_reset_form=PasswordResetForm): 
     78        password_reset_form=PasswordResetForm, token_generator=default_token_generator): 
    7179    if request.method == "POST": 
    7280        form = password_reset_form(request.POST) 
    7381        if form.is_valid(): 
     82            opts = {} 
     83            opts['use_https'] = request.is_secure() 
     84            opts['token_generator'] = token_generator 
    7485            if is_admin_site: 
    75                 form.save(domain_override=request.META['HTTP_HOST']) 
     86                opts['domain_override'] = request.META['HTTP_HOST'] 
    7687            else: 
    77                 if Site._meta.installed: 
    78                     form.save(email_template_name=email_template_name) 
    79                 else: 
    80                     form.save(domain_override=RequestSite(request).domain, email_template_name=email_template_name
     88                opts['email_template_name'] = email_template_name 
     89                if not Site._meta.installed: 
     90                    opts['domain_override'] = RequestSite(request).domain 
     91            form.save(**opts
    8192            return HttpResponseRedirect('%sdone/' % request.path) 
    8293    else: 
     
    8798 
    8899def password_reset_done(request, template_name='registration/password_reset_done.html'): 
     100    return render_to_response(template_name, context_instance=RequestContext(request)) 
     101 
     102def password_reset_confirm(request, uidb36=None, token=None, template_name='registration/password_reset_confirm.html', 
     103                           token_generator=default_token_generator, set_password_form=SetPasswordForm): 
     104    """ 
     105    View that checks the hash in a password reset link and presents a 
     106    form for entering a new password. 
     107    """ 
     108    assert uidb36 is not None and token is not None # checked by URLconf 
     109    try: 
     110        uid_int = base36_to_int(uidb36) 
     111    except ValueError: 
     112        raise Http404 
     113 
     114    user = get_object_or_404(User, id=uid_int) 
     115    context_instance = RequestContext(request) 
     116 
     117    if token_generator.check_token(user, token): 
     118        context_instance['validlink'] = True 
     119        if request.method == 'POST': 
     120            form = set_password_form(user, request.POST) 
     121            if form.is_valid(): 
     122                form.save() 
     123                return HttpResponseRedirect("../done/") 
     124        else: 
     125            form = set_password_form(None) 
     126    else: 
     127        context_instance['validlink'] = False 
     128        form = None 
     129    context_instance['form'] = form     
     130    return render_to_response(template_name, context_instance=context_instance) 
     131 
     132def password_reset_complete(request, template_name='registration/password_reset_complete.html'): 
    89133    return render_to_response(template_name, context_instance=RequestContext(request)) 
    90134 
  • django/trunk/django/utils/http.py

    r6634 r8162  
    6666    rfcdate = formatdate(epoch_seconds) 
    6767    return '%s GMT' % rfcdate[:25] 
     68 
     69# Base 36 functions: useful for generating compact URLs 
     70 
     71def base36_to_int(s): 
     72    """ 
     73    Convertd a base 36 string to an integer 
     74    """ 
     75    return int(s, 36) 
     76 
     77def int_to_base36(i): 
     78    """ 
     79    Converts an integer to a base36 string 
     80    """ 
     81    digits = "0123456789abcdefghijklmnopqrstuvwxyz" 
     82    factor = 0 
     83    # Find starting factor 
     84    while True: 
     85        factor += 1 
     86        if i < 36 ** factor: 
     87            factor -= 1 
     88            break 
     89    base36 = [] 
     90    # Construct base36 representation 
     91    while factor >= 0: 
     92        j = 36 ** factor 
     93        base36.append(digits[i / j]) 
     94        i = i % j 
     95        factor -= 1 
     96    return ''.join(base36)