Projet

Général

Profil

0001-misc-make-minimum-password-strength-configurable-in-.patch

Corentin Séchet, 06 octobre 2022 12:31

Télécharger (23,9 ko)

Voir les différences:

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_registration.py                    | 44 ++++++++++++++++
 tests/test_validators.py                      | 50 +-----------------
 tests/test_views.py                           | 33 ++++++++++++
 tests/test_widgets.py                         |  4 +-
 16 files changed, 263 insertions(+), 79 deletions(-)
 create mode 100644 src/authentic2/a2_rbac/migrations/0030_organizationalunit_min_password_strength.py
src/authentic2/a2_rbac/migrations/0030_organizationalunit_min_password_strength.py
1
# Generated by Django 2.2.26 on 2022-09-20 14:59
2

  
3
from django.db import migrations, models
4

  
5

  
6
class Migration(migrations.Migration):
7

  
8
    dependencies = [
9
        ('a2_rbac', '0029_use_unique_constraints'),
10
    ]
11

  
12
    operations = [
13
        migrations.AddField(
14
            model_name='organizationalunit',
15
            name='min_password_strength',
16
            field=models.IntegerField(
17
                blank=True,
18
                choices=[
19
                    (None, 'System default'),
20
                    (0, 'Very Weak'),
21
                    (1, 'Weak'),
22
                    (2, 'Fair'),
23
                    (3, 'Good'),
24
                    (4, 'Strong'),
25
                ],
26
                default=None,
27
                null=True,
28
                verbose_name='Minimum password strength',
29
            ),
30
        ),
31
    ]
src/authentic2/a2_rbac/models.py
69 69
        MANUAL_PASSWORD_POLICY: PolicyValue(False, False, True, False),
70 70
    }
71 71

  
72
    MIN_PASSWORD_STRENGTH_CHOICES = (
73
        (None, _("System default")),
74
        (0, _("Very Weak")),
75
        (1, _("Weak")),
76
        (2, _("Fair")),
77
        (3, _("Good")),
78
        (4, _("Strong")),
79
    )
80

  
72 81
    username_is_unique = models.BooleanField(blank=True, default=False, verbose_name=_('Username is unique'))
73 82
    email_is_unique = models.BooleanField(blank=True, default=False, verbose_name=_('Email is unique'))
74 83
    default = fields.UniqueBooleanField(verbose_name=_('Default organizational unit'))
......
91 100
        verbose_name=_('User creation password policy'), choices=USER_ADD_PASSWD_POLICY_CHOICES, default=0
92 101
    )
93 102

  
103
    min_password_strength = models.IntegerField(
104
        verbose_name=_('Minimum password strength'),
105
        choices=MIN_PASSWORD_STRENGTH_CHOICES,
106
        default=None,
107
        null=True,
108
        blank=True,
109
    )
110

  
94 111
    clean_unused_accounts_alert = models.PositiveIntegerField(
95 112
        verbose_name=_('Days after which the user receives an account deletion alert'),
96 113
        validators=[
src/authentic2/forms/fields.py
31 31
    PasswordInput,
32 32
    ProfileImageInput,
33 33
)
34
from authentic2.passwords import validate_password
34
from authentic2.passwords import get_password_checker, get_password_strength
35 35
from authentic2.validators import email_validator
36 36

  
37 37

  
......
41 41

  
42 42
class NewPasswordField(CharField):
43 43
    widget = NewPasswordInput
44
    default_validators = [validate_password]
44

  
45
    def __init__(self, *args, **kwargs):
46
        super().__init__(*args, **kwargs)
47
        self.min_strength = None
48

  
49
    def _get_min_strength(self):
50
        return self._min_strength
51

  
52
    def _set_min_strength(self, value):
53
        self._min_strength = value
54
        self.widget.min_strength = value
55

  
56
    min_strength = property(_get_min_strength, _set_min_strength)
57

  
58
    def validate(self, value):
59
        super().validate(value)
60
        if value == '':
61
            return
62

  
63
        min_strength = self.min_strength
64
        if min_strength is not None:
65
            if get_password_strength(value).strength < min_strength:
66
                raise ValidationError(_('This password is not strong enough.'))
67

  
68
            min_length = app_settings.A2_PASSWORD_POLICY_MIN_LENGTH
69
            if min_length > len(value):
70
                raise ValidationError(_('Password must be at least %s characters.') % min_length)
71
        else:
72
            password_checker = get_password_checker()
73
            errors = [not check.result for check in password_checker(value)]
74
            if any(errors):
75
                raise ValidationError(_('This password is not accepted.'))
45 76

  
46 77

  
47 78
class CheckPasswordField(CharField):
src/authentic2/forms/passwords.py
26 26

  
27 27
from authentic2.backends.ldap_backend import LDAPUser
28 28
from authentic2.journal import journal
29
from authentic2.passwords import get_min_password_strength
29 30

  
30 31
from .. import app_settings, hooks, models, validators
31 32
from ..backends import get_user_queryset
......
171 172
    new_password1 = NewPasswordField(label=_("New password"))
172 173
    new_password2 = CheckPasswordField(label=_("New password confirmation"))
173 174

  
175
    def __init__(self, user, *args, **kwargs):
176
        super().__init__(user, *args, **kwargs)
177
        self.fields['new_password1'].min_strength = get_min_password_strength(user)
178

  
174 179
    def clean_new_password1(self):
175 180
        new_password1 = self.cleaned_data.get('new_password1')
176 181
        if new_password1 and self.user.check_password(new_password1):
177 182
            raise ValidationError(_('New password must differ from old password'))
183

  
178 184
        return new_password1
179 185

  
180 186

  
......
187 193

  
188 194
    old_password.widget.attrs.update({'autocomplete': 'current-password'})
189 195

  
196
    def __init__(self, user, *args, **kwargs):
197
        super().__init__(user, *args, **kwargs)
198
        self.fields['new_password1'].min_strength = get_min_password_strength(user)
199

  
190 200
    def clean_new_password1(self):
191 201
        new_password1 = self.cleaned_data.get('new_password1')
192 202
        old_password = self.cleaned_data.get('old_password')
193 203
        if new_password1 and new_password1 == old_password:
194 204
            raise ValidationError(_('New password must differ from old password'))
205

  
195 206
        return new_password1
196 207

  
197 208

  
src/authentic2/forms/registration.py
25 25

  
26 26
from authentic2.a2_rbac.models import OrganizationalUnit
27 27
from authentic2.forms.fields import CheckPasswordField, NewPasswordField
28
from authentic2.passwords import get_min_password_strength
28 29

  
29 30
from .. import app_settings, models
30 31
from . import profile as profile_forms
......
154 155
    password1 = NewPasswordField(label=_('Password'))
155 156
    password2 = CheckPasswordField(label=_("Password (again)"))
156 157

  
158
    def __init__(self, *args, **kwargs):
159
        super().__init__(*args, **kwargs)
160
        self.fields['password1'].min_strength = get_min_password_strength(self.instance)
161

  
157 162
    def clean(self):
158 163
        """
159 164
        Verifiy that the values entered into the two password fields
src/authentic2/forms/widgets.py
275 275

  
276 276
class NewPasswordInput(PasswordInput):
277 277
    template_name = 'authentic2/widgets/new_password.html'
278

  
279
    def __init__(self, *args, **kwargs):
280
        super().__init__(*args, **kwargs)
281
        min_strength = app_settings.A2_PASSWORD_POLICY_MIN_STRENGTH
282
        if min_strength:
283
            self.attrs['data-min-strength'] = min_strength
278
    min_strength = None
284 279

  
285 280
    def get_context(self, *args, **kwargs):
286 281
        context = super().get_context(*args, **kwargs)
......
293 288
        if attrs is None:
294 289
            attrs = {}
295 290
        attrs['autocomplete'] = 'new-password'
291

  
292
        if self.min_strength is not None:
293
            attrs['data-min-strength'] = self.min_strength
294

  
296 295
        output = super().render(name, value, attrs=attrs, renderer=renderer)
297 296
        if attrs:
298 297
            _id = attrs.get('id')
src/authentic2/manager/forms.py
36 36
from authentic2.forms.mixins import SlugMixin
37 37
from authentic2.forms.profile import BaseUserForm
38 38
from authentic2.models import APIClient, PasswordReset, Service
39
from authentic2.passwords import generate_password
39
from authentic2.passwords import generate_password, get_min_password_strength
40 40
from authentic2.utils.misc import (
41 41
    import_module_or_class,
42 42
    send_email_change_email,
......
253 253
    password2 = CheckPasswordField(label=_("Confirmation"), required=False)
254 254
    send_mail = forms.BooleanField(initial=False, label=_('Send informations to user'), required=False)
255 255

  
256
    def __init__(self, **kwargs):
257
        super().__init__(**kwargs)
258
        self.fields['password1'].min_strength = get_min_password_strength(self.instance)
259

  
256 260
    class Meta:
257 261
        model = User
258 262
        fields = ()
......
629 633
            'check_required_on_login_attributes',
630 634
            'user_can_reset_password',
631 635
            'user_add_password_policy',
636
            'min_password_strength',
632 637
            'clean_unused_accounts_alert',
633 638
            'clean_unused_accounts_deletion',
634 639
            'home_url',
src/authentic2/passwords.py
19 19
import re
20 20
import string
21 21

  
22
from django.core.exceptions import ValidationError
23 22
from django.utils.module_loading import import_string
24 23
from django.utils.translation import ugettext as _
25 24
from zxcvbn import zxcvbn
......
110 109
    return import_string(app_settings.A2_PASSWORD_POLICY_CLASS)(*args, **kwargs)
111 110

  
112 111

  
113
def validate_password(password):
114
    min_strength = app_settings.A2_PASSWORD_POLICY_MIN_STRENGTH
115
    if min_strength is not None:
116
        if get_password_strength(password).strength < min_strength:
117
            raise ValidationError(_('This password is not strong enough.'))
118

  
119
        min_length = app_settings.A2_PASSWORD_POLICY_MIN_LENGTH
120
        if min_length > len(password):
121
            raise ValidationError(_('Password must be at least %s characters.') % min_length)
122
    else:
123
        password_checker = get_password_checker()
124
        errors = [not check.result for check in password_checker(password)]
125
        if any(errors):
126
            raise ValidationError(_('This password is not accepted.'))
112
def get_min_password_strength(user):
113
    if user.ou and user.ou.min_password_strength is not None:
114
        return user.ou.min_password_strength
115
    return app_settings.A2_PASSWORD_POLICY_MIN_STRENGTH
127 116

  
128 117

  
129 118
class StrengthReport:
src/authentic2/validators.py
28 28

  
29 29
from . import app_settings
30 30

  
31
# keep those symbols here for retrocompatibility
32
from .passwords import validate_password  # pylint: disable=unused-import
33

  
34 31

  
35 32
# copied from http://www.djangotips.com/real-email-validation
36 33
class EmailValidator:
tests/api/test_all.py
1759 1759
@pytest.mark.parametrize(
1760 1760
    'min_length, password,strength,label',
1761 1761
    [
1762
        (0, '?', 0, 'Very Weak'),
1762
        (0, '', 0, 'Very Weak'),
1763 1763
        (0, '?', 0, 'Very Weak'),
1764 1764
        (0, '?JR!', 1, 'Weak'),
1765 1765
        (0, '?JR!p4A', 2, 'Fair'),
tests/test_fields.py
19 19
from django.core.exceptions import ValidationError
20 20

  
21 21
from authentic2.attribute_kinds import PhoneNumberField
22
from authentic2.forms.passwords import NewPasswordField
22 23

  
23 24

  
24 25
def test_phonenumber_field():
......
32 33
    for value in ['01a01']:
33 34
        with pytest.raises(ValidationError):
34 35
            field.clean(value)
36

  
37

  
38
def test_validate_password(settings):
39
    field = NewPasswordField()
40
    with pytest.raises(ValidationError):
41
        field.validate('aaaaaZZZZZZ')
42
    with pytest.raises(ValidationError):
43
        field.validate('00000aaaaaa')
44
    with pytest.raises(ValidationError):
45
        field.validate('00000ZZZZZZ')
46
    field.validate('000aaaaZZZZ')
47

  
48

  
49
@pytest.mark.parametrize(
50
    'password,min_strength',
51
    [
52
        ('?', 0),
53
        ('?JR!', 1),
54
        ('?JR!p4A', 2),
55
        ('?JR!p4A2i', 3),
56
        ('?JR!p4A2i:#', 4),
57
    ],
58
)
59
def test_validate_password_strength(settings, password, min_strength):
60
    settings.A2_PASSWORD_POLICY_MIN_LENGTH = len(password)
61
    field = NewPasswordField()
62

  
63
    field.min_strength = min_strength
64
    field.validate(password)
65

  
66
    settings.A2_PASSWORD_POLICY_MIN_LENGTH = len(password) + 1
67
    with pytest.raises(ValidationError):
68
        field.validate(password)
69

  
70
    if min_strength < 4:
71
        settings.A2_PASSWORD_POLICY_MIN_LENGTH = len(password)
72
        field.min_strength = min_strength + 1
73
        with pytest.raises(ValidationError):
74
            field.validate(password)
75

  
76

  
77
def test_digits_password_policy(settings):
78
    settings.A2_PASSWORD_POLICY_REGEX = '^[0-9]{8}$'
79
    settings.A2_PASSWORD_POLICY_REGEX_ERROR_MSG = 'pasbon'
80
    settings.A2_PASSWORD_POLICY_MIN_LENGTH = 0
81
    settings.A2_PASSWORD_POLICY_MIN_CLASSES = 0
82
    field = NewPasswordField()
83

  
84
    with pytest.raises(ValidationError):
85
        field.validate('aaa')
86
    field.validate('12345678')
tests/test_manager.py
174 174
    assert str(app.session['_auth_user_id']) == str(simple_user.pk)
175 175

  
176 176

  
177
def test_manager_user_change_password_form(app, simple_user):
178
    from authentic2.manager.forms import UserChangePasswordForm
179

  
180
    data = {
181
        'password1': 'Password0',
182
        'password2': 'Password0',
183
    }
184

  
185
    form = UserChangePasswordForm(instance=simple_user, data=data)
186
    assert form.fields['password1'].widget.min_strength is None
187
    assert 'password1' not in form.errors
188

  
189
    simple_user.ou.min_password_strength = 3
190
    form = UserChangePasswordForm(instance=simple_user, data=data)
191
    assert form.fields['password1'].widget.min_strength == 3
192
    assert form.errors['password1'] == ['This password is not strong enough.']
193

  
194

  
177 195
def test_manager_user_detail_by_uuid(app, superuser, simple_user, simple_role):
178 196
    simple_user.roles.add(simple_role)
179 197
    url = reverse('a2-manager-user-by-uuid-detail', kwargs={'slug': simple_user.uuid})
tests/test_registration.py
23 23
from django.utils.http import urlquote
24 24

  
25 25
from authentic2 import models
26
from authentic2.a2_rbac.utils import get_default_ou
26 27
from authentic2.apps.journal.models import Event
28
from authentic2.forms.profile import modelform_factory
29
from authentic2.forms.registration import RegistrationCompletionForm
27 30
from authentic2.utils import misc as utils_misc
28 31
from authentic2.validators import EmailValidator
29 32

  
......
903 906
    assert response.context['home_ou'] == service.ou
904 907
    assert response.context['home_service'] == service
905 908
    assert response.context['home_url'] == 'https://portail.example.net/page/'
909

  
910

  
911
def test_registration_completion_form(db, simple_user):
912
    form_class = modelform_factory(get_user_model(), form=RegistrationCompletionForm)
913
    data = {
914
        'email': 'jonh.doe@yopmail.com',
915
        'password': 'blah',
916
        'password1': 'Password0',
917
        'password2': 'Password0',
918
        'date_joined': '2022-02-07',
919
        'ou': simple_user.ou.pk,
920
    }
921

  
922
    form = form_class(instance=simple_user, data=data)
923
    assert form.fields['password1'].widget.min_strength is None
924
    assert 'password1' not in form.errors
925

  
926
    simple_user.ou.min_password_strength = 3
927
    form = form_class(instance=simple_user, data=data)
928
    assert form.fields['password1'].widget.min_strength == 3
929
    assert form.errors['password1'] == ['This password is not strong enough.']
930

  
931

  
932
def test_registration_completion(db, app, mailoutbox):
933
    default_ou = get_default_ou()
934
    default_ou.min_password_strength = 3
935
    default_ou.save()
936

  
937
    resp = app.get(reverse('registration_register'))
938
    resp.form.set('email', 'testbot@entrouvert.com')
939
    resp = resp.form.submit().follow()
940
    link = get_link_from_mail(mailoutbox[0])
941
    resp = app.get(link)
942

  
943
    resp.form.set('password1', 'Password0')
944
    resp.form.set('password2', 'Password0')
945
    resp.form.set('first_name', 'John')
946
    resp.form.set('last_name', 'Doe')
947
    resp = resp.form.submit()
948

  
949
    assert 'This password is not strong enough' in resp.text
tests/test_validators.py
21 21
import pytest
22 22
from django.core.exceptions import ValidationError
23 23

  
24
from authentic2.validators import EmailValidator, HexaColourValidator, validate_password
25

  
26

  
27
def test_validate_password(settings):
28
    with pytest.raises(ValidationError):
29
        validate_password('aaaaaZZZZZZ')
30
    with pytest.raises(ValidationError):
31
        validate_password('00000aaaaaa')
32
    with pytest.raises(ValidationError):
33
        validate_password('00000ZZZZZZ')
34
    validate_password('000aaaaZZZZ')
35

  
36

  
37
@pytest.mark.parametrize(
38
    'password,min_strength',
39
    [
40
        ('', 0),
41
        ('?', 0),
42
        ('?JR!', 1),
43
        ('?JR!p4A', 2),
44
        ('?JR!p4A2i', 3),
45
        ('?JR!p4A2i:#', 4),
46
    ],
47
)
48
def test_validate_password_strength(settings, password, min_strength):
49
    settings.A2_PASSWORD_POLICY_MIN_LENGTH = len(password)
50
    settings.A2_PASSWORD_POLICY_MIN_STRENGTH = min_strength
51
    validate_password(password)
52

  
53
    settings.A2_PASSWORD_POLICY_MIN_LENGTH = len(password) + 1
54
    with pytest.raises(ValidationError):
55
        validate_password(password)
56

  
57
    if min_strength < 4:
58
        settings.A2_PASSWORD_POLICY_MIN_LENGTH = len(password)
59
        settings.A2_PASSWORD_POLICY_MIN_STRENGTH = min_strength + 1
60
        with pytest.raises(ValidationError):
61
            validate_password(password)
24
from authentic2.validators import EmailValidator, HexaColourValidator
62 25

  
63 26

  
64 27
def test_validate_colour():
......
72 35
    validator('#ff00ff')
73 36

  
74 37

  
75
def test_digits_password_policy(settings):
76
    settings.A2_PASSWORD_POLICY_REGEX = '^[0-9]{8}$'
77
    settings.A2_PASSWORD_POLICY_REGEX_ERROR_MSG = 'pasbon'
78
    settings.A2_PASSWORD_POLICY_MIN_LENGTH = 0
79
    settings.A2_PASSWORD_POLICY_MIN_CLASSES = 0
80

  
81
    with pytest.raises(ValidationError):
82
        validate_password('aaa')
83
    validate_password('12345678')
84

  
85

  
86 38
class TestEmailValidator:
87 39
    @pytest.mark.parametrize(
88 40
        'bad_email',
tests/test_views.py
23 23
from django.utils.html import escape
24 24

  
25 25
from authentic2.custom_user.models import DeletedUser, User
26
from authentic2.forms.passwords import PasswordChangeForm, SetPasswordForm
26 27

  
27 28
from .utils import assert_event, get_link_from_mail, login, logout
28 29

  
......
57 58
    assert_event('user.password.change', user=simple_user, session=app.session)
58 59

  
59 60

  
61
def test_password_change_form(simple_user):
62
    data = {
63
        'new_password1': 'Password0',
64
        'new_password2': 'Password0',
65
    }
66

  
67
    form = PasswordChangeForm(user=simple_user, data=data)
68
    assert form.fields['new_password1'].widget.min_strength is None
69
    assert 'new_password1' not in form.errors
70

  
71
    simple_user.ou.min_password_strength = 3
72
    form = PasswordChangeForm(user=simple_user, data=data)
73
    assert form.fields['new_password1'].widget.min_strength == 3
74
    assert form.errors['new_password1'] == ['This password is not strong enough.']
75

  
76

  
77
def test_set_password_form(simple_user):
78
    data = {
79
        'new_password1': 'Password0',
80
        'new_password2': 'Password0',
81
    }
82

  
83
    form = SetPasswordForm(user=simple_user, data=data)
84
    assert form.fields['new_password1'].widget.min_strength is None
85
    assert 'new_password1' not in form.errors
86

  
87
    simple_user.ou.min_password_strength = 3
88
    form = SetPasswordForm(user=simple_user, data=data)
89
    assert form.fields['new_password1'].widget.min_strength == 3
90
    assert form.errors['new_password1'] == ['This password is not strong enough.']
91

  
92

  
60 93
def test_well_known_password_change(app):
61 94
    resp = app.get('/.well-known/change-password')
62 95
    assert resp.location == '/accounts/password/change/'
tests/test_widgets.py
52 52
    assert not data
53 53

  
54 54

  
55
def test_new_password_input(settings):
55
def test_new_password_input():
56 56
    widget = NewPasswordInput()
57 57
    html = widget.render('foo', 'bar')
58 58
    query = PyQuery(html)
......
60 60
    textinput = query.find('input')
61 61
    assert textinput.attr('data-min-strength') is None
62 62

  
63
    settings.A2_PASSWORD_POLICY_MIN_STRENGTH = 3
64 63
    widget = NewPasswordInput()
64
    widget.min_strength = 3
65 65
    html = widget.render('foo', 'bar')
66 66
    query = PyQuery(html)
67 67

  
68
-