create assisted password input in registration (#24439)

This commit is contained in:
Elias Showk 2018-07-17 09:15:28 +02:00
parent 6235bc1782
commit 71785a47e3
11 changed files with 541 additions and 8 deletions

View File

@ -143,6 +143,7 @@ default_settings = dict(
A2_PASSWORD_POLICY_MIN_LENGTH=Setting(default=8, definition='Minimum number of characters in a password'),
A2_PASSWORD_POLICY_REGEX=Setting(default=None, definition='Regular expression for validating passwords'),
A2_PASSWORD_POLICY_REGEX_ERROR_MSG=Setting(default=None, definition='Error message to show when the password do not validate the regular expression'),
A2_PASSWORD_WIDGET_SHOW_ALL_BUTTON=Setting(default=False, definition='Show a button on BasePasswordInput for the user to see password input text'),
A2_PASSWORD_POLICY_CLASS=Setting(
default='authentic2.passwords.DefaultPasswordChecker',
definition='path of a class to validate passwords'),

View File

@ -1,6 +1,7 @@
import re
import copy
from collections import OrderedDict
import json
from django.conf import settings
from django.core.exceptions import ValidationError
@ -15,10 +16,10 @@ from django.contrib.auth import forms as auth_forms, get_user_model, REDIRECT_FI
from django.core.mail import send_mail
from django.core import signing
from django.template import RequestContext
from django.template.loader import render_to_string
from django.core.urlresolvers import reverse
from django.core.validators import RegexValidator
from .widgets import CheckPasswordInput, NewPasswordInput
from .. import app_settings, compat, forms, utils, validators, models, middleware, hooks
from authentic2.a2_rbac.models import OrganizationalUnit
@ -115,10 +116,11 @@ class RegistrationCompletionFormNoPassword(forms.BaseUserForm):
class RegistrationCompletionForm(RegistrationCompletionFormNoPassword):
password1 = CharField(widget=PasswordInput, label=_("Password"),
validators=[validators.validate_password],
help_text=validators.password_help_text())
password2 = CharField(widget=PasswordInput, label=_("Password (again)"))
password1 = CharField(widget=NewPasswordInput(), label=_("Password"),
validators=[validators.validate_password],
help_text=validators.password_help_text())
password2 = CharField(widget=CheckPasswordInput(), label=_("Password (again)"))
def clean(self):
"""

View File

@ -0,0 +1,92 @@
from django.forms import PasswordInput
from django.template.loader import render_to_string
from django.utils.encoding import force_text
from django.utils.safestring import mark_safe
from .. import app_settings
class BasePasswordInput(PasswordInput):
"""
a password Input with some features to help the user choosing a new password
Inspired by Django >= 1.11 new-style rendering
(cf. https://docs.djangoproject.com/fr/1.11/ref/forms/renderers)
"""
template_name = 'authentic2/widgets/assisted_password.html'
features = {}
class Media:
js = ('authentic2/js/password.js',)
css = {
'all': ('authentic2/css/password.css',)
}
def get_context(self, name, value, attrs):
"""
Base get_context
"""
context = {
'app_settings': {
'A2_PASSWORD_POLICY_MIN_LENGTH': app_settings.A2_PASSWORD_POLICY_MIN_LENGTH,
'A2_PASSWORD_POLICY_MIN_CLASSES': app_settings.A2_PASSWORD_POLICY_MIN_CLASSES,
'A2_PASSWORD_POLICY_REGEX': app_settings.A2_PASSWORD_POLICY_REGEX,
},
'features': self.features
}
# attach data-* attributes for password.js to activate events
attrs.update(dict([('data-%s' % feat.replace('_', '-'), is_active) for feat, is_active in self.features.items()]))
context['widget'] = {
'name': name,
'is_hidden': self.is_hidden,
'required': self.is_required,
'template_name': self.template_name,
'attrs': self.build_attrs(extra_attrs=attrs, name=name, type=self.input_type)
}
# Only add the 'value' attribute if a value is non-empty.
if value is None:
value = ''
if value != '':
context['widget']['value'] = force_text(self._format_value(value))
return context
def render(self, name, value, attrs=None, **kwargs):
"""
Override render with a template-based system
Remove this line when dropping Django 1.8, 1.9, 1.10 compatibility
"""
return mark_safe(render_to_string(self.template_name,
self.get_context(name, value, attrs)))
class CheckPasswordInput(BasePasswordInput):
"""
Password typing assistance widget (eg. password2)
"""
features = {
'check_equality': True,
'show_all': app_settings.A2_PASSWORD_WIDGET_SHOW_ALL_BUTTON,
'show_last': True,
}
def get_context(self, name, value, attrs):
context = super(CheckPasswordInput, self).get_context(
name, value, attrs)
return context
class NewPasswordInput(CheckPasswordInput):
"""
Password creation assistance widget with policy (eg. password1)
"""
features = {
'check_equality': False,
'show_all': app_settings.A2_PASSWORD_WIDGET_SHOW_ALL_BUTTON,
'show_last': True,
'check_policy': True,
}
def get_context(self, name, value, attrs):
context = super(NewPasswordInput, self).get_context(name, value, attrs)
return context

View File

@ -0,0 +1,140 @@
/* required in order to position a2-password-show-all and a2-password-show-last */
input[type=password].a2-password-assisted {
padding-right: 60px;
width: 100%;
}
.a2-password-icon {
display: inline-block;
width: calc(18em / 14);
text-align: center;
font-style: normal;
padding-right: 1em;
}
/* default circle icon */
.a2-password-icon:before {
font-family: FontAwesome;
content: "\f111"; /* right hand icon */
font-size: 50%;
}
.a2-password-policy-helper {
display: flex;
height: auto;
flex-direction: row;
flex-wrap: wrap;
position: relative;
padding: 0.5rem 1rem;
width: 90%;
}
/* we don't want helptext when a2-password-policy-helper is here */
.a2-password-policy-helper ~ .helptext {
display: none;
}
.a2-password-policy-rule {
flex: 1 1 50%;
list-style: none;
}
.password-error {
color: black;
}
.password-ok {
color: green;
}
.password-error .a2-password-icon:before {
content: "\f00d"; /* cross icon */
color: red;
}
.password-ok .a2-password-icon::before {
content: "\f00c"; /* ok icon */
color: green;
}
.a2-password-show-last {
position: relative;
display: inline-block;
float: right;
opacity: 0;
text-align: center;
right: 10px;
top: -4.5ex;
width: 20px;
}
.a2-password-show-button {
position: relative;
display: inline-block;
float: right;
padding: 0;
right: 10px;
top: -4.4ex;
cursor: pointer;
width: 20px;
}
.a2-password-show-button:after {
content: "\f06e"; /* eye */
font-family: FontAwesome;
font-size: 125%;
}
.hide-password-button:after {
content: "\f070"; /* crossed eye */
font-family: FontAwesome;
font-size: 125%;
}
.a2-passwords-messages {
display: block;
padding: 0.5rem 1rem;
}
.a2-passwords-default {
list-style: none;
opacity: 0;
}
.password-error .a2-passwords-default,
.password-ok .a2-passwords-default {
display: none;
}
.a2-passwords-matched,
.a2-passwords-unmatched {
display: none;
list-style: none;
opacity: 0;
transition: all 0.3s ease;
}
.password-error.a2-passwords-messages:before,
.password-ok.a2-passwords-messages:before {
display: none;
}
.password-error .a2-passwords-unmatched,
.password-ok .a2-passwords-matched {
display: block;
opacity: 1;
}
.password-error .a2-passwords-unmatched .a2-password-icon:before {
content: "\f00d"; /* cross icon */
color: red;
}
.password-ok .a2-passwords-matched .a2-password-icon:before {
content: "\f00c"; /* ok icon */
color: green;
}
.a2-password-policy-intro {
margin: 0;
}

View File

@ -76,3 +76,22 @@
.a2-log-message {
white-space: pre-wrap;
}
.a2-registration-completion {
padding: 1rem;
min-width: 320px;
width: 50%;
}
@media screen and (max-width: 800px) {
.a2-registration-completion {
width: 100%;
}
}
.a2-registration-completion input,
.a2-registration-completion select,
.a2-registration-completion textarea
{
width: 100%;
}

View File

@ -0,0 +1,196 @@
"use strict";
/* globals $, window, console */
$(function () {
var debounce = function (func, milliseconds) {
var timer;
return function() {
window.clearTimeout(timer);
timer = window.setTimeout(function() {
func();
}, milliseconds);
};
}
var toggleError = function($elt) {
$elt.removeClass('password-ok');
$elt.addClass('password-error');
}
var toggleOk = function($elt) {
$elt.removeClass('password-error');
$elt.addClass('password-ok');
}
/*
* toggle error/ok on element with class names same as the validation code names
* (cf. error_codes in authentic2.validators.validate_password)
*/
var validatePassword = function(event) {
var $this = $(event.target);
if (event.type == 'paste') {
window.setTimeout(function() {
$this.trigger('keyup');
});
return;
}
var password = $this.val();
var inputName = $this.attr('name');
getValidation(password, inputName);
}
var getValidation = function(password, inputName) {
var policyContainer = $('#a2-password-policy-helper-' + inputName);
$.ajax({
method: 'POST',
url: '/api/validate-password/',
data: JSON.stringify({'password': password}),
dataType: 'json',
contentType: 'application/json; charset=utf-8',
success: function(data) {
if (data.result) {
policyContainer
.empty()
.removeClass('password-error password-ok');
data.checks.forEach(function (error) {
var $li = $('<li class="a2-password-policy-rule"></li>')
.html('<i class="a2-password-icon"></i>' + error.label)
.appendTo(policyContainer);
if (!error.result) {
toggleError($li);
} else {
toggleOk($li);
}
});
}
},
error: function() {
if (!password.length) {
$('.a2-password-policy-rule').each(function() {
$(this).removeClass('password-ok password-error');
});
}
}
});
}
/*
* Check password equality
*/
var displayPasswordEquality = function($input, $inputTarget) {
var messages = $('#a2-password-equality-helper-' + $input.attr('name'));
var form = $input.parents('form');
if ($inputTarget === undefined) {
$inputTarget = form.find('input[type=password]:not(input[name='+$input.attr('name')+'])');
}
if (!$input.val() || !$inputTarget.val()) {
messages.removeClass('password-ok password-error');
return;
}
if ($inputTarget.val() !== $input.val()) {
toggleError(messages);
} else {
toggleOk(messages);
}
}
var passwordEquality = function () {
var $this = $(this);
displayPasswordEquality($this);
}
/*
* Hide and show password handlers
*/
var showPassword = function (event) {
var $this = $(event.target);
$this.addClass('hide-password-button');
var name = $this.attr('id').split('a2-password-show-button-')[1];
$('[name='+name+']').attr('type', 'text');
event.preventDefault();
}
var hidePassword = function (event) {
var $this = $(event.target);
window.setTimeout(function () {
$this.removeClass('hide-password-button');
var name = $this.attr('id').split('a2-password-show-button-')[1];
$('[name='+name+']').attr('type', 'password');
}, 3000);
}
/*
* Show the last character
*/
var showLastChar = function(event) {
if (event.keyCode == 32 || event.key === undefined || event.key == ""
|| event.key == "Unidentified" || event.key.length > 1) {
return;
}
var duration = 1000;
$('#a2-password-show-last-'+$(event.target).attr('name'))
.text(event.key)
.animate({'opacity': 1}, {
duration: 50,
queue: false,
complete: function () {
var $this = $(this);
window.setTimeout(
debounce(function () {
$this.animate({'opacity': 0}, {
duration: 50
});
}, duration), duration);
}
});
}
/*
* Init events
*/
/* add password validation and equality check event handlers */
$('form input[type=password]:not(input[data-check-policy])').each(function () {
$('#a2-password-policy-helper-' + $(this).attr('name')).hide();
});
$('body').on('keyup paste', 'form input[data-check-policy]', validatePassword);
$('body').on('keyup paste', 'form input[data-check-equality]', passwordEquality);
/*
* Add event to handle displaying error/OK
* while editing the first password
* only if the second one is not empty
*/
$('input[data-check-equality]')
.each(function () {
var $input2 = $(this);
$('body')
.on('keyup', 'form input[type=password]:not([name=' + $input2.attr('name') + '])',
function (event) {
var $input1 = $(event.target);
if ($input2.val().length) {
displayPasswordEquality($input2, $input1);
}
});
});
/* add the a2-password-show-button after the first input */
$('input[data-show-all]')
.each(function () {
var $this = $(this);
if (!$('#a2-password-show-button-' + $this.attr('name')).length) {
$(this).after($('<i class="a2-password-show-button" id="a2-password-show-button-'
+ $this.attr('name') + '"></i>')
.on('mousedown', showPassword)
.on('mouseup mouseleave', hidePassword)
);
}
});
/* show the last character on keypress */
$('input[data-show-last]')
.each(function () {
var $this = $(this);
if (!$('#a2-password-show-last-' + $this.attr('name')).length) {
// on crée un div placé dans le padding-right de l'input
var $span = $('<span class="a2-password-show-last" id="a2-password-show-last-'
+ $this.attr('name') + '"></span>)')
$span.css({
'font-size': $this.css('font-size'),
'font-family': $this.css('font-family'),
'line-height': parseInt($this.css('line-height').replace('px', '')) - parseInt($this.css('padding-bottom').replace('px', '')) + 'px',
'vertical-align': $this.css('vertical-align'),
'padding-top': $this.css('padding-top'),
'padding-bottom': $this.css('padding-bottom')
});
$this.after($span);
}
});
$('body').on('keyup', 'form input[data-show-last]', showLastChar);
});

View File

@ -9,10 +9,12 @@
{{ block.super }}
<link rel="stylesheet" href="{{ STATIC_URL }}authentic2/css/style.css" />
{% renderblock "css" %}
{{ form.media.css }}
{% endblock %}
{% block extrascripts %}
{{ block.super }}
{{ form.media.js }}
{% comment %}block extra_scripts is kept for compatibility with old themes{% endcomment %}
{% block extra_scripts %}
{% endblock %}

View File

@ -0,0 +1,34 @@
{% load i18n %}
<input class="a2-password-assisted" {% if widget.value != None %} value="{{ widget.value|stringformat:'s' }}"{% endif %}{% include "authentic2/widgets/attrs.html" %}>
{% if features.check_policy %}
<p class="a2-password-policy-intro">{% blocktrans %}In order to create a secure password, please use <i>at least</i> : {% endblocktrans %}</p>
<ul class="a2-password-policy-helper" id="a2-password-policy-helper-{{ widget.attrs.name }}">
{% comment %}Required to display the initial rules on page load{% endcomment %}
{% if app_settings.A2_PASSWORD_POLICY_MIN_LENGTH %}
<li class="a2-password-policy-rule"><i class="a2-password-icon"></i>{% blocktrans with A2_PASSWORD_POLICY_MIN_LENGTH=app_settings.A2_PASSWORD_POLICY_MIN_LENGTH %}{{ A2_PASSWORD_POLICY_MIN_LENGTH }} characters{% endblocktrans %}</li>
{% endif %}
{% if app_settings.A2_PASSWORD_POLICY_MIN_CLASSES > 0 %}
<li class="a2-password-policy-rule"><i class="a2-password-icon"></i>{% trans "1 lowercase letter" %}</li>
{% endif %}
{% if app_settings.A2_PASSWORD_POLICY_MIN_CLASSES > 1 %}
<li class="a2-password-policy-rule"><i class="a2-password-icon"></i>{% trans "1 digit" %}</li>
{% endif %}
{% if app_settings.A2_PASSWORD_POLICY_MIN_CLASSES > 2 %}
<li class="a2-password-policy-rule"><i class="a2-password-icon"></i>{% trans "1 uppercase letter" %}</li>
{% endif %}
{% if app_settings.A2_PASSWORD_POLICY_REGEX %}
{% if app_settings.A2_PASSWORD_POLICY_REGEX_ERROR_MSG %}
<li class="a2-password-policy-rule"><i class="a2-password-icon"></i>{% blocktrans with A2_PASSWORD_POLICY_REGEX_ERROR_MSG=app_settings.A2_PASSWORD_POLICY_REGEX_ERROR_MSG %}{{ A2_PASSWORD_POLICY_REGEX_ERROR_MSG }}{% endblocktrans %}</li>
{% else %}
<li class="a2-password-policy-rule"><i class="a2-password-icon"></i>{% blocktrans with A2_PASSWORD_POLICY_REGEX=app_settings.A2_PASSWORD_POLICY_REGEX %}Match the regular expression: {{ A2_PASSWORD_POLICY_REGEX }}, please change this message using the A2_PASSWORD_POLICY_REGEX_ERROR_MSG setting.'{% endblocktrans %}</li>
{% endif %}
{% endif %}
</ul>
{% endif %}
{% if features.check_equality %}
<ul class="a2-passwords-messages" id="a2-password-equality-helper-{{ widget.attrs.name }}">
<li class="a2-passwords-default"><i class="a2-password-icon"></i>{% trans 'Both passwords must match.' %}</li>
<li class="a2-passwords-matched"><i class="a2-password-icon"></i>{% trans 'Passwords match.' %}</li>
<li class="a2-passwords-unmatched"><i class="a2-password-icon"></i>{% trans 'Passwords do not match.' %}</li>
</ul>
{% endif %}

View File

@ -0,0 +1,2 @@
{% comment %}Will be deprecated in Django 1.11 : replace with django/forms/widgets/attrs.html{% endcomment %}
{% for name, value in widget.attrs.items %}{% if value != False %} {{ name }}{% if value != True %}="{{ value|stringformat:'s' }}"{% endif %}{% endif %}{% endfor %}

View File

@ -4,12 +4,10 @@
{% block css %}
{{ block.super }}
{{ form.media.css }}
{% endblock %}
{% block extra_scripts %}
{{ block.super }}
{{ form.media.js }}
{% endblock %}
@ -25,7 +23,7 @@
{% block content %}
<h2>{% trans "Registration" %}</h2>
<p>{% trans "Please fill the form to complete your registration" %}</p>
<form method="post">
<form method="post" class="a2-registration-completion">
{% csrf_token %}
{{ form.as_p }}
<button class="submit-button">{% trans 'Submit' %}</button>

View File

@ -1,5 +1,6 @@
# -*- coding: utf-8 -*-
import re
from urlparse import urlparse
from django.core.urlresolvers import reverse
@ -585,3 +586,49 @@ def test_registration_redirect_tuple(app, db, settings, mailoutbox, external_red
response = response.form.submit()
assert new_next_url in response.content
def test_registration_activate_passwords_not_equal(app, db, settings, mailoutbox):
settings.LANGUAGE_CODE = 'en-us'
settings.A2_VALIDATE_EMAIL_DOMAIN = can_resolve_dns()
settings.A2_EMAIL_IS_UNIQUE = True
response = app.get(reverse('registration_register'))
response.form.set('email', 'testbot@entrouvert.com')
response = response.form.submit()
response = response.follow()
link = get_link_from_mail(mailoutbox[0])
response = app.get(link)
response.form.set('password1', 'azerty12AZ')
response.form.set('password2', 'AAAazerty12AZ')
response = response.form.submit()
assert "The two password fields didn&#39;t match." in response.content
def test_registration_activate_assisted_password(app, db, settings, mailoutbox):
response = app.get(reverse('registration_register'))
response.form.set('email', 'testbot@entrouvert.com')
response = response.form.submit()
response = response.follow()
link = get_link_from_mail(mailoutbox[0])
response = app.get(link)
# check presence of the script and css for RegistrationCompletionForm to work
assert "password.js" in response.content
assert "password.css" in response.content
# check default attributes for password.js and css to work
assert re.search('<input class="a2-password-assisted".*data-show-last.*>', response.content, re.I | re.M | re.S)
assert re.search('<input class="a2-password-assisted".*data-check-equality.*>', response.content, re.I | re.M | re.S)
assert re.search('<input class="a2-password-assisted".*data-check-policy.*>', response.content, re.I | re.M | re.S)
# check template containers for password.js to display its results
assert re.search('class="a2-passwords-messages" id="a2-password-equality-helper-', response.content, re.I | re.M | re.S)
assert re.search('class="a2-password-policy-helper" id="a2-password-policy-helper-', response.content, re.I | re.M | re.S)
assert re.search('class="a2-password-policy-rule"', response.content, re.I | re.M | re.S)
def test_registration_activate_password_no_show_all_button(app, db, settings, mailoutbox):
response = app.get(reverse('registration_register'))
response.form.set('email', 'testbot@entrouvert.com')
response = response.form.submit()
response = response.follow()
link = get_link_from_mail(mailoutbox[0])
response = app.get(link)
assert not re.search('<input class="a2-password-assisted".*data-show-all.*>', response.content, re.I | re.M | re.S)