From efe5bb875532f877e7cafe11319287614de25851 Mon Sep 17 00:00:00 2001 From: Elias Showk Date: Thu, 21 Jun 2018 16:02:59 +0200 Subject: [PATCH] create AssistedPassword, AssistedPasswordFormMixin and a validation UI (#24438) --- src/authentic2/app_settings.py | 3 + src/authentic2/registration_backend/forms.py | 68 ++++++++- .../static/authentic2/css/password.css | 73 +++++++++ .../static/authentic2/js/password.js | 139 ++++++++++++++++++ .../authentic2/widgets/assisted_password.html | 8 + .../templates/authentic2/widgets/attrs.html | 2 + src/authentic2/validators.py | 40 +++-- 7 files changed, 311 insertions(+), 22 deletions(-) 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 e1ab8f06..b3e45ec9 100644 --- a/src/authentic2/app_settings.py +++ b/src/authentic2/app_settings.py @@ -140,6 +140,9 @@ default_settings = dict( A2_VALIDATE_EMAIL=Setting(default=False, definition='Validate user email server by doing an RCPT command'), A2_VALIDATE_EMAIL_DOMAIN=Setting(default=True, definition='Validate user email domain'), A2_PASSWORD_POLICY_MIN_CLASSES=Setting(default=3, definition='Minimum number of characters classes to be present in passwords'), + A2_PASSWORD_DISPLAY_LAST_CHAR_DURATION=Setting(default=300, definition='Minimum duration in milliseconds to display last character'), + A2_PASSWORD_DISPLAY_SHOW_ALL=Setting(default=True, definition='Boolean to display a button showing all the password characters'), + A2_PASSWORD_DISPLAY_CHECK_POLICY=Setting(default=True, definition='Boolean to display password validation policy while typing'), 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'), diff --git a/src/authentic2/registration_backend/forms.py b/src/authentic2/registration_backend/forms.py index 8ca7d9c5..36d35bd0 100644 --- a/src/authentic2/registration_backend/forms.py +++ b/src/authentic2/registration_backend/forms.py @@ -1,17 +1,20 @@ import re import copy from collections import OrderedDict +import json from django.conf import settings from django.core.exceptions import ValidationError from django.utils.translation import ugettext_lazy as _, ugettext from django.forms import ModelForm, Form, CharField, PasswordInput, EmailField from django.utils.datastructures import SortedDict +from django.utils.encoding import force_text from django.db.models.fields import FieldDoesNotExist from django.forms.util import ErrorList from django.contrib.auth.models import BaseUserManager, Group from django.contrib.auth import forms as auth_forms, get_user_model, REDIRECT_FIELD_NAME +from django.utils.safestring import mark_safe from django.core.mail import send_mail from django.core import signing from django.template import RequestContext @@ -24,6 +27,64 @@ from authentic2.a2_rbac.models import OrganizationalUnit User = compat.get_user_model() +class AssistedPasswordInput(PasswordInput): + """ + Custom Password Input with extra functionnality + Inspired by Django >= 1.11 new-style rendering, and ensuring an easy future compatibility + https://docs.djangoproject.com/fr/1.11/ref/forms/renderers/#overriding-built-in-widget-templates + """ + password_policy = { + '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, + } + messages_class = 'a2-passwords-messages' + template_name = 'authentic2/widgets/assisted_password.html' + + def render(self, name, value, attrs=None): + """ + Overridding render() to have a template-based widget + https://docs.djangoproject.com/en/1.8/ref/forms/widgets/#django.forms.Widget.render + """ + if self.attrs.get('data-check-equality-against'): + attrs['checkEquality'] = True + # Remove this part down when dropping Django 1.8, 1.9, 1.10 compatibility + if value is None: + value = '' + context = { + 'password_policy': json.dumps(self.password_policy), # to configure password.js using app_settings + 'messages_class': self.messages_class, # to configure the block for interactive messages + 'template_name': self.template_name, # to be used in templates to know who they are + 'widget': {} + } + context['widget']['attrs'] = self.build_attrs(extra_attrs=attrs, name=name, type=self.input_type) + if value != '': + # Only add the 'value' attribute if a value is non-empty. + context['widget']['value'] = force_text(self._format_value(value)) + return mark_safe(render_to_string(self.template_name, context)) + + +class AssistedPasswordFormMixin(Form): + class Media: + js = ('authentic2/js/password.js',) + css = {'all': ('authentic2/css/password.css',)} + + password1 = CharField( + widget=AssistedPasswordInput(attrs={ + 'data-show-last': app_settings.A2_PASSWORD_DISPLAY_LAST_CHAR_DURATION, + 'data-show-all': app_settings.A2_PASSWORD_DISPLAY_SHOW_ALL, + 'data-check-policy': app_settings.A2_PASSWORD_DISPLAY_CHECK_POLICY, + }), + label=_("Password"), + validators=[validators.validate_password], + help_text=validators.password_help_text()) + + password2 = CharField( + widget=AssistedPasswordInput(attrs={ + 'data-check-equality-against': 'password1', + }), + label=_("Password (again)")) + class RegistrationForm(Form): error_css_class = 'form-field-error' @@ -114,12 +175,7 @@ class RegistrationCompletionFormNoPassword(forms.BaseUserForm): return user -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)")) - +class RegistrationCompletionForm(RegistrationCompletionFormNoPassword, AssistedPasswordFormMixin): def clean(self): """ Verifiy that the values entered into the two password fields diff --git a/src/authentic2/static/authentic2/css/password.css b/src/authentic2/static/authentic2/css/password.css new file mode 100644 index 00000000..18b20d7a --- /dev/null +++ b/src/authentic2/static/authentic2/css/password.css @@ -0,0 +1,73 @@ + +.a2-min-class-policy, .a2-min-length-policy, .a2-regexp-policy { + display: inline; +} + +.password-error { + font-weight: bold; + color: red; +} + +.password-error:before { + content: "\f071"; + margin-right: 0.3em; + font-family: FontAwesome; + font-size: 100%; + color: red; +} + +.password-ok:before { + content: "\f00c"; + font-family: FontAwesome; + font-size: 100%; + color: green; +} + +.a2-show-password-button { + padding-left: 0.5em; + cursor: pointer; +} + +.a2-show-password-button:after { + content: "\f06e"; /* eye */ + font-family: FontAwesome; + font-size: 150%; +} + +.hide-password-button:after { + content: "\f070"; /* crossed eye */ + font-family: FontAwesome; + font-size: 150%; +} + +.a2-passwords-messages { + display: none; +} + +.a2-passwords-unmatched { + display: none; + color: red; +} + +.a2-passwords-matched { + display: none; + color: green; +} + +.password-error.a2-passwords-messages, .password-ok.a2-passwords-messages { + display: block; +} + +.password-error .a2-passwords-unmatched { + display: inline; +} + +.password-ok .a2-passwords-matched { + display: inline; +} + +.a2-password-show-last { + display: inline; + padding: 1em; + min-width: 1em; +} \ 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..f72bf69e --- /dev/null +++ b/src/authentic2/static/authentic2/js/password.js @@ -0,0 +1,139 @@ +"use strict"; +/* globals $, window, console */ + +$(function () { + var toggleError = function($elt) { + $elt.removeClass('password-ok'); + $elt.addClass('password-error'); + } + var toggleOk = function($elt) { + $elt.removeClass('password-error'); + $elt.addClass('password-ok'); + } + var validatePassword = function () { + var minClassElt = $(this).parents('form').find('.a2-min-class-policy'); + var minLengthElt = $(this).parents('form').find('.a2-min-length-policy'); + var regexpElt = $(this).parents('form').find('.a2-regexp-policy'); + $(this) + .each(function () { + var $this = $(this); + var settings = $this.data('passwordPolicy'); + var password = $this.val(); + var min_len = settings.A2_PASSWORD_POLICY_MIN_LENGTH; + if (min_len && password.length < min_len) { + toggleError(minLengthElt); + } else { + toggleOk(minLengthElt); + } + var digits = /\d/g; + var lowerCase = /[a-z]/g; + var upperCase = /[A-Z]/g; + var punctuation = /'!"#\$%&\\'\(\)\*\+,-\.\/:;<=>\?@\[\]\^_`\{\|\}~'/g; + var minClassCount = settings.A2_PASSWORD_POLICY_MIN_CLASSES; + var classCount = 0; + if (minClassCount) { + [digits, lowerCase, upperCase, punctuation].forEach(function (cls) { + if (cls.test(password)) classCount++; + }) + if (classCount < minClassCount) { + toggleError(minClassElt); + } else { + toggleOk(minClassElt); + } + } + if (settings.A2_PASSWORD_POLICY_REGEX) { + var regExpObject = new RegExp(settings.A2_PASSWORD_POLICY_REGEX, 'g'); + if (!regExpObject.test(password)) { + toggleError(regexpElt); + } else { + toggleOk(regexpElt); + } + } + }); + } + var passwordEquality = function () { + $(this) + .each(function () { + var input = $(this); + var form = input.parents('form'); + var messages = form.find('.'+input.data('passwordMessagesClass')); + var inputTarget = form.find('input[name='+input.data('checkEqualityAgainst')+']'); + if (!input.val() || !inputTarget.val()) return; + if (inputTarget.val() !== input.val()) { + toggleError(messages); + } else { + toggleOk(messages); + } + }); + } + var showPassword = function () { + $(this).addClass('hide-password-button'); + $(this).prevUntil().filter('input[data-show-all]').last().attr('type', 'text'); + } + var hidePassword = function () { + $(this).removeClass('hide-password-button'); + $(this).prevUntil().filter('input[data-show-all]').last().attr('type', 'password'); + } + /* + * 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 = Number.isNaN(parseInt($(this).data('showLast'))) ? 300 : parseInt($(this).data('showLast')); + $('#a2-show-last-'+$(this).attr('id')) + .html(event.key) + .fadeIn('fast', function () { + var $this = $(this); + window.setTimeout(function() { + $this.fadeOut('fast', function () { + $this.html(' '); + $this.css('display', 'inline'); + }); + }, duration); + }); + } + /* add password validation and equality check event handlers */ + $('form').on('keyup', 'input[data-check-policy]', validatePassword); + $('form').on('keyup', 'input[data-check-equality-against]', passwordEquality); + /* while editing the first password, toggleError if the second one is not empty */ + $('input[data-check-equality-against]') + .each(function () { + var input2 = $(this); + var input1 = $('form').find('input[name='+input2.data('checkEqualityAgainst')+']'); + $('form').on('keyup', input1, function () { + var form = $(this) + var messages = form.find('.'+input2.data('passwordMessagesClass')); + if (input2.val().length) { + if (input1.val() !== input2.val()) { + toggleError(messages); + } else { + toggleOk(messages); + } + } + }); + }); + + /* add the a2-show-password-button after the first input */ + $('input[data-show-all]') + .each(function () { + var $this = $(this); + if (!$('#a2-show-password-button-'+$this.attr('id')).length) { + $(this).after($('') + .on('mousedown', showPassword) + .on('mouseup', hidePassword) + ); + } + }); + /* show the last character on keypress */ + $('input[data-show-last]') + .each(function () { + var $this = $(this); + if (!$('#a2-show-last-'+$this.attr('id')).length) { + $this + .after($('
 
')); + } + }); + $('form').on('keyup', 'input[data-show-last]', showLastChar); +}); 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..2737b065 --- /dev/null +++ b/src/authentic2/templates/authentic2/widgets/assisted_password.html @@ -0,0 +1,8 @@ +{% load i18n %} + +{% if widget.attrs.checkEquality %} +
+ {% trans 'Passwords match.' %} + {% trans 'Passwords do not match.' %} +
+{% 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/validators.py b/src/authentic2/validators.py index c9e9b09b..ac24cb3d 100644 --- a/src/authentic2/validators.py +++ b/src/authentic2/validators.py @@ -120,26 +120,34 @@ class UsernameValidator(RegexValidator): def __password_help_text_helper(): - if app_settings.A2_PASSWORD_POLICY_MIN_LENGTH and \ - app_settings.A2_PASSWORD_POLICY_MIN_CLASSES: - yield ugettext('Your password must contain at least %(min_length)d characters from at ' - 'least %(min_classes)d classes among: lowercase letters, uppercase letters, ' - 'digits and punctuations.') % { - 'min_length': app_settings.A2_PASSWORD_POLICY_MIN_LENGTH, - 'min_classes': app_settings.A2_PASSWORD_POLICY_MIN_CLASSES} + ''' + Password fields help_text + ''' + min_length_html = '%s' %\ + ugettext('Your password must contain at least %(min_length)d characters.' % + {'min_length': app_settings.A2_PASSWORD_POLICY_MIN_LENGTH}) + + min_class_html = '%s' %\ + ugettext(('at least %(min_classes)d classes among: lowercase letters, uppercase letters, digits and punctuations.') % + {'min_classes': app_settings.A2_PASSWORD_POLICY_MIN_CLASSES}) + if app_settings.A2_PASSWORD_POLICY_REGEX_ERROR_MSG: + regexp_html = '%s' %\ + ugettext(app_settings.A2_PASSWORD_POLICY_REGEX_ERROR_MSG) + else: + regexp_html = '%s' %\ + ugettext('Your password must match the regular expression: %(regexp)s, please change this message using the A2_PASSWORD_POLICY_REGEX_ERROR_MSG setting.') % \ + {'regexp': app_settings.A2_PASSWORD_POLICY_REGEX} + + if app_settings.A2_PASSWORD_POLICY_MIN_LENGTH and app_settings.A2_PASSWORD_POLICY_MIN_CLASSES: + yield '%s %s %s' % (min_length_html, _('from'), min_class_html) else: if app_settings.A2_PASSWORD_POLICY_MIN_LENGTH: - yield ugettext('Your password must contain at least %(min_length)d characters.') % {'min_length': app_settings.A2_PASSWORD_POLICY_MIN_LENGTH} + yield min_length_html if app_settings.A2_PASSWORD_POLICY_MIN_CLASSES: - yield ugettext('Your password must contain characters from at least %(min_classes)d ' - 'classes among: lowercase letters, uppercase letters, digits ' - 'and punctuations.') % {'min_classes': app_settings.A2_PASSWORD_POLICY_MIN_CLASSES} + yield "%s %s" % (ugettext('Your password must contain characters from'), min_class_html) if app_settings.A2_PASSWORD_POLICY_REGEX: - yield ugettext(app_settings.A2_PASSWORD_POLICY_REGEX_ERROR_MSG) or \ - ugettext('Your password must match the regular expression: ' - '%(regexp)s, please change this message using the ' - 'A2_PASSWORD_POLICY_REGEX_ERROR_MSG setting.') % \ - {'regexp': app_settings.A2_PASSWORD_POLICY_REGEX} + yield regexp_html + def password_help_text(): return ' '.join(__password_help_text_helper()) -- 2.17.1