From a80e78fce9b64df97135e9d8393e962b3641b787 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Corentin=20S=C3=A9chet?= <csechet@entrouvert.com>
Date: Tue, 6 Sep 2022 18:02:12 +0200
Subject: [PATCH] misc: make minimum password strength configurable in ous
 (#68745)

---
 ...rganizationalunit_min_password_strength.py | 31 +++++++++++
 src/authentic2/a2_rbac/models.py              | 17 ++++++
 src/authentic2/forms/fields.py                | 35 ++++++++++++-
 src/authentic2/forms/passwords.py             | 11 ++++
 src/authentic2/forms/registration.py          |  5 ++
 src/authentic2/forms/widgets.py               | 11 ++--
 src/authentic2/manager/forms.py               |  7 ++-
 src/authentic2/passwords.py                   | 19 ++-----
 src/authentic2/validators.py                  |  3 --
 tests/api/test_all.py                         |  2 +-
 tests/test_fields.py                          | 52 +++++++++++++++++++
 tests/test_manager.py                         | 18 +++++++
 tests/test_password_reset.py                  | 24 +++++++++
 tests/test_validators.py                      | 50 +-----------------
 tests/test_views.py                           | 33 ++++++++++++
 tests/test_widgets.py                         |  4 +-
 16 files changed, 243 insertions(+), 79 deletions(-)
 create mode 100644 src/authentic2/a2_rbac/migrations/0030_organizationalunit_min_password_strength.py

diff --git a/src/authentic2/a2_rbac/migrations/0030_organizationalunit_min_password_strength.py b/src/authentic2/a2_rbac/migrations/0030_organizationalunit_min_password_strength.py
new file mode 100644
index 00000000..0939b2cb
--- /dev/null
+++ b/src/authentic2/a2_rbac/migrations/0030_organizationalunit_min_password_strength.py
@@ -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',
+            ),
+        ),
+    ]
diff --git a/src/authentic2/a2_rbac/models.py b/src/authentic2/a2_rbac/models.py
index bf3657ce..3e3b1025 100644
--- a/src/authentic2/a2_rbac/models.py
+++ b/src/authentic2/a2_rbac/models.py
@@ -69,6 +69,15 @@ class OrganizationalUnit(OrganizationalUnitAbstractBase):
         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'))
@@ -91,6 +100,14 @@ class OrganizationalUnit(OrganizationalUnitAbstractBase):
         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=[
diff --git a/src/authentic2/forms/fields.py b/src/authentic2/forms/fields.py
index faf88138..9a37277d 100644
--- a/src/authentic2/forms/fields.py
+++ b/src/authentic2/forms/fields.py
@@ -31,7 +31,7 @@ from authentic2.forms.widgets import (
     PasswordInput,
     ProfileImageInput,
 )
-from authentic2.passwords import validate_password
+from authentic2.passwords import get_password_checker, get_password_strength
 from authentic2.validators import email_validator
 
 
@@ -41,7 +41,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):
diff --git a/src/authentic2/forms/passwords.py b/src/authentic2/forms/passwords.py
index 052203e5..c7240fc6 100644
--- a/src/authentic2/forms/passwords.py
+++ b/src/authentic2/forms/passwords.py
@@ -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
@@ -171,10 +172,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
 
 
@@ -187,11 +193,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
 
 
diff --git a/src/authentic2/forms/registration.py b/src/authentic2/forms/registration.py
index a51ad1be..548cef09 100644
--- a/src/authentic2/forms/registration.py
+++ b/src/authentic2/forms/registration.py
@@ -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
diff --git a/src/authentic2/forms/widgets.py b/src/authentic2/forms/widgets.py
index cbfdb4af..9e353a01 100644
--- a/src/authentic2/forms/widgets.py
+++ b/src/authentic2/forms/widgets.py
@@ -272,12 +272,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)
@@ -290,6 +285,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')
diff --git a/src/authentic2/manager/forms.py b/src/authentic2/manager/forms.py
index 8cfde240..243d1a76 100644
--- a/src/authentic2/manager/forms.py
+++ b/src/authentic2/manager/forms.py
@@ -36,7 +36,7 @@ from authentic2.forms.fields import CheckPasswordField, NewPasswordField, Valida
 from authentic2.forms.mixins import SlugMixin
 from authentic2.forms.profile import BaseUserForm
 from authentic2.models import PasswordReset
-from authentic2.passwords import generate_password
+from authentic2.passwords import generate_password, get_min_password_strength
 from authentic2.utils.misc import (
     import_module_or_class,
     send_email_change_email,
@@ -252,6 +252,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 = ()
@@ -628,6 +632,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',
diff --git a/src/authentic2/passwords.py b/src/authentic2/passwords.py
index b337d628..5f856d33 100644
--- a/src/authentic2/passwords.py
+++ b/src/authentic2/passwords.py
@@ -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:
diff --git a/src/authentic2/validators.py b/src/authentic2/validators.py
index 414166e8..a40b1bc5 100644
--- a/src/authentic2/validators.py
+++ b/src/authentic2/validators.py
@@ -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:
diff --git a/tests/api/test_all.py b/tests/api/test_all.py
index ae27a093..f763206c 100644
--- a/tests/api/test_all.py
+++ b/tests/api/test_all.py
@@ -1759,7 +1759,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'),
diff --git a/tests/test_fields.py b/tests/test_fields.py
index e4561ae1..801124d1 100644
--- a/tests/test_fields.py
+++ b/tests/test_fields.py
@@ -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')
diff --git a/tests/test_manager.py b/tests/test_manager.py
index 6d2ac798..c9c738df 100644
--- a/tests/test_manager.py
+++ b/tests/test_manager.py
@@ -174,6 +174,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})
diff --git a/tests/test_password_reset.py b/tests/test_password_reset.py
index c7d7202a..3e35b6b4 100644
--- a/tests/test_password_reset.py
+++ b/tests/test_password_reset.py
@@ -14,9 +14,12 @@
 # You should have received a copy of the GNU Affero General Public License
 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
 import pytest
+from django.contrib.auth import get_user_model
 from django.test.utils import override_settings
 from django.urls import reverse
 
+from authentic2.forms.profile import modelform_factory
+from authentic2.forms.registration import RegistrationCompletionForm
 from authentic2.utils.misc import send_password_reset_mail
 
 from . import utils
@@ -255,3 +258,24 @@ def test_ou_policies(app, db, settings, user_ou1, ou1, user_ou2, ou2, mailoutbox
 
     url = reverse('password_reset')
     resp = app.get(url, status=404)  # globally deactivated, page not found
+
+
+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.']
diff --git a/tests/test_validators.py b/tests/test_validators.py
index 9939ae50..7744a391 100644
--- a/tests/test_validators.py
+++ b/tests/test_validators.py
@@ -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',
diff --git a/tests/test_views.py b/tests/test_views.py
index 3303d708..a728b237 100644
--- a/tests/test_views.py
+++ b/tests/test_views.py
@@ -23,6 +23,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
 
@@ -57,6 +58,38 @@ def test_password_change(app, simple_user):
     assert_event('user.password.change', user=simple_user, session=app.session)
 
 
+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/'
diff --git a/tests/test_widgets.py b/tests/test_widgets.py
index 70ffb57b..8e2a77eb 100644
--- a/tests/test_widgets.py
+++ b/tests/test_widgets.py
@@ -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)
 
-- 
2.35.1

