Skip to content

Commit 7dea44e

Browse files
committed
Added a change password page, and linked to it from the account profile page and user menu. Added zxcvbn-based browser-side password strength estimation on the various password setting, re-setting, and changing forms. Added a change password test. Changed ietfauth/urls.py to not use the deprecated string form for views in urlpatterns.
- Legacy-Id: 12798
1 parent 93efc44 commit 7dea44e

10 files changed

Lines changed: 349 additions & 47 deletions

File tree

ietf/ietfauth/forms.py

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import re
2+
from unidecode import unidecode
23

34
from django import forms
45
from django.conf import settings
@@ -8,7 +9,7 @@
89
from django.utils.html import mark_safe
910
from django.core.urlresolvers import reverse as urlreverse
1011

11-
from unidecode import unidecode
12+
from django_password_strength.widgets import PasswordStrengthInput, PasswordConfirmationInput
1213

1314
import debug # pyflakes:ignore
1415

@@ -31,8 +32,8 @@ def clean_email(self):
3132

3233

3334
class PasswordForm(forms.Form):
34-
password = forms.CharField(widget=forms.PasswordInput)
35-
password_confirmation = forms.CharField(widget=forms.PasswordInput,
35+
password = forms.CharField(widget=PasswordStrengthInput)
36+
password_confirmation = forms.CharField(widget=PasswordConfirmationInput,
3637
help_text="Enter the same password as above, for verification.")
3738

3839
def clean_password_confirmation(self):
@@ -166,3 +167,28 @@ class Meta:
166167
exclude = ['by', 'time' ]
167168

168169

170+
from django import forms
171+
172+
173+
class ChangePasswordForm(forms.Form):
174+
current_password = forms.CharField(widget=forms.PasswordInput)
175+
176+
177+
new_password = forms.CharField(widget=PasswordStrengthInput)
178+
new_password_confirmation = forms.CharField(widget=PasswordConfirmationInput)
179+
180+
def __init__(self, user, data=None):
181+
self.user = user
182+
super(ChangePasswordForm, self).__init__(data)
183+
184+
def clean_current_password(self):
185+
password = self.cleaned_data.get('current_password', None)
186+
if not self.user.check_password(password):
187+
raise ValidationError('Invalid password')
188+
189+
def clean(self):
190+
new_password = self.cleaned_data.get('new_password', None)
191+
conf_password = self.cleaned_data.get('new_password_confirmation', None)
192+
if not new_password == conf_password:
193+
raise ValidationError("The password confirmation is different than the new password")
194+

ietf/ietfauth/tests.py

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,13 @@
66
from pyquery import PyQuery
77
from unittest import skipIf
88

9-
from django.core.urlresolvers import reverse as urlreverse
109
import django.contrib.auth.views
10+
from django.core.urlresolvers import reverse as urlreverse
1111
from django.contrib.auth.models import User
1212
from django.conf import settings
1313

14+
import debug # pyflakes:ignore
15+
1416
from ietf.utils.test_utils import TestCase, login_testing_unauthorized, unicontent
1517
from ietf.utils.test_data import make_test_data, make_review_data
1618
from ietf.utils.mail import outbox, empty_outbox
@@ -399,3 +401,51 @@ def test_htpasswd_file_with_htpasswd_binary(self):
399401
update_htpasswd_file("foo", "passwd")
400402
self.assertTrue(self.username_in_htpasswd_file("foo"))
401403

404+
405+
def test_change_password(self):
406+
407+
chpw_url = urlreverse(ietf.ietfauth.views.change_password)
408+
prof_url = urlreverse(ietf.ietfauth.views.profile)
409+
login_url = urlreverse(django.contrib.auth.views.login)
410+
redir_url = '%s?next=%s' % (login_url, chpw_url)
411+
412+
# get without logging in
413+
r = self.client.get(chpw_url)
414+
self.assertRedirects(r, redir_url)
415+
416+
user = User.objects.create(username="someone@example.com", email="someone@example.com")
417+
user.set_password("password")
418+
user.save()
419+
p = Person.objects.create(name="Some One", ascii="Some One", user=user)
420+
Email.objects.create(address=user.username, person=p)
421+
422+
# log in
423+
r = self.client.post(redir_url, {"username":user.username, "password":"password"})
424+
self.assertRedirects(r, chpw_url)
425+
426+
# wrong current password
427+
r = self.client.post(chpw_url, {"current_password": "fiddlesticks",
428+
"new_password": "foobar",
429+
"new_password_confirmation": "foobar",
430+
})
431+
self.assertEqual(r.status_code, 200)
432+
self.assertFormError(r, 'form', 'current_password', 'Invalid password')
433+
434+
# mismatching new passwords
435+
r = self.client.post(chpw_url, {"current_password": "password",
436+
"new_password": "foobar",
437+
"new_password_confirmation": "barfoo",
438+
})
439+
self.assertEqual(r.status_code, 200)
440+
self.assertFormError(r, 'form', None, "The password confirmation is different than the new password")
441+
442+
# correct password change
443+
r = self.client.post(chpw_url, {"current_password": "password",
444+
"new_password": "foobar",
445+
"new_password_confirmation": "foobar",
446+
})
447+
self.assertRedirects(r, prof_url)
448+
# refresh user object
449+
user = User.objects.get(username="someone@example.com")
450+
self.assertTrue(user.check_password(u'foobar'))
451+

ietf/ietfauth/urls.py

Lines changed: 12 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3,23 +3,20 @@
33
from django.conf.urls import url
44
from django.contrib.auth.views import login, logout
55

6-
from ietf.ietfauth.views import add_account_whitelist
6+
from ietf.ietfauth import views
77

88
urlpatterns = [
9-
url(r'^$', 'ietf.ietfauth.views.index'),
10-
# url(r'^login/$', 'ietf.ietfauth.views.ietf_login'),
9+
url(r'^$', views.index),
10+
url(r'^confirmnewemail/(?P<auth>[^/]+)/$', views.confirm_new_email),
11+
url(r'^create/$', views.create_account),
12+
url(r'^create/confirm/(?P<auth>[^/]+)/$', views.confirm_account),
1113
url(r'^login/$', login),
1214
url(r'^logout/$', logout),
13-
# url(r'^loggedin/$', 'ietf.ietfauth.views.ietf_loggedin'),
14-
# url(r'^loggedout/$', 'ietf.ietfauth.views.logged_out'),
15-
url(r'^profile/$', 'ietf.ietfauth.views.profile'),
16-
# (r'^login/(?P<user>[a-z0-9.@]+)/(?P<passwd>.+)$', 'ietf.ietfauth.views.url_login'),
17-
url(r'^testemail/$', 'ietf.ietfauth.views.test_email'),
18-
url(r'^create/$', 'ietf.ietfauth.views.create_account'),
19-
url(r'^create/confirm/(?P<auth>[^/]+)/$', 'ietf.ietfauth.views.confirm_account'),
20-
url(r'^reset/$', 'ietf.ietfauth.views.password_reset'),
21-
url(r'^reset/confirm/(?P<auth>[^/]+)/$', 'ietf.ietfauth.views.confirm_password_reset'),
22-
url(r'^confirmnewemail/(?P<auth>[^/]+)/$', 'ietf.ietfauth.views.confirm_new_email'),
23-
url(r'whitelist/add/?$', add_account_whitelist),
24-
url(r'^review/$', 'ietf.ietfauth.views.review_overview'),
15+
url(r'^password/$', views.change_password),
16+
url(r'^profile/$', views.profile),
17+
url(r'^reset/$', views.password_reset),
18+
url(r'^reset/confirm/(?P<auth>[^/]+)/$', views.confirm_password_reset),
19+
url(r'^review/$', views.review_overview),
20+
url(r'^testemail/$', views.test_email),
21+
url(r'whitelist/add/?$', views.add_account_whitelist),
2522
]

ietf/ietfauth/views.py

Lines changed: 61 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -32,24 +32,27 @@
3232

3333
# Copyright The IETF Trust 2007, All Rights Reserved
3434

35+
import importlib
36+
3537
from datetime import datetime as DateTime, timedelta as TimeDelta, date as Date
3638
from collections import defaultdict
3739

40+
import django.core.signing
41+
from django import forms
42+
from django.contrib import messages
3843
from django.conf import settings
39-
from django.http import Http404 #, HttpResponse, HttpResponseRedirect
40-
from django.shortcuts import render, redirect, get_object_or_404
41-
#from django.contrib.auth import REDIRECT_FIELD_NAME, authenticate, login
44+
from django.contrib.auth import update_session_auth_hash
4245
from django.contrib.auth.decorators import login_required
43-
#from django.utils.http import urlquote
44-
import django.core.signing
45-
from django.contrib.sites.models import Site
4646
from django.contrib.auth.models import User
47-
from django import forms
47+
from django.contrib.sites.models import Site
48+
from django.core.urlresolvers import reverse as urlreverse
49+
from django.http import Http404, HttpResponseRedirect #, HttpResponse,
50+
from django.shortcuts import render, redirect, get_object_or_404
4851

4952
import debug # pyflakes:ignore
5053

5154
from ietf.group.models import Role, Group
52-
from ietf.ietfauth.forms import RegistrationForm, PasswordForm, ResetPasswordForm, TestEmailForm, WhitelistForm
55+
from ietf.ietfauth.forms import RegistrationForm, PasswordForm, ResetPasswordForm, TestEmailForm, WhitelistForm, ChangePasswordForm
5356
from ietf.ietfauth.forms import get_person_form, RoleEmailForm, NewEmailForm
5457
from ietf.ietfauth.htpasswd import update_htpasswd_file
5558
from ietf.ietfauth.utils import role_required
@@ -340,10 +343,14 @@ def confirm_password_reset(request, auth):
340343
else:
341344
form = PasswordForm()
342345

346+
hlibname, hashername = settings.PASSWORD_HASHERS[0].rsplit('.',1)
347+
hlib = importlib.import_module(hlibname)
348+
hasher = getattr(hlib, hashername)
343349
return render(request, 'registration/change_password.html', {
344350
'form': form,
345-
'username': username,
351+
'user': user,
346352
'success': success,
353+
'hasher': hasher,
347354
})
348355

349356
def test_email(request):
@@ -465,3 +472,48 @@ def review_overview(request):
465472
'review_wishes': review_wishes,
466473
'review_wish_form': review_wish_form,
467474
})
475+
476+
@login_required
477+
def change_password(request):
478+
success = False
479+
person = None
480+
481+
try:
482+
person = request.user.person
483+
except Person.DoesNotExist:
484+
return render(request, 'registration/missing_person.html')
485+
486+
emails = [ e.address for e in Email.objects.filter(person=person, active=True).order_by('-primary','-time') ]
487+
user = request.user
488+
489+
if request.method == 'POST':
490+
form = ChangePasswordForm(user, request.POST)
491+
if form.is_valid():
492+
new_password = form.cleaned_data["new_password"]
493+
494+
user.set_password(new_password)
495+
user.save()
496+
# password is also stored in htpasswd file
497+
update_htpasswd_file(user.username, new_password)
498+
# keep the session
499+
update_session_auth_hash(request, user)
500+
501+
send_mail(request, emails, None, "Datatracker password change notification", "registration/password_change_email.txt", {})
502+
503+
messages.success(request, "Your password was successfully changed")
504+
return HttpResponseRedirect(urlreverse('ietf.ietfauth.views.profile'))
505+
506+
else:
507+
form = ChangePasswordForm(request.user)
508+
509+
hlibname, hashername = settings.PASSWORD_HASHERS[0].rsplit('.',1)
510+
hlib = importlib.import_module(hlibname)
511+
hasher = getattr(hlib, hashername)
512+
return render(request, 'registration/change_password.html', {
513+
'form': form,
514+
'user': user,
515+
'success': success,
516+
'hasher': hasher,
517+
})
518+
519+

ietf/ipr/views.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -704,7 +704,6 @@ def get_details_tabs(ipr, selected):
704704
('History', urlreverse('ipr_history', kwargs={ 'id': ipr.pk }))
705705
]]
706706

707-
@debug.trace
708707
def show(request, id):
709708
"""View of individual declaration"""
710709
ipr = get_object_or_404(IprDisclosureBase, id=id).get_child()
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
// Taken from django-password-strength, with changes to use the bower-managed zxcvbn.js The
2+
// bower-managed zxcvbn.js is kept up-to-date to a larger extent than the copy packaged with
3+
// the django-password-strength component.
4+
(function($, window, document, undefined){
5+
window.djangoPasswordStrength = {
6+
config: {
7+
passwordClass: 'password_strength',
8+
confirmationClass: 'password_confirmation'
9+
},
10+
11+
init: function (config) {
12+
var self = this;
13+
// Setup configuration
14+
if ($.isPlainObject(config)) {
15+
$.extend(self.config, config);
16+
}
17+
18+
self.initListeners();
19+
},
20+
21+
initListeners: function() {
22+
var self = this;
23+
var body = $('body');
24+
25+
$('.' + self.config.passwordClass).on('keyup', function() {
26+
var password_strength_bar = $(this).parent().find('.password_strength_bar');
27+
var password_strength_info = $(this).parent().find('.password_strength_info');
28+
29+
if( $(this).val() ) {
30+
var result = zxcvbn( $(this).val() );
31+
32+
if( result.score < 3 ) {
33+
password_strength_bar.removeClass('progress-bar-success').addClass('progress-bar-warning');
34+
password_strength_info.find('.label').removeClass('hidden');
35+
} else {
36+
password_strength_bar.removeClass('progress-bar-warning').addClass('progress-bar-success');
37+
password_strength_info.find('.label').addClass('hidden');
38+
}
39+
40+
password_strength_bar.width( ((result.score+1)/5)*100 + '%' ).attr('aria-valuenow', result.score + 1);
41+
// henrik@levkowetz.com -- this is the only changed line:
42+
password_strength_info.find('.password_strength_time').html(result.crack_times_display.online_no_throttling_10_per_second);
43+
password_strength_info.removeClass('hidden');
44+
} else {
45+
password_strength_bar.removeClass('progress-bar-success').addClass('progress-bar-warning');
46+
password_strength_bar.width( '0%' ).attr('aria-valuenow', 0);
47+
password_strength_info.addClass('hidden');
48+
}
49+
self.match_passwords($(this));
50+
});
51+
52+
var timer = null;
53+
$('.' + self.config.confirmationClass).on('keyup', function() {
54+
var password_field;
55+
var confirm_with = $(this).data('confirm-with');
56+
57+
if( confirm_with ) {
58+
password_field = $('#' + confirm_with);
59+
} else {
60+
password_field = $('.' + self.config.passwordClass);
61+
}
62+
63+
if (timer !== null) clearTimeout(timer);
64+
65+
timer = setTimeout(function(){
66+
self.match_passwords(password_field);
67+
}, 400);
68+
});
69+
},
70+
71+
display_time: function(seconds) {
72+
var minute = 60;
73+
var hour = minute * 60;
74+
var day = hour * 24;
75+
var month = day * 31;
76+
var year = month * 12;
77+
var century = year * 100;
78+
79+
// Provide fake gettext for when it is not available
80+
if( typeof gettext !== 'function' ) { gettext = function(text) { return text; }; };
81+
82+
if( seconds < minute ) return gettext('only an instant');
83+
if( seconds < hour) return (1 + Math.ceil(seconds / minute)) + ' ' + gettext('minutes');
84+
if( seconds < day) return (1 + Math.ceil(seconds / hour)) + ' ' + gettext('hours');
85+
if( seconds < month) return (1 + Math.ceil(seconds / day)) + ' ' + gettext('days');
86+
if( seconds < year) return (1 + Math.ceil(seconds / month)) + ' ' + gettext('months');
87+
if( seconds < century) return (1 + Math.ceil(seconds / year)) + ' ' + gettext('years');
88+
89+
return gettext('centuries');
90+
},
91+
92+
match_passwords: function(password_field, confirmation_fields) {
93+
var self = this;
94+
// Optional parameter: if no specific confirmation field is given, check all
95+
if( confirmation_fields === undefined ) { confirmation_fields = $('.' + self.config.confirmationClass) }
96+
if( confirmation_fields === undefined ) { return; }
97+
98+
var password = password_field.val();
99+
100+
confirmation_fields.each(function(index, confirm_field) {
101+
var confirm_value = $(confirm_field).val();
102+
var confirm_with = $(confirm_field).data('confirm-with');
103+
104+
if( confirm_with && confirm_with == password_field.attr('id')) {
105+
if( confirm_value && password ) {
106+
if (confirm_value === password) {
107+
$(confirm_field).parent().find('.password_strength_info').addClass('hidden');
108+
} else {
109+
$(confirm_field).parent().find('.password_strength_info').removeClass('hidden');
110+
}
111+
} else {
112+
$(confirm_field).parent().find('.password_strength_info').addClass('hidden');
113+
}
114+
}
115+
});
116+
117+
// If a password field other than our own has been used, add the listener here
118+
if( !password_field.hasClass(self.config.passwordClass) && !password_field.data('password-listener') ) {
119+
password_field.on('keyup', function() {
120+
self.match_passwords($(this));
121+
});
122+
password_field.data('password-listener', true);
123+
}
124+
}
125+
};
126+
127+
// Call the init for backwards compatibility
128+
djangoPasswordStrength.init();
129+
130+
})(jQuery, window, document);

0 commit comments

Comments
 (0)