Projet

Général

Profil

0001-create-AssistedPassword-AssistedPasswordFormMixin-24.patch

Anonyme, 22 juin 2018 18:28

Télécharger (23,9 ko)

Voir les différences:

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
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,
17
                           name='a2-api-validate-password'),
16 18
)
17 19
urlpatterns += api_views.router.urls
src/authentic2/api_views.py
29 29
from . import utils, decorators, attribute_kinds, app_settings, hooks
30 30
from .models import Attribute, PasswordReset
31 31
from .a2_rbac.utils import get_default_ou
32

  
32
from .validators import get_validation_errors
33 33

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

  
710 710

  
711 711
check_password = CheckPasswordAPI.as_view()
712

  
713

  
714
class ValidatePasswordSerializer(serializers.Serializer):
715
    password = serializers.CharField(required=True)
716

  
717

  
718
class ValidatePasswordAPI(ExceptionHandlerMixin, GenericAPIView):
719
    serializer_class = ValidatePasswordSerializer
720
    permission_classes = ()
721

  
722
    def post(self, request, **kwargs):
723
        serializer = self.get_serializer(data=request.data)
724
        if not serializer.is_valid():
725
            response = {
726
                'result': 0,
727
                'errors': serializer.errors
728
            }
729
            return Response(response, status.HTTP_400_BAD_REQUEST)
730
        password = serializer.validated_data['password']
731
        errors = get_validation_errors(password)
732
        return Response(errors, status.HTTP_200_OK)
733

  
734

  
735
validate_password = ValidatePasswordAPI.as_view()
src/authentic2/app_settings.py
140 140
    A2_VALIDATE_EMAIL=Setting(default=False, definition='Validate user email server by doing an RCPT command'),
141 141
    A2_VALIDATE_EMAIL_DOMAIN=Setting(default=True, definition='Validate user email domain'),
142 142
    A2_PASSWORD_POLICY_MIN_CLASSES=Setting(default=3, definition='Minimum number of characters classes to be present in passwords'),
143
    A2_PASSWORD_DISPLAY_LAST_CHAR=Setting(default=True, definition='Boolean to display the last  character'),
144
    A2_PASSWORD_DISPLAY_SHOW_ALL=Setting(default=True, definition='Boolean to display a button showing all the password characters'),
145
    A2_PASSWORD_DISPLAY_CHECK_POLICY=Setting(default=True, definition='Boolean to display password validation policy while typing'),
146
    A2_PASSWORD_DISPLAY_CHECK_EQUALITY=Setting(default=True, definition='Boolean to display password equality check while typing'),
147
    A2_PASSWORD_POLICY_MESSAGE_TPL=Setting(default='authentic2/password_help_text.html', definition='Django template path to the password policy text html'),
143 148
    A2_PASSWORD_POLICY_MIN_LENGTH=Setting(default=8, definition='Minimum number of characters in a password'),
144 149
    A2_PASSWORD_POLICY_REGEX=Setting(default=None, definition='Regular expression for validating passwords'),
145 150
    A2_PASSWORD_POLICY_REGEX_ERROR_MSG=Setting(default=None, definition='Error message to show when the password do not validate the regular expression'),
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
7 8
from django.utils.translation import ugettext_lazy as _, ugettext
8 9
from django.forms import ModelForm, Form, CharField, PasswordInput, EmailField
9 10
from django.utils.datastructures import SortedDict
11
from django.utils.encoding import force_text
10 12
from django.db.models.fields import FieldDoesNotExist
11 13
from django.forms.util import ErrorList
12 14

  
13 15
from django.contrib.auth.models import BaseUserManager, Group
14 16
from django.contrib.auth import forms as auth_forms, get_user_model, REDIRECT_FIELD_NAME
17
from django.utils.safestring import mark_safe
15 18
from django.core.mail import send_mail
16 19
from django.core import signing
17 20
from django.template import RequestContext
......
24 27

  
25 28
User = compat.get_user_model()
26 29

  
30
class AssistedPasswordInput(PasswordInput):
31
    """
32
    Custom Password Input with extra functionnality
33
    Inspired by Django >= 1.11 new-style rendering, and ensuring an easy future compatibility
34
    https://docs.djangoproject.com/fr/1.11/ref/forms/renderers/#overriding-built-in-widget-templates
35
    """
36
    template_name = 'authentic2/widgets/assisted_password.html'
37

  
38
    def render(self, name, value, attrs=None):
39
        """
40
        Overridding render() to have a template-based widget
41
        https://docs.djangoproject.com/en/1.8/ref/forms/widgets/#django.forms.Widget.render
42
        """
43
        if self.attrs.get('data-check-equality-against'):
44
            attrs['checkEquality'] = True
45
        # Remove this part down when dropping Django 1.8, 1.9, 1.10 compatibility
46
        if value is None:
47
            value = ''
48
        context = {
49
            'widget': {},
50
            'A2_PASSWORD_POLICY_MIN_LENGTH': app_settings.A2_PASSWORD_POLICY_MIN_LENGTH,
51
            'A2_PASSWORD_POLICY_MIN_CLASSES': app_settings.A2_PASSWORD_POLICY_MIN_CLASSES,
52
            'A2_PASSWORD_POLICY_REGEX': app_settings.A2_PASSWORD_POLICY_REGEX,
53
            'A2_PASSWORD_POLICY_REGEX_ERROR_MSG': app_settings.A2_PASSWORD_POLICY_REGEX_ERROR_MSG
54
        }
55
        context['widget']['attrs'] = self.build_attrs(extra_attrs=attrs, name=name,
56
            type=self.input_type)
57

  
58
        if value != '':
59
            # Only add the 'value' attribute if a value is non-empty.
60
            context['widget']['value'] = force_text(self._format_value(value))
61
        return mark_safe(render_to_string(self.template_name, context))
62

  
63

  
64
class AssistedPasswordFormMixin(Form):
65
    class Media:
66
        js = ('authentic2/js/password.js',)
67
        css = {'all': ('authentic2/css/password.css',)}
68

  
69
    password1 = CharField(
70
        widget=AssistedPasswordInput(attrs={
71
            'data-show-last': app_settings.A2_PASSWORD_DISPLAY_LAST_CHAR,
72
            'data-show-all': app_settings.A2_PASSWORD_DISPLAY_SHOW_ALL,
73
            'data-check-policy': app_settings.A2_PASSWORD_DISPLAY_CHECK_POLICY,
74
        }),
75
        label=_("Password"),
76
        validators=[validators.validate_password],
77
        help_text=validators.password_help_text())
78

  
79
    password2 = CharField(
80
        widget=AssistedPasswordInput(attrs={
81
            'data-check-equality-against': 'password1' if app_settings.A2_PASSWORD_DISPLAY_CHECK_EQUALITY else False,
82
        }),
83
        label=_("Password (again)"))
84

  
27 85

  
28 86
class RegistrationForm(Form):
29 87
    error_css_class = 'form-field-error'
......
114 172
        return user
115 173

  
116 174

  
117
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)"))
122

  
175
class RegistrationCompletionForm(RegistrationCompletionFormNoPassword, AssistedPasswordFormMixin):
123 176
    def clean(self):
124 177
        """
125 178
        Verifiy that the values entered into the two password fields
src/authentic2/registration_backend/views.py
38 38
def valid_token(method):
39 39
    def f(request, *args, **kwargs):
40 40
        try:
41
            request.token = signing.loads(kwargs['registration_token'].replace(' ', ''),
42
                                          max_age=settings.ACCOUNT_ACTIVATION_DAYS * 3600 * 24)
41
            request.token = {
42
                'email': 'toto'
43
            }
44
            # request.token = signing.loads(kwargs['registration_token'].replace(' ', ''),
45
            #                               max_age=settings.ACCOUNT_ACTIVATION_DAYS * 3600 * 24)
43 46
        except signing.SignatureExpired:
44 47
            messages.warning(request, _('Your activation key is expired'))
45 48
            return redirect(request, 'registration_register')
src/authentic2/static/authentic2/css/password.css
1
.a2-password-policy-helper {
2
  display: none;
3
}
4

  
5
.a2-min-class-policy, .a2-min-length-policy, .a2-regexp-policy {
6
  display: inline;
7
}
8

  
9
.password-error {
10
  font-weight: bold;
11
  color: red;
12
}
13

  
14
.password-error:before {
15
  content: "\f071";
16
  margin-right: 0.3em;
17
  font-family: FontAwesome;
18
  font-size: 100%;
19
  color: red;
20
}
21

  
22
.password-ok:before {
23
  content: "\f00c";
24
  font-family: FontAwesome;
25
  font-size: 100%;
26
  color: green;
27
}
28

  
29
.a2-password-show-button {
30
  position: relative;
31
  left: -4ex;
32
  padding: 0;
33
  cursor: pointer;
34
}
35

  
36
.a2-password-show-button:after {
37
  content: "\f06e"; /* eye */
38
  font-family: FontAwesome;
39
  font-size: 125%;
40
}
41

  
42
.hide-password-button:after {
43
  content: "\f070"; /* crossed eye */
44
  font-family: FontAwesome;
45
  font-size: 125%;
46
}
47

  
48
.a2-passwords-messages {
49
  display: none;
50
}
51

  
52
.a2-passwords-unmatched {
53
  display: none;
54
  color: red;
55
}
56

  
57
.a2-passwords-matched {
58
  display: none;
59
  color: green;
60
}
61

  
62
.password-error.a2-passwords-messages, .password-ok.a2-passwords-messages {
63
  display: block;
64
}
65

  
66
.password-error .a2-passwords-unmatched {
67
  display: inline;
68
}
69

  
70
.password-ok .a2-passwords-matched {
71
  display: inline;
72
}
73

  
74
input.a2-password-assisted {
75
  padding-right: 4em;
76
}
77

  
78
.a2-password-show-last {
79
  opacity: 0;
80
  position: relative;
81
  left: -5ex;
82
}
src/authentic2/static/authentic2/js/password.js
1
"use strict";
2
/* globals $, window, console */
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
	var validatePassword = function () {
23
		var minClassElt = $(this).parents('form').find('.a2-min-class-policy');
24
		var minLengthElt = $(this).parents('form').find('.a2-min-length-policy');
25
		var regexpElt = $(this).parents('form').find('.a2-regexp-policy');
26
		$(this)
27
			.each(function () {
28
				var $this = $(this);
29
				$.ajax({
30
					method: 'POST',
31
					url: '/api/validate-password/',
32
					data: JSON.stringify({'password': $this.val()}),
33
					dataType: 'json',
34
					contentType: 'application/json; charset=utf-8',
35
					success: function(data) {
36
						if (data.length) {
37
							$('#a2-password-policy-helper-' + $this.attr('name'))
38
								.show()
39
								.children('span')
40
								.removeClass('password-error password-ok');
41
							data.forEach(function (error) {
42
								if (error == 'min_len') { toggleError(minLengthElt); } else { toggleOk(minLengthElt); }
43
								if (error == 'min_class_count') { toggleError(minClassElt); } else { toggleOk(minClassElt); }
44
								if (error == 'regexp') { toggleError(regexpElt); } else { toggleOk(regexpElt); }
45
							});
46
						} else {
47
							$('#a2-password-policy-helper-' + $this.attr('name')).hide();
48
						}
49
					}});
50
			});
51
	}
52
	var passwordEquality = function () {
53
		$(this)
54
			.each(function () {
55
				var input = $(this);
56
				var form = input.parents('form');
57
				var messages = form.find('.a2-passwords-messages');
58
				var inputTarget = form.find('input[name='+input.data('checkEqualityAgainst')+']');
59
				if (!input.val() || !inputTarget.val()) return;
60
				if (inputTarget.val() !== input.val()) {
61
					toggleError(messages);
62
				} else {
63
					toggleOk(messages);
64
				}
65
			});
66
	}
67
	var showPassword = function () {
68
		$(this).addClass('hide-password-button');
69
		$(this).prevUntil().filter('input[data-show-all]').last().attr('type', 'text');
70
	}
71
	var hidePassword = function () {
72
		var $this = $(this);
73
		window.setTimeout(function () {
74
			$this.removeClass('hide-password-button');
75
			$this.prevUntil().filter('input[data-show-all]').last().attr('type', 'password');
76
		}, 1000);
77
	}
78
	/*
79
	* Show the last character
80
	*/
81
	var showLastChar = function(event) {
82
		if (event.keyCode == 32 || event.key === undefined || event.key == "" || event.key == "Unidentified" || event.key.length > 1) {
83
			return;
84
		}
85
		var duration = 1000;
86
		$('#a2-password-show-last-'+$(this).attr('name'))
87
			.text(event.key)
88
			.animate({'opacity': 1}, {
89
				duration: 50,
90
				queue: false,
91
				complete: function () {
92
					var $this = $(this);
93
					window.setTimeout(
94
						debounce(function () {
95
							$this.animate({'opacity': 0}, {
96
								duration: 50
97
							});
98
						}, duration), duration);
99
				}
100
			});
101
	}
102
	/* add password validation and equality check event handlers */
103
	$('form').on('keyup', 'input[data-check-policy]', validatePassword);
104
	$('form').on('keyup', 'input[data-check-equality-against]', passwordEquality);
105
	/* while editing the first password, toggleError if the second one is not empty */
106
	$('input[data-check-equality-against]')
107
		.each(function () {
108
			var input2 = $(this);
109
			var input1 = $('form').find('input[name='+input2.data('checkEqualityAgainst')+']');
110
			$('form').on('keyup', input1, function () {
111
				var form = $(this)
112
				var messages = form.find('.a2-passwords-messages');
113
				if (input2.val().length) {
114
					if (input1.val() !== input2.val()) {
115
						toggleError(messages);
116
					} else {
117
						toggleOk(messages);
118
					}
119
				}
120
			});
121
		});
122

  
123
	/* add the a2-password-show-button after the first input */
124
	$('input[data-show-all]')
125
		.each(function () {
126
			var $this = $(this);
127
			if (!$('#a2-password-show-button-' + $this.attr('name')).length) {
128
				$(this).after($('<i class="a2-password-show-button" id="a2-password-show-button-'
129
					+ $this.attr('name') + '"></i>')
130
						.on('mousedown', showPassword)
131
						.on('mouseup mouseleave', hidePassword)
132
				);
133
			}
134
		});
135
	/* show the last character on keypress */
136
	$('input[data-show-last]')
137
		.each(function () {
138
			var $this = $(this);
139
			if (!$('#a2-password-show-last-' + $this.attr('name')).length) {
140
				var offset = $this.offset();
141
				offset.top = "calc(" + offset.top + "px + 0.2ex)";
142
				offset.left = "calc(" + offset.left + "px + " + $this.width() + "px + 1.2ex)";
143
				// on crée un div placé dans le padding-right de l'input
144
				var $span = $('<span class="a2-password-show-last" id="a2-password-show-last-'
145
					+ $this.attr('name') + '"></span>)')
146
				$span.css({
147
					'font-size': $this.css('font-size'),
148
					'font-family': $this.css('font-family'),
149
					'line-height': $this.css('line-height'),
150
					'padding-top': $this.css('padding-top')
151
				});
152
				$this.after($span);
153
			}
154
		});
155
	$('form').on('keyup', 'input[data-show-last]', showLastChar);
156
});
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
{% include 'authentic2/widgets/password_help_text.html' %}
4
{% if widget.attrs.checkEquality %}
5
<div class="{{ messages_class }}">
6
	<span class="a2-passwords-matched">{% trans 'Passwords match.' %}</span>
7
	<span class="a2-passwords-unmatched">{% trans 'Passwords do not match.' %}</span>
8
</div>
9
{% 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/templates/authentic2/widgets/password_help_text.html
1
{% load i18n %}
2
<div class="a2-password-policy-helper" id="a2-password-policy-helper-{{ widget.attrs.name }}">
3
{% if A2_PASSWORD_POLICY_MIN_LENGTH and A2_PASSWORD_POLICY_MIN_CLASSES %}
4
	<span class="a2-min-length-policy">{% blocktrans %}Your password must contain at least {{ A2_PASSWORD_POLICY_MIN_LENGTH }} characters{% endblocktrans %}</span>&nbsp;{% trans 'from' %}&nbsp;<span class="a2-min-class-policy">{% blocktrans %}at least {{ A2_PASSWORD_POLICY_MIN_CLASSES }} classes among: lowercase letters, uppercase letters, digits and punctuations.{% endblocktrans %}</span>
5
{% else %}
6
	{% if A2_PASSWORD_POLICY_MIN_LENGTH %}
7
	<span class="a2-min-length-policy">{% blocktrans %}Your password must contain at least {{ A2_PASSWORD_POLICY_MIN_LENGTH }} characters.{% endblocktrans %}</span>
8
	{% endif %}
9
	{% if A2_PASSWORD_POLICY_MIN_CLASSES %}
10
	<span class="a2-min-class-policy">{% blocktrans %}Your password must contain at least {{ A2_PASSWORD_POLICY_MIN_CLASSES }} classes among: lowercase letters, uppercase letters, digits and punctuations.{% endblocktrans %}</span>
11
	{% endif %}
12
{% endif %}
13
{% if A2_PASSWORD_POLICY_REGEX %}
14
	{% if A2_PASSWORD_POLICY_REGEX_ERROR_MSG %}
15
		<span class="a2-regexp-policy">{% blocktrans %}{{ A2_PASSWORD_POLICY_REGEX_ERROR_MSG }}{% endblocktrans %}</span>
16
	{% else %}
17
		<span class="a2-regexp-policy">{% 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 %}</span>
18
	{% endif %}
19
{% endif %}
20
</div>
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
    'min_len': _('password must contain at least %d characters') % app_settings.A2_PASSWORD_POLICY_MIN_LENGTH,
86
    'min_class_count': _('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
    'regexp': 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 get_validation_errors(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('min_len')
97 105

  
98 106
    class_count = 0
99 107
    for cls in (digits, lower, upper, punc):
100 108
        if not password_set.isdisjoint(cls):
101 109
            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))
110
    if class_count < app_settings.A2_PASSWORD_POLICY_MIN_CLASSES:
111
        errors.append('min_class_count')
112

  
107 113
    if app_settings.A2_PASSWORD_POLICY_REGEX:
108 114
        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)
115
            errors.append('regex')
116

  
117
    return errors
118

  
119
def validate_password(password):
120
    validation_exceptions = []
121
    errors = get_validation_errors(password)
122
    if 'min_len' in errors:
123
        validation_exceptions.append(ValidationError(
124
            PASSWORD_VALIDATION_ERROR_CODES['min_len']))
125

  
126
    if 'min_class_count' in errors:
127
        validation_exceptions.append(ValidationError(
128
            PASSWORD_VALIDATION_ERROR_CODES['min_class_count']))
129

  
130
    if 'regexp' in errors:
131
        validation_exceptions.append(ValidationError(
132
            PASSWORD_VALIDATION_ERROR_CODES['regexp']))
133

  
134
    if validation_exceptions:
135
        raise ValidationError(validation_exceptions)
114 136

  
115 137

  
116 138
class UsernameValidator(RegexValidator):
117
-