Projet

Général

Profil

0001-create-assisted-password-input-widgets-24438.patch

Anonyme, 29 juin 2018 15:05

Télécharger (23,2 ko)

Voir les différences:

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
src/authentic2/api_urls.py
13 13
                           api_views.role_memberships, name='a2-api-role-member'),
14 14
                       url(r'^check-password/$', api_views.check_password,
15 15
                           name='a2-api-check-password'),
16
                       url(r'^validate-password/$', api_views.validate_password_view,
17
                           name='a2-api-validate-password'),
16 18
)
17 19
urlpatterns += api_views.router.urls
src/authentic2/api_views.py
22 22
from rest_framework.exceptions import PermissionDenied, AuthenticationFailed
23 23
from rest_framework.fields import CreateOnlyDefault
24 24
from rest_framework.decorators import list_route, detail_route
25
from rest_framework.authentication import SessionAuthentication, BasicAuthentication
25 26

  
26 27
from django_filters.rest_framework import FilterSet
27 28

  
......
29 30
from . import utils, decorators, attribute_kinds, app_settings, hooks
30 31
from .models import Attribute, PasswordReset
31 32
from .a2_rbac.utils import get_default_ou
32

  
33
from .validators import password_validation_rules
33 34

  
34 35
class HookMixin(object):
35 36
    def get_serializer(self, *args, **kwargs):
......
709 710

  
710 711

  
711 712
check_password = CheckPasswordAPI.as_view()
713

  
714

  
715
class CsrfExemptSessionAuthentication(SessionAuthentication):
716
    def enforce_csrf(self, request):
717
        return  # To not perform the csrf check previously happening
718

  
719

  
720
class ValidatePasswordSerializer(serializers.Serializer):
721
    password = serializers.CharField(required=True)
722

  
723

  
724
class ValidatePasswordAPI(ExceptionHandlerMixin, GenericAPIView):
725
    serializer_class = ValidatePasswordSerializer
726
    permission_classes = (permissions.AllowAny,)
727
    authentication_classes = (CsrfExemptSessionAuthentication,)
728

  
729
    def post(self, request, **kwargs):
730
        serializer = self.get_serializer(data=request.data)
731
        if not serializer.is_valid():
732
            response = {
733
                'result': 0,
734
                'errors': serializer.errors
735
            }
736
            return Response(response, status.HTTP_400_BAD_REQUEST)
737
        password = serializer.validated_data['password']
738
        return Response({
739
            'result': 1,
740
            'validation': password_validation_rules(password),
741
        }, status.HTTP_200_OK)
742

  
743

  
744
validate_password_view = ValidatePasswordAPI.as_view()
src/authentic2/registration_backend/forms.py
1 1
import re
2 2
import copy
3 3
from collections import OrderedDict
4
import json
4 5

  
5 6
from django.conf import settings
6 7
from django.core.exceptions import ValidationError
......
15 16
from django.core.mail import send_mail
16 17
from django.core import signing
17 18
from django.template import RequestContext
18
from django.template.loader import render_to_string
19 19
from django.core.urlresolvers import reverse
20 20
from django.core.validators import RegexValidator
21 21

  
22
from .widgets import CheckPasswordInput, NewPasswordInput
22 23
from .. import app_settings, compat, forms, utils, validators, models, middleware, hooks
23 24
from authentic2.a2_rbac.models import OrganizationalUnit
24 25

  
......
115 116

  
116 117

  
117 118
class RegistrationCompletionForm(RegistrationCompletionFormNoPassword):
118
    password1 = CharField(widget=PasswordInput, label=_("Password"),
119
            validators=[validators.validate_password],
120
            help_text=validators.password_help_text())
121
    password2 = CharField(widget=PasswordInput, label=_("Password (again)"))
119

  
120
    password1 = CharField(
121
        widget=NewPasswordInput(),
122
        label=_("Password"),
123
        validators=[validators.validate_password],
124
        help_text=validators.password_help_text())
125

  
126
    password2 = CharField(
127
        widget=CheckPasswordInput(attrs={
128
            'data-check-equality-against': 'password1',
129
        }),
130
        label=_("Password (again)"))
122 131

  
123 132
    def clean(self):
124 133
        """
src/authentic2/registration_backend/widgets.py
1
from django.forms import PasswordInput
2
from django.template.loader import render_to_string
3
from django.utils.encoding import force_text
4
from django.utils.safestring import mark_safe
5

  
6
from .. import app_settings
7

  
8

  
9
class AssistedPasswordInput(PasswordInput):
10
    """
11
    Custom Password Input with extra functionnality
12
    Inspired by Django >= 1.11 new-style rendering,
13
    and ensuring an easy future compatibility
14
    https://docs.djangoproject.com/fr/1.11/ref/forms/renderers/#overriding-built-in-widget-templates
15
    """
16
    template_name = 'authentic2/widgets/assisted_password.html'
17

  
18
    class Media:
19
        js = ('authentic2/js/password.js',)
20
        css = {
21
            'all': ('authentic2/css/password.css',)
22
        }
23

  
24
    def get_context(self, name, value, attrs):
25
        """
26
        Django 1.11 style get_context
27
        """
28
        context = {
29
            'A2_PASSWORD_POLICY_MIN_LENGTH': app_settings.A2_PASSWORD_POLICY_MIN_LENGTH,
30
            'A2_PASSWORD_POLICY_MIN_CLASSES': app_settings.A2_PASSWORD_POLICY_MIN_CLASSES,
31
            'A2_PASSWORD_POLICY_REGEX': app_settings.A2_PASSWORD_POLICY_REGEX,
32
        }
33
        context['widget'] = {
34
            'name': name,
35
            'is_hidden': self.is_hidden,
36
            'required': self.is_required,
37
            'template_name': self.template_name,
38
            'check_equality': bool(self.attrs.get('data-check-equality-against', False)),
39
            'attrs': self.build_attrs(extra_attrs=attrs, name=name, type=self.input_type)
40
        }
41
        # Only add the 'value' attribute if a value is non-empty.
42
        if value is None:
43
            value = ''
44
        if value != '':
45
            context['widget']['value'] = force_text(self._format_value(value))
46
        return context
47

  
48
    def render(self, name, value, attrs=None, **kwargs):
49
        """
50
        Overridding render() with a template-based widget
51
        Remove this line when dropping Django 1.8, 1.9, 1.10 compatibility
52
        """
53
        return mark_safe(render_to_string(self.template_name,
54
                                          self.get_context(name, value, attrs)))
55

  
56

  
57
class CheckPasswordInput(AssistedPasswordInput):
58
    """
59
    Password typing assistance
60
    """
61

  
62
    def get_context(self, name, value, attrs):
63
        context = super(CheckPasswordInput, self).get_context(
64
            name, value, attrs)
65
        context['widget']['attrs'].update({
66
            'data-show-last': True,
67
            'data-show-all': True,
68
        })
69
        return context
70

  
71

  
72
class NewPasswordInput(CheckPasswordInput):
73
    """
74
    Password creation assistance
75
    """
76

  
77
    def get_context(self, name, value, attrs):
78
        context = super(NewPasswordInput, self).get_context(name, value, attrs)
79
        context['widget']['attrs'].update({
80
            'data-check-policy': True,
81
        })
82
        return context
src/authentic2/static/authentic2/css/password.css
1
input[type=password].a2-password-assisted {
2
  padding-right: 60px;
3
}
4

  
5
.a2-password-policy-helper {
6
  display: none;
7
  opacity: 0;
8
  height: 0;
9
  flex-direction: row;
10
  flex-wrap: wrap;
11
  position: relative;
12
  padding: .75rem 2rem;
13
  transition: all 0.3s ease;
14
}
15

  
16
.a2-min-class-policy,
17
.a2-min-length-policy,
18
.a2-regexp-policy {
19
  flex: 1 1 50px;
20
}
21

  
22
.password-error {
23
  font-weight: bold;
24
  color: red;
25
}
26

  
27
.password-error:before {
28
  content: "\f071";
29
  margin-right: 0.3em;
30
  font-family: FontAwesome;
31
  font-size: 100%;
32
  color: red;
33
}
34

  
35
.password-ok:before {
36
  content: "\f00c";
37
  font-family: FontAwesome;
38
  font-size: 100%;
39
  color: green;
40
}
41

  
42
.a2-password-show-last {
43
  display: inline-block;
44
  opacity: 0;
45
  float: right;
46
  text-align: center;
47
  position: relative;
48
  right: 30px;
49
  top: -4.5ex;
50
  width: 20px;
51
}
52

  
53

  
54
.a2-password-show-button {
55
  display: inline-block;
56
  float: right;
57
  position: relative;
58
  padding: 0;
59
  width: 0px;
60
  right: 10px;
61
  top: -4ex;
62
  cursor: pointer;
63
}
64

  
65
.a2-password-show-button:after {
66
  content: "\f06e"; /* eye */
67
  font-family: FontAwesome;
68
  font-size: 125%;
69
}
70

  
71
.hide-password-button:after {
72
  content: "\f070"; /* crossed eye */
73
  font-family: FontAwesome;
74
  font-size: 125%;
75
}
76

  
77
.a2-passwords-messages {
78
  display: none;
79
  opacity: 0;
80
  transition: all 0.3s ease;
81
}
82

  
83
.a2-passwords-unmatched {
84
  display: none;
85
}
86

  
87
.a2-passwords-matched {
88
  display: none;
89
}
90

  
91
.password-error.a2-passwords-messages,
92
.password-ok.a2-passwords-messages {
93
  display: block;
94
  opacity: 1;
95

  
96
}
97
.password-error.a2-passwords-messages:before,
98
.password-ok.a2-passwords-messages:before {
99
    display: none;
100
}
101

  
102
.password-error .a2-passwords-unmatched,
103
.password-ok .a2-passwords-matched {
104
  display: list-item;
105
}
106

  
107
.password-error .a2-passwords-unmatched:before {
108
  content: "\f071";
109
  margin-right: 0.3em;
110
  font-family: FontAwesome;
111
  font-size: 100%;
112
  color: red;
113
}
114

  
115
.password-ok .a2-passwords-matched:before {
116
  content: "\f00c";
117
  font-family: FontAwesome;
118
  font-size: 100%;
119
  color: green;
120
}
src/authentic2/static/authentic2/js/password.js
1
"use strict";
2
/* globals $, window, console, document */
3

  
4
$(function () {
5
	var debounce = function (func, milliseconds) {
6
		var timer;
7
		return function() {
8
			window.clearTimeout(timer);
9
			timer = window.setTimeout(function() {
10
				func();
11
			}, milliseconds);
12
		};
13
	}
14
	var toggleError = function($elt) {
15
		$elt.removeClass('password-ok');
16
		$elt.addClass('password-error');
17
	}
18
	var toggleOk = function($elt) {
19
		$elt.removeClass('password-error');
20
		$elt.addClass('password-ok');
21
	}
22
	/*
23
	* toggle error/ok on element with class names same as the validation code names
24
	* (cf. authentic2.validators.PASSWORD_VALIDATION_ERROR_CODES)
25
	*/
26
	var validatePassword = function(event) {
27
		var $this = $(event.target);
28
		if (!$this.val()) return;
29
		var policyContainer = $('#a2-password-policy-helper-' + $this.attr('name'));
30
			$.ajax({
31
				method: 'POST',
32
				url: '/api/validate-password/',
33
				data: JSON.stringify({'password': $this.val()}),
34
				dataType: 'json',
35
				contentType: 'application/json; charset=utf-8',
36
				success: function(data) {
37
					if (data.result) {
38
						policyContainer
39
						.css('height', 'auto')
40
						.css('display', 'flex')
41
						.css('opacity', 1)
42
						.children('span')
43
						.removeClass('password-error password-ok');
44
						data.validation.forEach(function (error) {
45
							// error is like [True, 'validation-code-name']
46
							if (!error[0]) {
47
								toggleError(policyContainer.find('.' + error[1]));
48
							} else {
49
								toggleOk(policyContainer.find('.' + error[1]));
50
							}
51
						});
52
					}
53
				}
54
			});
55
	}
56
	/*
57
	* Check password equality
58
	*/
59
	var displayPasswordEquality = function($input, $inputTarget) {
60
		var messages = $('#a2-password-equality-helper-' + $input.attr('name'));
61
		var form = $input.parents('form');
62
		if ($inputTarget === undefined) {
63
			$inputTarget = form.find('input[name='+$input.data('checkEqualityAgainst')+']');
64
		}
65
		if (!$input.val() || !$inputTarget.val()) return;
66
		if ($inputTarget.val() !== $input.val()) {
67
			toggleError(messages);
68
		} else {
69
			toggleOk(messages);
70
		}
71
	}
72
	var passwordEquality = function () {
73
		var $this = $(this);
74
		displayPasswordEquality($this);
75
	}
76
	/*
77
	* Hide and show password handlers
78
	*/
79
	var showPassword = function (event) {
80
		var $this = $(event.target);
81
		$this.addClass('hide-password-button');
82
		var name = $this.attr('id').split('a2-password-show-button-')[1];
83
		$('[name='+name+']').attr('type', 'text');
84
		event.preventDefault();
85
	}
86
	var hidePassword = function (event) {
87
		var $this = $(event.target);
88
		window.setTimeout(function () {
89
			$this.removeClass('hide-password-button');
90
			var name = $this.attr('id').split('a2-password-show-button-')[1];
91
			$('[name='+name+']').attr('type', 'password');
92
		}, 1000);
93
	}
94
	/*
95
	* Show the last character
96
	*/
97
	var showLastChar = function(event) {
98
		if (event.keyCode == 32 || event.key === undefined || event.key == "" || event.key == "Unidentified" || event.key.length > 1) {
99
			return;
100
		}
101
		var duration = 1000;
102
		$('#a2-password-show-last-'+$(event.target).attr('name'))
103
			.text(event.key)
104
			.animate({'opacity': 1}, {
105
				duration: 50,
106
				queue: false,
107
				complete: function () {
108
					var $this = $(this);
109
					window.setTimeout(
110
						debounce(function () {
111
							$this.animate({'opacity': 0}, {
112
								duration: 50
113
							});
114
						}, duration), duration);
115
				}
116
			});
117
	}
118
	/*
119
	* Init events
120
	*/
121
	/* add password validation and equality check event handlers */
122
	$('body').on('keyup', 'form input[data-check-policy]', validatePassword);
123
	$('body').on('keyup', 'form input[data-check-equality-against]', passwordEquality);
124
	/*
125
	* Add event to handle displaying error/OK
126
	* while editing the first password
127
	* only if the second one is not empty
128
	*/
129
	$('input[data-check-equality-against]')
130
		.each(function () {
131
			var $input2 = $(this);
132
			$('body').on('keyup', 'input[name=' + $input2.data('checkEqualityAgainst') + ']', function (event) {
133
				var $input1 = $(event.target);
134
				if ($input2.val().length) {
135
					displayPasswordEquality($input2, $input1);
136
				}
137
			});
138
		});
139
	/* add the a2-password-show-button after the first input */
140
	$('input[data-show-all]')
141
		.each(function () {
142
			var $this = $(this);
143
			if (!$('#a2-password-show-button-' + $this.attr('name')).length) {
144
				$(this).after($('<i class="a2-password-show-button" id="a2-password-show-button-'
145
					+ $this.attr('name') + '"></i>')
146
						.on('mousedown', showPassword)
147
						.on('mouseup mouseleave', hidePassword)
148
				);
149
			}
150
		});
151
	/* show the last character on keypress */
152
	$('input[data-show-last]')
153
		.each(function () {
154
			var $this = $(this);
155
			if (!$('#a2-password-show-last-' + $this.attr('name')).length) {
156
				var offset = $this.offset();
157
				offset.top = "calc(" + offset.top + "px + 0.2ex)";
158
				offset.left = "calc(" + offset.left + "px + " + $this.width() + "px + 1.2ex)";
159
				// on crée un div placé dans le padding-right de l'input
160
				var $span = $('<span class="a2-password-show-last" id="a2-password-show-last-'
161
					+ $this.attr('name') + '"></span>)')
162
				$span.css({
163
					'font-size': $this.css('font-size'),
164
					'font-family': $this.css('font-family'),
165
					'line-height': $this.css('line-height'),
166
					'vertical-align': $this.css('vertical-align'),
167
					'padding-top': $this.css('padding-top'),
168
					'padding-bottom': $this.css('padding-bottom')
169
				});
170
				$this.after($span);
171
			}
172
		});
173
	$('body').on('keyup', 'form input[data-show-last]', showLastChar);
174
});
src/authentic2/templates/authentic2/widgets/assisted_password.html
1
{% load i18n %}
2
<input class="a2-password-assisted" {% if widget.value != None %} value="{{ widget.value|stringformat:'s' }}"{% endif %}{% include "authentic2/widgets/attrs.html" %}>
3
{% comment %}Class names are the same as the validation code names (cf. authentic2.validators.PASSWORD_VALIDATION_ERROR_CODES){% endcomment %}
4
<ul class="a2-password-policy-helper" id="a2-password-policy-helper-{{ widget.attrs.name }}">
5
	{% if A2_PASSWORD_POLICY_MIN_LENGTH %}
6
	<li class="a2-min-length-policy">{% blocktrans %}Your password contains at least {{ A2_PASSWORD_POLICY_MIN_LENGTH }} characters.{% endblocktrans %}</li>
7
	{% endif %}
8
	{% if A2_PASSWORD_POLICY_MIN_CLASSES %}
9
	<li class="a2-min-class-policy">{% blocktrans %}Your password contains at least {{ A2_PASSWORD_POLICY_MIN_CLASSES }} classes among: lowercase letters, uppercase letters, digits and punctuations.{% endblocktrans %}</li>
10
	{% endif %}
11
	{% if A2_PASSWORD_POLICY_REGEX %}
12
		{% if A2_PASSWORD_POLICY_REGEX_ERROR_MSG %}
13
			<li class="a2-regexp-policy">{% blocktrans %}{{ A2_PASSWORD_POLICY_REGEX_ERROR_MSG }}{% endblocktrans %}</li>
14
		{% else %}
15
			<li class="a2-regexp-policy">{% blocktrans %}Your password matches the regular expression: {{ A2_PASSWORD_POLICY_REGEX }}, please change this message using the A2_PASSWORD_POLICY_REGEX_ERROR_MSG setting.'{% endblocktrans %}</li>
16
		{% endif %}
17
	{% endif %}
18
</ul>
19
{% if widget.check_equality %}
20
<ul class="a2-passwords-messages" id="a2-password-equality-helper-{{ widget.attrs.name }}">
21
	<li class="a2-passwords-matched">{% trans 'Passwords match.' %}</li>
22
	<li class="a2-passwords-unmatched">{% trans 'Passwords do not match.' %}</li>
23
</ul>
24
{% endif %}
src/authentic2/templates/authentic2/widgets/attrs.html
1
{% comment %}Will be deprecated in Django 1.11 : replace with django/forms/widgets/attrs.html{% endcomment %}
2
{% for name, value in widget.attrs.items %}{% if value != False %} {{ name }}{% if value != True %}="{{ value|stringformat:'s' }}"{% endif %}{% endif %}{% endfor %}
src/authentic2/validators.py
1 1
from __future__ import unicode_literals
2 2
import string
3 3
import re
4
import six
5

  
6 4
import smtplib
5
import six
7 6

  
8 7
from django.utils.translation import ugettext_lazy as _, ugettext
9 8
from django.utils.encoding import force_text
......
17 16

  
18 17
from . import app_settings
19 18

  
19

  
20 20
# copied from http://www.djangotips.com/real-email-validation
21 21
class EmailValidator(object):
22 22
    def __init__(self, rcpt_check=False):
......
80 80

  
81 81
email_validator = EmailValidator()
82 82

  
83
def validate_password(password):
83

  
84
PASSWORD_VALIDATION_ERROR_CODES = {
85
    'a2-min-length-policy': _('password must contain at least %d characters') % app_settings.A2_PASSWORD_POLICY_MIN_LENGTH,
86
    'a2-min-class-policy': _('password must contain characters '
87
        'from at least %d classes among: lowercase letters, '
88
        'uppercase letters, digits, and punctuations') % app_settings.A2_PASSWORD_POLICY_MIN_CLASSES,
89
    'a2-regexp-policy': app_settings.A2_PASSWORD_POLICY_REGEX_ERROR_MSG or
90
        _('your password dit not match the regular expession %s') % app_settings.A2_PASSWORD_POLICY_REGEX
91
}
92

  
93
def password_validation_rules(password):
84 94
    password_set = set(password)
85 95
    digits = set(string.digits)
86 96
    lower = set(string.lowercase)
......
90 100

  
91 101
    if not password:
92 102
        return
93
    min_len = app_settings.A2_PASSWORD_POLICY_MIN_LENGTH
94
    if len(password) < min_len:
95
        errors.append(ValidationError(_('password must contain at least %d '
96
            'characters') % min_len))
103
    if len(password) < app_settings.A2_PASSWORD_POLICY_MIN_LENGTH:
104
        errors.append((False, 'a2-min-length-policy'))
105
    else:
106
        errors.append((True, 'a2-min-length-policy'))
97 107

  
98 108
    class_count = 0
99 109
    for cls in (digits, lower, upper, punc):
100 110
        if not password_set.isdisjoint(cls):
101 111
            class_count += 1
102
    min_class_count = app_settings.A2_PASSWORD_POLICY_MIN_CLASSES
103
    if class_count < min_class_count:
104
        errors.append(ValidationError(_('password must contain characters '
105
            'from at least %d classes among: lowercase letters, '
106
            'uppercase letters, digits, and punctuations') % min_class_count))
112
    if class_count < app_settings.A2_PASSWORD_POLICY_MIN_CLASSES:
113
        errors.append((False, 'a2-min-class-policy'))
114
    else:
115
        errors.append((True, 'a2-min-class-policy'))
116

  
107 117
    if app_settings.A2_PASSWORD_POLICY_REGEX:
108 118
        if not re.match(app_settings.A2_PASSWORD_POLICY_REGEX, password):
109
            msg = app_settings.A2_PASSWORD_POLICY_REGEX_ERROR_MSG
110
            msg = msg or _('your password dit not match the regular expession %s') % app_settings.A2_PASSWORD_POLICY_REGEX
111
            errors.append(ValidationError(msg))
112
    if errors:
113
        raise ValidationError(errors)
119
            errors.append((False, 'a2-regexp-policy'))
120
        else:
121
            errors.append((True, 'a2-regexp-policy'))
122

  
123
    return errors
124

  
125
def validate_password(password):
126
    validation_exceptions = []
127
    for (is_success, validation_code) in password_validation_rules(password):
128
        if not is_success:
129
            validation_exceptions.append(ValidationError(
130
                PASSWORD_VALIDATION_ERROR_CODES[validation_code]))
131

  
132
    if validation_exceptions:
133
        raise ValidationError(validation_exceptions)
114 134

  
115 135

  
116 136
class UsernameValidator(RegexValidator):
......
141 161
                        'A2_PASSWORD_POLICY_REGEX_ERROR_MSG setting.') % \
142 162
                        {'regexp': app_settings.A2_PASSWORD_POLICY_REGEX}
143 163

  
164

  
144 165
def password_help_text():
145 166
    return ' '.join(__password_help_text_helper())
146 167

  
168

  
147 169
password_help_text = lazy(password_help_text, six.text_type)
148
-