Projet

Général

Profil

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

Corentin Séchet, 21 septembre 2022 14:18

Télécharger (23,2 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_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
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
272 272

  
273 273
class NewPasswordInput(PasswordInput):
274 274
    template_name = 'authentic2/widgets/new_password.html'
275

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

  
282 277
    def get_context(self, *args, **kwargs):
283 278
        context = super().get_context(*args, **kwargs)
......
290 285
        if attrs is None:
291 286
            attrs = {}
292 287
        attrs['autocomplete'] = 'new-password'
288

  
289
        if self.min_strength is not None:
290
            attrs['data-min-strength'] = self.min_strength
291

  
293 292
        output = super().render(name, value, attrs=attrs, renderer=renderer)
294 293
        if attrs:
295 294
            _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 PasswordReset
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,
......
252 252
    password2 = CheckPasswordField(label=_("Confirmation"), required=False)
253 253
    send_mail = forms.BooleanField(initial=False, label=_('Send informations to user'), required=False)
254 254

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

  
255 259
    class Meta:
256 260
        model = User
257 261
        fields = ()
......
628 632
            'check_required_on_login_attributes',
629 633
            'user_can_reset_password',
630 634
            'user_add_password_policy',
635
            'min_password_strength',
631 636
            'clean_unused_accounts_alert',
632 637
            'clean_unused_accounts_deletion',
633 638
            '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_password_reset.py
14 14
# You should have received a copy of the GNU Affero General Public License
15 15
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
16 16
import pytest
17
from django.contrib.auth import get_user_model
17 18
from django.test.utils import override_settings
18 19
from django.urls import reverse
19 20

  
21
from authentic2.forms.profile import modelform_factory
22
from authentic2.forms.registration import RegistrationCompletionForm
20 23
from authentic2.utils.misc import send_password_reset_mail
21 24

  
22 25
from . import utils
......
255 258

  
256 259
    url = reverse('password_reset')
257 260
    resp = app.get(url, status=404)  # globally deactivated, page not found
261

  
262

  
263
def test_registration_completion_form(db, simple_user):
264
    form_class = modelform_factory(get_user_model(), form=RegistrationCompletionForm)
265
    data = {
266
        'email': 'jonh.doe@yopmail.com',
267
        'password': 'blah',
268
        'password1': 'Password0',
269
        'password2': 'Password0',
270
        'date_joined': '2022-02-07',
271
        'ou': simple_user.ou.pk,
272
    }
273

  
274
    form = form_class(instance=simple_user, data=data)
275
    assert form.fields['password1'].widget.min_strength is None
276
    assert 'password1' not in form.errors
277

  
278
    simple_user.ou.min_password_strength = 3
279
    form = form_class(instance=simple_user, data=data)
280
    assert form.fields['password1'].widget.min_strength == 3
281
    assert form.errors['password1'] == ['This password is not strong enough.']
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
-