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
|
||||
vdeniaud marked this conversation as resolved
Outdated
|
||||
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:
|
||||
vdeniaud marked this conversation as resolved
Outdated
vdeniaud
commented
Tu peux directement utiliser Tu peux directement utiliser `self.authenticator`.
pmarillonnet
commented
C’est corrigé ici ainsi que tous les autres endroits où tu le mentionnes (+ quelques autres occurrences dans ce même formulaire). C’est corrigé ici ainsi que tous les autres endroits où tu le mentionnes (+ quelques autres occurrences dans ce même formulaire).
|
||||
del self.fields['phone']
|
||||
if 'email' in self.fields:
|
||||
self.fields['email'].required = True
|
||||
|
@ -90,12 +91,18 @@ class PasswordResetForm(HoneypotForm):
|
|||
|
||||
def clean_phone(self):
|
||||
phone = self.cleaned_data.get('phone')
|
||||
user_ct = ContentType.objects.get_for_model(get_user_model())
|
||||
if phone:
|
||||
self.users = get_user_queryset().filter(phone=phone)
|
||||
user_ids = models.AttributeValue.objects.filter(
|
||||
attribute=self.authenticator.phone_identifier_field,
|
||||
content=phone,
|
||||
content_type=user_ct,
|
||||
).values_list('object_id', flat=True)
|
||||
self.users = get_user_queryset().filter(id__in=user_ids)
|
||||
return phone
|
||||
|
||||
def clean(self):
|
||||
if app_settings.A2_ACCEPT_PHONE_AUTHENTICATION and get_user_model()._meta.get_field('phone'):
|
||||
if self.authenticator.is_phone_authn_active:
|
||||
pmarillonnet marked this conversation as resolved
Outdated
vdeniaud
commented
Pareil, Pareil, `self.authenticator`
|
||||
if (
|
||||
not self.cleaned_data['email']
|
||||
and not self.cleaned_data.get('email_or_username')
|
||||
|
@ -120,8 +127,18 @@ class PasswordResetForm(HoneypotForm):
|
|||
email_sent = False
|
||||
sms_sent = False
|
||||
|
||||
phone_authn_active = self.authenticator.is_phone_authn_active
|
||||
user_ct = ContentType.objects.get_for_model(get_user_model())
|
||||
for user in active_users:
|
||||
if not user.email and not user.phone:
|
||||
if not user.email and not (
|
||||
phone_authn_active
|
||||
and models.AttributeValue.objects.filter(
|
||||
attribute=self.authenticator.phone_identifier_field,
|
||||
pmarillonnet marked this conversation as resolved
Outdated
vdeniaud
commented
Pareil, Pareil, `self.authenticator`
|
||||
object_id=user.id,
|
||||
content_type=user_ct,
|
||||
content__isnull=False,
|
||||
)
|
||||
):
|
||||
logger.info(
|
||||
'password reset failed for account "%r": account has no email nor mobile phone number',
|
||||
user,
|
||||
|
|
|
@ -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)
|
||||
|
||||
pmarillonnet marked this conversation as resolved
Outdated
vdeniaud
commented
Pareil, Pareil, `self.authenticator`
|
||||
|
@ -1437,6 +1442,8 @@ class RegistrationCompletionView(CreateView):
|
|||
@atomic(savepoint=False)
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
registration_token = kwargs['registration_token'].replace(' ', '')
|
||||
self.authenticator = utils_misc.get_password_authenticator()
|
||||
user_ct = ContentType.objects.get_for_model(get_user_model())
|
||||
try:
|
||||
token = models.Token.use('registration', registration_token, delete=False)
|
||||
except models.Token.DoesNotExist:
|
||||
|
@ -1461,9 +1468,15 @@ class RegistrationCompletionView(CreateView):
|
|||
self.email = self.token['email']
|
||||
qs_filter = {'email__iexact': self.email}
|
||||
Lock.lock_email(self.email)
|
||||
elif self.token.get('phone', None):
|
||||
elif self.token.get('phone', None) and self.authenticator.is_phone_authn_active:
|
||||
self.phone = self.token['phone']
|
||||
qs_filter = {'phone': self.phone}
|
||||
user_ids = models.AttributeValue.objects.filter(
|
||||
attribute=self.authenticator.phone_identifier_field,
|
||||
content=self.phone,
|
||||
content_type=user_ct,
|
||||
).values_list('object_id', flat=True)
|
||||
|
||||
qs_filter = {'id__in': user_ids}
|
||||
Lock.lock_identifier(self.phone)
|
||||
else:
|
||||
messages.warning(request, _('Activation failed'))
|
||||
|
@ -1572,7 +1585,9 @@ class RegistrationCompletionView(CreateView):
|
|||
kwargs['instance'] = User.objects.get(id=self.token.get('user_id'))
|
||||
else:
|
||||
init_kwargs = {}
|
||||
for key in ('email', 'first_name', 'last_name', 'ou', 'phone'):
|
||||
keys = ['email', 'first_name', 'last_name', 'ou']
|
||||
# phone identifier is a separate attribute and is set post user-creation
|
||||
for key in keys:
|
||||
if key in attributes:
|
||||
init_kwargs[key] = attributes[key]
|
||||
kwargs['instance'] = get_user_model()(**init_kwargs)
|
||||
|
@ -1678,6 +1693,14 @@ class RegistrationCompletionView(CreateView):
|
|||
if count:
|
||||
super().form_valid(form) # user creation happens here
|
||||
user = form.instance
|
||||
if (phone := getattr(self, 'phone', None)) and self.authenticator.is_phone_authn_active:
|
||||
# phone identifier set post user-creation
|
||||
models.AttributeValue.objects.create(
|
||||
content_type=ContentType.objects.get_for_model(get_user_model()),
|
||||
object_id=user.id,
|
||||
content=phone,
|
||||
pmarillonnet marked this conversation as resolved
Outdated
vdeniaud
commented
Pareil, Pareil, `self.authenticator`
|
||||
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
|
||||
vdeniaud
commented
Dernière remarque tatillonne, ça serait bien ici de vérifier que les champs sont bien apparus avec des « in » (ces listes explicites sont pénibles à maintenir (ma faute c'est moi qui ai ajouté celle plus haut)) Dernière remarque tatillonne, ça serait bien ici de vérifier que les champs sont bien apparus avec des « in » (ces listes explicites sont pénibles à maintenir (ma faute c'est moi qui ai ajouté celle plus haut))
pmarillonnet
commented
Plutôt en manipulant des ensembles (et donc sans ce soucier d’un ordre quel qu’il soit), par exemple en testant que l’ensemble des champs écrit en dur à droite est bien inclus dans l’ensemble champs présentés par le formulaire ? > Dernière remarque tatillonne, ça serait bien ici de vérifier que les champs sont bien apparus avec des « in » (ces listes explicites sont pénibles à maintenir (ma faute c'est moi qui ai ajouté celle plus haut))
Plutôt en manipulant des ensembles (et donc sans ce soucier d’un ordre quel qu’il soit), par exemple en testant que l’ensemble des champs écrit en dur à droite est bien inclus dans l’ensemble champs présentés par le formulaire ?
vdeniaud
commented
Pour moi c'est superflu de vérifier que les autres champs sont toujours là, mais comme tu veux. À relire le code, comme plus bas on a
on vérifie déjà que les champs sont apparus, dont contrairement à ce que j'écrivais en parlant de « in », on pourrait juste supprimer ce assert sans remplacer. Pour moi c'est superflu de vérifier que les autres champs sont toujours là, mais comme tu veux.
À relire le code, comme plus bas on a
```
resp.form['accept_email_authentication'] = False
resp.form['accept_phone_authentication'] = True
assert resp.form['phone_identifier_field'].options == [
```
on vérifie déjà que les champs sont apparus, dont contrairement à ce que j'écrivais en parlant de « in », on pourrait juste supprimer ce assert sans remplacer.
pmarillonnet
commented
Oui, fair enough, je retire ce assert et je merge. Oui, fair enough, je retire ce assert et je merge.
|
||||
resp.form['accept_phone_authentication'] = True
|
||||
assert resp.form['phone_identifier_field'].options == [
|
||||
(str(phone1.id), False, 'Another phone'),
|
||||
(str(phone2.id), False, 'Yet another phone'),
|
||||
]
|
||||
resp.form['phone_identifier_field'] = phone2.id
|
||||
resp.form.submit()
|
||||
|
||||
authenticator.refresh_from_db()
|
||||
assert authenticator.accept_email_authentication is False
|
||||
assert authenticator.accept_phone_authentication is True
|
||||
assert authenticator.phone_identifier_field == phone2
|
||||
|
||||
|
||||
@pytest.mark.freeze_time('2022-04-19 14:00')
|
||||
def test_authenticators_password_export(app, superuser):
|
||||
|
@ -170,6 +200,8 @@ def test_authenticators_password_export(app, superuser):
|
|||
'sms_number_ratelimit': '10/h',
|
||||
'ou': None,
|
||||
'related_objects': [],
|
||||
'accept_email_authentication': True,
|
||||
'accept_phone_authentication': False,
|
||||
}
|
||||
|
||||
resp = app.get('/manage/authenticators/')
|
||||
|
|
|
@ -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')
|
||||
vdeniaud marked this conversation as resolved
Outdated
vdeniaud
commented
Cosmétique, cette ligne n'aurait pas dû bouger Cosmétique, cette ligne n'aurait pas dû bouger
pmarillonnet
commented
Oui en effet, j’ai rétabli. Oui en effet, j’ai rétabli.
|
||||
settings.A2_ACCEPT_PHONE_AUTHENTICATION = True
|
||||
settings.SMS_SENDER = 'EO'
|
||||
settings.SMS_URL = 'https://www.example.com/send'
|
||||
|
||||
|
@ -344,7 +343,9 @@ def test_views_sms_ratelimit(app, db, simple_user, settings, freezer, view_name)
|
|||
response.form.set('phone_0', '33')
|
||||
response.form.set('phone_1', '0612345678')
|
||||
response = response.form.submit()
|
||||
response = response.form.submit() # validate warning message "sms already sent"
|
||||
if view_name == 'registration_register': # XXX not supported in password_reset yet
|
||||
assert 'An SMS code has already been sent' in response.text
|
||||
response = response.form.submit()
|
||||
assert 'try again later' not in response.text
|
||||
|
||||
response = app.get(reverse(view_name))
|
||||
|
@ -378,11 +379,13 @@ def test_views_sms_ratelimit(app, db, simple_user, settings, freezer, view_name)
|
|||
response = response.form.submit()
|
||||
assert 'try again later' not in response.text
|
||||
|
||||
# email ratelimits are lifted after a day
|
||||
# identifier ratelimits are lifted after a day
|
||||
response = app.get(reverse(view_name))
|
||||
response.form.set('phone_0', '33')
|
||||
response.form.set('phone_1', '0612345678')
|
||||
response = response.form.submit().form.submit()
|
||||
response = response.form.submit()
|
||||
assert 'Multiple SMSs have already been sent to this number.' in response.text
|
||||
response = response.form.submit()
|
||||
assert 'try again later' in response.text
|
||||
|
||||
freezer.tick(datetime.timedelta(days=1))
|
||||
|
|
Peut-être récupérer l'objet hors de la boucle, pour éviter une requête à chaque fois.
Oui complètement, c’est corrigé, merci.