Pouvoir définir quels attributs de type téléphone sont à vocation identifiante pour l’usager (#78046) #75
|
@ -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',
|
||||
|
|
|
@ -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'
|
||||
),
|
||||
|
|
|
@ -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',
|
||||
)
|
||||
|
||||
|
||||
|
|
|
@ -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',
|
||||
),
|
||||
),
|
||||
]
|
|
@ -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
|
||||
),
|
||||
]
|
|
@ -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
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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.'))
|
||||
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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/')
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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))
|
||||
|
|
Loading…
Reference in New Issue