Pouvoir définir quels attributs de type téléphone sont à vocation identifiante pour l’usager (#78046) #75

Merged
pmarillonnet merged 6 commits from wip/78046-password-authn-identifier-selection into main 2023-06-26 11:27:13 +02:00
20 changed files with 585 additions and 85 deletions

View File

@ -142,7 +142,6 @@ def extract_settings_from_environ():
'SHOW_DISCO_IN_MD',
'SSLAUTH_CREATE_USER',
'PUSH_PROFILE_UPDATES',
'A2_ACCEPT_EMAIL_AUTHENTICATION',
'A2_CAN_RESET_PASSWORD',
'A2_REGISTRATION_CAN_DELETE_ACCOUNT',
'A2_REGISTRATION_EMAIL_IS_UNIQUE',

View File

@ -283,8 +283,10 @@ default_settings = dict(
),
A2_ACCOUNTS_URL=Setting(default=None, definition='IdP has no account page, redirect to this one.'),
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_ALLOW_PHONE_AUTHN_MANAGEMENT=Setting(
default=False,
definition='Allow phone-authentication backoffice-management by authentic\'s administrators',
),
A2_USER_DELETED_KEEP_DATA=Setting(
default=['email', 'uuid', 'phone'], definition='User data to keep after deletion'
),

View File

@ -21,7 +21,9 @@ from django.core.exceptions import ValidationError
from django.db.models import Max
from django.utils.translation import gettext as _
from authentic2 import app_settings
from authentic2.forms.mixins import SlugMixin
from authentic2.models import Attribute
from .models import BaseAuthenticator, LoginPasswordAuthenticator
@ -72,6 +74,25 @@ class AuthenticatorImportForm(forms.Form):
class LoginPasswordAuthenticatorAdvancedForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['phone_identifier_field'].choices = Attribute.objects.filter(
disabled=False,
multiple=False,
kind__in=('phone_number', 'fr_phone_number'),
).values_list('id', 'label')
# TODO drop temporary feature-flag app setting once phone number
# verification is enforced everywhere in /accounts/
if not app_settings.A2_ALLOW_PHONE_AUTHN_MANAGEMENT:
for field in (
'accept_email_authentication',
'accept_phone_authentication',
'phone_identifier_field',
):
del self.fields[field]
class Meta:
model = LoginPasswordAuthenticator
fields = (
@ -87,6 +108,9 @@ class LoginPasswordAuthenticatorAdvancedForm(forms.ModelForm):
'sms_ip_ratelimit',
'emails_address_ratelimit',
'sms_number_ratelimit',
'accept_email_authentication',
'accept_phone_authentication',
'phone_identifier_field',
)

View File

@ -0,0 +1,39 @@
# Generated by Django 3.2.18 on 2023-06-14 08:17
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('authentic2', '0048_rename_services_runtime_settings'),
('authenticators', '0009_migrate_new_password_settings'),
]
operations = [
migrations.AddField(
model_name='loginpasswordauthenticator',
name='accept_email_authentication',
field=models.BooleanField(
default=True, verbose_name='Let the users identify with their email address'
),
),
migrations.AddField(
model_name='loginpasswordauthenticator',
name='accept_phone_authentication',
field=models.BooleanField(
default=False, verbose_name='Let the users identify with their phone number'
),
),
migrations.AddField(
model_name='loginpasswordauthenticator',
name='phone_identifier_field',
field=models.ForeignKey(
null=True,
blank=True,
on_delete=django.db.models.deletion.PROTECT,
to='authentic2.attribute',
verbose_name='Phone field used as user identifier',
),
),
]

View File

@ -0,0 +1,27 @@
# Generated by Django 3.2.18 on 2023-06-01 15:44
from django.conf import settings
from django.db import migrations
def migrate_a2_accept_authentication_settings(apps, schema_editor):
LoginPasswordAuthenticator = apps.get_model('authenticators', 'LoginPasswordAuthenticator')
authenticator, _ = LoginPasswordAuthenticator.objects.get_or_create(
slug='password-authenticator',
defaults={'enabled': True},
)
authenticator.accept_email_authentication = getattr(settings, 'A2_ACCEPT_EMAIL_AUTHENTICATION', True)
authenticator.accept_phone_authentication = getattr(settings, 'A2_ACCEPT_PHONE_AUTHENTICATION', False)
authenticator.save()
class Migration(migrations.Migration):
dependencies = [
('authenticators', '0010_auto_20230614_1017'),
]
operations = [
migrations.RunPython(
migrate_a2_accept_authentication_settings, reverse_code=migrations.RunPython.noop
),
]

View File

@ -33,6 +33,7 @@ from authentic2 import views
from authentic2.a2_rbac.models import Role
from authentic2.data_transfer import search_ou, search_role
from authentic2.manager.utils import label_from_role
from authentic2.models import Attribute
from authentic2.utils.evaluate import condition_validator, evaluate_condition
from .query import AuthenticatorManager
@ -290,6 +291,19 @@ class LoginPasswordAuthenticator(BaseAuthenticator):
),
)
include_ou_selector = models.BooleanField(_('Include OU selector in login form'), default=False)
accept_email_authentication = models.BooleanField(
_('Let the users identify with their email address'), default=True
)
accept_phone_authentication = models.BooleanField(
_('Let the users identify with their phone number'), default=False
)
phone_identifier_field = models.ForeignKey(
Attribute,
verbose_name=_('Phone field used as user identifier'),
on_delete=models.PROTECT,
null=True,
blank=True,
)
password_min_length = models.PositiveIntegerField(_('Password minimum length'), default=8, null=True)
password_regex = models.CharField(
@ -362,6 +376,10 @@ class LoginPasswordAuthenticator(BaseAuthenticator):
class Meta:
verbose_name = _('Password')
@property
def is_phone_authn_active(self):
return bool(self.accept_phone_authentication and self.phone_identifier_field)
@property
def manager_form_classes(self):
from .forms import LoginPasswordAuthenticatorAdvancedForm, LoginPasswordAuthenticatorEditForm

View File

@ -46,7 +46,6 @@ from ldap.dn import escape_dn_chars
from ldap.filter import filter_format
from ldap.ldapobject import ReconnectLDAPObject as NativeLDAPObject
from authentic2 import app_settings
from authentic2.a2_rbac.models import OrganizationalUnit, Role
from authentic2.a2_rbac.utils import get_default_ou
from authentic2.backends import is_user_authenticable
@ -56,7 +55,7 @@ from authentic2.middleware import StoreRequestMiddleware
from authentic2.models import Lock, UserExternalId
from authentic2.user_login_failure import user_login_failure, user_login_success
from authentic2.utils import crypto
from authentic2.utils.misc import PasswordChangeError, to_list
from authentic2.utils.misc import PasswordChangeError, get_password_authenticator, to_list
# code originaly copied from by now merely inspired by
# http://www.amherst.k12.oh.us/django-ldap.html
@ -450,7 +449,6 @@ class LDAPBackend:
'bindsasl': (),
'user_dn_template': '',
'user_filter': 'uid=%s', # will be '(|(mail=%s)(uid=%s))' if
# A2_ACCEPT_EMAIL_AUTHENTICATION is set (see update_default)
'sync_ldap_users_filter': '',
'user_basedn': '',
'group_basedn': '',
@ -1955,10 +1953,11 @@ class LDAPBackend:
if i in block and isinstance(block[i], str):
block[i] = (block[i],)
email_authn = get_password_authenticator().accept_email_authentication
for d, value in cls._DEFAULTS.items():
if d not in block:
block[d] = value
vdeniaud marked this conversation as resolved Outdated

Peut-être récupérer l'objet hors de la boucle, pour éviter une requête à chaque fois.

Peut-être récupérer l'objet hors de la boucle, pour éviter une requête à chaque fois.

Oui complètement, c’est corrigé, merci.

Oui complètement, c’est corrigé, merci.
if d == 'user_filter' and app_settings.A2_ACCEPT_EMAIL_AUTHENTICATION:
if d == 'user_filter' and email_authn:
block[d] = '(|(mail=%s)(uid=%s))'
else:
if isinstance(value, str):

View File

@ -19,12 +19,14 @@ import functools
from django.contrib.auth import get_user_model
from django.contrib.auth.backends import ModelBackend as BaseModelBackend
from django.contrib.contenttypes.models import ContentType
from django.db import models
from phonenumbers import PhoneNumberFormat, format_number, is_valid_number
from authentic2.backends import get_user_queryset
from authentic2.models import AttributeValue
from authentic2.user_login_failure import user_login_failure, user_login_success
from authentic2.utils.misc import parse_phone_number
from authentic2.utils.misc import get_password_authenticator, parse_phone_number
from .. import app_settings
@ -45,12 +47,20 @@ class ModelBackend(BaseModelBackend):
def get_query(self, username, realm=None, ou=None):
username_field = 'username'
queries = []
if app_settings.A2_ACCEPT_EMAIL_AUTHENTICATION:
password_authenticator = get_password_authenticator()
user_ct = ContentType.objects.get_for_model(get_user_model())
if password_authenticator.accept_email_authentication:
queries.append(models.Q(**{'email__iexact': username}))
if app_settings.A2_ACCEPT_PHONE_AUTHENTICATION:
if password_authenticator.is_phone_authn_active:
# try with the phone number as user identifier
if (pn := parse_phone_number(username)) and is_valid_number(pn):
query = {'phone': format_number(pn, PhoneNumberFormat.E164)}
user_ids = AttributeValue.objects.filter(
multiple=False,
content_type=user_ct,
attribute=password_authenticator.phone_identifier_field,
content=format_number(pn, PhoneNumberFormat.E164),
).values_list('object_id', flat=True)
query = {'id__in': user_ids}
queries.append(models.Q(**query))
if realm is None:

View File

@ -20,6 +20,7 @@ from collections import OrderedDict
from django import forms
from django.contrib.auth import forms as auth_forms
from django.contrib.auth import get_user_model
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError
from django.forms import Form
from django.utils.translation import gettext_lazy as _
@ -61,7 +62,7 @@ class PasswordResetForm(HoneypotForm):
label=_('Email or username'), max_length=254, required=False
)
if not app_settings.A2_ACCEPT_PHONE_AUTHENTICATION or not get_user_model()._meta.get_field('phone'):
if not self.authenticator.is_phone_authn_active:
vdeniaud marked this conversation as resolved Outdated

Tu peux directement utiliser self.authenticator.

Tu peux directement utiliser `self.authenticator`.

C’est corrigé ici ainsi que tous les autres endroits où tu le mentionnes (+ quelques autres occurrences dans ce même formulaire).

C’est corrigé ici ainsi que tous les autres endroits où tu le mentionnes (+ quelques autres occurrences dans ce même formulaire).
del self.fields['phone']
if 'email' in self.fields:
self.fields['email'].required = True
@ -90,12 +91,18 @@ class PasswordResetForm(HoneypotForm):
def clean_phone(self):
phone = self.cleaned_data.get('phone')
user_ct = ContentType.objects.get_for_model(get_user_model())
if phone:
self.users = get_user_queryset().filter(phone=phone)
user_ids = models.AttributeValue.objects.filter(
attribute=self.authenticator.phone_identifier_field,
content=phone,
content_type=user_ct,
).values_list('object_id', flat=True)
self.users = get_user_queryset().filter(id__in=user_ids)
return phone
def clean(self):
if app_settings.A2_ACCEPT_PHONE_AUTHENTICATION and get_user_model()._meta.get_field('phone'):
if self.authenticator.is_phone_authn_active:
pmarillonnet marked this conversation as resolved Outdated

Pareil, self.authenticator

Pareil, `self.authenticator`
if (
not self.cleaned_data['email']
and not self.cleaned_data.get('email_or_username')
@ -120,8 +127,18 @@ class PasswordResetForm(HoneypotForm):
email_sent = False
sms_sent = False
phone_authn_active = self.authenticator.is_phone_authn_active
user_ct = ContentType.objects.get_for_model(get_user_model())
for user in active_users:
if not user.email and not user.phone:
if not user.email and not (
phone_authn_active
and models.AttributeValue.objects.filter(
attribute=self.authenticator.phone_identifier_field,
pmarillonnet marked this conversation as resolved Outdated

Pareil, self.authenticator

Pareil, `self.authenticator`
object_id=user.id,
content_type=user_ct,
content__isnull=False,
)
):
logger.info(
'password reset failed for account "%r": account has no email nor mobile phone number',
user,

View File

@ -30,6 +30,7 @@ from authentic2.forms.fields import CharField, CheckPasswordField, NewPasswordFi
from authentic2.passwords import get_min_password_strength
from .. import app_settings, models
from ..utils import misc as utils_misc
from . import profile as profile_forms
from .fields import PhoneField, ValidatedEmailField
from .honeypot import HoneypotForm
@ -58,7 +59,7 @@ class RegistrationForm(HoneypotForm):
super().__init__(*args, **kwargs)
attributes = {a.name: a for a in models.Attribute.objects.all()}
if not app_settings.A2_ACCEPT_PHONE_AUTHENTICATION or not get_user_model()._meta.get_field('phone'):
if not utils_misc.get_password_authenticator().is_phone_authn_active:
del self.fields['phone']
self.fields['email'].required = True
@ -81,7 +82,7 @@ class RegistrationForm(HoneypotForm):
return email
def clean(self):
if app_settings.A2_ACCEPT_PHONE_AUTHENTICATION and get_user_model()._meta.get_field('phone'):
if utils_misc.get_password_authenticator().is_phone_authn_active:
if not self.cleaned_data.get('email') and not self.cleaned_data.get('phone'):
raise ValidationError(gettext('Please provide an email address or a mobile phone number.'))

View File

@ -27,6 +27,7 @@ from django.contrib.auth import REDIRECT_FIELD_NAME, get_user_model
from django.contrib.auth import logout as auth_logout
from django.contrib.auth.decorators import login_required
from django.contrib.auth.views import PasswordChangeView as DjPasswordChangeView
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import FieldDoesNotExist, ValidationError
from django.db.models import Count
from django.db.models.query import Q
@ -748,11 +749,11 @@ def login_password_login(request, authenticator, *args, **kwargs):
if request.user.is_authenticated and request.login_token.get('action'):
form.initial['username'] = request.user.username or request.user.email
form.fields['username'].widget.attrs['readonly'] = True
if app_settings.A2_ACCEPT_EMAIL_AUTHENTICATION:
if authenticator.accept_email_authentication:
form.fields['username'].label = _('Username or email')
if app_settings.A2_ACCEPT_PHONE_AUTHENTICATION:
if authenticator.is_phone_authn_active:
form.fields['username'].label = _('Username, email or phone number')
elif app_settings.A2_ACCEPT_PHONE_AUTHENTICATION:
elif authenticator.is_phone_authn_active:
form.fields['username'].label = _('Username or phone number')
if app_settings.A2_USERNAME_LABEL:
form.fields['username'].label = app_settings.A2_USERNAME_LABEL
@ -858,7 +859,9 @@ class PasswordResetView(FormView):
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
if (
not utils_misc.get_password_authenticator().is_phone_authn_active or not self.code
): # user input is email
return reverse('password_reset_instructions')
else: # user input is phone number
params = {}
@ -901,6 +904,7 @@ class PasswordResetView(FormView):
phone = form.cleaned_data.get('phone')
# if an email has already been sent, warn once before allowing resend
# TODO handle multiple SMS warning message
token = models.Token.objects.filter(
kind='pw-reset', content__email__iexact=email, expires__gt=timezone.now()
).exists()
@ -917,6 +921,7 @@ class PasswordResetView(FormView):
)
return self.form_invalid(form)
self.request.session[resend_key] = False
self.request.session['phone'] = phone
if email:
if is_ratelimited(
@ -1168,7 +1173,7 @@ class BaseRegistrationView(HomeURLMixin, FormView):
if email:
return self.perform_email_registration(form, email)
if app_settings.A2_ACCEPT_PHONE_AUTHENTICATION:
if self.authenticator.is_phone_authn_active:
phone = form.cleaned_data.pop('phone')
return self.perform_phone_registration(form, phone)
pmarillonnet marked this conversation as resolved Outdated

Pareil, self.authenticator

Pareil, `self.authenticator`
@ -1437,6 +1442,8 @@ class RegistrationCompletionView(CreateView):
@atomic(savepoint=False)
def dispatch(self, request, *args, **kwargs):
registration_token = kwargs['registration_token'].replace(' ', '')
self.authenticator = utils_misc.get_password_authenticator()
user_ct = ContentType.objects.get_for_model(get_user_model())
try:
token = models.Token.use('registration', registration_token, delete=False)
except models.Token.DoesNotExist:
@ -1461,9 +1468,15 @@ class RegistrationCompletionView(CreateView):
self.email = self.token['email']
qs_filter = {'email__iexact': self.email}
Lock.lock_email(self.email)
elif self.token.get('phone', None):
elif self.token.get('phone', None) and self.authenticator.is_phone_authn_active:
self.phone = self.token['phone']
qs_filter = {'phone': self.phone}
user_ids = models.AttributeValue.objects.filter(
attribute=self.authenticator.phone_identifier_field,
content=self.phone,
content_type=user_ct,
).values_list('object_id', flat=True)
qs_filter = {'id__in': user_ids}
Lock.lock_identifier(self.phone)
else:
messages.warning(request, _('Activation failed'))
@ -1572,7 +1585,9 @@ class RegistrationCompletionView(CreateView):
kwargs['instance'] = User.objects.get(id=self.token.get('user_id'))
else:
init_kwargs = {}
for key in ('email', 'first_name', 'last_name', 'ou', 'phone'):
keys = ['email', 'first_name', 'last_name', 'ou']
# phone identifier is a separate attribute and is set post user-creation
for key in keys:
if key in attributes:
init_kwargs[key] = attributes[key]
kwargs['instance'] = get_user_model()(**init_kwargs)
@ -1678,6 +1693,14 @@ class RegistrationCompletionView(CreateView):
if count:
super().form_valid(form) # user creation happens here
user = form.instance
if (phone := getattr(self, 'phone', None)) and self.authenticator.is_phone_authn_active:
# phone identifier set post user-creation
models.AttributeValue.objects.create(
content_type=ContentType.objects.get_for_model(get_user_model()),
object_id=user.id,
content=phone,
pmarillonnet marked this conversation as resolved Outdated

Pareil, self.authenticator

Pareil, `self.authenticator`
attribute=self.authenticator.phone_identifier_field,
)
self.process_registration(self.request, user, form)
else:
try:

View File

@ -3206,10 +3206,7 @@ def test_check_api_client_role_inheritance(app, superuser):
assert set(resp.json['data']['roles']) == {role1.uuid, role2.uuid, role3.uuid}
def test_api_basic_authz_user_phone_number(app, settings, superuser):
settings.A2_ACCEPT_PHONE_AUTHENTICATION = True
Attribute.objects.get_or_create(name='phone', kind='phone_number')
def test_api_basic_authz_user_phone_number(app, settings, superuser, phone_activated_authn):
headers = {'Authorization': 'Basic abc'}
app.get('/api/users/', headers=headers, status=401)
@ -3235,3 +3232,53 @@ def test_api_basic_authz_user_phone_number(app, settings, superuser):
# wrong phone number
headers = basic_authorization_header('+33499985644', superuser.username)
app.get('/api/users/', headers=headers, status=401)
def test_api_basic_authz_user_phone_number_nondefault_attribute(app, settings, superuser):
phone, dummy = Attribute.objects.get_or_create(
name='another_phone',
kind='phone_number',
defaults={'label': 'Another phone'},
)
LoginPasswordAuthenticator.objects.update(
accept_phone_authentication=True,
phone_identifier_field=phone,
)
headers = {'Authorization': 'Basic abc'}
app.get('/api/users/', headers=headers, status=401)
headers = basic_authorization_header(superuser)
app.get('/api/users/', headers=headers, status=200)
superuser.attributes.another_phone = '+33499985643'
superuser.save()
# authn valid
headers = basic_authorization_header('+33499985643', superuser.username)
app.get('/api/users/', headers=headers, status=200)
headers = basic_authorization_header('+33499985643 ', superuser.username)
app.get('/api/users/', headers=headers, status=200)
headers = basic_authorization_header('+33-4/99/985643', superuser.username)
app.get('/api/users/', headers=headers, status=200)
headers = basic_authorization_header('0499985643', superuser.username)
app.get('/api/users/', headers=headers, status=200)
# wrong phone number
headers = basic_authorization_header('+33499985644', superuser.username)
app.get('/api/users/', headers=headers, status=401)
# having another known phone does not change anything
superuser.phone = '+33122334455'
superuser.save()
# authn valid
headers = basic_authorization_header('+33499985643', superuser.username)
app.get('/api/users/', headers=headers, status=200)
# wrong phone number
headers = basic_authorization_header('+33499985644', superuser.username)
app.get('/api/users/', headers=headers, status=401)

View File

@ -30,6 +30,7 @@ from django.db.migrations.executor import MigrationExecutor
from authentic2.a2_rbac.models import OrganizationalUnit, Role
from authentic2.a2_rbac.utils import get_default_ou
from authentic2.apps.authenticators.models import LoginPasswordAuthenticator
from authentic2.authentication import OIDCUser
from authentic2.manager.utils import get_ou_count
from authentic2.models import Attribute, Service
@ -329,6 +330,20 @@ def api_user(
return locals().get(request.param)
@pytest.fixture
def phone_activated_authn(db):
phone, dummy = Attribute.objects.get_or_create(
name='phone',
kind='phone_number',
defaults={'label': 'Phone'},
)
LoginPasswordAuthenticator.objects.update(
accept_phone_authentication=True,
phone_identifier_field=phone,
)
return LoginPasswordAuthenticator.objects.get()
@pytest.fixture(autouse=True)
def clear_cache():
cache.clear()

View File

@ -14,7 +14,9 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from authentic2.apps.authenticators.models import LoginPasswordAuthenticator
from authentic2.backends import is_user_authenticable
from authentic2.models import Attribute
from authentic2.utils.misc import authenticate
@ -40,16 +42,54 @@ def test_user_filters(settings, db, simple_user, user_ou1, ou1):
assert not is_user_authenticable(user_ou1)
def test_model_backend_phone_number(settings, db, simple_user, nomail_user, ou1):
settings.A2_ACCEPT_PHONE_AUTHENTICATION = True
def test_model_backend_phone_number(settings, db, simple_user, nomail_user, ou1, phone_activated_authn):
nomail_user.attributes.phone = '+33123456789'
nomail_user.save()
simple_user.attributes.phone = '+33123456789'
simple_user.save()
assert authenticate(username=simple_user.phone, password=simple_user.username)
assert is_user_authenticable(simple_user)
assert authenticate(username=nomail_user.phone, password=nomail_user.username)
assert is_user_authenticable(nomail_user)
def test_model_backend_phone_number_email(settings, db, simple_user):
settings.A2_ACCEPT_PHONE_AUTHENTICATION = True
def test_model_backend_phone_number_nondefault_attribute(settings, db, simple_user, nomail_user, ou1):
phone, dummy = Attribute.objects.get_or_create(
name='another_phone',
kind='phone_number',
defaults={'label': 'Another phone'},
)
LoginPasswordAuthenticator.objects.update(
accept_phone_authentication=True,
phone_identifier_field=phone,
)
nomail_user.phone = ''
nomail_user.attributes.another_phone = '+33123456789'
nomail_user.save()
simple_user.phone = ''
simple_user.attributes.another_phone = '+33123456789'
simple_user.save()
assert authenticate(username=simple_user.attributes.another_phone, password=simple_user.username)
assert is_user_authenticable(simple_user)
assert authenticate(username=nomail_user.attributes.another_phone, password=nomail_user.username)
assert is_user_authenticable(nomail_user)
nomail_user.attributes.another_phone = ''
nomail_user.phone = '+33123456789'
nomail_user.save()
simple_user.attributes.another_phone = ''
simple_user.phone = '+33123456789'
simple_user.save()
assert not authenticate(username=simple_user.phone, password=simple_user.username)
assert is_user_authenticable(simple_user)
assert not authenticate(username=nomail_user.phone, password=nomail_user.username)
assert is_user_authenticable(nomail_user)
def test_model_backend_phone_number_email(settings, db, simple_user, phone_activated_authn):
simple_user.attributes.phone = '+33123456789'
simple_user.save()
# user with both phone number and username can authenticate in two different ways
assert authenticate(username=simple_user.username, password=simple_user.username)
assert authenticate(username=simple_user.phone, password=simple_user.username)

View File

@ -2119,7 +2119,7 @@ def test_get_extra_attributes(slapd, settings, client):
assert {'id': EE_O, 'street': EE_STREET, 'city': EE_CITY, 'postal_code': EE_POSTALCODE} in orgas
def test_config_to_lowercase():
def test_config_to_lowercase(db):
config = {
'fname_field': 'givenName',
'lname_field': 'surName',

View File

@ -21,6 +21,7 @@ from django.contrib.auth import get_user_model
from authentic2 import models
from authentic2.apps.authenticators.models import LoginPasswordAuthenticator
from authentic2.apps.journal.models import Event
from authentic2.utils.misc import get_authenticators, get_token_login_url
from .utils import assert_event, login, set_service
@ -36,8 +37,7 @@ def test_success(db, app, simple_user):
assert_event('user.logout', user=simple_user, session=session)
def test_success_email_with_phone_authn_activated(db, app, simple_user, settings):
settings.A2_ACCEPT_PHONE_AUTHENTICATION = True
def test_success_email_with_phone_authn_activated(db, app, simple_user, settings, phone_activated_authn):
login(app, simple_user)
assert_event('user.login', user=simple_user, session=app.session, how='password-on-https')
session = app.session
@ -45,8 +45,26 @@ def test_success_email_with_phone_authn_activated(db, app, simple_user, settings
assert_event('user.logout', user=simple_user, session=session)
def test_success_phone_authn_nomail_user(db, app, nomail_user, settings):
settings.A2_ACCEPT_PHONE_AUTHENTICATION = True
def test_success_email_with_phone_authn_nondefault_attribute(db, app, simple_user, settings):
phone, dummy = models.Attribute.objects.get_or_create(
name='another_phone',
kind='phone_number',
defaults={'label': 'Another phone'},
)
LoginPasswordAuthenticator.objects.update(
accept_phone_authentication=True,
phone_identifier_field=phone,
)
login(app, simple_user)
assert_event('user.login', user=simple_user, session=app.session, how='password-on-https')
session = app.session
app.get('/logout/').form.submit()
assert_event('user.logout', user=simple_user, session=session)
def test_success_phone_authn_nomail_user(db, app, nomail_user, settings, phone_activated_authn):
nomail_user.attributes.phone = '+33123456789'
nomail_user.save()
login(app, nomail_user, login='123456789', phone_authn=True)
assert_event('user.login', user=nomail_user, session=app.session, how='password-on-https')
session = app.session
@ -54,9 +72,71 @@ def test_success_phone_authn_nomail_user(db, app, nomail_user, settings):
assert_event('user.logout', user=nomail_user, session=session)
def test_success_phone_authn_simple_user(db, app, simple_user, settings):
settings.A2_ACCEPT_PHONE_AUTHENTICATION = True
def test_success_phone_authn_nomail_user_nondefault_attribute(
db, app, nomail_user, settings, phone_activated_authn
):
phone, dummy = models.Attribute.objects.get_or_create(
name='another_phone',
kind='phone_number',
defaults={'label': 'Another phone'},
)
LoginPasswordAuthenticator.objects.update(
accept_phone_authentication=True,
phone_identifier_field=phone,
)
nomail_user.phone = ''
nomail_user.attributes.another_phone = '+33123456789'
nomail_user.save()
login(app, nomail_user, login='123456789', phone_authn=True)
assert_event('user.login', user=nomail_user, session=app.session, how='password-on-https')
session = app.session
app.get('/logout/').form.submit()
assert_event('user.logout', user=nomail_user, session=session)
Event.objects.all().delete()
nomail_user.phone = '+33122334455'
nomail_user.save()
login(app, nomail_user, login='123456789', phone_authn=True)
assert_event('user.login', user=nomail_user, session=app.session, how='password-on-https')
session = app.session
app.get('/logout/').form.submit()
assert_event('user.logout', user=nomail_user, session=session)
def test_success_phone_authn_simple_user(db, app, simple_user, settings, phone_activated_authn):
simple_user.attributes.phone = '+33123456789'
simple_user.save()
login(app, simple_user, login='123456789', phone_authn=True)
assert_event('user.login', user=simple_user, session=app.session, how='password-on-https')
session = app.session
app.get('/logout/').form.submit()
assert_event('user.logout', user=simple_user, session=session)
def test_success_phone_authn_simpler_user_nondefault_attribute(db, app, simple_user, settings):
phone, dummy = models.Attribute.objects.get_or_create(
name='another_phone',
kind='phone_number',
defaults={'label': 'Another phone'},
)
LoginPasswordAuthenticator.objects.update(
accept_phone_authentication=True,
phone_identifier_field=phone,
)
simple_user.phone = ''
simple_user.attributes.another_phone = '+33123456789'
simple_user.save()
login(app, simple_user, login='123456789', phone_authn=True)
assert_event('user.login', user=simple_user, session=app.session, how='password-on-https')
session = app.session
app.get('/logout/').form.submit()
assert_event('user.logout', user=simple_user, session=session)
Event.objects.all().delete()
simple_user.phone = '+33122334455'
simple_user.save()
login(app, simple_user, login='123456789', phone_authn=True)
assert_event('user.login', user=simple_user, session=app.session, how='password-on-https')
session = app.session
@ -72,8 +152,7 @@ def test_failure(db, app, simple_user):
assert_event('user.login.failure', username='noone')
def test_failure_no_means_of_authentication(db, app, nomail_user, settings):
settings.A2_ACCEPT_PHONE_AUTHENTICATION = True
def test_failure_no_means_of_authentication(db, app, nomail_user, settings, phone_activated_authn):
nomail_user.username = None
nomail_user.phone = None
nomail_user.save()
@ -92,17 +171,25 @@ def test_required_username_identifier(db, app, settings, caplog):
assert not response.pyquery('span.optional')
assert response.pyquery('label[for="id_username"]').text() == 'Username or email:'
settings.A2_ACCEPT_PHONE_AUTHENTICATION = True
phone, dummy = models.Attribute.objects.get_or_create(
name='phone',
kind='phone_number',
defaults={'label': 'Phone'},
)
LoginPasswordAuthenticator.objects.update(
accept_phone_authentication=True,
phone_identifier_field=phone,
)
response = app.get('/login/')
assert not response.pyquery('span.optional')
assert response.pyquery('label[for="id_username"]').text() == 'Username, email or phone number:'
settings.A2_ACCEPT_EMAIL_AUTHENTICATION = False
LoginPasswordAuthenticator.objects.update(accept_email_authentication=False)
response = app.get('/login/')
assert not response.pyquery('span.optional')
assert response.pyquery('label[for="id_username"]').text() == 'Username or phone number:'
settings.A2_ACCEPT_PHONE_AUTHENTICATION = False
LoginPasswordAuthenticator.objects.update(accept_phone_authentication=False)
response = app.get('/login/')
assert not response.pyquery('span.optional')
assert response.pyquery('label[for="id_username"]').text() == 'Username:'
@ -117,7 +204,7 @@ def test_login_form_fields_order(db, app, settings, ou1, ou2):
'login-password-submit',
]
settings.A2_ACCEPT_PHONE_AUTHENTICATION = True
LoginPasswordAuthenticator.objects.update(accept_phone_authentication=True)
response = app.get('/login/')
assert list(response.form.fields.keys()) == [
@ -572,3 +659,22 @@ def test_password_authenticator_data_migration_new_settings_invalid(migration, s
assert authenticator.login_exponential_retry_timeout_max_duration == 10
assert authenticator.emails_ip_ratelimit == '10/h'
assert authenticator.sms_ip_ratelimit == '10/h'
@pytest.mark.parametrize('email,phone', [(True, True), (True, False), (False, True)])
def test_password_authenticator_migration_accept_authentication_settings(migration, settings, email, phone):
app = 'authenticators'
migrate_from = [(app, '0010_auto_20230614_1017')]
migrate_to = [(app, '0011_migrate_a2_accept_authentication_settings')]
migration.before(migrate_from)
settings.A2_ACCEPT_EMAIL_AUTHENTICATION = email
settings.A2_ACCEPT_PHONE_AUTHENTICATION = phone
new_apps = migration.apply(migrate_to)
LoginPasswordAuthenticator = new_apps.get_model(app, 'LoginPasswordAuthenticator')
authenticator = LoginPasswordAuthenticator.objects.get()
assert authenticator.accept_email_authentication == email
assert authenticator.accept_phone_authentication == phone

View File

@ -61,7 +61,7 @@ def test_authenticators_authorization(app, simple_user, simple_role, admin, supe
assert 'Authenticators' in resp.text
def test_authenticators_password(app, superuser_or_admin):
def test_authenticators_password(app, superuser_or_admin, settings):
resp = login(app, superuser_or_admin, path='/manage/authenticators/')
# Password authenticator already exists
assert 'Password' in resp.text
@ -132,6 +132,36 @@ def test_authenticators_password(app, superuser_or_admin):
resp = app.get('/manage/authenticators/add/')
assert 'Password' not in resp.text
# phone authn management feature flag is activated
settings.A2_ALLOW_PHONE_AUTHN_MANAGEMENT = True
phone1 = Attribute.objects.create(
name='another_phone',
kind='phone_number',
label='Another phone',
)
phone2 = Attribute.objects.create(
name='yet_another_phone',
kind='fr_phone_number',
label='Yet another phone',
)
resp = app.get('/manage/authenticators/%s/edit/' % authenticator.pk)
resp.form['accept_email_authentication'] = False

Dernière remarque tatillonne, ça serait bien ici de vérifier que les champs sont bien apparus avec des « in » (ces listes explicites sont pénibles à maintenir (ma faute c'est moi qui ai ajouté celle plus haut))

Dernière remarque tatillonne, ça serait bien ici de vérifier que les champs sont bien apparus avec des « in » (ces listes explicites sont pénibles à maintenir (ma faute c'est moi qui ai ajouté celle plus haut))

Dernière remarque tatillonne, ça serait bien ici de vérifier que les champs sont bien apparus avec des « in » (ces listes explicites sont pénibles à maintenir (ma faute c'est moi qui ai ajouté celle plus haut))

Plutôt en manipulant des ensembles (et donc sans ce soucier d’un ordre quel qu’il soit), par exemple en testant que l’ensemble des champs écrit en dur à droite est bien inclus dans l’ensemble champs présentés par le formulaire ?

> Dernière remarque tatillonne, ça serait bien ici de vérifier que les champs sont bien apparus avec des « in » (ces listes explicites sont pénibles à maintenir (ma faute c'est moi qui ai ajouté celle plus haut)) Plutôt en manipulant des ensembles (et donc sans ce soucier d’un ordre quel qu’il soit), par exemple en testant que l’ensemble des champs écrit en dur à droite est bien inclus dans l’ensemble champs présentés par le formulaire ?

Pour moi c'est superflu de vérifier que les autres champs sont toujours là, mais comme tu veux.

À relire le code, comme plus bas on a

    resp.form['accept_email_authentication'] = False
    resp.form['accept_phone_authentication'] = True
    assert resp.form['phone_identifier_field'].options == [

on vérifie déjà que les champs sont apparus, dont contrairement à ce que j'écrivais en parlant de « in », on pourrait juste supprimer ce assert sans remplacer.

Pour moi c'est superflu de vérifier que les autres champs sont toujours là, mais comme tu veux. À relire le code, comme plus bas on a ``` resp.form['accept_email_authentication'] = False resp.form['accept_phone_authentication'] = True assert resp.form['phone_identifier_field'].options == [ ``` on vérifie déjà que les champs sont apparus, dont contrairement à ce que j'écrivais en parlant de « in », on pourrait juste supprimer ce assert sans remplacer.

Oui, fair enough, je retire ce assert et je merge.

Oui, fair enough, je retire ce assert et je merge.
resp.form['accept_phone_authentication'] = True
assert resp.form['phone_identifier_field'].options == [
(str(phone1.id), False, 'Another phone'),
(str(phone2.id), False, 'Yet another phone'),
]
resp.form['phone_identifier_field'] = phone2.id
resp.form.submit()
authenticator.refresh_from_db()
assert authenticator.accept_email_authentication is False
assert authenticator.accept_phone_authentication is True
assert authenticator.phone_identifier_field == phone2
@pytest.mark.freeze_time('2022-04-19 14:00')
def test_authenticators_password_export(app, superuser):
@ -170,6 +200,8 @@ def test_authenticators_password_export(app, superuser):
'sms_number_ratelimit': '10/h',
'ou': None,
'related_objects': [],
'accept_email_authentication': True,
'accept_phone_authentication': False,
}
resp = app.get('/manage/authenticators/')

View File

@ -23,7 +23,7 @@ from django.urls import reverse
from httmock import HTTMock, remember_called, urlmatch
from authentic2.apps.authenticators.models import LoginPasswordAuthenticator
from authentic2.models import SMSCode, Token
from authentic2.models import Attribute, SMSCode, Token
from authentic2.utils.misc import send_password_reset_mail
from . import utils
@ -60,8 +60,11 @@ def test_send_password_reset_email(app, simple_user, mailoutbox):
utils.assert_event('user.password.reset', user=simple_user, session=app.session)
def test_send_password_reset_by_sms_code_improperly_configured(app, nomail_user, settings):
settings.A2_ACCEPT_PHONE_AUTHENTICATION = True
def test_send_password_reset_by_sms_code_improperly_configured(
app, nomail_user, settings, phone_activated_authn
):
nomail_user.attributes.phone = nomail_user.phone
nomail_user.save()
settings.SMS_URL = 'https://foo.whatever.none/'
assert not SMSCode.objects.count()
@ -74,8 +77,9 @@ def test_send_password_reset_by_sms_code_improperly_configured(app, nomail_user,
assert 'Something went wrong while trying to send' in resp.pyquery('li.error').text()
def test_send_password_reset_by_sms_code(app, nomail_user, settings):
settings.A2_ACCEPT_PHONE_AUTHENTICATION = True
def test_send_password_reset_by_sms_code(app, nomail_user, settings, phone_activated_authn):
nomail_user.attributes.phone = '+33123456789'
nomail_user.save()
settings.SMS_URL = 'https://foo.whatever.none/'
code_length = settings.SMS_CODE_LENGTH
@ -111,8 +115,72 @@ def test_send_password_reset_by_sms_code(app, nomail_user, settings):
app.get(url, status=404)
def test_send_password_reset_by_sms_code_next_url(app, nomail_user, settings):
settings.A2_ACCEPT_PHONE_AUTHENTICATION = True
def test_send_password_reset_by_sms_code_nondefault_attribute(app, nomail_user, simple_user, settings):
phone, dummy = Attribute.objects.get_or_create(
name='another_phone',
kind='phone_number',
defaults={'label': 'Another phone'},
)
LoginPasswordAuthenticator.objects.update(
accept_phone_authentication=True,
phone_identifier_field=phone,
)
nomail_user.attributes.another_phone = '+33122446688'
nomail_user.phone = ''
nomail_user.save()
settings.SMS_URL = 'https://foo.whatever.none/'
url = reverse('password_reset')
resp = app.get(url, status=200)
resp.form.set('phone_1', '0122446688')
with HTTMock(sms_service_mock):
resp = resp.form.submit().follow().maybe_follow()
json.loads(sms_service_mock.call['requests'][0].body)
code = SMSCode.objects.get()
resp.form.set('sms_code', code.value)
resp = resp.form.submit().follow()
resp.form.set('new_password1', '1234==aA')
resp.form.set('new_password2', '1234==aA')
resp.form.submit()
# verify user is logged
assert str(app.session['_auth_user_id']) == str(nomail_user.pk)
user = authenticate(username='user', password='1234==aA')
assert user == nomail_user
SMSCode.objects.all().delete()
simple_user.attributes.another_phone = '+33122446677'
simple_user.phone = '+33122112211'
simple_user.username = 'simpleuser'
simple_user.save()
url = reverse('password_reset')
resp = app.get(url, status=200)
resp.form.set('phone_1', '0122446677')
with HTTMock(sms_service_mock):
resp = resp.form.submit().follow().maybe_follow()
json.loads(sms_service_mock.call['requests'][0].body)
code = SMSCode.objects.get()
resp.form.set('sms_code', code.value)
resp = resp.form.submit().follow()
resp.form.set('new_password1', '1234==aA')
resp.form.set('new_password2', '1234==aA')
resp.form.submit()
# verify user is logged
assert str(app.session['_auth_user_id']) == str(simple_user.pk)
user = authenticate(username='simpleuser', password='1234==aA')
assert user == simple_user
with override_settings(A2_USER_CAN_RESET_PASSWORD=False):
url = reverse('password_reset')
app.get(url, status=404)
def test_send_password_reset_by_sms_code_next_url(app, nomail_user, settings, phone_activated_authn):
nomail_user.attributes.phone = '+33123456789'
nomail_user.save()
settings.SMS_URL = 'https://foo.whatever.none/'
resp = app.get('/accounts/consents/').follow()
@ -134,8 +202,7 @@ def test_send_password_reset_by_sms_code_next_url(app, nomail_user, settings):
assert "Consent Management" in resp
def test_password_reset_empty_form(app, db, settings):
settings.A2_ACCEPT_PHONE_AUTHENTICATION = True
def test_password_reset_empty_form(app, db, settings, phone_activated_authn):
settings.SMS_URL = 'https://foo.whatever.none/'
url = reverse('password_reset')
@ -147,8 +214,11 @@ def test_password_reset_empty_form(app, db, settings):
)
def test_password_reset_both_fields_filled_email_precedence(app, simple_user, settings, mailoutbox):
settings.A2_ACCEPT_PHONE_AUTHENTICATION = True
def test_password_reset_both_fields_filled_email_precedence(
app, simple_user, settings, mailoutbox, phone_activated_authn
):
simple_user.attributes.phone = '+33123456789'
simple_user.save()
settings.SMS_URL = 'https://foo.whatever.none/'
url = reverse('password_reset')
@ -163,8 +233,9 @@ def test_password_reset_both_fields_filled_email_precedence(app, simple_user, se
assert not SMSCode.objects.count()
def test_send_password_reset_by_sms_code_erroneous_phone_number(app, nomail_user, settings):
settings.A2_ACCEPT_PHONE_AUTHENTICATION = True
def test_send_password_reset_by_sms_code_erroneous_phone_number(
app, nomail_user, settings, phone_activated_authn
):
settings.SMS_URL = 'https://foo.whatever.none/'
assert not SMSCode.objects.count()

View File

@ -26,6 +26,7 @@ from httmock import HTTMock, remember_called, urlmatch
from authentic2 import models
from authentic2.a2_rbac.utils import get_default_ou
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
@ -951,15 +952,13 @@ def test_registration_completion(db, app, mailoutbox):
assert 'This password is not strong enough' in resp.text
def test_registration_no_identifier(app, db, settings):
settings.A2_ACCEPT_PHONE_AUTHENTICATION = True
def test_registration_no_identifier(app, db, settings, phone_activated_authn):
resp = app.get(reverse('registration_register'))
resp = resp.form.submit()
assert 'Please provide an email address or a mobile' in resp.text
def test_registration_erroneous_phone_identifier(app, db, settings):
settings.A2_ACCEPT_PHONE_AUTHENTICATION = True
def test_registration_erroneous_phone_identifier(app, db, settings, phone_activated_authn):
resp = app.get(reverse('registration_register'))
resp.form.set('phone_1', 'thatsnotquiteit')
resp = resp.form.submit()
@ -981,8 +980,7 @@ def sms_service_mock(url, request):
}
def test_phone_registration_wrong_code(app, db, settings):
settings.A2_ACCEPT_PHONE_AUTHENTICATION = True
def test_phone_registration_wrong_code(app, db, settings, phone_activated_authn):
settings.SMS_URL = 'https://foo.whatever.none/'
resp = app.get(reverse('registration_register'))
@ -995,8 +993,7 @@ def test_phone_registration_wrong_code(app, db, settings):
assert resp.pyquery('li')[0].text_content() == 'Wrong SMS code.'
def test_phone_registration_expired_code(app, db, settings, freezer):
settings.A2_ACCEPT_PHONE_AUTHENTICATION = True
def test_phone_registration_expired_code(app, db, settings, freezer, phone_activated_authn):
settings.SMS_URL = 'https://foo.whatever.none/'
resp = app.get(reverse('registration_register'))
@ -1011,8 +1008,7 @@ def test_phone_registration_expired_code(app, db, settings, freezer):
assert resp.pyquery('li')[0].text_content() == 'The code has expired.'
def test_phone_registration_cancel(app, db, settings, freezer):
settings.A2_ACCEPT_PHONE_AUTHENTICATION = True
def test_phone_registration_cancel(app, db, settings, freezer, phone_activated_authn):
settings.SMS_URL = 'https://foo.whatever.none/'
resp = app.get(reverse('registration_register'))
@ -1026,8 +1022,7 @@ def test_phone_registration_cancel(app, db, settings, freezer):
assert not SMSCode.objects.count()
def test_phone_registration_improperly_configured(app, db, settings, freezer, caplog):
settings.A2_ACCEPT_PHONE_AUTHENTICATION = True
def test_phone_registration_improperly_configured(app, db, settings, freezer, caplog, phone_activated_authn):
settings.SMS_URL = ''
resp = app.get(reverse('registration_register'))
@ -1043,8 +1038,7 @@ def test_phone_registration_improperly_configured(app, db, settings, freezer, ca
assert caplog.records[0].message == 'settings.SMS_URL is not set'
def test_phone_registration_connection_error(app, db, settings, freezer, caplog):
settings.A2_ACCEPT_PHONE_AUTHENTICATION = True
def test_phone_registration_connection_error(app, db, settings, freezer, caplog, phone_activated_authn):
settings.SMS_URL = 'https://foo.whatever.none/'
def mocked_requests_connection_error(*args, **kwargs):
@ -1067,8 +1061,7 @@ def test_phone_registration_connection_error(app, db, settings, freezer, caplog)
)
def test_phone_registration(app, db, settings):
settings.A2_ACCEPT_PHONE_AUTHENTICATION = True
def test_phone_registration(app, db, settings, phone_activated_authn):
settings.SMS_URL = 'https://foo.whatever.none/'
code_length = settings.SMS_CODE_LENGTH
@ -1096,11 +1089,45 @@ def test_phone_registration(app, db, settings):
assert "You have just created an account" in resp.text
user = User.objects.get(first_name='John', last_name='Doe')
assert user.phone == '+33612345678'
assert user.attributes.phone == '+33612345678'
def test_phone_registration_redirect_url(app, db, settings):
settings.A2_ACCEPT_PHONE_AUTHENTICATION = True
def test_phone_registration_nondefault_attribute(app, db, settings):
phone, dummy = models.Attribute.objects.get_or_create(
name='another_phone',
kind='phone_number',
defaults={'label': 'Another phone'},
)
LoginPasswordAuthenticator.objects.update(
accept_phone_authentication=True,
phone_identifier_field=phone,
)
settings.SMS_URL = 'https://foo.whatever.none/'
resp = app.get(reverse('registration_register'))
resp.form.set('phone_1', '612345678')
with HTTMock(sms_service_mock):
resp = resp.form.submit().follow()
json.loads(sms_service_mock.call['requests'][0].body)
code = SMSCode.objects.get()
resp.form.set('sms_code', code.value)
resp = resp.form.submit().follow()
resp.form.set('password1', 'Password0')
resp.form.set('password2', 'Password0')
resp.form.set('first_name', 'John')
resp.form.set('last_name', 'Doe')
resp = resp.form.submit().follow()
assert "You have just created an account" in resp.text
user = User.objects.get(first_name='John', last_name='Doe')
assert user.attributes.another_phone == '+33612345678'
# no standard attribute set nor even created
assert not getattr(user.attributes, 'phone', None)
assert not user.phone
def test_phone_registration_redirect_url(app, db, settings, phone_activated_authn):
settings.SMS_URL = 'https://foo.whatever.none/'
resp = app.get('/accounts/consents/').follow()
@ -1120,4 +1147,4 @@ def test_phone_registration_redirect_url(app, db, settings):
assert resp.location == '/accounts/consents/'
resp.follow()
user = User.objects.get(first_name='John', last_name='Doe')
assert user.phone == '+33612345678'
assert user.attributes.phone == '+33612345678'

View File

@ -321,10 +321,9 @@ 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):
def test_views_sms_ratelimit(app, db, simple_user, settings, freezer, view_name, phone_activated_authn):
freezer.move_to('2020-01-01')
LoginPasswordAuthenticator.objects.update(sms_ip_ratelimit='10/h', sms_number_ratelimit='3/d')
vdeniaud marked this conversation as resolved Outdated

Cosmétique, cette ligne n'aurait pas dû bouger

Cosmétique, cette ligne n'aurait pas dû bouger

Oui en effet, j’ai rétabli.

Oui en effet, j’ai rétabli.
settings.A2_ACCEPT_PHONE_AUTHENTICATION = True
settings.SMS_SENDER = 'EO'
settings.SMS_URL = 'https://www.example.com/send'
@ -344,7 +343,9 @@ def test_views_sms_ratelimit(app, db, simple_user, settings, freezer, view_name)
response.form.set('phone_0', '33')
response.form.set('phone_1', '0612345678')
response = response.form.submit()
response = response.form.submit() # validate warning message "sms already sent"
if view_name == 'registration_register': # XXX not supported in password_reset yet
assert 'An SMS code has already been sent' in response.text
response = response.form.submit()
assert 'try again later' not in response.text
response = app.get(reverse(view_name))
@ -378,11 +379,13 @@ def test_views_sms_ratelimit(app, db, simple_user, settings, freezer, view_name)
response = response.form.submit()
assert 'try again later' not in response.text
# email ratelimits are lifted after a day
# identifier ratelimits are lifted after a day
response = app.get(reverse(view_name))
response.form.set('phone_0', '33')
response.form.set('phone_1', '0612345678')
response = response.form.submit().form.submit()
response = response.form.submit()
assert 'Multiple SMSs have already been sent to this number.' in response.text
response = response.form.submit()
assert 'try again later' in response.text
freezer.tick(datetime.timedelta(days=1))