From 1a1ba138b5fc3eede4c6734e823f6d49c4394aad Mon Sep 17 00:00:00 2001 From: Elias Showk Date: Fri, 22 Jun 2018 18:26:15 +0200 Subject: [PATCH] create assisted password input widgets (#24438) --- src/authentic2/api_urls.py | 2 + src/authentic2/api_views.py | 35 +++- src/authentic2/registration_backend/forms.py | 19 +- .../registration_backend/widgets.py | 82 +++++++++ .../static/authentic2/css/password.css | 120 ++++++++++++ .../static/authentic2/js/password.js | 174 ++++++++++++++++++ .../authentic2/widgets/assisted_password.html | 24 +++ .../templates/authentic2/widgets/attrs.html | 2 + src/authentic2/validators.py | 56 ++++-- 9 files changed, 491 insertions(+), 23 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/api_urls.py b/src/authentic2/api_urls.py index 61e6d9df..1fb2ef1e 100644 --- a/src/authentic2/api_urls.py +++ b/src/authentic2/api_urls.py @@ -13,5 +13,7 @@ urlpatterns = patterns('', api_views.role_memberships, name='a2-api-role-member'), url(r'^check-password/$', api_views.check_password, name='a2-api-check-password'), + url(r'^validate-password/$', api_views.validate_password_view, + name='a2-api-validate-password'), ) urlpatterns += api_views.router.urls diff --git a/src/authentic2/api_views.py b/src/authentic2/api_views.py index 1292237c..f9bfc847 100644 --- a/src/authentic2/api_views.py +++ b/src/authentic2/api_views.py @@ -22,6 +22,7 @@ from rest_framework import permissions, status from rest_framework.exceptions import PermissionDenied, AuthenticationFailed from rest_framework.fields import CreateOnlyDefault from rest_framework.decorators import list_route, detail_route +from rest_framework.authentication import SessionAuthentication, BasicAuthentication from django_filters.rest_framework import FilterSet @@ -29,7 +30,7 @@ from .custom_user.models import User from . import utils, decorators, attribute_kinds, app_settings, hooks from .models import Attribute, PasswordReset from .a2_rbac.utils import get_default_ou - +from .validators import password_validation_rules class HookMixin(object): def get_serializer(self, *args, **kwargs): @@ -709,3 +710,35 @@ class CheckPasswordAPI(BaseRpcView): check_password = CheckPasswordAPI.as_view() + + +class CsrfExemptSessionAuthentication(SessionAuthentication): + def enforce_csrf(self, request): + return # To not perform the csrf check previously happening + + +class ValidatePasswordSerializer(serializers.Serializer): + password = serializers.CharField(required=True) + + +class ValidatePasswordAPI(ExceptionHandlerMixin, GenericAPIView): + serializer_class = ValidatePasswordSerializer + permission_classes = (permissions.AllowAny,) + authentication_classes = (CsrfExemptSessionAuthentication,) + + def post(self, request, **kwargs): + serializer = self.get_serializer(data=request.data) + if not serializer.is_valid(): + response = { + 'result': 0, + 'errors': serializer.errors + } + return Response(response, status.HTTP_400_BAD_REQUEST) + password = serializer.validated_data['password'] + return Response({ + 'result': 1, + 'validation': password_validation_rules(password), + }, status.HTTP_200_OK) + + +validate_password_view = ValidatePasswordAPI.as_view() diff --git a/src/authentic2/registration_backend/forms.py b/src/authentic2/registration_backend/forms.py index 8ca7d9c5..6c13c4a1 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,18 @@ 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(attrs={ + 'data-check-equality-against': 'password1', + }), + 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..6a26a7ca --- /dev/null +++ b/src/authentic2/registration_backend/widgets.py @@ -0,0 +1,82 @@ +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 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 + """ + template_name = 'authentic2/widgets/assisted_password.html' + + class Media: + js = ('authentic2/js/password.js',) + css = { + 'all': ('authentic2/css/password.css',) + } + + def get_context(self, name, value, attrs): + """ + Django 1.11 style get_context + """ + context = { + '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, + } + context['widget'] = { + 'name': name, + 'is_hidden': self.is_hidden, + 'required': self.is_required, + 'template_name': self.template_name, + 'check_equality': bool(self.attrs.get('data-check-equality-against', False)), + '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): + """ + Overridding render() with a template-based widget + 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(AssistedPasswordInput): + """ + Password typing assistance + """ + + def get_context(self, name, value, attrs): + context = super(CheckPasswordInput, self).get_context( + name, value, attrs) + context['widget']['attrs'].update({ + 'data-show-last': True, + 'data-show-all': True, + }) + return context + + +class NewPasswordInput(CheckPasswordInput): + """ + Password creation assistance + """ + + def get_context(self, name, value, attrs): + context = super(NewPasswordInput, self).get_context(name, value, attrs) + context['widget']['attrs'].update({ + 'data-check-policy': True, + }) + 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..d65520ae --- /dev/null +++ b/src/authentic2/static/authentic2/css/password.css @@ -0,0 +1,120 @@ +input[type=password].a2-password-assisted { + padding-right: 60px; +} + +.a2-password-policy-helper { + display: none; + opacity: 0; + height: 0; + flex-direction: row; + flex-wrap: wrap; + position: relative; + padding: .75rem 2rem; + transition: all 0.3s ease; +} + +.a2-min-class-policy, +.a2-min-length-policy, +.a2-regexp-policy { + flex: 1 1 50px; +} + +.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-password-show-last { + display: inline-block; + opacity: 0; + float: right; + text-align: center; + position: relative; + right: 30px; + top: -4.5ex; + width: 20px; +} + + +.a2-password-show-button { + display: inline-block; + float: right; + position: relative; + padding: 0; + width: 0px; + right: 10px; + top: -4ex; + cursor: pointer; +} + +.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: none; + opacity: 0; + transition: all 0.3s ease; +} + +.a2-passwords-unmatched { + display: none; +} + +.a2-passwords-matched { + display: none; +} + +.password-error.a2-passwords-messages, +.password-ok.a2-passwords-messages { + display: block; + opacity: 1; + +} +.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: list-item; +} + +.password-error .a2-passwords-unmatched:before { + content: "\f071"; + margin-right: 0.3em; + font-family: FontAwesome; + font-size: 100%; + color: red; +} + +.password-ok .a2-passwords-matched:before { + content: "\f00c"; + font-family: FontAwesome; + font-size: 100%; + color: green; +} diff --git a/src/authentic2/static/authentic2/js/password.js b/src/authentic2/static/authentic2/js/password.js new file mode 100644 index 00000000..3b063723 --- /dev/null +++ b/src/authentic2/static/authentic2/js/password.js @@ -0,0 +1,174 @@ +"use strict"; +/* globals $, window, console, document */ + +$(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. authentic2.validators.PASSWORD_VALIDATION_ERROR_CODES) + */ + var validatePassword = function(event) { + var $this = $(event.target); + if (!$this.val()) return; + var policyContainer = $('#a2-password-policy-helper-' + $this.attr('name')); + $.ajax({ + method: 'POST', + url: '/api/validate-password/', + data: JSON.stringify({'password': $this.val()}), + dataType: 'json', + contentType: 'application/json; charset=utf-8', + success: function(data) { + if (data.result) { + policyContainer + .css('height', 'auto') + .css('display', 'flex') + .css('opacity', 1) + .children('span') + .removeClass('password-error password-ok'); + data.validation.forEach(function (error) { + // error is like [True, 'validation-code-name'] + if (!error[0]) { + toggleError(policyContainer.find('.' + error[1])); + } else { + toggleOk(policyContainer.find('.' + error[1])); + } + }); + } + } + }); + } + /* + * 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[name='+$input.data('checkEqualityAgainst')+']'); + } + if (!$input.val() || !$inputTarget.val()) 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'); + }, 1000); + } + /* + * 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 */ + $('body').on('keyup', 'form input[data-check-policy]', validatePassword); + $('body').on('keyup', 'form input[data-check-equality-against]', 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-against]') + .each(function () { + var $input2 = $(this); + $('body').on('keyup', 'input[name=' + $input2.data('checkEqualityAgainst') + ']', 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) { + var offset = $this.offset(); + offset.top = "calc(" + offset.top + "px + 0.2ex)"; + offset.left = "calc(" + offset.left + "px + " + $this.width() + "px + 1.2ex)"; + // 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': $this.css('line-height'), + '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/widgets/assisted_password.html b/src/authentic2/templates/authentic2/widgets/assisted_password.html new file mode 100644 index 00000000..3d65ca8f --- /dev/null +++ b/src/authentic2/templates/authentic2/widgets/assisted_password.html @@ -0,0 +1,24 @@ +{% load i18n %} + +{% comment %}Class names are the same as the validation code names (cf. authentic2.validators.PASSWORD_VALIDATION_ERROR_CODES){% endcomment %} + +{% if widget.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/validators.py b/src/authentic2/validators.py index c9e9b09b..d8987a56 100644 --- a/src/authentic2/validators.py +++ b/src/authentic2/validators.py @@ -1,9 +1,8 @@ from __future__ import unicode_literals import string import re -import six - import smtplib +import six from django.utils.translation import ugettext_lazy as _, ugettext from django.utils.encoding import force_text @@ -17,6 +16,7 @@ import dns.exception from . import app_settings + # copied from http://www.djangotips.com/real-email-validation class EmailValidator(object): def __init__(self, rcpt_check=False): @@ -80,7 +80,17 @@ class EmailValidator(object): email_validator = EmailValidator() -def validate_password(password): + +PASSWORD_VALIDATION_ERROR_CODES = { + 'a2-min-length-policy': _('password must contain at least %d characters') % app_settings.A2_PASSWORD_POLICY_MIN_LENGTH, + 'a2-min-class-policy': _('password must contain characters ' + 'from at least %d classes among: lowercase letters, ' + 'uppercase letters, digits, and punctuations') % app_settings.A2_PASSWORD_POLICY_MIN_CLASSES, + 'a2-regexp-policy': app_settings.A2_PASSWORD_POLICY_REGEX_ERROR_MSG or + _('your password dit not match the regular expession %s') % app_settings.A2_PASSWORD_POLICY_REGEX +} + +def password_validation_rules(password): password_set = set(password) digits = set(string.digits) lower = set(string.lowercase) @@ -90,27 +100,37 @@ def validate_password(password): if not password: return - min_len = app_settings.A2_PASSWORD_POLICY_MIN_LENGTH - if len(password) < min_len: - errors.append(ValidationError(_('password must contain at least %d ' - 'characters') % min_len)) + if len(password) < app_settings.A2_PASSWORD_POLICY_MIN_LENGTH: + errors.append((False, 'a2-min-length-policy')) + else: + errors.append((True, 'a2-min-length-policy')) class_count = 0 for cls in (digits, lower, upper, punc): if not password_set.isdisjoint(cls): class_count += 1 - min_class_count = app_settings.A2_PASSWORD_POLICY_MIN_CLASSES - if class_count < min_class_count: - errors.append(ValidationError(_('password must contain characters ' - 'from at least %d classes among: lowercase letters, ' - 'uppercase letters, digits, and punctuations') % min_class_count)) + if class_count < app_settings.A2_PASSWORD_POLICY_MIN_CLASSES: + errors.append((False, 'a2-min-class-policy')) + else: + errors.append((True, 'a2-min-class-policy')) + if app_settings.A2_PASSWORD_POLICY_REGEX: if not re.match(app_settings.A2_PASSWORD_POLICY_REGEX, password): - msg = app_settings.A2_PASSWORD_POLICY_REGEX_ERROR_MSG - msg = msg or _('your password dit not match the regular expession %s') % app_settings.A2_PASSWORD_POLICY_REGEX - errors.append(ValidationError(msg)) - if errors: - raise ValidationError(errors) + errors.append((False, 'a2-regexp-policy')) + else: + errors.append((True, 'a2-regexp-policy')) + + return errors + +def validate_password(password): + validation_exceptions = [] + for (is_success, validation_code) in password_validation_rules(password): + if not is_success: + validation_exceptions.append(ValidationError( + PASSWORD_VALIDATION_ERROR_CODES[validation_code])) + + if validation_exceptions: + raise ValidationError(validation_exceptions) class UsernameValidator(RegexValidator): @@ -141,7 +161,9 @@ def __password_help_text_helper(): 'A2_PASSWORD_POLICY_REGEX_ERROR_MSG setting.') % \ {'regexp': app_settings.A2_PASSWORD_POLICY_REGEX} + def password_help_text(): return ' '.join(__password_help_text_helper()) + password_help_text = lazy(password_help_text, six.text_type) -- 2.18.0