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
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:
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,

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)
@ -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:

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
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')
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))