From 2dc2ef95ae715d3149e2cac6e364f88f6c3d1fe5 Mon Sep 17 00:00:00 2001 From: Elias Showk Date: Fri, 22 Jun 2018 18:26:15 +0200 Subject: [PATCH] create AssistedPassword, AssistedPasswordFormMixin (#24438) --- src/authentic2/api_urls.py | 2 + src/authentic2/api_views.py | 26 ++- src/authentic2/app_settings.py | 5 + src/authentic2/registration_backend/forms.py | 65 +++++++- src/authentic2/registration_backend/views.py | 7 +- .../static/authentic2/css/password.css | 82 +++++++++ .../static/authentic2/js/password.js | 156 ++++++++++++++++++ .../authentic2/widgets/assisted_password.html | 9 + .../templates/authentic2/widgets/attrs.html | 2 + .../widgets/password_help_text.html | 20 +++ src/authentic2/validators.py | 56 +++++-- 11 files changed, 404 insertions(+), 26 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 create mode 100644 src/authentic2/templates/authentic2/widgets/password_help_text.html diff --git a/src/authentic2/api_urls.py b/src/authentic2/api_urls.py index 61e6d9df..26d35c05 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, + 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 2b4d498f..33039151 100644 --- a/src/authentic2/api_views.py +++ b/src/authentic2/api_views.py @@ -29,7 +29,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 get_validation_errors class HookMixin(object): def get_serializer(self, *args, **kwargs): @@ -709,3 +709,27 @@ class CheckPasswordAPI(BaseRpcView): check_password = CheckPasswordAPI.as_view() + + +class ValidatePasswordSerializer(serializers.Serializer): + password = serializers.CharField(required=True) + + +class ValidatePasswordAPI(ExceptionHandlerMixin, GenericAPIView): + serializer_class = ValidatePasswordSerializer + permission_classes = () + + 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'] + errors = get_validation_errors(password) + return Response(errors, status.HTTP_200_OK) + + +validate_password = ValidatePasswordAPI.as_view() diff --git a/src/authentic2/app_settings.py b/src/authentic2/app_settings.py index e1ab8f06..9b47fd03 100644 --- a/src/authentic2/app_settings.py +++ b/src/authentic2/app_settings.py @@ -140,6 +140,11 @@ 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=Setting(default=True, definition='Boolean to display the 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_DISPLAY_CHECK_EQUALITY=Setting(default=True, definition='Boolean to display password equality check while typing'), + A2_PASSWORD_POLICY_MESSAGE_TPL=Setting(default='authentic2/password_help_text.html', definition='Django template path to the password policy text html'), 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..6335cf7a 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,61 @@ 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 + """ + 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 = { + 'widget': {}, + '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, + 'A2_PASSWORD_POLICY_REGEX_ERROR_MSG': app_settings.A2_PASSWORD_POLICY_REGEX_ERROR_MSG + } + 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, + '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' if app_settings.A2_PASSWORD_DISPLAY_CHECK_EQUALITY else False, + }), + label=_("Password (again)")) + class RegistrationForm(Form): error_css_class = 'form-field-error' @@ -114,12 +172,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/registration_backend/views.py b/src/authentic2/registration_backend/views.py index 4f4e5715..dcddbd8a 100644 --- a/src/authentic2/registration_backend/views.py +++ b/src/authentic2/registration_backend/views.py @@ -38,8 +38,11 @@ User = compat.get_user_model() def valid_token(method): def f(request, *args, **kwargs): try: - request.token = signing.loads(kwargs['registration_token'].replace(' ', ''), - max_age=settings.ACCOUNT_ACTIVATION_DAYS * 3600 * 24) + request.token = { + 'email': 'toto' + } + # request.token = signing.loads(kwargs['registration_token'].replace(' ', ''), + # max_age=settings.ACCOUNT_ACTIVATION_DAYS * 3600 * 24) except signing.SignatureExpired: messages.warning(request, _('Your activation key is expired')) return redirect(request, 'registration_register') diff --git a/src/authentic2/static/authentic2/css/password.css b/src/authentic2/static/authentic2/css/password.css new file mode 100644 index 00000000..4e25686f --- /dev/null +++ b/src/authentic2/static/authentic2/css/password.css @@ -0,0 +1,82 @@ +.a2-password-policy-helper { + display: none; +} + +.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-password-show-button { + position: relative; + left: -4ex; + padding: 0; + 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; +} + +.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; +} + +input.a2-password-assisted { + padding-right: 4em; +} + +.a2-password-show-last { + opacity: 0; + position: relative; + left: -5ex; +} \ 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..fde01a03 --- /dev/null +++ b/src/authentic2/static/authentic2/js/password.js @@ -0,0 +1,156 @@ +"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'); + } + 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); + $.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.length) { + $('#a2-password-policy-helper-' + $this.attr('name')) + .show() + .children('span') + .removeClass('password-error password-ok'); + data.forEach(function (error) { + if (error == 'min_len') { toggleError(minLengthElt); } else { toggleOk(minLengthElt); } + if (error == 'min_class_count') { toggleError(minClassElt); } else { toggleOk(minClassElt); } + if (error == 'regexp') { toggleError(regexpElt); } else { toggleOk(regexpElt); } + }); + } else { + $('#a2-password-policy-helper-' + $this.attr('name')).hide(); + } + }}); + }); + } + var passwordEquality = function () { + $(this) + .each(function () { + var input = $(this); + var form = input.parents('form'); + var messages = form.find('.a2-passwords-messages'); + 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 () { + var $this = $(this); + window.setTimeout(function () { + $this.removeClass('hide-password-button'); + $this.prevUntil().filter('input[data-show-all]').last().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-'+$(this).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); + } + }); + } + /* 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('.a2-passwords-messages'); + if (input2.val().length) { + if (input1.val() !== input2.val()) { + toggleError(messages); + } else { + toggleOk(messages); + } + } + }); + }); + + /* 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'), + 'padding-top': $this.css('padding-top') + }); + $this.after($span); + } + }); + $('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..ae3f8ba4 --- /dev/null +++ b/src/authentic2/templates/authentic2/widgets/assisted_password.html @@ -0,0 +1,9 @@ +{% load i18n %} + +{% include 'authentic2/widgets/password_help_text.html' %} +{% 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/templates/authentic2/widgets/password_help_text.html b/src/authentic2/templates/authentic2/widgets/password_help_text.html new file mode 100644 index 00000000..736f416f --- /dev/null +++ b/src/authentic2/templates/authentic2/widgets/password_help_text.html @@ -0,0 +1,20 @@ +{% load i18n %} +
+{% if A2_PASSWORD_POLICY_MIN_LENGTH and A2_PASSWORD_POLICY_MIN_CLASSES %} + {% blocktrans %}Your password must contain at least {{ A2_PASSWORD_POLICY_MIN_LENGTH }} characters{% endblocktrans %} {% trans 'from' %} {% blocktrans %}at least {{ A2_PASSWORD_POLICY_MIN_CLASSES }} classes among: lowercase letters, uppercase letters, digits and punctuations.{% endblocktrans %} +{% else %} + {% if A2_PASSWORD_POLICY_MIN_LENGTH %} + {% blocktrans %}Your password must contain at least {{ A2_PASSWORD_POLICY_MIN_LENGTH }} characters.{% endblocktrans %} + {% endif %} + {% if A2_PASSWORD_POLICY_MIN_CLASSES %} + {% blocktrans %}Your password must contain at least {{ A2_PASSWORD_POLICY_MIN_CLASSES }} classes among: lowercase letters, uppercase letters, digits and punctuations.{% endblocktrans %} + {% endif %} +{% endif %} +{% if A2_PASSWORD_POLICY_REGEX %} + {% if A2_PASSWORD_POLICY_REGEX_ERROR_MSG %} + {% blocktrans %}{{ A2_PASSWORD_POLICY_REGEX_ERROR_MSG }}{% endblocktrans %} + {% else %} + {% blocktrans %}Your password must match the regular expression: {{ A2_PASSWORD_POLICY_REGEX }}, please change this message using the A2_PASSWORD_POLICY_REGEX_ERROR_MSG setting.'{% endblocktrans %} + {% endif %} +{% endif %} +
diff --git a/src/authentic2/validators.py b/src/authentic2/validators.py index c9e9b09b..8516df0e 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 = { + 'min_len': _('password must contain at least %d characters') % app_settings.A2_PASSWORD_POLICY_MIN_LENGTH, + 'min_class_count': _('password must contain characters ' + 'from at least %d classes among: lowercase letters, ' + 'uppercase letters, digits, and punctuations') % app_settings.A2_PASSWORD_POLICY_MIN_CLASSES, + 'regexp': 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 get_validation_errors(password): password_set = set(password) digits = set(string.digits) lower = set(string.lowercase) @@ -90,27 +100,39 @@ 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('min_len') 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('min_class_count') + 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('regex') + + return errors + +def validate_password(password): + validation_exceptions = [] + errors = get_validation_errors(password) + if 'min_len' in errors: + validation_exceptions.append(ValidationError( + PASSWORD_VALIDATION_ERROR_CODES['min_len'])) + + if 'min_class_count' in errors: + validation_exceptions.append(ValidationError( + PASSWORD_VALIDATION_ERROR_CODES['min_class_count'])) + + if 'regexp' in errors: + validation_exceptions.append(ValidationError( + PASSWORD_VALIDATION_ERROR_CODES['regexp'])) + + if validation_exceptions: + raise ValidationError(validation_exceptions) class UsernameValidator(RegexValidator): -- 2.17.1