authenticators: migrate some settings to password authenticator (#41671)
gitea/authentic/pipeline/head This commit looks good Details

This commit is contained in:
Valentin Deniaud 2023-06-13 10:45:55 +02:00
parent f8a85c28fa
commit f10d5b80bc
17 changed files with 431 additions and 75 deletions

View File

@ -188,12 +188,6 @@ default_settings = dict(
A2_PASSWORD_POLICY_MIN_CLASSES=Setting(
default=3, definition='Minimum number of characters classes to be present in passwords'
),
A2_PASSWORD_POLICY_MIN_LENGTH=Setting(default=8, definition='Minimum number of characters in a password'),
A2_PASSWORD_POLICY_REGEX=Setting(default=None, definition='Regular expression for validating passwords'),
A2_PASSWORD_POLICY_REGEX_ERROR_MSG=Setting(
default=None,
definition='Error message to show when the password do not validate the regular expression',
),
A2_PASSWORD_POLICY_CLASS=Setting(
default='authentic2.passwords.DefaultPasswordChecker',
definition='path of a class to validate passwords',
@ -291,18 +285,6 @@ default_settings = dict(
A2_CACHE_ENABLED=Setting(default=True, definition='Disable all cache decorators for testing purpose.'),
A2_ACCEPT_EMAIL_AUTHENTICATION=Setting(default=True, definition='Enable authentication by email'),
A2_ACCEPT_PHONE_AUTHENTICATION=Setting(default=False, definition='Enable authentication by phone'),
A2_EMAILS_IP_RATELIMIT=Setting(
default='10/h', definition='Maximum rate of email sendings triggered by the same IP address.'
),
A2_SMS_IP_RATELIMIT=Setting(
default='10/h', definition='Maximum rate of SMSs triggered by the same IP address.'
),
A2_EMAILS_ADDRESS_RATELIMIT=Setting(
default='3/d', definition='Maximum rate of emails sent to the same email address.'
),
A2_SMS_NUMBER_RATELIMIT=Setting(
default='10/h', definition='Maximum rate of SMSs sent to the same phone number.'
),
A2_USER_DELETED_KEEP_DATA=Setting(
default=['email', 'uuid', 'phone'], definition='User data to keep after deletion'
),

View File

@ -71,7 +71,26 @@ class AuthenticatorImportForm(forms.Form):
raise ValidationError(_('File is not in the expected JSON format.'))
class LoginPasswordAuthenticatorAdvancedForm(forms.ModelForm):
class Meta:
model = LoginPasswordAuthenticator
fields = (
'remember_me',
'include_ou_selector',
'password_regex',
'password_regex_error_msg',
'login_exponential_retry_timeout_duration',
'login_exponential_retry_timeout_factor',
'login_exponential_retry_timeout_max_duration',
'login_exponential_retry_timeout_min_duration',
'emails_ip_ratelimit',
'sms_ip_ratelimit',
'emails_address_ratelimit',
'sms_number_ratelimit',
)
class LoginPasswordAuthenticatorEditForm(forms.ModelForm):
class Meta:
model = LoginPasswordAuthenticator
exclude = ('name', 'slug', 'ou', 'button_label')
exclude = ('name', 'slug', 'ou', 'button_label') + LoginPasswordAuthenticatorAdvancedForm.Meta.fields

View File

@ -0,0 +1,113 @@
# Generated by Django 3.2.18 on 2023-06-13 15:04
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('authenticators', '0007_migrate_registration_open'),
]
operations = [
migrations.AddField(
model_name='loginpasswordauthenticator',
name='emails_address_ratelimit',
field=models.CharField(
default='3/d',
help_text='Maximum rate of emails sent to the same email address.',
max_length=32,
verbose_name='Emails address ratelimit',
),
),
migrations.AddField(
model_name='loginpasswordauthenticator',
name='emails_ip_ratelimit',
field=models.CharField(
default='10/h',
help_text='Maximum rate of email sendings triggered by the same IP address.',
max_length=32,
verbose_name='Emails IP ratelimit',
),
),
migrations.AddField(
model_name='loginpasswordauthenticator',
name='login_exponential_retry_timeout_duration',
field=models.FloatField(
default=1,
help_text='Exponential backoff base factor duration as seconds until next try after a login failure.',
verbose_name='Retry timeout duration',
),
),
migrations.AddField(
model_name='loginpasswordauthenticator',
name='login_exponential_retry_timeout_factor',
field=models.FloatField(
default=1.8,
help_text='Exponential backoff factor duration as seconds until next try after a login failure.',
verbose_name='Retry timeout factor',
),
),
migrations.AddField(
model_name='loginpasswordauthenticator',
name='login_exponential_retry_timeout_max_duration',
field=models.PositiveIntegerField(
default=3600,
help_text='Maximum exponential backoff maximum duration as seconds until next try after a login failure.',
verbose_name='Retry timeout max duration',
),
),
migrations.AddField(
model_name='loginpasswordauthenticator',
name='login_exponential_retry_timeout_min_duration',
field=models.PositiveIntegerField(
default=10,
help_text='Minimum exponential backoff maximum duration as seconds until next try after a login failure.',
verbose_name='Retry timeout min duration',
),
),
migrations.AddField(
model_name='loginpasswordauthenticator',
name='password_min_length',
field=models.PositiveIntegerField(default=8, null=True, verbose_name='Password minimum length'),
),
migrations.AddField(
model_name='loginpasswordauthenticator',
name='password_regex',
field=models.CharField(
blank=True,
default='',
max_length=512,
verbose_name='Regular expression for validating passwords',
),
),
migrations.AddField(
model_name='loginpasswordauthenticator',
name='password_regex_error_msg',
field=models.CharField(
blank=True,
default='',
max_length=1024,
verbose_name='Error message to show when the password do not validate the regular expression',
),
),
migrations.AddField(
model_name='loginpasswordauthenticator',
name='sms_ip_ratelimit',
field=models.CharField(
default='10/h',
help_text='Maximum rate of SMSs triggered by the same IP address.',
max_length=32,
verbose_name='SMS IP ratelimit',
),
),
migrations.AddField(
model_name='loginpasswordauthenticator',
name='sms_number_ratelimit',
field=models.CharField(
default='10/h',
help_text='Maximum rate of SMSs sent to the same phone number.',
max_length=32,
verbose_name='SMS number ratelimit',
),
),
]

View File

@ -0,0 +1,77 @@
# Generated by Django 3.2.18 on 2023-06-13 13:17
import logging
from django.conf import settings
from django.db import migrations, transaction
logger = logging.getLogger('authentic2.authenticators')
def get_setting(name, default, max_length=None):
setting = getattr(settings, name, default)
expected_type = type(default)
if expected_type in (float, int) and isinstance(setting, (float, int)):
pass
elif not isinstance(setting, expected_type):
setting = None
if setting is None:
setting = default
return setting[:max_length] if max_length else setting
def update_authenticator(qs):
qs.update(
password_min_length=get_setting('A2_PASSWORD_POLICY_MIN_LENGTH', default=8),
password_regex=get_setting('A2_PASSWORD_POLICY_REGEX', default='', max_length=512),
password_regex_error_msg=get_setting(
'A2_PASSWORD_POLICY_REGEX_ERROR_MSG', default='', max_length=1024
),
login_exponential_retry_timeout_duration=get_setting(
'A2_LOGIN_EXPONENTIAL_RETRY_TIMEOUT_DURATION',
default=1.0,
),
login_exponential_retry_timeout_factor=get_setting(
'A2_LOGIN_EXPONENTIAL_RETRY_TIMEOUT_FACTOR',
default=1.8,
),
login_exponential_retry_timeout_max_duration=get_setting(
'A2_LOGIN_EXPONENTIAL_RETRY_TIMEOUT_MAX_DURATION', default=3600
),
login_exponential_retry_timeout_min_duration=get_setting(
'A2_LOGIN_EXPONENTIAL_RETRY_TIMEOUT_MIN_DURATION', default=10
),
emails_ip_ratelimit=get_setting('A2_EMAILS_IP_RATELIMIT', default='10/h', max_length=32),
sms_ip_ratelimit=get_setting('A2_SMS_IP_RATELIMIT', default='10/h', max_length=32),
emails_address_ratelimit=get_setting('A2_EMAILS_ADDRESS_RATELIMIT', default='3/d', max_length=32),
sms_number_ratelimit=get_setting('A2_SMS_NUMBER_RATELIMIT', default='10/h', max_length=32),
)
def migrate_password_settings(apps, schema_editor):
LoginPasswordAuthenticator = apps.get_model('authenticators', 'LoginPasswordAuthenticator')
authenticator = LoginPasswordAuthenticator.objects.get_or_create(
slug='password-authenticator',
defaults={'enabled': True},
)[0]
qs = LoginPasswordAuthenticator.objects.filter(pk=authenticator.pk)
try:
with transaction.atomic():
update_authenticator(qs)
except Exception:
logger.exception('error on login password authenticator settings migration')
class Migration(migrations.Migration):
dependencies = [
('authenticators', '0008_new_password_settings_fields'),
]
operations = [
migrations.RunPython(migrate_password_settings, reverse_code=migrations.RunPython.noop),
]

View File

@ -291,6 +291,69 @@ class LoginPasswordAuthenticator(BaseAuthenticator):
)
include_ou_selector = models.BooleanField(_('Include OU selector in login form'), default=False)
password_min_length = models.PositiveIntegerField(_('Password minimum length'), default=8, null=True)
password_regex = models.CharField(
_('Regular expression for validating passwords'), max_length=512, blank=True, default=''
)
password_regex_error_msg = models.CharField(
_('Error message to show when the password do not validate the regular expression'),
max_length=1024,
blank=True,
default='',
)
login_exponential_retry_timeout_duration = models.FloatField(
_('Retry timeout duration'),
default=1,
help_text=_(
'Exponential backoff base factor duration as seconds until next try after a login failure.'
),
)
login_exponential_retry_timeout_factor = models.FloatField(
_('Retry timeout factor'),
default=1.8,
help_text=_('Exponential backoff factor duration as seconds until next try after a login failure.'),
)
login_exponential_retry_timeout_max_duration = models.PositiveIntegerField(
_('Retry timeout max duration'),
default=3600,
help_text=_(
'Maximum exponential backoff maximum duration as seconds until next try after a login failure.'
),
)
login_exponential_retry_timeout_min_duration = models.PositiveIntegerField(
_('Retry timeout min duration'),
default=10,
help_text=_(
'Minimum exponential backoff maximum duration as seconds until next try after a login failure.'
),
)
emails_ip_ratelimit = models.CharField(
_('Emails IP ratelimit'),
default='10/h',
max_length=32,
help_text=_('Maximum rate of email sendings triggered by the same IP address.'),
)
sms_ip_ratelimit = models.CharField(
_('SMS IP ratelimit'),
default='10/h',
max_length=32,
help_text=_('Maximum rate of SMSs triggered by the same IP address.'),
)
emails_address_ratelimit = models.CharField(
_('Emails address ratelimit'),
default='3/d',
max_length=32,
help_text=_('Maximum rate of emails sent to the same email address.'),
)
sms_number_ratelimit = models.CharField(
_('SMS number ratelimit'),
default='10/h',
max_length=32,
help_text=_('Maximum rate of SMSs sent to the same phone number.'),
)
type = 'password'
how = ['password', 'password-on-https']
unique = True
@ -300,10 +363,13 @@ class LoginPasswordAuthenticator(BaseAuthenticator):
verbose_name = _('Password')
@property
def manager_form_class(self):
from .forms import LoginPasswordAuthenticatorEditForm
def manager_form_classes(self):
from .forms import LoginPasswordAuthenticatorAdvancedForm, LoginPasswordAuthenticatorEditForm
return LoginPasswordAuthenticatorEditForm
return [
(_('General'), LoginPasswordAuthenticatorEditForm),
(_('Advanced'), LoginPasswordAuthenticatorAdvancedForm),
]
def login(self, request, *args, **kwargs):
return views.login_password_login(request, self, *args, **kwargs)

View File

@ -62,8 +62,8 @@ class AuthenticationForm(auth_forms.AuthenticationForm):
self.exponential_backoff = ExponentialRetryTimeout(
key_prefix='login-exp-backoff-',
duration=app_settings.A2_LOGIN_EXPONENTIAL_RETRY_TIMEOUT_DURATION,
factor=app_settings.A2_LOGIN_EXPONENTIAL_RETRY_TIMEOUT_FACTOR,
duration=self.authenticator.login_exponential_retry_timeout_duration,
factor=self.authenticator.login_exponential_retry_timeout_factor,
)
if not self.authenticator.remember_me:
@ -96,8 +96,8 @@ class AuthenticationForm(auth_forms.AuthenticationForm):
if username and password:
keys = self.exp_backoff_keys()
seconds_to_wait = self.exponential_backoff.seconds_to_wait(*keys)
if seconds_to_wait > app_settings.A2_LOGIN_EXPONENTIAL_RETRY_TIMEOUT_MIN_DURATION:
seconds_to_wait -= app_settings.A2_LOGIN_EXPONENTIAL_RETRY_TIMEOUT_MIN_DURATION
if seconds_to_wait > self.authenticator.login_exponential_retry_timeout_min_duration:
seconds_to_wait -= self.authenticator.login_exponential_retry_timeout_min_duration
msg = _(
'You made too many login errors recently, you must wait <span'
' class="js-seconds-until">%s</span> seconds to try again.'

View File

@ -39,6 +39,7 @@ from authentic2.forms.widgets import (
)
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
@ -72,7 +73,7 @@ class NewPasswordField(CharField):
if get_password_strength(value).strength < min_strength:
raise ValidationError(_('This password is not strong enough.'))
min_length = app_settings.A2_PASSWORD_POLICY_MIN_LENGTH
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:

View File

@ -24,19 +24,21 @@ from django.utils.translation import gettext as _
from zxcvbn import zxcvbn
from . import app_settings
from .utils.misc import get_password_authenticator
def generate_password():
"""Generate a password that validates current password policy.
Beware that A2_PASSWORD_POLICY_REGEX cannot be validated.
Beware that custom regex validation cannot be validated.
"""
digits = string.digits
lower = string.ascii_lowercase
upper = string.ascii_uppercase
punc = string.punctuation
min_len = max(app_settings.A2_PASSWORD_POLICY_MIN_LENGTH, 8)
authenticator = get_password_authenticator()
min_len = max(authenticator.password_min_length, 8)
min_class_count = max(app_settings.A2_PASSWORD_POLICY_MIN_CLASSES, 3)
new_password = []
@ -64,7 +66,7 @@ class PasswordChecker(metaclass=abc.ABCMeta):
class DefaultPasswordChecker(PasswordChecker):
@property
def min_length(self):
return app_settings.A2_PASSWORD_POLICY_MIN_LENGTH
return self.authenticator.password_min_length
@property
def at_least_one_lowercase(self):
@ -80,13 +82,14 @@ class DefaultPasswordChecker(PasswordChecker):
@property
def regexp(self):
return app_settings.A2_PASSWORD_POLICY_REGEX
return self.authenticator.password_regex
@property
def regexp_label(self):
return app_settings.A2_PASSWORD_POLICY_REGEX_ERROR_MSG
return self.authenticator.password_regex_error_msg
def __call__(self, password, **kwargs):
self.authenticator = get_password_authenticator()
if self.min_length:
yield self.Check(
result=len(password) >= self.min_length, label=_('%s characters') % self.min_length
@ -123,7 +126,7 @@ class StrengthReport:
def get_password_strength(password):
min_length = app_settings.A2_PASSWORD_POLICY_MIN_LENGTH
min_length = get_password_authenticator().password_min_length
hint = _('add more words or characters.')
strength = 0

View File

@ -856,6 +856,10 @@ class PasswordResetView(FormView):
title = _('Password Reset')
code = None
def dispatch(self, *args, **kwargs):
self.authenticator = utils_misc.get_password_authenticator()
return super().dispatch(*args, **kwargs)
def get_success_url(self):
if not app_settings.A2_ACCEPT_PHONE_AUTHENTICATION or not self.code: # user input is email
return reverse('password_reset_instructions')
@ -873,7 +877,7 @@ class PasswordResetView(FormView):
def get_form_kwargs(self, **kwargs):
kwargs = super().get_form_kwargs(**kwargs)
kwargs['password_authenticator'] = utils_misc.get_password_authenticator()
kwargs['password_authenticator'] = self.authenticator
initial = kwargs.setdefault('initial', {})
if next_url := utils_misc.select_next_url(self.request, ''):
initial['next_url'] = next_url
@ -922,7 +926,7 @@ class PasswordResetView(FormView):
self.request,
key='post:email',
group='pw-reset-email',
rate=app_settings.A2_EMAILS_ADDRESS_RATELIMIT,
rate=self.authenticator.emails_address_ratelimit or None,
increment=True,
):
self.request.journal.record('user.password.reset.failure', email=email)
@ -938,7 +942,7 @@ class PasswordResetView(FormView):
self.request,
key='ip',
group='pw-reset-email',
rate=app_settings.A2_EMAILS_IP_RATELIMIT,
rate=self.authenticator.emails_ip_ratelimit or None,
increment=True,
):
self.request.journal.record('user.password.reset.failure', email=email)
@ -960,7 +964,7 @@ class PasswordResetView(FormView):
self.request,
key=sms_ratelimit_key,
group='pw-reset-sms',
rate=app_settings.A2_SMS_NUMBER_RATELIMIT,
rate=self.authenticator.sms_number_ratelimit or None,
increment=True,
):
form.add_error(
@ -975,7 +979,7 @@ class PasswordResetView(FormView):
self.request,
key='ip',
group='pw-reset-sms',
rate=app_settings.A2_SMS_IP_RATELIMIT,
rate=self.authenticator.sms_ip_ratelimit or None,
increment=True,
):
form.add_error(
@ -1126,8 +1130,8 @@ class BaseRegistrationView(HomeURLMixin, FormView):
title = _('Registration')
def dispatch(self, request, *args, **kwargs):
password_authenticator = utils_misc.get_password_authenticator()
if not password_authenticator.registration_open:
self.authenticator = utils_misc.get_password_authenticator()
if not self.authenticator.registration_open:
raise Http404('Registration is not open.')
self.token = {}
@ -1200,7 +1204,7 @@ class BaseRegistrationView(HomeURLMixin, FormView):
self.request,
key=sms_ratelimit_key,
group='registration-sms',
rate=app_settings.A2_SMS_NUMBER_RATELIMIT,
rate=self.authenticator.sms_number_ratelimit,
increment=True,
):
form.add_error(
@ -1215,7 +1219,7 @@ class BaseRegistrationView(HomeURLMixin, FormView):
self.request,
key='ip',
group='registration-sms',
rate=app_settings.A2_SMS_IP_RATELIMIT,
rate=self.authenticator.sms_ip_ratelimit,
increment=True,
):
form.add_error(
@ -1268,7 +1272,7 @@ class BaseRegistrationView(HomeURLMixin, FormView):
self.request,
key='post:email',
group='registration-email',
rate=app_settings.A2_EMAILS_ADDRESS_RATELIMIT,
rate=self.authenticator.emails_address_ratelimit or None,
increment=True,
):
form.add_error(
@ -1283,7 +1287,7 @@ class BaseRegistrationView(HomeURLMixin, FormView):
self.request,
key='ip',
group='registration-email',
rate=app_settings.A2_EMAILS_IP_RATELIMIT,
rate=self.authenticator.emails_ip_ratelimit or None,
increment=True,
):
form.add_error(

View File

@ -37,6 +37,7 @@ from authentic2.a2_rbac.models import SEARCH_OP
from authentic2.a2_rbac.models import OrganizationalUnit as OU
from authentic2.a2_rbac.models import Role
from authentic2.a2_rbac.utils import get_default_ou
from authentic2.apps.authenticators.models import LoginPasswordAuthenticator
from authentic2.apps.journal.models import Event, EventType
from authentic2.custom_user.models import Profile, ProfileType
from authentic2.models import APIClient, Attribute, AttributeValue, AuthorizedRole, PasswordReset, Service
@ -1767,8 +1768,9 @@ def test_validate_password_default(app):
def test_validate_password_regex(app, settings):
settings.A2_PASSWORD_POLICY_REGEX = '^.*ok.*$'
settings.A2_PASSWORD_POLICY_REGEX_ERROR_MSG = 'must contain "ok"'
LoginPasswordAuthenticator.objects.update(
password_regex='^.*ok.*$', password_regex_error_msg='must contain "ok"'
)
response = app.post_json('/api/validate-password/', params={'password': 'x' * 8 + '1X'})
assert response.json['result'] == 1
@ -1814,7 +1816,7 @@ def test_validate_password_regex(app, settings):
],
)
def test_password_strength(app, settings, min_length, password, strength, label):
settings.A2_PASSWORD_POLICY_MIN_LENGTH = min_length
LoginPasswordAuthenticator.objects.update(password_min_length=min_length)
response = app.post_json('/api/password-strength/', params={'password': password})
assert response.json['result'] == 1
assert response.json['strength'] == strength
@ -1838,7 +1840,7 @@ def test_password_strength(app, settings, min_length, password, strength, label)
],
)
def test_password_strength_hints(app, settings, min_length, password, hint):
settings.A2_PASSWORD_POLICY_MIN_LENGTH = min_length
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})
assert response.json['result'] == 1

View File

@ -22,6 +22,7 @@ import PIL.Image
from django.conf import settings
from webtest import Upload
from authentic2.apps.authenticators.models import LoginPasswordAuthenticator
from authentic2.custom_user.models import User
from authentic2.models import Attribute
@ -188,7 +189,7 @@ def test_fr_postcode(db, app, admin, mailoutbox):
def test_phone_number(db, app, admin, mailoutbox, settings):
settings.A2_EMAILS_ADDRESS_RATELIMIT = None
LoginPasswordAuthenticator.objects.update(emails_address_ratelimit='')
def register_john():
response = app.get('/register/')
@ -282,7 +283,7 @@ def test_phone_number(db, app, admin, mailoutbox, settings):
def test_french_phone_number(db, app, admin, mailoutbox, settings):
settings.A2_EMAILS_ADDRESS_RATELIMIT = None
LoginPasswordAuthenticator.objects.update(emails_address_ratelimit='')
def register_john():
response = app.get('/register/')

View File

@ -18,6 +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
@ -50,7 +51,7 @@ def test_phonenumber_field(settings):
field.clean(value)
def test_validate_password(settings):
def test_validate_password(db, settings):
field = NewPasswordField()
with pytest.raises(ValidationError):
field.validate('aaaaaZZZZZZ')
@ -71,28 +72,28 @@ def test_validate_password(settings):
('?JR!p4A2i:#', 4),
],
)
def test_validate_password_strength(settings, password, min_strength):
settings.A2_PASSWORD_POLICY_MIN_LENGTH = len(password)
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)
settings.A2_PASSWORD_POLICY_MIN_LENGTH = len(password) + 1
LoginPasswordAuthenticator.objects.update(password_min_length=len(password) + 1)
with pytest.raises(ValidationError):
field.validate(password)
if min_strength < 4:
settings.A2_PASSWORD_POLICY_MIN_LENGTH = len(password)
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(settings):
settings.A2_PASSWORD_POLICY_REGEX = '^[0-9]{8}$'
settings.A2_PASSWORD_POLICY_REGEX_ERROR_MSG = 'pasbon'
settings.A2_PASSWORD_POLICY_MIN_LENGTH = 0
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()

View File

@ -226,7 +226,7 @@ def test_redirect_login_to_homepage(db, app, settings, simple_user, superuser):
def test_exponential_backoff(db, app, settings):
settings.A2_LOGIN_EXPONENTIAL_RETRY_TIMEOUT_DURATION = 0
LoginPasswordAuthenticator.objects.update(login_exponential_retry_timeout_duration=0)
response = app.get('/login/')
response.form.set('username', '')
response.form.set('password', 'zozo')
@ -239,8 +239,9 @@ def test_exponential_backoff(db, app, settings):
response = response.form.submit('login-password-submit')
assert 'too many login' not in response.text
settings.A2_LOGIN_EXPONENTIAL_RETRY_TIMEOUT_DURATION = 1.0
settings.A2_LOGIN_EXPONENTIAL_RETRY_TIMEOUT_MIN_DURATION = 10.0
LoginPasswordAuthenticator.objects.update(
login_exponential_retry_timeout_duration=1.0, login_exponential_retry_timeout_min_duration=10.0
)
for i in range(10):
response.form.set('username', 'zozo')
@ -507,3 +508,67 @@ def test_password_authenticator_data_migration(migration, settings):
assert authenticator.enabled is False
assert authenticator.remember_me == 42
assert authenticator.include_ou_selector is True
def test_password_authenticator_data_migration_new_settings(migration, settings):
app = 'authenticators'
migrate_from = [(app, '0008_new_password_settings_fields')]
migrate_to = [(app, '0009_migrate_new_password_settings')]
old_apps = migration.before(migrate_from)
LoginPasswordAuthenticator = old_apps.get_model(app, 'LoginPasswordAuthenticator')
settings.A2_PASSWORD_POLICY_MIN_LENGTH = 10
settings.A2_PASSWORD_POLICY_REGEX = '^.*ok.*$'
settings.A2_PASSWORD_POLICY_REGEX_ERROR_MSG = 'not ok'
settings.A2_LOGIN_EXPONENTIAL_RETRY_TIMEOUT_DURATION = 10.5
settings.A2_LOGIN_EXPONENTIAL_RETRY_TIMEOUT_FACTOR = 1
settings.A2_LOGIN_EXPONENTIAL_RETRY_TIMEOUT_MAX_DURATION = 100
settings.A2_LOGIN_EXPONENTIAL_RETRY_TIMEOUT_MIN_DURATION = 200
settings.A2_EMAILS_IP_RATELIMIT = '42/h'
settings.A2_SMS_IP_RATELIMIT = '43/h'
settings.A2_EMAILS_ADDRESS_RATELIMIT = '44/h'
settings.A2_SMS_NUMBER_RATELIMIT = '45/h'
new_apps = migration.apply(migrate_to)
LoginPasswordAuthenticator = new_apps.get_model(app, 'LoginPasswordAuthenticator')
authenticator = LoginPasswordAuthenticator.objects.get()
assert authenticator.password_min_length == 10
assert authenticator.password_regex == '^.*ok.*$'
assert authenticator.password_regex_error_msg == 'not ok'
assert authenticator.login_exponential_retry_timeout_duration == 10.5
assert authenticator.login_exponential_retry_timeout_factor == 1
assert authenticator.login_exponential_retry_timeout_max_duration == 100
assert authenticator.login_exponential_retry_timeout_min_duration == 200
assert authenticator.emails_ip_ratelimit == '42/h'
assert authenticator.sms_ip_ratelimit == '43/h'
assert authenticator.emails_address_ratelimit == '44/h'
assert authenticator.sms_number_ratelimit == '45/h'
def test_password_authenticator_data_migration_new_settings_invalid(migration, settings):
app = 'authenticators'
migrate_from = [(app, '0008_new_password_settings_fields')]
migrate_to = [(app, '0009_migrate_new_password_settings')]
old_apps = migration.before(migrate_from)
LoginPasswordAuthenticator = old_apps.get_model(app, 'LoginPasswordAuthenticator')
settings.A2_PASSWORD_POLICY_MIN_LENGTH = 'abc'
settings.A2_PASSWORD_POLICY_REGEX = None
settings.A2_PASSWORD_POLICY_REGEX_ERROR_MSG = 42
settings.A2_LOGIN_EXPONENTIAL_RETRY_TIMEOUT_DURATION = None
settings.A2_LOGIN_EXPONENTIAL_RETRY_TIMEOUT_MAX_DURATION = 10.5
settings.A2_EMAILS_IP_RATELIMIT = None
settings.A2_SMS_IP_RATELIMIT = 42
new_apps = migration.apply(migrate_to)
LoginPasswordAuthenticator = new_apps.get_model(app, 'LoginPasswordAuthenticator')
authenticator = LoginPasswordAuthenticator.objects.get()
assert authenticator.password_min_length == 8
assert authenticator.password_regex == ''
assert authenticator.password_regex_error_msg == ''
assert authenticator.login_exponential_retry_timeout_duration == 1
assert authenticator.login_exponential_retry_timeout_max_duration == 10
assert authenticator.emails_ip_ratelimit == '10/h'
assert authenticator.sms_ip_ratelimit == '10/h'

View File

@ -80,8 +80,19 @@ def test_authenticators_password(app, superuser_or_admin):
'show_condition',
'button_description',
'registration_open',
'password_min_length',
'remember_me',
'include_ou_selector',
'password_regex',
'password_regex_error_msg',
'login_exponential_retry_timeout_duration',
'login_exponential_retry_timeout_factor',
'login_exponential_retry_timeout_max_duration',
'login_exponential_retry_timeout_min_duration',
'emails_ip_ratelimit',
'sms_ip_ratelimit',
'emails_address_ratelimit',
'sms_number_ratelimit',
None,
]
@ -138,16 +149,27 @@ def test_authenticators_password_export(app, superuser):
authenticator_json = json.loads(resp.text)
assert authenticator_json == {
'authenticator_type': 'authenticators.loginpasswordauthenticator',
'name': '',
'slug': 'password-authenticator',
'show_condition': '',
'button_description': '',
'button_label': 'Login',
'include_ou_selector': False,
'name': '',
'ou': None,
'registration_open': True,
'related_objects': [],
'remember_me': None,
'show_condition': '',
'slug': 'password-authenticator',
'include_ou_selector': False,
'password_min_length': 8,
'password_regex': '',
'password_regex_error_msg': '',
'login_exponential_retry_timeout_duration': 1,
'login_exponential_retry_timeout_factor': 1.8,
'login_exponential_retry_timeout_max_duration': 3600,
'login_exponential_retry_timeout_min_duration': 10,
'emails_ip_ratelimit': '10/h',
'sms_ip_ratelimit': '10/h',
'emails_address_ratelimit': '3/d',
'sms_number_ratelimit': '10/h',
'ou': None,
'related_objects': [],
}
resp = app.get('/manage/authenticators/')

View File

@ -19,15 +19,16 @@ import string
from authentic2 import app_settings
from authentic2.passwords import generate_password
from authentic2.utils.misc import get_password_authenticator
def test_generate_password():
def test_generate_password(db):
passwords = {generate_password() for i in range(10)}
char_classes = [string.digits, string.ascii_lowercase, string.ascii_uppercase, string.punctuation]
assert len(passwords) == 10
for password in passwords:
assert len(password) >= max(app_settings.A2_PASSWORD_POLICY_MIN_LENGTH, 8)
assert len(password) >= max(get_password_authenticator().password_min_length, 8)
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
)

View File

@ -27,6 +27,7 @@ from httmock import HTTMock
from httmock import response as httmock_response
from httmock import urlmatch
from authentic2.apps.authenticators.models import LoginPasswordAuthenticator
from authentic2.custom_user.models import DeletedUser, User
from authentic2.forms.passwords import PasswordChangeForm, SetPasswordForm
from authentic2.models import Attribute
@ -322,8 +323,7 @@ def test_custom_account(settings, app, simple_user):
@pytest.mark.parametrize('view_name', ['registration_register']) # password_lost to be added with #69890
def test_views_sms_ratelimit(app, db, simple_user, settings, freezer, view_name):
freezer.move_to('2020-01-01')
settings.A2_SMS_IP_RATELIMIT = '10/h'
settings.A2_SMS_NUMBER_RATELIMIT = '3/d'
LoginPasswordAuthenticator.objects.update(sms_ip_ratelimit='10/h', sms_number_ratelimit='3/d')
settings.A2_ACCEPT_PHONE_AUTHENTICATION = True
settings.SMS_SENDER = 'EO'
settings.SMS_URL = 'https://www.example.com/send'
@ -396,8 +396,7 @@ def test_views_sms_ratelimit(app, db, simple_user, settings, freezer, view_name)
@pytest.mark.parametrize('view_name', ['registration_register', 'password_reset'])
def test_views_email_ratelimit(app, db, simple_user, settings, mailoutbox, freezer, view_name):
freezer.move_to('2020-01-01')
settings.A2_EMAILS_IP_RATELIMIT = '10/h'
settings.A2_EMAILS_ADDRESS_RATELIMIT = '3/d'
LoginPasswordAuthenticator.objects.update(emails_ip_ratelimit='10/h', emails_address_ratelimit='3/d')
users = [User.objects.create(email='test%s@test.com' % i) for i in range(8)]
# reach email limit

View File

@ -52,7 +52,7 @@ def test_datalisttextinput_init_and_render():
assert not data
def test_new_password_input():
def test_new_password_input(db):
widget = NewPasswordInput()
html = widget.render('foo', 'bar')
query = PyQuery(html)