From 813bcc8f4e66f20accf42ba933e070bfe1d08c10 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 | 34 ++++ src/authentic2/registration_backend/forms.py | 12 +- .../registration_backend/widgets.py | 92 +++++++++ .../static/authentic2/css/password.css | 137 ++++++++++++++ .../static/authentic2/css/style.css | 19 ++ .../static/authentic2/js/password.js | 177 ++++++++++++++++++ .../authentic2/widgets/assisted_password.html | 28 +++ .../templates/authentic2/widgets/attrs.html | 2 + .../registration_completion_form.html | 2 +- src/authentic2/validators.py | 67 +++++-- tests/test_api.py | 37 ++++ tests/test_registration.py | 53 ++++++ 13 files changed, 636 insertions(+), 26 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 6923b01a..b8993218 100644 --- a/src/authentic2/api_urls.py +++ b/src/authentic2/api_urls.py @@ -13,6 +13,8 @@ urlpatterns = [ 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..6bb0daa0 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,6 +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): @@ -709,3 +711,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..8f5fc0b9 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,11 @@ 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(), 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..6aa5ae83 --- /dev/null +++ b/src/authentic2/registration_backend/widgets.py @@ -0,0 +1,92 @@ +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 BasePasswordInput(PasswordInput): + """ + a password Input with some features to help the user choosing a new password + Inspired by Django >= 1.11 new-style rendering + (cf. https://docs.djangoproject.com/fr/1.11/ref/forms/renderers) + """ + template_name = 'authentic2/widgets/assisted_password.html' + features = {} + + class Media: + js = ('authentic2/js/password.js',) + css = { + 'all': ('authentic2/css/password.css',) + } + + def get_context(self, name, value, attrs): + """ + Base get_context + """ + context = { + 'app_settings': { + '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, + }, + 'features': self.features + } + # attach data-* attributes for password.js to activate events + attrs.update(dict([('data-%s' % feat.replace('_', '-'), is_active) for feat, is_active in self.features.items()])) + + context['widget'] = { + 'name': name, + 'is_hidden': self.is_hidden, + 'required': self.is_required, + 'template_name': self.template_name, + '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): + """ + Override render with a template-based system + 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(BasePasswordInput): + """ + Password typing assistance widget (eg. password2) + """ + features = { + 'check_equality': True, + 'show_all': True, + 'show_last': True, + } + + def get_context(self, name, value, attrs): + context = super(CheckPasswordInput, self).get_context( + name, value, attrs) + return context + + +class NewPasswordInput(CheckPasswordInput): + """ + Password creation assistance widget (eg. password1) + """ + features = { + 'check_equality': False, + 'show_all': True, + 'show_last': True, + 'check_policy': True, + } + + def get_context(self, name, value, attrs): + context = super(NewPasswordInput, self).get_context(name, value, attrs) + 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..313267b7 --- /dev/null +++ b/src/authentic2/static/authentic2/css/password.css @@ -0,0 +1,137 @@ +/* required in order to position a2-password-show-all and a2-password-show-last */ +input[type=password].a2-password-assisted { + padding-right: 60px; + width: 100%; +} + +.a2-password-icon { + display: inline-block; + width: calc(18em / 14); + text-align: center; + font-style: normal; + padding-right: 1em; +} + +/* default circle icon */ +.a2-password-icon:before { + font-family: FontAwesome; + content: "\f111"; /* right hand icon */ + font-size: 50%; +} + +.a2-password-policy-helper { + display: flex; + height: auto; + flex-direction: row; + flex-wrap: wrap; + position: relative; + padding: 0.5rem 1rem; +} + +/* we don't want helptext when a2-password-policy-helper is here */ +.a2-password-policy-helper ~ .helptext { + display: none; +} + +.a2-min-class-policy, +.a2-min-length-policy, +.a2-regexp-policy { + flex: 1 1 0; + list-style: none; +} + +.password-error { + color: black; +} + +.password-ok { + color: green; +} + +.password-error .a2-password-icon:before { + content: "\f00d"; /* cross icon */ + color: red; +} + +.password-ok .a2-password-icon::before { + content: "\f00c"; /* ok icon */ + 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: block; + padding: 0.5rem 1rem; +} + +.a2-passwords-default { + list-style: none; + opacity: 0; +} + +.password-error .a2-passwords-default, +.password-ok .a2-passwords-default { + display: none; +} + +.a2-passwords-matched, +.a2-passwords-unmatched { + display: none; + list-style: none; + opacity: 0; + transition: all 0.3s ease; +} + +.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: block; + opacity: 1; +} + +.password-error .a2-passwords-unmatched .a2-password-icon:before { + content: "\f00d"; /* cross icon */ + color: red; +} + +.password-ok .a2-passwords-matched .a2-password-icon:before { + content: "\f00c"; /* ok icon */ + color: green; +} \ No newline at end of file diff --git a/src/authentic2/static/authentic2/css/style.css b/src/authentic2/static/authentic2/css/style.css index c7545cae..7197282b 100644 --- a/src/authentic2/static/authentic2/css/style.css +++ b/src/authentic2/static/authentic2/css/style.css @@ -76,3 +76,22 @@ .a2-log-message { white-space: pre-wrap; } + +.a2-registration-completion { + padding: 1rem; + min-width: 330px; + width: 50%; +} + +@media screen and (max-width: 900px) { + .a2-registration-completion { + width: 100%; + } +} + +.a2-registration-completion input, +.a2-registration-completion select, +.a2-registration-completion textarea +{ + width: 100%; +} \ 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..a82842eb --- /dev/null +++ b/src/authentic2/static/authentic2/js/password.js @@ -0,0 +1,177 @@ +"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. error_codes in authentic2.validators.validate_password) + */ + 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 + .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[type=password]:not(input[name='+$input.attr('name')+'])'); + } + 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'); + }, 3000); + } + /* + * 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 */ + $('form input[type=password]:not(input[data-check-policy])').each(function () { + $('#a2-password-policy-helper-' + $(this).attr('name')).hide(); + }); + $('body').on('keyup', 'form input[data-check-policy]', validatePassword); + $('body').on('keyup', 'form input[data-check-equality]', 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]') + .each(function () { + var $input2 = $(this); + $('body') + .on('keyup', 'form input[type=password]:not([name=' + $input2.attr('name') + '])', + 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..1411830f --- /dev/null +++ b/src/authentic2/templates/authentic2/widgets/assisted_password.html @@ -0,0 +1,28 @@ +{% load i18n %} + +{% if features.check_policy %} +

{% trans "In order to create a secure password, please use at least :" %}

+ +{% endif %} +{% if features.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/templates/registration/registration_completion_form.html b/src/authentic2/templates/registration/registration_completion_form.html index cd179aaa..1ab0d5ac 100644 --- a/src/authentic2/templates/registration/registration_completion_form.html +++ b/src/authentic2/templates/registration/registration_completion_form.html @@ -25,7 +25,7 @@ {% block content %}

{% trans "Registration" %}

{% trans "Please fill the form to complete your registration" %}

-
+ {% csrf_token %} {{ form.as_p }} diff --git a/src/authentic2/validators.py b/src/authentic2/validators.py index c9e9b09b..598c79bd 100644 --- a/src/authentic2/validators.py +++ b/src/authentic2/validators.py @@ -80,7 +80,12 @@ class EmailValidator(object): email_validator = EmailValidator() -def validate_password(password): + +def password_validation_rules(password): + """ + Indicate whether or not the password complies each password validation rule + return a list of tuples like (True or False, 'policy-code-name') + """ password_set = set(password) digits = set(string.digits) lower = set(string.lowercase) @@ -90,27 +95,45 @@ 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): + 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 expression %s') % app_settings.A2_PASSWORD_POLICY_REGEX + } + validation_exceptions = [] + for (is_success, validation_code) in password_validation_rules(password): + if not is_success: + validation_exceptions.append(ValidationError( + error_codes[validation_code])) + + if validation_exceptions: + raise ValidationError(validation_exceptions) class UsernameValidator(RegexValidator): @@ -135,13 +158,17 @@ def __password_help_text_helper(): 'classes among: lowercase letters, uppercase letters, digits ' 'and punctuations.') % {'min_classes': app_settings.A2_PASSWORD_POLICY_MIN_CLASSES} if app_settings.A2_PASSWORD_POLICY_REGEX: - yield ugettext(app_settings.A2_PASSWORD_POLICY_REGEX_ERROR_MSG) or \ - ugettext('Your password must match the regular expression: ' - '%(regexp)s, please change this message using the ' - 'A2_PASSWORD_POLICY_REGEX_ERROR_MSG setting.') % \ - {'regexp': app_settings.A2_PASSWORD_POLICY_REGEX} + if app_settings.A2_PASSWORD_POLICY_REGEX_ERROR_MSG: + yield ugettext(app_settings.A2_PASSWORD_POLICY_REGEX_ERROR_MSG) + else: + yield ugettext('Your password must match the regular expression: ' + '%(regexp)s, please change this message using the ' + '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) diff --git a/tests/test_api.py b/tests/test_api.py index 23e4efff..9885663b 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -826,3 +826,40 @@ def test_no_opened_session_cookie_on_api(app, user, settings): app.authorization = ('Basic', (user.username, user.username)) resp = app.get('/api/users/') assert 'A2_OPENED_SESSION' not in app.cookies + + +def test_api_validate_password_bad_request(app): + payload = { + 'bad_arg': 'boby lapointe' + } + resp = app.post_json(reverse('a2-api-validate-password'), params=payload, status=400) + assert resp.json['result'] == 0 + assert set(['password']) == set(resp.json['errors']) + + +def test_api_validate_password_bad_password(app, settings): + settings.A2_PASSWORD_POLICY_REGEX = '^[0-9]$' + settings.A2_PASSWORD_POLICY_MIN_LENGTH = 100 + settings.A2_PASSWORD_POLICY_MIN_CLASSES = 100 + payload = { + 'password': 'boby2lapointe' + } + resp = app.post_json(reverse('a2-api-validate-password'), params=payload, status=200) + assert resp.json['result'] == 1 + assert [False, 'a2-min-length-policy'] in resp.json['validation'] + assert [False, 'a2-min-class-policy'] in resp.json['validation'] + assert [False, 'a2-regexp-policy'] in resp.json['validation'] + + +def test_api_validate_password_good_password(app, settings): + settings.A2_PASSWORD_POLICY_REGEX = '^[a-z]*$' + settings.A2_PASSWORD_POLICY_MIN_LENGTH = 4 + settings.A2_PASSWORD_POLICY_MIN_CLASSES = 0 + payload = { + 'password': 'abcd' + } + resp = app.post_json(reverse('a2-api-validate-password'), params=payload, status=200) + assert resp.json['result'] == 1 + assert [True, 'a2-min-length-policy'] in resp.json['validation'] + assert [True, 'a2-min-class-policy'] in resp.json['validation'] + assert [True, 'a2-regexp-policy'] in resp.json['validation'] \ No newline at end of file diff --git a/tests/test_registration.py b/tests/test_registration.py index 1f39cd8a..ebd49a03 100644 --- a/tests/test_registration.py +++ b/tests/test_registration.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- +import re from urlparse import urlparse from django.core.urlresolvers import reverse @@ -585,3 +586,55 @@ def test_registration_redirect_tuple(app, db, settings, mailoutbox, external_red response = response.form.submit() assert new_next_url in response.content + +def test_registration_activate_passwords_not_equal(app, db, settings, mailoutbox): + settings.LANGUAGE_CODE = 'en-us' + settings.A2_VALIDATE_EMAIL_DOMAIN = can_resolve_dns() + settings.A2_EMAIL_IS_UNIQUE = True + + response = app.get(reverse('registration_register')) + response.form.set('email', 'testbot@entrouvert.com') + response = response.form.submit() + response = response.follow() + link = get_link_from_mail(mailoutbox[0]) + response = app.get(link) + response.form.set('password1', 'azerty12AZ') + response.form.set('password2', 'AAAazerty12AZ') + response = response.form.submit() + assert "The two password fields didn't match." in response.content + + +def test_registration_activate_assisted_password(app, db, settings, mailoutbox): + settings.LANGUAGE_CODE = 'en-us' + settings.A2_VALIDATE_EMAIL_DOMAIN = can_resolve_dns() + settings.A2_EMAIL_IS_UNIQUE = True + settings.A2_PASSWORD_POLICY_MIN_CLASSES = 3 + settings.A2_PASSWORD_POLICY_MIN_LENGTH = 10 + settings.A2_PASSWORD_POLICY_REGEX = '^.*$' + response = app.get(reverse('registration_register')) + response.form.set('email', 'testbot@entrouvert.com') + response = response.form.submit() + response = response.follow() + link = get_link_from_mail(mailoutbox[0]) + response = app.get(link) + assert "password.js" in response.content + assert "password.css" in response.content + assert re.search('', response.content, re.I | re.M | re.S) + assert re.search('', response.content, re.I | re.M | re.S) + assert re.search('', response.content, re.I | re.M | re.S) + assert re.search('class="a2-passwords-messages" id="a2-password-equality-helper-', response.content, re.I | re.M | re.S) + assert re.search('class="a2-password-policy-helper" id="a2-password-policy-helper-', response.content, re.I | re.M | re.S) + assert re.search('class="a2-min-length-policy"', response.content, re.I | re.M | re.S) + assert re.search('class="a2-min-class-policy"', response.content, re.I | re.M | re.S) + assert re.search('class="a2-regexp-policy"', response.content, re.I | re.M | re.S) + + +def test_registration_activate_password_no_min_class_count(app, db, settings, mailoutbox): + settings.A2_PASSWORD_POLICY_MIN_CLASSES = False + response = app.get(reverse('registration_register')) + response.form.set('email', 'testbot@entrouvert.com') + response = response.form.submit() + response = response.follow() + link = get_link_from_mail(mailoutbox[0]) + response = app.get(link) + assert not re.search('class="a2-min-class-policy"', response.content, re.I | re.M | re.S) -- 2.18.0