forms: include user attributes in password strength check (#79807)

This commit is contained in:
Corentin Sechet 2023-07-25 12:54:23 +02:00 committed by Corentin Sechet
parent ad918e4114
commit 5b852457c7
12 changed files with 311 additions and 116 deletions

View File

@ -1569,6 +1569,7 @@ validate_password = ValidatePasswordAPI.as_view()
class PasswordStrengthSerializer(serializers.Serializer):
password = serializers.CharField(required=True, allow_blank=True)
inputs = serializers.DictField(child=serializers.CharField(allow_blank=True), default={})
class PasswordStrengthAPI(BaseRpcView):
@ -1577,7 +1578,11 @@ class PasswordStrengthAPI(BaseRpcView):
serializer_class = PasswordStrengthSerializer
def rpc(self, request, serializer):
report = get_password_strength(serializer.validated_data['password'])
report = get_password_strength(
serializer.validated_data['password'],
user=request.user,
inputs=serializer.validated_data['inputs'],
)
result = {
'result': 1,
'strength': report.strength,

View File

@ -38,8 +38,6 @@ from authentic2.forms.widgets import (
ProfileImageInput,
)
from authentic2.manager.utils import label_from_role
from authentic2.passwords import get_password_checker, get_password_strength
from authentic2.utils.misc import get_password_authenticator
from authentic2.validators import email_validator
@ -63,25 +61,6 @@ class NewPasswordField(CharField):
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 = get_password_authenticator().password_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):
widget = CheckPasswordInput

View File

@ -27,6 +27,7 @@ from django.utils.translation import gettext_lazy as _
from authentic2.backends.ldap_backend import LDAPUser
from authentic2.journal import journal
from authentic2.passwords import validate_password
from authentic2.utils.misc import get_password_authenticator
from .. import app_settings, models, validators
@ -259,13 +260,16 @@ class SetPasswordForm(NotifyOfPasswordChange, PasswordResetMixin, auth_forms.Set
def __init__(self, user, *args, **kwargs):
super().__init__(user, *args, **kwargs)
self.fields['new_password1'].min_strength = get_password_authenticator().min_password_strength
self.authenticator = get_password_authenticator()
self.fields['new_password1'].min_strength = self.authenticator.min_password_strength
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'))
validate_password(new_password1, user=self.user, authenticator=self.authenticator)
return new_password1
@ -280,7 +284,8 @@ class PasswordChangeForm(
def __init__(self, user, *args, **kwargs):
super().__init__(user, *args, **kwargs)
self.fields['new_password1'].min_strength = get_password_authenticator().min_password_strength
self.authenticator = get_password_authenticator()
self.fields['new_password1'].min_strength = self.authenticator.min_password_strength
def clean_new_password1(self):
new_password1 = self.cleaned_data.get('new_password1')
@ -288,6 +293,8 @@ class PasswordChangeForm(
if new_password1 and new_password1 == old_password:
raise ValidationError(_('New password must differ from old password'))
validate_password(new_password1, user=self.user, authenticator=self.authenticator)
return new_password1

View File

@ -27,6 +27,7 @@ from django.utils.translation import gettext_lazy as _
from authentic2.a2_rbac.models import OrganizationalUnit
from authentic2.forms.fields import CharField, CheckPasswordField, NewPasswordField
from authentic2.passwords import validate_password
from .. import app_settings, models
from ..utils import misc as utils_misc
@ -172,7 +173,25 @@ class RegistrationCompletionForm(RegistrationCompletionFormNoPassword):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['password1'].min_strength = utils_misc.get_password_authenticator().min_password_strength
self.authenticator = utils_misc.get_password_authenticator()
self.fields['password1'].min_strength = self.authenticator.min_password_strength
self.strength_input_attributes = set()
for attribute in models.Attribute.objects.all():
kind = attribute.get_kind()
if issubclass(kind.get('field_class'), CharField):
self.strength_input_attributes.add(attribute.name)
for name, field in self.fields.items():
if name in self.strength_input_attributes:
field.widget.attrs['data-password-strength-input'] = True
def clean_password1(self):
password = self.cleaned_data['password1']
inputs = {k: v for k, v in self.cleaned_data.items() if k in self.strength_input_attributes}
validate_password(password, user=self.instance, inputs=inputs, authenticator=self.authenticator)
return password
def clean(self):
"""

View File

@ -44,7 +44,7 @@ from authentic2.forms.fields import (
from authentic2.forms.mixins import SlugMixin
from authentic2.forms.profile import BaseUserForm
from authentic2.models import APIClient, PasswordReset, Service, Setting
from authentic2.passwords import generate_password
from authentic2.passwords import generate_password, validate_password
from authentic2.utils.misc import (
RUNTIME_SETTINGS,
get_password_authenticator,
@ -192,6 +192,11 @@ class UserChangePasswordForm(CssClass, forms.ModelForm):
notification_template_prefix = 'authentic2/manager/change-password-notification'
require_password = True
def clean_password1(self):
password = self.cleaned_data.get("password1")
validate_password(password, user=self.instance)
return password
def clean_password2(self):
password1 = self.cleaned_data.get('password1')
password2 = self.cleaned_data.get('password2')

View File

@ -19,6 +19,7 @@ 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 gettext as _
from zxcvbn import zxcvbn
@ -119,15 +120,33 @@ class StrengthReport:
self.hint = hint
def get_password_strength(password):
min_length = get_password_authenticator().password_min_length
def get_password_strength(password, user=None, inputs=None, authenticator=None):
authenticator = authenticator or get_password_authenticator()
user_inputs = {}
if user is not None and hasattr(user, 'attributes'):
user_inputs = {name: attr.content for name, attr in user.attributes.values.items() if attr.content}
user_inputs['email'] = user.email
if inputs is not None:
user_inputs.update(inputs)
splitted_inputs = set()
for input in user_inputs.values():
# check against full inputs and each word of it
if not input:
continue
splitted_inputs.add(input)
splitted_inputs.update(input.split(' '))
min_length = authenticator.password_min_length
hint = _('add more words or characters.')
strength = 0
if min_length and len(password) < min_length:
hint = _('use at least %s characters.') % min_length
elif password:
report = zxcvbn(password)
report = zxcvbn(password, user_inputs=splitted_inputs)
strength = report['score']
hint = get_hint(report['sequence'])
@ -166,7 +185,9 @@ def get_hint_for_match(match):
hint = _('avoid dates and years that are associated with you.')
if pattern == 'dictionary':
if match['l33t'] or match['reversed']:
if match['dictionary_name'] == 'user_inputs':
hint = _('avoid "{token}" : it\'s similar to one of your personal informations.')
elif match['l33t'] or match['reversed']:
hint = _('avoid "{token}" : it\'s similar to a commonly used password')
else:
hint = _('avoid "{token}" : it\'s a commonly used password.')
@ -175,3 +196,26 @@ def get_hint_for_match(match):
return hint.format(token=match['token'])
return None
def validate_password(password, user=None, inputs=None, authenticator=None):
if password == '':
return
authenticator = authenticator or get_password_authenticator()
min_strength = authenticator.min_password_strength
min_length = authenticator.password_min_length
if min_strength is not None:
if get_password_strength(password, user=user, inputs=inputs).strength < min_strength:
raise ValidationError(_('This password is not strong enough.'))
if min_length > len(password):
raise ValidationError(_('Password must be at least %s characters.') % min_length)
else:
# legacy password policy
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.'))

View File

@ -26,15 +26,20 @@ a2_password_check_equality = (function () {
})();
function update_password_strength($input, password, min_strength) {
var $parent_form = $input.parents('form')
var $feedback = $input.parent().find('.a2-password-feedback')
var $hint = $feedback.find('.a2-password-hint');
var $hint_content = $feedback.find('.a2-password-hint--content');
var $strength = $feedback.find('.a2-password-strength');
var $strength_name = $feedback.find('.a2-password-strength--name');
const $inputs = $parent_form.find('input[data-password-strength-input]')
const input_values = Object.fromEntries(Array.from($inputs).map(elt => [elt.name, elt.value]))
$.ajax({
method: 'POST',
url: '/api/password-strength/',
data: JSON.stringify({'password': password}),
data: JSON.stringify({'password': password, 'inputs': input_values}),
dataType: 'json',
contentType: 'application/json; charset=utf-8',
success: function(data) {
@ -101,15 +106,20 @@ a2_password_validate = (function () {
}
});
}
function validate_password(event) {
var $input = $(event.target);
setTimeout(function () {
get_validation($input);
}, 0);
}
return function (id) {
var $input = $('#' + id);
function validate_password() {
setTimeout(function () {
get_validation($input);
}, 0);
}
$input.on('input.a2-password-validate', validate_password);
const $parent_form = $input.parents('form')
const $user_inputs = $parent_form.find('input[data-password-strength-input]')
$user_inputs.on('input.a2-password-validate', validate_password)
}
})();

View File

@ -1808,45 +1808,53 @@ def test_validate_password_regex(app, settings):
@pytest.mark.parametrize(
'min_length, password,strength,label',
'min_length, password,strength,label,inputs',
[
(0, '', 0, 'Very Weak'),
(0, '?', 0, 'Very Weak'),
(0, '?JR!', 1, 'Weak'),
(0, '?JR!p4A', 2, 'Fair'),
(0, '?JR!p4A2i', 3, 'Good'),
(0, '?JR!p4A2i:#', 4, 'Strong'),
(12, '?JR!p4A2i:#', 0, 'Very Weak'),
(0, '', 0, 'Very Weak', {}),
(0, '?', 0, 'Very Weak', {}),
(0, '?JR!', 1, 'Weak', {}),
(0, '?JR!p4A', 2, 'Fair', {}),
(0, '?JR!p4A2i', 3, 'Good', {}),
(0, '?JR!p4A2i:#', 4, 'Strong', {}),
(0, 'Kaczynski', 0, 'Very Weak', {'first_name': 'Kaczynski'}),
(0, 'Faas-Hardegger', 4, 'Strong', {'first_name': 'Kaczynski'}),
(12, '?JR!p4A2i:#', 0, 'Very Weak', {}),
],
)
def test_password_strength(app, settings, min_length, password, strength, label):
def test_password_strength(app, settings, min_length, password, strength, label, inputs):
LoginPasswordAuthenticator.objects.update(password_min_length=min_length)
response = app.post_json('/api/password-strength/', params={'password': password})
response = app.post_json('/api/password-strength/', params={'password': password, 'inputs': inputs})
assert response.json['result'] == 1
assert response.json['strength'] == strength
assert response.json['strength_label'] == label
@pytest.mark.parametrize(
'min_length, password, hint',
'min_length, password, hint,inputs',
[
(0, '', 'add more words or characters.'),
(0, 'sdfgh', 'avoid straight rows of keys like "sdfgh".'),
(0, 'ertgfd', 'avoid short keyboard patterns like "ertgfd".'),
(0, 'abab', 'avoid repeated words and characters like "abab".'),
(0, 'abcd', 'avoid sequences like "abcd".'),
(0, '2019', 'avoid recent years.'),
(0, '02/08/14', 'avoid dates and years that are associated with you.'),
(0, '02/08/14', 'avoid dates and years that are associated with you.'),
(0, 'p@ssword', 'avoid "p@ssword" : it\'s similar to a commonly used password'),
(0, 'password', 'avoid "password" : it\'s a commonly used password.'),
(42, 'password', 'use at least 42 characters.'),
(0, '', 'add more words or characters.', {}),
(0, 'sdfgh', 'avoid straight rows of keys like "sdfgh".', {}),
(0, 'ertgfd', 'avoid short keyboard patterns like "ertgfd".', {}),
(0, 'abab', 'avoid repeated words and characters like "abab".', {}),
(0, 'abcd', 'avoid sequences like "abcd".', {}),
(0, '2019', 'avoid recent years.', {}),
(0, '02/08/14', 'avoid dates and years that are associated with you.', {}),
(0, '02/08/14', 'avoid dates and years that are associated with you.', {}),
(0, 'p@ssword', 'avoid "p@ssword" : it\'s similar to a commonly used password', {}),
(0, 'password', 'avoid "password" : it\'s a commonly used password.', {}),
(
0,
'Kaczynski',
'avoid "Kaczynski" : it\'s similar to one of your personal informations.',
{'first_name': 'Kaczynski'},
),
(42, 'password', 'use at least 42 characters.', {}),
],
)
def test_password_strength_hints(app, settings, min_length, password, hint):
def test_password_strength_hints(app, settings, min_length, password, hint, inputs):
LoginPasswordAuthenticator.objects.update(password_min_length=min_length)
settings.A2_PASSWORD_POLICY_MIN_STRENGTH = 3
response = app.post_json('/api/password-strength/', params={'password': password})
response = app.post_json('/api/password-strength/', params={'password': password, 'inputs': inputs})
assert response.json['result'] == 1
assert response.json['hint'] == hint

View File

@ -18,9 +18,7 @@
import pytest
from django.core.exceptions import ValidationError
from authentic2.apps.authenticators.models import LoginPasswordAuthenticator
from authentic2.forms.fields import PhoneField
from authentic2.forms.passwords import NewPasswordField
def test_phonenumber_field(settings):
@ -49,54 +47,3 @@ def test_phonenumber_field(settings):
]:
with pytest.raises(ValidationError):
field.clean(value)
def test_validate_password(db, 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(db, settings, password, min_strength):
LoginPasswordAuthenticator.objects.update(password_min_length=len(password))
field = NewPasswordField()
field.min_strength = min_strength
field.validate(password)
LoginPasswordAuthenticator.objects.update(password_min_length=len(password) + 1)
with pytest.raises(ValidationError):
field.validate(password)
if min_strength < 4:
LoginPasswordAuthenticator.objects.update(password_min_length=len(password))
field.min_strength = min_strength + 1
with pytest.raises(ValidationError):
field.validate(password)
def test_digits_password_policy(db, settings):
LoginPasswordAuthenticator.objects.update(
password_regex='^[0-9]{8}$', password_regex_error_msg='pasbon', password_min_length=0
)
settings.A2_PASSWORD_POLICY_MIN_CLASSES = 0
field = NewPasswordField()
with pytest.raises(ValidationError):
field.validate('aaa')
field.validate('12345678')

View File

@ -17,8 +17,13 @@
import string
import pytest
from django.core.exceptions import ValidationError
from authentic2 import app_settings
from authentic2.passwords import generate_password
from authentic2.apps.authenticators.models import LoginPasswordAuthenticator
from authentic2.models import Attribute
from authentic2.passwords import generate_password, validate_password
from authentic2.utils.misc import get_password_authenticator
@ -32,3 +37,94 @@ def test_generate_password(db):
assert sum(any(char in char_class for char in password) for char_class in char_classes) == max(
app_settings.A2_PASSWORD_POLICY_MIN_CLASSES, 3
)
def test_validate_password_default_policy(db, 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')
def test_digits_password_policy(db, settings):
LoginPasswordAuthenticator.objects.update(
password_regex='^[0-9]{8}$', password_regex_error_msg='pasbon', password_min_length=0
)
settings.A2_PASSWORD_POLICY_MIN_CLASSES = 0
with pytest.raises(ValidationError):
validate_password('aaa')
validate_password('12345678')
@pytest.mark.parametrize(
'password,min_strength',
[
('?', 0),
('?JR!', 1),
('?JR!p4A', 2),
('?JR!p4A2i', 3),
('?JR!p4A2i:#', 4),
],
)
def test_validate_password_strength(db, settings, password, min_strength):
LoginPasswordAuthenticator.objects.update(
password_min_length=len(password), min_password_strength=min_strength
)
validate_password(password)
with pytest.raises(ValidationError):
LoginPasswordAuthenticator.objects.update(password_min_length=len(password) + 1)
validate_password(password)
if min_strength < 4:
LoginPasswordAuthenticator.objects.update(
password_min_length=len(password), min_password_strength=min_strength + 1
)
with pytest.raises(ValidationError):
validate_password(password)
def test_validate_password_strength_user_attributes(db, simple_user):
LoginPasswordAuthenticator.objects.update(min_password_strength=3)
simple_user.attributes.last_name = 'Kaczynski'
validate_password('Kaczynski')
with pytest.raises(ValidationError):
validate_password('Kaczynski', inputs={'last_name': 'Kaczynski'})
# each word of input should be matched
with pytest.raises(ValidationError):
validate_password('Kaczynski', inputs={'last_name': 'Kaczynski Faas-Hardegger'})
with pytest.raises(ValidationError):
validate_password('Kaczynski Faas-Hardegger', inputs={'last_name': 'Kaczynski Faas-Hardegger'})
simple_user.attributes.last_name = 'Kaczynski Faas-Hardegger'
with pytest.raises(ValidationError):
validate_password('Kaczynski', user=simple_user)
with pytest.raises(ValidationError):
validate_password('Kaczynski Faas-Hardegger', user=simple_user)
simple_user.attributes.last_name = 'Kaczynski'
# inputs dict should override user attributes
validate_password('Kaczynski', user=simple_user, inputs={'last_name': 'Faas-Hardegger'})
def test_validate_password_strength_custom_attribute(db, simple_user):
LoginPasswordAuthenticator.objects.update(min_password_strength=3)
Attribute.objects.create(
kind='string',
name='favourite_song',
)
validate_password('0opS 1 D1t iT @GAiN', user=simple_user)
simple_user.attributes.favourite_song = "0opS 1 D1t iT @GAiN"
with pytest.raises(ValidationError):
validate_password('0opS 1 D1t iT @GAiN', user=simple_user)

View File

@ -29,7 +29,7 @@ from authentic2.apps.authenticators.models import LoginPasswordAuthenticator
from authentic2.apps.journal.models import Event
from authentic2.forms.profile import modelform_factory
from authentic2.forms.registration import RegistrationCompletionForm
from authentic2.models import SMSCode, Token
from authentic2.models import Attribute, SMSCode, Token
from authentic2.utils import misc as utils_misc
from authentic2.validators import EmailValidator
@ -932,6 +932,20 @@ def test_registration_completion_form(db, simple_user):
def test_registration_completion(db, app, mailoutbox):
Attribute.objects.create(
kind='string',
label='Favourite Song',
name='favourite_song',
asked_on_registration=True,
)
Attribute.objects.create(
kind='boolean',
label='Favourite Boolean',
name='favourite_boolean',
asked_on_registration=True,
)
LoginPasswordAuthenticator.objects.update(min_password_strength=3)
resp = app.get(reverse('registration_register'))
@ -944,10 +958,35 @@ def test_registration_completion(db, app, mailoutbox):
resp.form.set('password2', 'Password0')
resp.form.set('first_name', 'John')
resp.form.set('last_name', 'Doe')
resp.form.set('favourite_song', '0opS 1 D1t iT @GAiN')
resp.form.set('favourite_boolean', True)
assert resp.pyquery('input[name=first_name][data-password-strength-input]')
assert resp.pyquery('input[name=last_name][data-password-strength-input]')
assert resp.pyquery('input[name=favourite_song][data-password-strength-input]')
assert not resp.pyquery('input[name=password1][data-password-strength-input]')
assert not resp.pyquery('input[name=password2][data-password-strength-input]')
resp = resp.form.submit()
assert 'This password is not strong enough' in resp.text
resp.form.set('password1', 'testbot@entrouvert.com')
resp.form.set('password2', 'testbot@entrouvert.com')
resp = resp.form.submit()
assert 'This password is not strong enough' in resp.text
resp.form.set('password1', '0opS 1 D1t iT @GAiN')
resp.form.set('password2', '0opS 1 D1t iT @GAiN')
resp = resp.form.submit()
assert 'This password is not strong enough' in resp.text
resp.form.set('favourite_song', 'Baby one more time')
resp = resp.form.submit()
assert 'This password is not strong enough' not in resp.text
def test_registration_no_identifier(app, db, settings, phone_activated_authn):
resp = app.get(reverse('registration_register'))

View File

@ -134,6 +134,13 @@ def test_password_change_error(
def test_password_change_form(simple_user):
Attribute.objects.create(
kind='string',
name='favourite_song',
)
simple_user.attributes.favourite_song = "0opS 1 D1t iT @GAiN"
data = {
'new_password1': 'Password0',
'new_password2': 'Password0',
@ -148,8 +155,26 @@ def test_password_change_form(simple_user):
assert form.fields['new_password1'].widget.min_strength == 3
assert form.errors['new_password1'] == ['This password is not strong enough.']
data = {
'new_password1': '0opS 1 D1t iT @GAiN',
'new_password2': '0opS 1 D1t iT @GAiN',
}
form = PasswordChangeForm(user=simple_user, data=data)
assert form.errors['new_password1'] == ['This password is not strong enough.']
simple_user.attributes.favourite_song = "Baby one more time"
form = PasswordChangeForm(user=simple_user, data=data)
assert 'new_password1' not in form.errors
def test_set_password_form(simple_user):
Attribute.objects.create(
kind='string',
name='favourite_song',
)
simple_user.attributes.favourite_song = "0opS 1 D1t iT @GAiN"
data = {
'new_password1': 'Password0',
'new_password2': 'Password0',
@ -164,6 +189,17 @@ def test_set_password_form(simple_user):
assert form.fields['new_password1'].widget.min_strength == 3
assert form.errors['new_password1'] == ['This password is not strong enough.']
data = {
'new_password1': '0opS 1 D1t iT @GAiN',
'new_password2': '0opS 1 D1t iT @GAiN',
}
form = SetPasswordForm(user=simple_user, data=data)
assert form.errors['new_password1'] == ['This password is not strong enough.']
simple_user.attributes.favourite_song = "Baby one more time"
form = SetPasswordForm(user=simple_user, data=data)
assert 'new_password1' not in form.errors
def test_well_known_password_change(app):
resp = app.get('/.well-known/change-password')