From ad33b9c14a5065ad0bd1738ea62470b5116de7e1 Mon Sep 17 00:00:00 2001 From: Benjamin Dauvergne Date: Thu, 19 Jul 2018 16:12:33 +0200 Subject: [PATCH 7/9] add new widget and fields for passwords (#24439) --- src/authentic2/app_settings.py | 1 + src/authentic2/forms/fields.py | 35 +++++ src/authentic2/forms/widgets.py | 45 ++++++ src/authentic2/passwords.py | 7 +- .../static/authentic2/css/password.css | 91 +++++++++++ .../static/authentic2/js/password.js | 141 ++++++++++++++++++ 6 files changed, 318 insertions(+), 2 deletions(-) create mode 100644 src/authentic2/forms/fields.py create mode 100644 src/authentic2/static/authentic2/css/password.css create mode 100644 src/authentic2/static/authentic2/js/password.js diff --git a/src/authentic2/app_settings.py b/src/authentic2/app_settings.py index 015ea499..6c1b3a3a 100644 --- a/src/authentic2/app_settings.py +++ b/src/authentic2/app_settings.py @@ -146,6 +146,7 @@ default_settings = dict( A2_PASSWORD_POLICY_CLASS=Setting( default='authentic2.passwords.DefaultPasswordChecker', definition='path of a class to validate passwords'), + A2_PASSWORD_POLICY_SHOW_LAST_CHAR=Setting(default=False, definition='Show last character in password fields'), A2_AUTH_PASSWORD_ENABLE=Setting(default=True, definition='Activate login/password authentication', names=('AUTH_PASSWORD',)), A2_LOGIN_FAILURE_COUNT_BEFORE_WARNING=Setting(default=0, definition='Failure count before logging a warning to ' diff --git a/src/authentic2/forms/fields.py b/src/authentic2/forms/fields.py new file mode 100644 index 00000000..0ca312cc --- /dev/null +++ b/src/authentic2/forms/fields.py @@ -0,0 +1,35 @@ +from django.forms import CharField +from django.utils.translation import ugettext_lazy as _ + +from authentic2.passwords import password_help_text, validate_password +from authentic2.forms.widgets import PasswordInput, NewPasswordInput, CheckPasswordInput + + +class PasswordField(CharField): + widget = PasswordInput + + +class NewPasswordField(CharField): + widget = NewPasswordInput + default_validators = [validate_password] + + def __init__(self, *args, **kwargs): + kwargs['help_text'] = password_help_text() + super(NewPasswordField, self).__init__(*args, **kwargs) + + +class CheckPasswordField(CharField): + widget = CheckPasswordInput + + def __init__(self, *args, **kwargs): + kwargs['help_text'] = u''' + %(default)s + %(match)s + %(nomatch)s +''' % { + 'default': _('Both passwords must match.'), + 'match': _('Passwords match.'), + 'nomatch': _('Passwords do not match.'), + } + super(CheckPasswordField, self).__init__(*args, **kwargs) + diff --git a/src/authentic2/forms/widgets.py b/src/authentic2/forms/widgets.py index f8256cfe..a5811162 100644 --- a/src/authentic2/forms/widgets.py +++ b/src/authentic2/forms/widgets.py @@ -12,12 +12,15 @@ import re import uuid from django.forms.widgets import DateTimeInput, DateInput, TimeInput +from django.forms.widgets import PasswordInput as BasePasswordInput from django.utils.formats import get_language, get_format from django.utils.safestring import mark_safe from django.utils.translation import ugettext_lazy as _ from gadjo.templatetags.gadjo import xstatic +from authentic2 import app_settings + DATE_FORMAT_JS_PY_MAPPING = { 'P': '%p', 'ss': '%S', @@ -197,3 +200,45 @@ class TimeWidget(PickerWidgetMixin, TimeInput): options['format'] = options.get('format', self.get_format()) super(TimeWidget, self).__init__(attrs, options, usel10n) + + +class PasswordInput(BasePasswordInput): + class Media: + js = ('authentic2/js/password.js',) + css = { + 'all': ('authentic2/css/password.css',) + } + + def render(self, name, value, attrs=None): + output = super(PasswordInput, self).render(name, value, attrs=attrs) + if attrs and app_settings.A2_PASSWORD_POLICY_SHOW_LAST_CHAR: + _id = attrs.get('id') + if _id: + output += u'''\n''' % json.dumps(_id) + return output + + +class NewPasswordInput(PasswordInput): + def render(self, name, value, attrs=None): + output = super(NewPasswordInput, self).render(name, value, attrs=attrs) + if attrs: + _id = attrs.get('id') + if _id: + output += u'''\n''' % json.dumps(_id) + return output + + +class CheckPasswordInput(PasswordInput): + # this widget must be named xxx2 and the other widget xxx1, it's a + # convention, js code expect it. + def render(self, name, value, attrs=None): + output = super(CheckPasswordInput, self).render(name, value, attrs=attrs) + if attrs: + _id = attrs.get('id') + if _id and _id.endswith('2'): + other_id = _id[:-1] + '1' + output += u'''\n''' % ( + json.dumps(other_id), + json.dumps(_id), + ) + return output diff --git a/src/authentic2/passwords.py b/src/authentic2/passwords.py index 605f5877..9dce7d9b 100644 --- a/src/authentic2/passwords.py +++ b/src/authentic2/passwords.py @@ -7,6 +7,7 @@ import six from django.utils.translation import ugettext as _ from django.utils.module_loading import import_string from django.utils.functional import lazy +from django.utils.safestring import mark_safe from django.core.exceptions import ValidationError from . import app_settings @@ -110,14 +111,16 @@ def get_password_checker(*args, **kwargs): def validate_password(password): error = password_help_text(password, only_errors=True) if error: - raise ValidationError(error) + raise ValidationError(mark_safe(error)) def password_help_text(password='', only_errors=False): password_checker = get_password_checker() criteria = [check.label for check in password_checker(password) if not (only_errors and check.result)] if criteria: - return _('In order to create a secure password, please use at least: %s') % (', '.join(criteria)) + html_criteria = [u'%s' % criter for criter in criteria] + return _('In order to create a secure password, please use at least: ' + '%s') % (''.join(html_criteria)) else: return '' diff --git a/src/authentic2/static/authentic2/css/password.css b/src/authentic2/static/authentic2/css/password.css new file mode 100644 index 00000000..97089659 --- /dev/null +++ b/src/authentic2/static/authentic2/css/password.css @@ -0,0 +1,91 @@ +/* position span to show last char */ +.a2-password-show-last-char { + text-align: center; + width: 20px; + font-weight: bold; +} + +.a2-password-show-last-char + input[type=password] { + padding-left: 1.25rem; +} + +.a2-password-nok { + color: red; +} + +.a2-password-ok { + color: green; +} + +.a2-password-icon { + display: inline-block; + width: calc(18em / 14); + text-align: center; + font-style: normal; + padding-right: 1em; +} + +/* default circle icon */ +.a2-password-policy-rule { + padding: 1rex; +} +.a2-password-policy-rule:after { + font-family: FontAwesome; + display: inline-block; + width: 3ex; + text-align: center; + content: "\f00d"; /* cross icon */ + opacity: 0; +} + +.a2-password-nok.a2-password-policy-rule:after { + content: "\f00d"; /* cross icon */ + color: red; + opacity: 1; +} + +.a2-password-ok.a2-password-policy-rule:after { + content: "\f00c"; /* ok icon */ + color: green; + opacity: 1; +} + +/* Equality check */ + +.a2-password-nok .a2-password-check-equality-default, +.a2-password-ok .a2-password-check-equality-default { + display: none; +} + +.a2-password-check-equality-matched, +.a2-password-check-equality-unmatched { + display: none; + opacity: 0; + transition: all 0.3s ease; +} + +.a2-password-nok .a2-password-check-equality-unmatched, +.a2-password-ok .a2-password-check-equality-matched { + display: inline; + opacity: 1; +} + +.a2-password-check-equality-default:after, +.a2-password-check-equality-unmatched:after, +.a2-password-check-equality-matched:after { + font-family: FontAwesome; + width: 1rem; + display: inline-block; +} +.a2-password-check-equality-default:after { + content: "\f00d"; /* cross icon */ + opacity: 0; +} + +.a2-password-check-equality-unmatched:after { + content: "\f00d"; /* cross icon */ +} + +.a2-password-check-equality-matched:after { + content: "\f00c"; /* ok icon */ +} diff --git a/src/authentic2/static/authentic2/js/password.js b/src/authentic2/static/authentic2/js/password.js new file mode 100644 index 00000000..e63e38cb --- /dev/null +++ b/src/authentic2/static/authentic2/js/password.js @@ -0,0 +1,141 @@ +a2_password_check_equality = (function () { + return function(id1, id2) { + $(function () { + function check_equality() { + setTimeout(function () { + var $help_text = $input2.parent().find('.helptext'); + var password1 = $input1.val(); + var password2 = $input2.val(); + + if (! password2) { + $help_text.removeClass('a2-password-nok'); + $help_text.removeClass('a2-password-ok'); + } else { + var equal = (password1 == password2); + $help_text.toggleClass('a2-password-ok', equal); + $help_text.toggleClass('a2-password-nok', ! equal); + } + }, 0); + } + var $input1 = $('#' + id1); + var $input2 = $('#' + id2); + $input1.on('change keydown keyup keypress paste', check_equality); + $input2.on('change keydown keyup keypress paste', check_equality); + }); + } +})(); + +a2_password_validate = (function () { + function toggle_error($elt) { + $elt.removeClass('a2-password-check-equality-ok'); + $elt.addClass('a2-password-check-equality-error'); + } + function toggle_ok($elt) { + $elt.removeClass('a2-password-check-equality-error'); + $elt.addClass('a2-password-check-equality-ok'); + } + function get_validation($input) { + var password = $input.val(); + var $help_text = $input.parent().find('.helptext'); + var $policyContainer = $help_text.find('.a2-password-policy-container'); + $.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) { + return; + } + + $policyContainer.empty(); + $policyContainer.removeClass('a2-password-ok a2-password-nok'); + for (var i = 0; i < data.checks.length; i++) { + var error = data.checks[i]; + + var $rule = $(''); + $rule.text(error.label) + $rule.appendTo($policyContainer); + $rule.toggleClass('a2-password-ok', error.result); + $rule.toggleClass('a2-password-nok', ! error.result); + } + } + }); + } + function validate_password(event) { + var $input = $(event.target); + setTimeout(function () { + get_validation($input); + }, 0); + } + return function (id) { + var $input = $('#' + id); + $input.on('keyup.a2-password-validate paste.a2-password-validate', validate_password); + } +})(); + +a2_password_show_last_char = (function () { + function debounce(func, milliseconds) { + var timer; + + return function() { + window.clearTimeout(timer); + timer = window.setTimeout(function() { + func(); + }, milliseconds); + }; + } + return function(id) { + var $input = $('#' + id); + var last_char_id = id + '-last-char'; + + var $span = $(''); + + function show_last_char(event) { + if (event.keyCode == 32 || event.key === undefined || event.key == "" + || event.key == "Unidentified" || event.key.length > 1 || event.ctrlKey) { + return; + } + // import input's layout to the span + $span.css({ + 'position': 'absolute', + 'font-size': $input.css('font-size'), + 'font-family': $input.css('font-family'), + 'line-height': $input.css('line-height'), + 'padding-top': $input.css('padding-top'), + 'padding-bottom': $input.css('padding-bottom'), + 'margin-top': $input.css('margin-top'), + 'margin-bottom': $input.css('margin-bottom'), + 'border-top-width': $input.css('border-top-width'), + 'border-bottom-width': $input.css('border-bottom-width'), + 'border-style': 'hidden', + 'top': $input.position().top, + 'left': $input.position().left, + }); + var duration = 1000; + var id = $input.attr('id'); + var last_char_id = id + '-last-char'; + $('#' + last_char_id) + .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); + } + }); + } + console.log($input.position()); + // place span absolutery in padding-left of the input + $input.before($span); + // $input.parent().css({'position': 'relative'}); + $input.on('keypress.a2-password-show-last-char', show_last_char); + } +})(); -- 2.18.0