From 71785a47e3211e9e4d7f70c5b354286006199b97 Mon Sep 17 00:00:00 2001 From: Elias Showk Date: Tue, 17 Jul 2018 09:15:28 +0200 Subject: [PATCH 1/2] create assisted password input in registration (#24439) --- src/authentic2/app_settings.py | 1 + src/authentic2/registration_backend/forms.py | 12 +- .../registration_backend/widgets.py | 92 ++++++++ .../static/authentic2/css/password.css | 140 +++++++++++++ .../static/authentic2/css/style.css | 19 ++ .../static/authentic2/js/password.js | 196 ++++++++++++++++++ src/authentic2/templates/authentic2/base.html | 2 + .../authentic2/widgets/assisted_password.html | 34 +++ .../templates/authentic2/widgets/attrs.html | 2 + .../registration_completion_form.html | 4 +- tests/test_registration.py | 47 +++++ 11 files changed, 541 insertions(+), 8 deletions(-) create mode 100644 src/authentic2/registration_backend/widgets.py create mode 100644 src/authentic2/static/authentic2/css/password.css create mode 100644 src/authentic2/static/authentic2/js/password.js create mode 100644 src/authentic2/templates/authentic2/widgets/assisted_password.html create mode 100644 src/authentic2/templates/authentic2/widgets/attrs.html diff --git a/src/authentic2/app_settings.py b/src/authentic2/app_settings.py index 015ea499..87f62772 100644 --- a/src/authentic2/app_settings.py +++ b/src/authentic2/app_settings.py @@ -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'), diff --git a/src/authentic2/registration_backend/forms.py b/src/authentic2/registration_backend/forms.py index 8ca7d9c5..8f5fc0b9 100644 --- a/src/authentic2/registration_backend/forms.py +++ b/src/authentic2/registration_backend/forms.py @@ -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): """ diff --git a/src/authentic2/registration_backend/widgets.py b/src/authentic2/registration_backend/widgets.py new file mode 100644 index 00000000..ab84c62f --- /dev/null +++ b/src/authentic2/registration_backend/widgets.py @@ -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 diff --git a/src/authentic2/static/authentic2/css/password.css b/src/authentic2/static/authentic2/css/password.css new file mode 100644 index 00000000..59c9b758 --- /dev/null +++ b/src/authentic2/static/authentic2/css/password.css @@ -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; +} \ No newline at end of file diff --git a/src/authentic2/static/authentic2/css/style.css b/src/authentic2/static/authentic2/css/style.css index c7545cae..c729213c 100644 --- a/src/authentic2/static/authentic2/css/style.css +++ b/src/authentic2/static/authentic2/css/style.css @@ -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%; +} \ No newline at end of file diff --git a/src/authentic2/static/authentic2/js/password.js b/src/authentic2/static/authentic2/js/password.js new file mode 100644 index 00000000..046ad636 --- /dev/null +++ b/src/authentic2/static/authentic2/js/password.js @@ -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 = $('
  • ') + .html('' + 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($('') + .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.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); +}); diff --git a/src/authentic2/templates/authentic2/base.html b/src/authentic2/templates/authentic2/base.html index d7ce8105..c35b7887 100644 --- a/src/authentic2/templates/authentic2/base.html +++ b/src/authentic2/templates/authentic2/base.html @@ -9,10 +9,12 @@ {{ block.super }} {% 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 %} diff --git a/src/authentic2/templates/authentic2/widgets/assisted_password.html b/src/authentic2/templates/authentic2/widgets/assisted_password.html new file mode 100644 index 00000000..97fa29aa --- /dev/null +++ b/src/authentic2/templates/authentic2/widgets/assisted_password.html @@ -0,0 +1,34 @@ +{% load i18n %} + +{% if features.check_policy %} +

    {% blocktrans %}In order to create a secure password, please use at least : {% endblocktrans %}

    + +{% endif %} +{% if features.check_equality %} + +{% endif %} diff --git a/src/authentic2/templates/authentic2/widgets/attrs.html b/src/authentic2/templates/authentic2/widgets/attrs.html new file mode 100644 index 00000000..6fba365e --- /dev/null +++ b/src/authentic2/templates/authentic2/widgets/attrs.html @@ -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 %} \ No newline at end of file diff --git a/src/authentic2/templates/registration/registration_completion_form.html b/src/authentic2/templates/registration/registration_completion_form.html index cd179aaa..79a9d66c 100644 --- a/src/authentic2/templates/registration/registration_completion_form.html +++ b/src/authentic2/templates/registration/registration_completion_form.html @@ -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 %}

    {% trans "Registration" %}

    {% trans "Please fill the form to complete your registration" %}

    -
    + {% csrf_token %} {{ form.as_p }} diff --git a/tests/test_registration.py b/tests/test_registration.py index d6d3a104..d73069fd 100644 --- a/tests/test_registration.py +++ b/tests/test_registration.py @@ -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'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('', response.content, re.I | re.M | re.S) + assert re.search('', response.content, re.I | re.M | re.S) + assert re.search('', 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('', response.content, re.I | re.M | re.S) \ No newline at end of file -- 2.18.0