diff --git a/debian/debian_config.py b/debian/debian_config.py index 845694eef..8b0371fb4 100644 --- a/debian/debian_config.py +++ b/debian/debian_config.py @@ -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', diff --git a/src/authentic2/app_settings.py b/src/authentic2/app_settings.py index 7178e23ea..c730e36bd 100644 --- a/src/authentic2/app_settings.py +++ b/src/authentic2/app_settings.py @@ -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' ), diff --git a/src/authentic2/apps/authenticators/forms.py b/src/authentic2/apps/authenticators/forms.py index e21ad905d..b04090424 100644 --- a/src/authentic2/apps/authenticators/forms.py +++ b/src/authentic2/apps/authenticators/forms.py @@ -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', ) diff --git a/src/authentic2/apps/authenticators/migrations/0010_auto_20230614_1017.py b/src/authentic2/apps/authenticators/migrations/0010_auto_20230614_1017.py new file mode 100644 index 000000000..b858e9e07 --- /dev/null +++ b/src/authentic2/apps/authenticators/migrations/0010_auto_20230614_1017.py @@ -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', + ), + ), + ] diff --git a/src/authentic2/apps/authenticators/migrations/0011_migrate_a2_accept_authentication_settings.py b/src/authentic2/apps/authenticators/migrations/0011_migrate_a2_accept_authentication_settings.py new file mode 100644 index 000000000..c1ac92fc6 --- /dev/null +++ b/src/authentic2/apps/authenticators/migrations/0011_migrate_a2_accept_authentication_settings.py @@ -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 + ), + ] diff --git a/src/authentic2/apps/authenticators/models.py b/src/authentic2/apps/authenticators/models.py index ab4590279..a0ca0a612 100644 --- a/src/authentic2/apps/authenticators/models.py +++ b/src/authentic2/apps/authenticators/models.py @@ -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 diff --git a/src/authentic2/backends/ldap_backend.py b/src/authentic2/backends/ldap_backend.py index 11c2d9260..36052bb45 100644 --- a/src/authentic2/backends/ldap_backend.py +++ b/src/authentic2/backends/ldap_backend.py @@ -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 - 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): diff --git a/src/authentic2/backends/models_backend.py b/src/authentic2/backends/models_backend.py index 9d69c7568..e441c8855 100644 --- a/src/authentic2/backends/models_backend.py +++ b/src/authentic2/backends/models_backend.py @@ -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: diff --git a/src/authentic2/forms/passwords.py b/src/authentic2/forms/passwords.py index d50bd58c2..49a0bbe79 100644 --- a/src/authentic2/forms/passwords.py +++ b/src/authentic2/forms/passwords.py @@ -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: 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: 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, + 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, diff --git a/src/authentic2/forms/registration.py b/src/authentic2/forms/registration.py index c1ac153df..7ee9907fc 100644 --- a/src/authentic2/forms/registration.py +++ b/src/authentic2/forms/registration.py @@ -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.')) diff --git a/src/authentic2/views.py b/src/authentic2/views.py index 4d17ae6a1..2f869f376 100644 --- a/src/authentic2/views.py +++ b/src/authentic2/views.py @@ -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) @@ -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, + attribute=self.authenticator.phone_identifier_field, + ) self.process_registration(self.request, user, form) else: try: diff --git a/tests/api/test_all.py b/tests/api/test_all.py index c7d590fd1..e20b1d866 100644 --- a/tests/api/test_all.py +++ b/tests/api/test_all.py @@ -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) diff --git a/tests/conftest.py b/tests/conftest.py index 8668e37bb..e659200b6 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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() diff --git a/tests/test_backends.py b/tests/test_backends.py index 9287db3fb..c25c359a8 100644 --- a/tests/test_backends.py +++ b/tests/test_backends.py @@ -14,7 +14,9 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . +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) diff --git a/tests/test_ldap.py b/tests/test_ldap.py index bb7481363..9cb07e78c 100644 --- a/tests/test_ldap.py +++ b/tests/test_ldap.py @@ -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', diff --git a/tests/test_login.py b/tests/test_login.py index e3afeeb1f..05a2787a5 100644 --- a/tests/test_login.py +++ b/tests/test_login.py @@ -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 diff --git a/tests/test_manager_authenticators.py b/tests/test_manager_authenticators.py index b780d6056..7a32b3cae 100644 --- a/tests/test_manager_authenticators.py +++ b/tests/test_manager_authenticators.py @@ -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 + 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/') diff --git a/tests/test_password_reset.py b/tests/test_password_reset.py index d15207aec..ae290d6e5 100644 --- a/tests/test_password_reset.py +++ b/tests/test_password_reset.py @@ -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() diff --git a/tests/test_registration.py b/tests/test_registration.py index 2fe020d86..c8f068f92 100644 --- a/tests/test_registration.py +++ b/tests/test_registration.py @@ -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' diff --git a/tests/test_views.py b/tests/test_views.py index 3be609d59..93332877d 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -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') - 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))