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
This commit is contained in:
Henrik Levkowetz 2017-02-09 17:03:44 +00:00
parent 93efc4470a
commit 7dea44e626
10 changed files with 348 additions and 46 deletions

View file

@ -1,4 +1,5 @@
import re
from unidecode import unidecode
from django import forms
from django.conf import settings
@ -8,7 +9,7 @@ from django.contrib.auth.models import User
from django.utils.html import mark_safe
from django.core.urlresolvers import reverse as urlreverse
from unidecode import unidecode
from django_password_strength.widgets import PasswordStrengthInput, PasswordConfirmationInput
import debug # pyflakes:ignore
@ -31,8 +32,8 @@ class RegistrationForm(forms.Form):
class PasswordForm(forms.Form):
password = forms.CharField(widget=forms.PasswordInput)
password_confirmation = forms.CharField(widget=forms.PasswordInput,
password = forms.CharField(widget=PasswordStrengthInput)
password_confirmation = forms.CharField(widget=PasswordConfirmationInput,
help_text="Enter the same password as above, for verification.")
def clean_password_confirmation(self):
@ -166,3 +167,28 @@ class WhitelistForm(forms.ModelForm):
exclude = ['by', 'time' ]
from django import forms
class ChangePasswordForm(forms.Form):
current_password = forms.CharField(widget=forms.PasswordInput)
new_password = forms.CharField(widget=PasswordStrengthInput)
new_password_confirmation = forms.CharField(widget=PasswordConfirmationInput)
def __init__(self, user, data=None):
self.user = user
super(ChangePasswordForm, self).__init__(data)
def clean_current_password(self):
password = self.cleaned_data.get('current_password', None)
if not self.user.check_password(password):
raise ValidationError('Invalid password')
def clean(self):
new_password = self.cleaned_data.get('new_password', None)
conf_password = self.cleaned_data.get('new_password_confirmation', None)
if not new_password == conf_password:
raise ValidationError("The password confirmation is different than the new password")

View file

@ -6,11 +6,13 @@ from urlparse import urlsplit
from pyquery import PyQuery
from unittest import skipIf
from django.core.urlresolvers import reverse as urlreverse
import django.contrib.auth.views
from django.core.urlresolvers import reverse as urlreverse
from django.contrib.auth.models import User
from django.conf import settings
import debug # pyflakes:ignore
from ietf.utils.test_utils import TestCase, login_testing_unauthorized, unicontent
from ietf.utils.test_data import make_test_data, make_review_data
from ietf.utils.mail import outbox, empty_outbox
@ -399,3 +401,51 @@ class IetfAuthTests(TestCase):
update_htpasswd_file("foo", "passwd")
self.assertTrue(self.username_in_htpasswd_file("foo"))
def test_change_password(self):
chpw_url = urlreverse(ietf.ietfauth.views.change_password)
prof_url = urlreverse(ietf.ietfauth.views.profile)
login_url = urlreverse(django.contrib.auth.views.login)
redir_url = '%s?next=%s' % (login_url, chpw_url)
# get without logging in
r = self.client.get(chpw_url)
self.assertRedirects(r, redir_url)
user = User.objects.create(username="someone@example.com", email="someone@example.com")
user.set_password("password")
user.save()
p = Person.objects.create(name="Some One", ascii="Some One", user=user)
Email.objects.create(address=user.username, person=p)
# log in
r = self.client.post(redir_url, {"username":user.username, "password":"password"})
self.assertRedirects(r, chpw_url)
# wrong current password
r = self.client.post(chpw_url, {"current_password": "fiddlesticks",
"new_password": "foobar",
"new_password_confirmation": "foobar",
})
self.assertEqual(r.status_code, 200)
self.assertFormError(r, 'form', 'current_password', 'Invalid password')
# mismatching new passwords
r = self.client.post(chpw_url, {"current_password": "password",
"new_password": "foobar",
"new_password_confirmation": "barfoo",
})
self.assertEqual(r.status_code, 200)
self.assertFormError(r, 'form', None, "The password confirmation is different than the new password")
# correct password change
r = self.client.post(chpw_url, {"current_password": "password",
"new_password": "foobar",
"new_password_confirmation": "foobar",
})
self.assertRedirects(r, prof_url)
# refresh user object
user = User.objects.get(username="someone@example.com")
self.assertTrue(user.check_password(u'foobar'))

View file

@ -3,23 +3,20 @@
from django.conf.urls import url
from django.contrib.auth.views import login, logout
from ietf.ietfauth.views import add_account_whitelist
from ietf.ietfauth import views
urlpatterns = [
url(r'^$', 'ietf.ietfauth.views.index'),
# url(r'^login/$', 'ietf.ietfauth.views.ietf_login'),
url(r'^$', views.index),
url(r'^confirmnewemail/(?P<auth>[^/]+)/$', views.confirm_new_email),
url(r'^create/$', views.create_account),
url(r'^create/confirm/(?P<auth>[^/]+)/$', views.confirm_account),
url(r'^login/$', login),
url(r'^logout/$', logout),
# url(r'^loggedin/$', 'ietf.ietfauth.views.ietf_loggedin'),
# url(r'^loggedout/$', 'ietf.ietfauth.views.logged_out'),
url(r'^profile/$', 'ietf.ietfauth.views.profile'),
# (r'^login/(?P<user>[a-z0-9.@]+)/(?P<passwd>.+)$', 'ietf.ietfauth.views.url_login'),
url(r'^testemail/$', 'ietf.ietfauth.views.test_email'),
url(r'^create/$', 'ietf.ietfauth.views.create_account'),
url(r'^create/confirm/(?P<auth>[^/]+)/$', 'ietf.ietfauth.views.confirm_account'),
url(r'^reset/$', 'ietf.ietfauth.views.password_reset'),
url(r'^reset/confirm/(?P<auth>[^/]+)/$', 'ietf.ietfauth.views.confirm_password_reset'),
url(r'^confirmnewemail/(?P<auth>[^/]+)/$', 'ietf.ietfauth.views.confirm_new_email'),
url(r'whitelist/add/?$', add_account_whitelist),
url(r'^review/$', 'ietf.ietfauth.views.review_overview'),
url(r'^password/$', views.change_password),
url(r'^profile/$', views.profile),
url(r'^reset/$', views.password_reset),
url(r'^reset/confirm/(?P<auth>[^/]+)/$', views.confirm_password_reset),
url(r'^review/$', views.review_overview),
url(r'^testemail/$', views.test_email),
url(r'whitelist/add/?$', views.add_account_whitelist),
]

View file

@ -32,24 +32,27 @@
# Copyright The IETF Trust 2007, All Rights Reserved
import importlib
from datetime import datetime as DateTime, timedelta as TimeDelta, date as Date
from collections import defaultdict
from django.conf import settings
from django.http import Http404 #, HttpResponse, HttpResponseRedirect
from django.shortcuts import render, redirect, get_object_or_404
#from django.contrib.auth import REDIRECT_FIELD_NAME, authenticate, login
from django.contrib.auth.decorators import login_required
#from django.utils.http import urlquote
import django.core.signing
from django.contrib.sites.models import Site
from django.contrib.auth.models import User
from django import forms
from django.contrib import messages
from django.conf import settings
from django.contrib.auth import update_session_auth_hash
from django.contrib.auth.decorators import login_required
from django.contrib.auth.models import User
from django.contrib.sites.models import Site
from django.core.urlresolvers import reverse as urlreverse
from django.http import Http404, HttpResponseRedirect #, HttpResponse,
from django.shortcuts import render, redirect, get_object_or_404
import debug # pyflakes:ignore
from ietf.group.models import Role, Group
from ietf.ietfauth.forms import RegistrationForm, PasswordForm, ResetPasswordForm, TestEmailForm, WhitelistForm
from ietf.ietfauth.forms import RegistrationForm, PasswordForm, ResetPasswordForm, TestEmailForm, WhitelistForm, ChangePasswordForm
from ietf.ietfauth.forms import get_person_form, RoleEmailForm, NewEmailForm
from ietf.ietfauth.htpasswd import update_htpasswd_file
from ietf.ietfauth.utils import role_required
@ -340,10 +343,14 @@ def confirm_password_reset(request, auth):
else:
form = PasswordForm()
hlibname, hashername = settings.PASSWORD_HASHERS[0].rsplit('.',1)
hlib = importlib.import_module(hlibname)
hasher = getattr(hlib, hashername)
return render(request, 'registration/change_password.html', {
'form': form,
'username': username,
'user': user,
'success': success,
'hasher': hasher,
})
def test_email(request):
@ -465,3 +472,48 @@ def review_overview(request):
'review_wishes': review_wishes,
'review_wish_form': review_wish_form,
})
@login_required
def change_password(request):
success = False
person = None
try:
person = request.user.person
except Person.DoesNotExist:
return render(request, 'registration/missing_person.html')
emails = [ e.address for e in Email.objects.filter(person=person, active=True).order_by('-primary','-time') ]
user = request.user
if request.method == 'POST':
form = ChangePasswordForm(user, request.POST)
if form.is_valid():
new_password = form.cleaned_data["new_password"]
user.set_password(new_password)
user.save()
# password is also stored in htpasswd file
update_htpasswd_file(user.username, new_password)
# keep the session
update_session_auth_hash(request, user)
send_mail(request, emails, None, "Datatracker password change notification", "registration/password_change_email.txt", {})
messages.success(request, "Your password was successfully changed")
return HttpResponseRedirect(urlreverse('ietf.ietfauth.views.profile'))
else:
form = ChangePasswordForm(request.user)
hlibname, hashername = settings.PASSWORD_HASHERS[0].rsplit('.',1)
hlib = importlib.import_module(hlibname)
hasher = getattr(hlib, hashername)
return render(request, 'registration/change_password.html', {
'form': form,
'user': user,
'success': success,
'hasher': hasher,
})

View file

@ -704,7 +704,6 @@ def get_details_tabs(ipr, selected):
('History', urlreverse('ipr_history', kwargs={ 'id': ipr.pk }))
]]
@debug.trace
def show(request, id):
"""View of individual declaration"""
ipr = get_object_or_404(IprDisclosureBase, id=id).get_child()

View file

@ -0,0 +1,130 @@
// Taken from django-password-strength, with changes to use the bower-managed zxcvbn.js The
// bower-managed zxcvbn.js is kept up-to-date to a larger extent than the copy packaged with
// the django-password-strength component.
(function($, window, document, undefined){
window.djangoPasswordStrength = {
config: {
passwordClass: 'password_strength',
confirmationClass: 'password_confirmation'
},
init: function (config) {
var self = this;
// Setup configuration
if ($.isPlainObject(config)) {
$.extend(self.config, config);
}
self.initListeners();
},
initListeners: function() {
var self = this;
var body = $('body');
$('.' + self.config.passwordClass).on('keyup', function() {
var password_strength_bar = $(this).parent().find('.password_strength_bar');
var password_strength_info = $(this).parent().find('.password_strength_info');
if( $(this).val() ) {
var result = zxcvbn( $(this).val() );
if( result.score < 3 ) {
password_strength_bar.removeClass('progress-bar-success').addClass('progress-bar-warning');
password_strength_info.find('.label').removeClass('hidden');
} else {
password_strength_bar.removeClass('progress-bar-warning').addClass('progress-bar-success');
password_strength_info.find('.label').addClass('hidden');
}
password_strength_bar.width( ((result.score+1)/5)*100 + '%' ).attr('aria-valuenow', result.score + 1);
// henrik@levkowetz.com -- this is the only changed line:
password_strength_info.find('.password_strength_time').html(result.crack_times_display.online_no_throttling_10_per_second);
password_strength_info.removeClass('hidden');
} else {
password_strength_bar.removeClass('progress-bar-success').addClass('progress-bar-warning');
password_strength_bar.width( '0%' ).attr('aria-valuenow', 0);
password_strength_info.addClass('hidden');
}
self.match_passwords($(this));
});
var timer = null;
$('.' + self.config.confirmationClass).on('keyup', function() {
var password_field;
var confirm_with = $(this).data('confirm-with');
if( confirm_with ) {
password_field = $('#' + confirm_with);
} else {
password_field = $('.' + self.config.passwordClass);
}
if (timer !== null) clearTimeout(timer);
timer = setTimeout(function(){
self.match_passwords(password_field);
}, 400);
});
},
display_time: function(seconds) {
var minute = 60;
var hour = minute * 60;
var day = hour * 24;
var month = day * 31;
var year = month * 12;
var century = year * 100;
// Provide fake gettext for when it is not available
if( typeof gettext !== 'function' ) { gettext = function(text) { return text; }; };
if( seconds < minute ) return gettext('only an instant');
if( seconds < hour) return (1 + Math.ceil(seconds / minute)) + ' ' + gettext('minutes');
if( seconds < day) return (1 + Math.ceil(seconds / hour)) + ' ' + gettext('hours');
if( seconds < month) return (1 + Math.ceil(seconds / day)) + ' ' + gettext('days');
if( seconds < year) return (1 + Math.ceil(seconds / month)) + ' ' + gettext('months');
if( seconds < century) return (1 + Math.ceil(seconds / year)) + ' ' + gettext('years');
return gettext('centuries');
},
match_passwords: function(password_field, confirmation_fields) {
var self = this;
// Optional parameter: if no specific confirmation field is given, check all
if( confirmation_fields === undefined ) { confirmation_fields = $('.' + self.config.confirmationClass) }
if( confirmation_fields === undefined ) { return; }
var password = password_field.val();
confirmation_fields.each(function(index, confirm_field) {
var confirm_value = $(confirm_field).val();
var confirm_with = $(confirm_field).data('confirm-with');
if( confirm_with && confirm_with == password_field.attr('id')) {
if( confirm_value && password ) {
if (confirm_value === password) {
$(confirm_field).parent().find('.password_strength_info').addClass('hidden');
} else {
$(confirm_field).parent().find('.password_strength_info').removeClass('hidden');
}
} else {
$(confirm_field).parent().find('.password_strength_info').addClass('hidden');
}
}
});
// If a password field other than our own has been used, add the listener here
if( !password_field.hasClass(self.config.passwordClass) && !password_field.data('password-listener') ) {
password_field.on('keyup', function() {
self.match_passwords($(this));
});
password_field.data('password-listener', true);
}
}
};
// Call the init for backwards compatibility
djangoPasswordStrength.init();
})(jQuery, window, document);

View file

@ -16,14 +16,17 @@
{% else %}
{% if user.is_authenticated %}
<li><a rel="nofollow" href="/accounts/logout/" >Sign out</a></li>
<li><a rel="nofollow" href="/accounts/profile/">Edit profile</a></li>
<li><a rel="nofollow" href="/accounts/profile/">Account info</a></li>
<li><a rel="nofollow" href="/accounts/password/">Change password</a></li>
{% else %}
<li><a rel="nofollow" href="/accounts/login/?next={{request.get_full_path|urlencode}}">Sign in</a></li>
<li><a rel="nofollow" href="/accounts/reset/">Password reset</a></li>
{% endif %}
{% endif %}
<li><a href="{% url "ietf.ietfauth.views.create_account" %}">{% if request.user.is_authenticated %}Manage account{% else %}New account{% endif %}</a></li>
{% if not request.user.is_authenticated %}
<li><a href="{% url "ietf.ietfauth.views.create_account" %}">New account</a></li>
{% endif %}
<li><a href="{%url "ietf.cookies.views.preferences" %}" rel="nofollow">Preferences</a></li>
{% if user|has_role:"Reviewer" %}

View file

@ -3,30 +3,58 @@
{% load origin %}
{% load bootstrap3 %}
{% load staticfiles %}
{% block title %}Change password{% endblock %}
{% block js %}
{{ block.super }}
<script type="text/javascript" src="{% static 'zxcvbn/zxcvbn.js' %}"></script>
<script type="text/javascript" src="{% static 'ietf/js/password_strength.js' %}"></script>
{% endblock %}
{% block content %}
{% origin %}
{% if success %}
<h1>Password change successful</h1>
<p>Your password has been updated.</p>
<a type="a" class="btn btn-primary" href="/accounts/login/" rel="nofollow">Sign in</a>
<h1>Your password was successfully changed.</h1>
{% if not user.is_authenticated %}
<a type="a" class="btn btn-primary" href="/accounts/login/" rel="nofollow">Sign in</a>
{% endif %}
{% else %}
<h1>Change password</h1>
<div class="row">
<div class="col-md-2 col-sm-0"></div>
<div class="col-md-8 col-sm-12">
<h1>Change password</h1>
<p>You can change the password below for your user {{ username }} below.</p>
<form method="post">
{% csrf_token %}
{% bootstrap_form form %}
<form method="post">
{% csrf_token %}
{% bootstrap_form form %}
{% buttons %}
<button type="submit" class="btn btn-primary">Change password</button>
{% endbuttons %}
</form>
{% buttons %}
<button type="submit" class="btn btn-primary">Change password</button>
{% endbuttons %}
</form>
<div class="help-block">
This password form uses the
<a href="https://blogs.dropbox.com/tech/2012/04/zxcvbn-realistic-password-strength-estimation/">zxcvbn</a>
password strength estimator to give an indication of password strength.
The crack times given assume online attack without rate limiting,
at a rate of 10 attempts per second.
</div>
<div class="help-block">
The datatracker currently uses a <b>{{ hasher.algorithm }}</b>-based
password hasher with
<b>{% if hasher.iterations %}{{ hasher.iterations }} iterations{% else %}{{ hasher.rounds }} rounds{% endif %}</b>.
Calculating offline attack time if password hashes would leak is left
as an excercise for the reader.
</div>
</div>
<div class="col-md-2 col-sm-0"></div>
</div>
{% endif %}
{% endblock %}

View file

@ -1,11 +1,18 @@
{% extends "base.html" %}
{# Copyright The IETF Trust 2015, All Rights Reserved #}
{% load origin %}
{% load staticfiles %}
{% load bootstrap3 %}
{% block title %}Complete account creation{% endblock %}
{% block js %}
{{ block.super }}
<script type="text/javascript" src="{% static 'zxcvbn/zxcvbn.js' %}"></script>
<script type="text/javascript" src="{% static 'ietf/js/password_strength.js' %}"></script>
{% endblock %}
{% block content %}
{% origin %}

View file

@ -0,0 +1,10 @@
{% autoescape off %}
Hello,
{% filter wordwrap:73 %}The password for your datatracker account was just changed using the password change form. If this was not done by you, please contact the secretariat at ietf-action@ietf.org for assistance.{% endfilter %}
Best regards,
The datatracker account manager service
(for the IETF Secretariat)
{% endautoescape %}