0001-misc-make-minimum-password-strength-configurable-in-.patch
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 |
- |