Projet

Général

Profil

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

Corentin Séchet, 07 septembre 2022 13:47

Télécharger (19,8 ko)

Voir les différences:

Subject: [PATCH] misc: make minimum password strength configurable in ous
 (#68745)

 ...rganizationalunit_min_password_strength.py | 30 +++++++++++++++++
 src/authentic2/a2_rbac/models.py              | 16 +++++++++
 src/authentic2/forms/fields.py                |  2 --
 src/authentic2/forms/passwords.py             | 15 +++++++++
 src/authentic2/forms/registration.py          | 10 ++++++
 src/authentic2/forms/widgets.py               | 11 +++----
 src/authentic2/manager/forms.py               | 12 ++++++-
 src/authentic2/passwords.py                   | 10 ++++--
 tests/api/test_all.py                         |  2 +-
 tests/test_manager.py                         | 18 ++++++++++
 tests/test_password_reset.py                  | 24 ++++++++++++++
 tests/test_validators.py                      | 33 ++++++++++++-------
 tests/test_views.py                           | 33 +++++++++++++++++++
 tests/test_widgets.py                         |  4 +--
 14 files changed, 194 insertions(+), 26 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-06 15:10
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
                choices=[
18
                    (None, 'System default'),
19
                    (0, 'Very Weak'),
20
                    (1, 'Weak'),
21
                    (2, 'Fair'),
22
                    (3, 'Good'),
23
                    (4, 'Strong'),
24
                ],
25
                default=None,
26
                null=True,
27
                verbose_name='Minimum password strength',
28
            ),
29
        ),
30
    ]
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
    )
109

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

  
37 36

  
......
41 40

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

  
46 44

  
47 45
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, validate_password
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'].widget.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

  
184
        validate_password(self.user, new_password1)
185

  
178 186
        return new_password1
179 187

  
180 188

  
......
187 195

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

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

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

  
208
        validate_password(self.user, new_password1)
209

  
195 210
        return new_password1
196 211

  
197 212

  
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, validate_password
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'].widget.min_strength = get_min_password_strength(self.instance)
161

  
162
    def clean_password1(self):
163
        password1 = self.cleaned_data.get("password1")
164
        validate_password(self.instance, password1)
165
        return password1
166

  
157 167
    def clean(self):
158 168
        """
159 169
        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, validate_password
40 40
from authentic2.utils.misc import (
41 41
    import_module_or_class,
42 42
    send_email_change_email,
......
198 198
            )
199 199
        return password2
200 200

  
201
    def clean_password1(self):
202
        password1 = self.cleaned_data.get("password1")
203
        if self.require_password:
204
            validate_password(self.instance, password1)
205
        return password1
206

  
201 207
    def clean(self):
202 208
        super().clean()
203 209
        if (
......
252 258
    password2 = CheckPasswordField(label=_("Confirmation"), required=False)
253 259
    send_mail = forms.BooleanField(initial=False, label=_('Send informations to user'), required=False)
254 260

  
261
    def __init__(self, **kwargs):
262
        super().__init__(**kwargs)
263
        self.fields['password1'].widget.min_strength = get_min_password_strength(self.instance)
264

  
255 265
    class Meta:
256 266
        model = User
257 267
        fields = ()
src/authentic2/passwords.py
110 110
    return import_string(app_settings.A2_PASSWORD_POLICY_CLASS)(*args, **kwargs)
111 111

  
112 112

  
113
def validate_password(password):
114
    min_strength = app_settings.A2_PASSWORD_POLICY_MIN_STRENGTH
113
def get_min_password_strength(user):
114
    if user.ou and user.ou.min_password_strength is not None:
115
        return user.ou.min_password_strength
116
    return app_settings.A2_PASSWORD_POLICY_MIN_STRENGTH
117

  
118

  
119
def validate_password(user, password):
120
    min_strength = get_min_password_strength(user)
115 121
    if min_strength is not None:
116 122
        if get_password_strength(password).strength < min_strength:
117 123
            raise ValidationError(_('This password is not strong enough.'))
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_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
24 24
from authentic2.validators import EmailValidator, HexaColourValidator, validate_password
25 25

  
26 26

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

  
36 36

  
37 37
@pytest.mark.parametrize(
......
45 45
        ('?JR!p4A2i:#', 4),
46 46
    ],
47 47
)
48
def test_validate_password_strength(settings, password, min_strength):
48
def test_validate_password_strength(settings, simple_user, password, min_strength):
49 49
    settings.A2_PASSWORD_POLICY_MIN_LENGTH = len(password)
50 50
    settings.A2_PASSWORD_POLICY_MIN_STRENGTH = min_strength
51
    validate_password(password)
51
    validate_password(simple_user, password)
52 52

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

  
57 57
    if min_strength < 4:
58 58
        settings.A2_PASSWORD_POLICY_MIN_LENGTH = len(password)
59 59
        settings.A2_PASSWORD_POLICY_MIN_STRENGTH = min_strength + 1
60 60
        with pytest.raises(ValidationError):
61
            validate_password(password)
61
            validate_password(simple_user, password)
62

  
63

  
64
def test_validate_password_strength_ou_setting(settings, simple_user):
65
    settings.A2_PASSWORD_POLICY_MIN_LENGTH = 0
66
    settings.A2_PASSWORD_POLICY_MIN_STRENGTH = 0
67
    validate_password(simple_user, 'password')
68
    simple_user.ou.min_password_strength = 4
69
    with pytest.raises(ValidationError):
70
        validate_password(simple_user, 'password')
62 71

  
63 72

  
64 73
def test_validate_colour():
......
72 81
    validator('#ff00ff')
73 82

  
74 83

  
75
def test_digits_password_policy(settings):
84
def test_digits_password_policy(settings, simple_user):
76 85
    settings.A2_PASSWORD_POLICY_REGEX = '^[0-9]{8}$'
77 86
    settings.A2_PASSWORD_POLICY_REGEX_ERROR_MSG = 'pasbon'
78 87
    settings.A2_PASSWORD_POLICY_MIN_LENGTH = 0
79 88
    settings.A2_PASSWORD_POLICY_MIN_CLASSES = 0
80 89

  
81 90
    with pytest.raises(ValidationError):
82
        validate_password('aaa')
83
    validate_password('12345678')
91
        validate_password(simple_user, 'aaa')
92
    validate_password(simple_user, '12345678')
84 93

  
85 94

  
86 95
class TestEmailValidator:
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
-