misc: make minimum password strength configurable in ous (#68745)

This commit is contained in:
Corentin Sechet 2022-09-06 18:02:12 +02:00
parent bc20207e2b
commit c393adcdd5
16 changed files with 263 additions and 79 deletions

View File

@ -0,0 +1,31 @@
# Generated by Django 2.2.26 on 2022-09-20 14:59
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('a2_rbac', '0029_use_unique_constraints'),
]
operations = [
migrations.AddField(
model_name='organizationalunit',
name='min_password_strength',
field=models.IntegerField(
blank=True,
choices=[
(None, 'System default'),
(0, 'Very Weak'),
(1, 'Weak'),
(2, 'Fair'),
(3, 'Good'),
(4, 'Strong'),
],
default=None,
null=True,
verbose_name='Minimum password strength',
),
),
]

View File

@ -106,6 +106,15 @@ class OrganizationalUnit(AbstractBase):
MANUAL_PASSWORD_POLICY: PolicyValue(False, False, True, False),
}
MIN_PASSWORD_STRENGTH_CHOICES = (
(None, _("System default")),
(0, _("Very Weak")),
(1, _("Weak")),
(2, _("Fair")),
(3, _("Good")),
(4, _("Strong")),
)
username_is_unique = models.BooleanField(blank=True, default=False, verbose_name=_('Username is unique'))
email_is_unique = models.BooleanField(blank=True, default=False, verbose_name=_('Email is unique'))
default = fields.UniqueBooleanField(verbose_name=_('Default organizational unit'))
@ -128,6 +137,14 @@ class OrganizationalUnit(AbstractBase):
verbose_name=_('User creation password policy'), choices=USER_ADD_PASSWD_POLICY_CHOICES, default=0
)
min_password_strength = models.IntegerField(
verbose_name=_('Minimum password strength'),
choices=MIN_PASSWORD_STRENGTH_CHOICES,
default=None,
null=True,
blank=True,
)
clean_unused_accounts_alert = models.PositiveIntegerField(
verbose_name=_('Days after which the user receives an account deletion alert'),
validators=[

View File

@ -35,7 +35,7 @@ from authentic2.forms.widgets import (
ProfileImageInput,
)
from authentic2.manager.utils import label_from_role
from authentic2.passwords import validate_password
from authentic2.passwords import get_password_checker, get_password_strength
from authentic2.validators import email_validator
@ -45,7 +45,38 @@ class PasswordField(CharField):
class NewPasswordField(CharField):
widget = NewPasswordInput
default_validators = [validate_password]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.min_strength = None
def _get_min_strength(self):
return self._min_strength
def _set_min_strength(self, value):
self._min_strength = value
self.widget.min_strength = value
min_strength = property(_get_min_strength, _set_min_strength)
def validate(self, value):
super().validate(value)
if value == '':
return
min_strength = self.min_strength
if min_strength is not None:
if get_password_strength(value).strength < min_strength:
raise ValidationError(_('This password is not strong enough.'))
min_length = app_settings.A2_PASSWORD_POLICY_MIN_LENGTH
if min_length > len(value):
raise ValidationError(_('Password must be at least %s characters.') % min_length)
else:
password_checker = get_password_checker()
errors = [not check.result for check in password_checker(value)]
if any(errors):
raise ValidationError(_('This password is not accepted.'))
class CheckPasswordField(CharField):

View File

@ -26,6 +26,7 @@ from django.utils.translation import ugettext_lazy as _
from authentic2.backends.ldap_backend import LDAPUser
from authentic2.journal import journal
from authentic2.passwords import get_min_password_strength
from .. import app_settings, hooks, models, validators
from ..backends import get_user_queryset
@ -176,10 +177,15 @@ class SetPasswordForm(NotifyOfPasswordChange, PasswordResetMixin, auth_forms.Set
new_password1 = NewPasswordField(label=_("New password"))
new_password2 = CheckPasswordField(label=_("New password confirmation"))
def __init__(self, user, *args, **kwargs):
super().__init__(user, *args, **kwargs)
self.fields['new_password1'].min_strength = get_min_password_strength(user)
def clean_new_password1(self):
new_password1 = self.cleaned_data.get('new_password1')
if new_password1 and self.user.check_password(new_password1):
raise ValidationError(_('New password must differ from old password'))
return new_password1
@ -192,11 +198,16 @@ class PasswordChangeForm(
old_password.widget.attrs.update({'autocomplete': 'current-password'})
def __init__(self, user, *args, **kwargs):
super().__init__(user, *args, **kwargs)
self.fields['new_password1'].min_strength = get_min_password_strength(user)
def clean_new_password1(self):
new_password1 = self.cleaned_data.get('new_password1')
old_password = self.cleaned_data.get('old_password')
if new_password1 and new_password1 == old_password:
raise ValidationError(_('New password must differ from old password'))
return new_password1

View File

@ -25,6 +25,7 @@ from django.utils.translation import ugettext_lazy as _
from authentic2.a2_rbac.models import OrganizationalUnit
from authentic2.forms.fields import CheckPasswordField, NewPasswordField
from authentic2.passwords import get_min_password_strength
from .. import app_settings, models
from . import profile as profile_forms
@ -154,6 +155,10 @@ class RegistrationCompletionForm(RegistrationCompletionFormNoPassword):
password1 = NewPasswordField(label=_('Password'))
password2 = CheckPasswordField(label=_("Password (again)"))
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['password1'].min_strength = get_min_password_strength(self.instance)
def clean(self):
"""
Verifiy that the values entered into the two password fields

View File

@ -274,12 +274,7 @@ class PasswordInput(BasePasswordInput):
class NewPasswordInput(PasswordInput):
template_name = 'authentic2/widgets/new_password.html'
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
min_strength = app_settings.A2_PASSWORD_POLICY_MIN_STRENGTH
if min_strength:
self.attrs['data-min-strength'] = min_strength
min_strength = None
def get_context(self, *args, **kwargs):
context = super().get_context(*args, **kwargs)
@ -292,6 +287,10 @@ class NewPasswordInput(PasswordInput):
if attrs is None:
attrs = {}
attrs['autocomplete'] = 'new-password'
if self.min_strength is not None:
attrs['data-min-strength'] = self.min_strength
output = super().render(name, value, attrs=attrs, renderer=renderer)
if attrs:
_id = attrs.get('id')

View File

@ -41,7 +41,7 @@ from authentic2.forms.fields import (
from authentic2.forms.mixins import SlugMixin
from authentic2.forms.profile import BaseUserForm
from authentic2.models import APIClient, PasswordReset, Service
from authentic2.passwords import generate_password
from authentic2.passwords import generate_password, get_min_password_strength
from authentic2.utils.misc import send_email_change_email, send_password_reset_mail, send_templated_mail
from authentic2.validators import EmailValidator
from django_rbac.backends import DjangoRBACBackend
@ -254,6 +254,10 @@ class UserChangePasswordForm(CssClass, forms.ModelForm):
password2 = CheckPasswordField(label=_("Confirmation"), required=False)
send_mail = forms.BooleanField(initial=False, label=_('Send informations to user'), required=False)
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.fields['password1'].min_strength = get_min_password_strength(self.instance)
class Meta:
model = User
fields = ()
@ -669,6 +673,7 @@ class OUEditForm(SlugMixin, CssClass, forms.ModelForm):
'check_required_on_login_attributes',
'user_can_reset_password',
'user_add_password_policy',
'min_password_strength',
'clean_unused_accounts_alert',
'clean_unused_accounts_deletion',
'home_url',

View File

@ -19,7 +19,6 @@ import random
import re
import string
from django.core.exceptions import ValidationError
from django.utils.module_loading import import_string
from django.utils.translation import ugettext as _
from zxcvbn import zxcvbn
@ -110,20 +109,10 @@ def get_password_checker(*args, **kwargs):
return import_string(app_settings.A2_PASSWORD_POLICY_CLASS)(*args, **kwargs)
def validate_password(password):
min_strength = app_settings.A2_PASSWORD_POLICY_MIN_STRENGTH
if min_strength is not None:
if get_password_strength(password).strength < min_strength:
raise ValidationError(_('This password is not strong enough.'))
min_length = app_settings.A2_PASSWORD_POLICY_MIN_LENGTH
if min_length > len(password):
raise ValidationError(_('Password must be at least %s characters.') % min_length)
else:
password_checker = get_password_checker()
errors = [not check.result for check in password_checker(password)]
if any(errors):
raise ValidationError(_('This password is not accepted.'))
def get_min_password_strength(user):
if user.ou and user.ou.min_password_strength is not None:
return user.ou.min_password_strength
return app_settings.A2_PASSWORD_POLICY_MIN_STRENGTH
class StrengthReport:

View File

@ -28,9 +28,6 @@ from django.utils.translation import gettext_lazy as _
from . import app_settings
# keep those symbols here for retrocompatibility
from .passwords import validate_password # pylint: disable=unused-import
# copied from http://www.djangotips.com/real-email-validation
class EmailValidator:

View File

@ -1760,7 +1760,7 @@ def test_validate_password_regex(app, settings):
@pytest.mark.parametrize(
'min_length, password,strength,label',
[
(0, '?', 0, 'Very Weak'),
(0, '', 0, 'Very Weak'),
(0, '?', 0, 'Very Weak'),
(0, '?JR!', 1, 'Weak'),
(0, '?JR!p4A', 2, 'Fair'),

View File

@ -19,6 +19,7 @@ import pytest
from django.core.exceptions import ValidationError
from authentic2.attribute_kinds import PhoneNumberField
from authentic2.forms.passwords import NewPasswordField
def test_phonenumber_field():
@ -32,3 +33,54 @@ def test_phonenumber_field():
for value in ['01a01']:
with pytest.raises(ValidationError):
field.clean(value)
def test_validate_password(settings):
field = NewPasswordField()
with pytest.raises(ValidationError):
field.validate('aaaaaZZZZZZ')
with pytest.raises(ValidationError):
field.validate('00000aaaaaa')
with pytest.raises(ValidationError):
field.validate('00000ZZZZZZ')
field.validate('000aaaaZZZZ')
@pytest.mark.parametrize(
'password,min_strength',
[
('?', 0),
('?JR!', 1),
('?JR!p4A', 2),
('?JR!p4A2i', 3),
('?JR!p4A2i:#', 4),
],
)
def test_validate_password_strength(settings, password, min_strength):
settings.A2_PASSWORD_POLICY_MIN_LENGTH = len(password)
field = NewPasswordField()
field.min_strength = min_strength
field.validate(password)
settings.A2_PASSWORD_POLICY_MIN_LENGTH = len(password) + 1
with pytest.raises(ValidationError):
field.validate(password)
if min_strength < 4:
settings.A2_PASSWORD_POLICY_MIN_LENGTH = len(password)
field.min_strength = min_strength + 1
with pytest.raises(ValidationError):
field.validate(password)
def test_digits_password_policy(settings):
settings.A2_PASSWORD_POLICY_REGEX = '^[0-9]{8}$'
settings.A2_PASSWORD_POLICY_REGEX_ERROR_MSG = 'pasbon'
settings.A2_PASSWORD_POLICY_MIN_LENGTH = 0
settings.A2_PASSWORD_POLICY_MIN_CLASSES = 0
field = NewPasswordField()
with pytest.raises(ValidationError):
field.validate('aaa')
field.validate('12345678')

View File

@ -198,6 +198,24 @@ def test_manager_user_password_reset(app, superuser, simple_user):
assert str(app.session['_auth_user_id']) == str(simple_user.pk)
def test_manager_user_change_password_form(app, simple_user):
from authentic2.manager.forms import UserChangePasswordForm
data = {
'password1': 'Password0',
'password2': 'Password0',
}
form = UserChangePasswordForm(instance=simple_user, data=data)
assert form.fields['password1'].widget.min_strength is None
assert 'password1' not in form.errors
simple_user.ou.min_password_strength = 3
form = UserChangePasswordForm(instance=simple_user, data=data)
assert form.fields['password1'].widget.min_strength == 3
assert form.errors['password1'] == ['This password is not strong enough.']
def test_manager_user_detail_by_uuid(app, superuser, simple_user, simple_role):
simple_user.roles.add(simple_role)
url = reverse('a2-manager-user-by-uuid-detail', kwargs={'slug': simple_user.uuid})

View File

@ -23,7 +23,10 @@ from django.urls import reverse
from django.utils.http import urlquote
from authentic2 import models
from authentic2.a2_rbac.utils import get_default_ou
from authentic2.apps.journal.models import Event
from authentic2.forms.profile import modelform_factory
from authentic2.forms.registration import RegistrationCompletionForm
from authentic2.utils import misc as utils_misc
from authentic2.validators import EmailValidator
@ -907,3 +910,44 @@ def test_registration_service_integration(app, service, settings):
assert response.context['home_ou'] == service.ou
assert response.context['home_service'] == service
assert response.context['home_url'] == 'https://portail.example.net/page/'
def test_registration_completion_form(db, simple_user):
form_class = modelform_factory(get_user_model(), form=RegistrationCompletionForm)
data = {
'email': 'jonh.doe@yopmail.com',
'password': 'blah',
'password1': 'Password0',
'password2': 'Password0',
'date_joined': '2022-02-07',
'ou': simple_user.ou.pk,
}
form = form_class(instance=simple_user, data=data)
assert form.fields['password1'].widget.min_strength is None
assert 'password1' not in form.errors
simple_user.ou.min_password_strength = 3
form = form_class(instance=simple_user, data=data)
assert form.fields['password1'].widget.min_strength == 3
assert form.errors['password1'] == ['This password is not strong enough.']
def test_registration_completion(db, app, mailoutbox):
default_ou = get_default_ou()
default_ou.min_password_strength = 3
default_ou.save()
resp = app.get(reverse('registration_register'))
resp.form.set('email', 'testbot@entrouvert.com')
resp = resp.form.submit().follow()
link = get_link_from_mail(mailoutbox[0])
resp = app.get(link)
resp.form.set('password1', 'Password0')
resp.form.set('password2', 'Password0')
resp.form.set('first_name', 'John')
resp.form.set('last_name', 'Doe')
resp = resp.form.submit()
assert 'This password is not strong enough' in resp.text

View File

@ -21,44 +21,7 @@ from unittest import mock
import pytest
from django.core.exceptions import ValidationError
from authentic2.validators import EmailValidator, HexaColourValidator, validate_password
def test_validate_password(settings):
with pytest.raises(ValidationError):
validate_password('aaaaaZZZZZZ')
with pytest.raises(ValidationError):
validate_password('00000aaaaaa')
with pytest.raises(ValidationError):
validate_password('00000ZZZZZZ')
validate_password('000aaaaZZZZ')
@pytest.mark.parametrize(
'password,min_strength',
[
('', 0),
('?', 0),
('?JR!', 1),
('?JR!p4A', 2),
('?JR!p4A2i', 3),
('?JR!p4A2i:#', 4),
],
)
def test_validate_password_strength(settings, password, min_strength):
settings.A2_PASSWORD_POLICY_MIN_LENGTH = len(password)
settings.A2_PASSWORD_POLICY_MIN_STRENGTH = min_strength
validate_password(password)
settings.A2_PASSWORD_POLICY_MIN_LENGTH = len(password) + 1
with pytest.raises(ValidationError):
validate_password(password)
if min_strength < 4:
settings.A2_PASSWORD_POLICY_MIN_LENGTH = len(password)
settings.A2_PASSWORD_POLICY_MIN_STRENGTH = min_strength + 1
with pytest.raises(ValidationError):
validate_password(password)
from authentic2.validators import EmailValidator, HexaColourValidator
def test_validate_colour():
@ -72,17 +35,6 @@ def test_validate_colour():
validator('#ff00ff')
def test_digits_password_policy(settings):
settings.A2_PASSWORD_POLICY_REGEX = '^[0-9]{8}$'
settings.A2_PASSWORD_POLICY_REGEX_ERROR_MSG = 'pasbon'
settings.A2_PASSWORD_POLICY_MIN_LENGTH = 0
settings.A2_PASSWORD_POLICY_MIN_CLASSES = 0
with pytest.raises(ValidationError):
validate_password('aaa')
validate_password('12345678')
class TestEmailValidator:
@pytest.mark.parametrize(
'bad_email',

View File

@ -24,6 +24,7 @@ from django.urls import reverse
from django.utils.html import escape
from authentic2.custom_user.models import DeletedUser, User
from authentic2.forms.passwords import PasswordChangeForm, SetPasswordForm
from .utils import assert_event, get_link_from_mail, login, logout
@ -83,6 +84,38 @@ def test_password_change_error(
assert 'boum!' in resp
def test_password_change_form(simple_user):
data = {
'new_password1': 'Password0',
'new_password2': 'Password0',
}
form = PasswordChangeForm(user=simple_user, data=data)
assert form.fields['new_password1'].widget.min_strength is None
assert 'new_password1' not in form.errors
simple_user.ou.min_password_strength = 3
form = PasswordChangeForm(user=simple_user, data=data)
assert form.fields['new_password1'].widget.min_strength == 3
assert form.errors['new_password1'] == ['This password is not strong enough.']
def test_set_password_form(simple_user):
data = {
'new_password1': 'Password0',
'new_password2': 'Password0',
}
form = SetPasswordForm(user=simple_user, data=data)
assert form.fields['new_password1'].widget.min_strength is None
assert 'new_password1' not in form.errors
simple_user.ou.min_password_strength = 3
form = SetPasswordForm(user=simple_user, data=data)
assert form.fields['new_password1'].widget.min_strength == 3
assert form.errors['new_password1'] == ['This password is not strong enough.']
def test_well_known_password_change(app):
resp = app.get('/.well-known/change-password')
assert resp.location == '/accounts/password/change/'

View File

@ -52,7 +52,7 @@ def test_datalisttextinput_init_and_render():
assert not data
def test_new_password_input(settings):
def test_new_password_input():
widget = NewPasswordInput()
html = widget.render('foo', 'bar')
query = PyQuery(html)
@ -60,8 +60,8 @@ def test_new_password_input(settings):
textinput = query.find('input')
assert textinput.attr('data-min-strength') is None
settings.A2_PASSWORD_POLICY_MIN_STRENGTH = 3
widget = NewPasswordInput()
widget.min_strength = 3
html = widget.render('foo', 'bar')
query = PyQuery(html)