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

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

View File

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

View File

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

View File

@ -21,7 +21,9 @@ from django.core.exceptions import ValidationError
from django.db.models import Max from django.db.models import Max
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from authentic2 import app_settings
from authentic2.forms.mixins import SlugMixin from authentic2.forms.mixins import SlugMixin
from authentic2.models import Attribute
from .models import BaseAuthenticator, LoginPasswordAuthenticator from .models import BaseAuthenticator, LoginPasswordAuthenticator
@ -72,6 +74,25 @@ class AuthenticatorImportForm(forms.Form):
class LoginPasswordAuthenticatorAdvancedForm(forms.ModelForm): 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: class Meta:
model = LoginPasswordAuthenticator model = LoginPasswordAuthenticator
fields = ( fields = (
@ -87,6 +108,9 @@ class LoginPasswordAuthenticatorAdvancedForm(forms.ModelForm):
'sms_ip_ratelimit', 'sms_ip_ratelimit',
'emails_address_ratelimit', 'emails_address_ratelimit',
'sms_number_ratelimit', 'sms_number_ratelimit',
'accept_email_authentication',
'accept_phone_authentication',
'phone_identifier_field',
) )

View File

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

View File

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

View File

@ -33,6 +33,7 @@ from authentic2 import views
from authentic2.a2_rbac.models import Role from authentic2.a2_rbac.models import Role
from authentic2.data_transfer import search_ou, search_role from authentic2.data_transfer import search_ou, search_role
from authentic2.manager.utils import label_from_role from authentic2.manager.utils import label_from_role
from authentic2.models import Attribute
from authentic2.utils.evaluate import condition_validator, evaluate_condition from authentic2.utils.evaluate import condition_validator, evaluate_condition
from .query import AuthenticatorManager from .query import AuthenticatorManager
@ -290,6 +291,19 @@ class LoginPasswordAuthenticator(BaseAuthenticator):
), ),
) )
include_ou_selector = models.BooleanField(_('Include OU selector in login form'), default=False) 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_min_length = models.PositiveIntegerField(_('Password minimum length'), default=8, null=True)
password_regex = models.CharField( password_regex = models.CharField(
@ -362,6 +376,10 @@ class LoginPasswordAuthenticator(BaseAuthenticator):
class Meta: class Meta:
verbose_name = _('Password') verbose_name = _('Password')
@property
def is_phone_authn_active(self):
return bool(self.accept_phone_authentication and self.phone_identifier_field)
@property @property
def manager_form_classes(self): def manager_form_classes(self):
from .forms import LoginPasswordAuthenticatorAdvancedForm, LoginPasswordAuthenticatorEditForm from .forms import LoginPasswordAuthenticatorAdvancedForm, LoginPasswordAuthenticatorEditForm

View File

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

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

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

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

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

View File

@ -19,12 +19,14 @@ import functools
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.contrib.auth.backends import ModelBackend as BaseModelBackend from django.contrib.auth.backends import ModelBackend as BaseModelBackend
from django.contrib.contenttypes.models import ContentType
from django.db import models from django.db import models
from phonenumbers import PhoneNumberFormat, format_number, is_valid_number from phonenumbers import PhoneNumberFormat, format_number, is_valid_number
from authentic2.backends import get_user_queryset 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.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 from .. import app_settings
@ -45,12 +47,20 @@ class ModelBackend(BaseModelBackend):
def get_query(self, username, realm=None, ou=None): def get_query(self, username, realm=None, ou=None):
username_field = 'username' username_field = 'username'
queries = [] 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})) 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 # try with the phone number as user identifier
if (pn := parse_phone_number(username)) and is_valid_number(pn): 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)) queries.append(models.Q(**query))
if realm is None: if realm is None:

View File

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

Tu peux directement utiliser self.authenticator.

Tu peux directement utiliser `self.authenticator`.

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

C’est corrigé ici ainsi que tous les autres endroits où tu le mentionnes (+ quelques autres occurrences dans ce même formulaire).
del self.fields['phone'] del self.fields['phone']
if 'email' in self.fields: if 'email' in self.fields:
self.fields['email'].required = True self.fields['email'].required = True
@ -90,12 +91,18 @@ class PasswordResetForm(HoneypotForm):
def clean_phone(self): def clean_phone(self):
phone = self.cleaned_data.get('phone') phone = self.cleaned_data.get('phone')
user_ct = ContentType.objects.get_for_model(get_user_model())
if phone: 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 return phone
def clean(self): def clean(self):
if app_settings.A2_ACCEPT_PHONE_AUTHENTICATION and get_user_model()._meta.get_field('phone'): if self.authenticator.is_phone_authn_active:
pmarillonnet marked this conversation as resolved Outdated

Pareil, self.authenticator

Pareil, `self.authenticator`
if ( if (
not self.cleaned_data['email'] not self.cleaned_data['email']
and not self.cleaned_data.get('email_or_username') and not self.cleaned_data.get('email_or_username')
@ -120,8 +127,18 @@ class PasswordResetForm(HoneypotForm):
email_sent = False email_sent = False
sms_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: for user in active_users:
if not user.email and not user.phone: if not user.email and not (
phone_authn_active
and models.AttributeValue.objects.filter(
attribute=self.authenticator.phone_identifier_field,
pmarillonnet marked this conversation as resolved Outdated

Pareil, self.authenticator

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

View File

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

View File

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

Pareil, self.authenticator

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

Pareil, self.authenticator

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

View File

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

View File

@ -30,6 +30,7 @@ from django.db.migrations.executor import MigrationExecutor
from authentic2.a2_rbac.models import OrganizationalUnit, Role from authentic2.a2_rbac.models import OrganizationalUnit, Role
from authentic2.a2_rbac.utils import get_default_ou from authentic2.a2_rbac.utils import get_default_ou
from authentic2.apps.authenticators.models import LoginPasswordAuthenticator
from authentic2.authentication import OIDCUser from authentic2.authentication import OIDCUser
from authentic2.manager.utils import get_ou_count from authentic2.manager.utils import get_ou_count
from authentic2.models import Attribute, Service from authentic2.models import Attribute, Service
@ -329,6 +330,20 @@ def api_user(
return locals().get(request.param) 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) @pytest.fixture(autouse=True)
def clear_cache(): def clear_cache():
cache.clear() cache.clear()

View File

@ -14,7 +14,9 @@
# You should have received a copy of the GNU Affero General Public License # 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/>. # 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.backends import is_user_authenticable
from authentic2.models import Attribute
from authentic2.utils.misc import authenticate 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) assert not is_user_authenticable(user_ou1)
def test_model_backend_phone_number(settings, db, simple_user, nomail_user, ou1): def test_model_backend_phone_number(settings, db, simple_user, nomail_user, ou1, phone_activated_authn):
settings.A2_ACCEPT_PHONE_AUTHENTICATION = True 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 authenticate(username=simple_user.phone, password=simple_user.username)
assert is_user_authenticable(simple_user) assert is_user_authenticable(simple_user)
assert authenticate(username=nomail_user.phone, password=nomail_user.username) assert authenticate(username=nomail_user.phone, password=nomail_user.username)
assert is_user_authenticable(nomail_user) assert is_user_authenticable(nomail_user)
def test_model_backend_phone_number_email(settings, db, simple_user): def test_model_backend_phone_number_nondefault_attribute(settings, db, simple_user, nomail_user, ou1):
settings.A2_ACCEPT_PHONE_AUTHENTICATION = True 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 # 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.username, password=simple_user.username)
assert authenticate(username=simple_user.phone, password=simple_user.username) assert authenticate(username=simple_user.phone, password=simple_user.username)

View File

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

View File

@ -21,6 +21,7 @@ from django.contrib.auth import get_user_model
from authentic2 import models from authentic2 import models
from authentic2.apps.authenticators.models import LoginPasswordAuthenticator 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 authentic2.utils.misc import get_authenticators, get_token_login_url
from .utils import assert_event, login, set_service 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) assert_event('user.logout', user=simple_user, session=session)
def test_success_email_with_phone_authn_activated(db, app, simple_user, settings): def test_success_email_with_phone_authn_activated(db, app, simple_user, settings, phone_activated_authn):
settings.A2_ACCEPT_PHONE_AUTHENTICATION = True
login(app, simple_user) login(app, simple_user)
assert_event('user.login', user=simple_user, session=app.session, how='password-on-https') assert_event('user.login', user=simple_user, session=app.session, how='password-on-https')
session = app.session 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) assert_event('user.logout', user=simple_user, session=session)
def test_success_phone_authn_nomail_user(db, app, nomail_user, settings): def test_success_email_with_phone_authn_nondefault_attribute(db, app, simple_user, settings):
settings.A2_ACCEPT_PHONE_AUTHENTICATION = True 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) login(app, nomail_user, login='123456789', phone_authn=True)
assert_event('user.login', user=nomail_user, session=app.session, how='password-on-https') assert_event('user.login', user=nomail_user, session=app.session, how='password-on-https')
session = app.session 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) assert_event('user.logout', user=nomail_user, session=session)
def test_success_phone_authn_simple_user(db, app, simple_user, settings): def test_success_phone_authn_nomail_user_nondefault_attribute(
settings.A2_ACCEPT_PHONE_AUTHENTICATION = True 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) login(app, simple_user, login='123456789', phone_authn=True)
assert_event('user.login', user=simple_user, session=app.session, how='password-on-https') assert_event('user.login', user=simple_user, session=app.session, how='password-on-https')
session = app.session session = app.session
@ -72,8 +152,7 @@ def test_failure(db, app, simple_user):
assert_event('user.login.failure', username='noone') assert_event('user.login.failure', username='noone')
def test_failure_no_means_of_authentication(db, app, nomail_user, settings): def test_failure_no_means_of_authentication(db, app, nomail_user, settings, phone_activated_authn):
settings.A2_ACCEPT_PHONE_AUTHENTICATION = True
nomail_user.username = None nomail_user.username = None
nomail_user.phone = None nomail_user.phone = None
nomail_user.save() nomail_user.save()
@ -92,17 +171,25 @@ def test_required_username_identifier(db, app, settings, caplog):
assert not response.pyquery('span.optional') assert not response.pyquery('span.optional')
assert response.pyquery('label[for="id_username"]').text() == 'Username or email:' 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/') response = app.get('/login/')
assert not response.pyquery('span.optional') assert not response.pyquery('span.optional')
assert response.pyquery('label[for="id_username"]').text() == 'Username, email or phone number:' 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/') response = app.get('/login/')
assert not response.pyquery('span.optional') assert not response.pyquery('span.optional')
assert response.pyquery('label[for="id_username"]').text() == 'Username or phone number:' 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/') response = app.get('/login/')
assert not response.pyquery('span.optional') assert not response.pyquery('span.optional')
assert response.pyquery('label[for="id_username"]').text() == 'Username:' 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', 'login-password-submit',
] ]
settings.A2_ACCEPT_PHONE_AUTHENTICATION = True LoginPasswordAuthenticator.objects.update(accept_phone_authentication=True)
response = app.get('/login/') response = app.get('/login/')
assert list(response.form.fields.keys()) == [ 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.login_exponential_retry_timeout_max_duration == 10
assert authenticator.emails_ip_ratelimit == '10/h' assert authenticator.emails_ip_ratelimit == '10/h'
assert authenticator.sms_ip_ratelimit == '10/h' assert authenticator.sms_ip_ratelimit == '10/h'
@pytest.mark.parametrize('email,phone', [(True, True), (True, False), (False, True)])
def test_password_authenticator_migration_accept_authentication_settings(migration, settings, email, phone):
app = 'authenticators'
migrate_from = [(app, '0010_auto_20230614_1017')]
migrate_to = [(app, '0011_migrate_a2_accept_authentication_settings')]
migration.before(migrate_from)
settings.A2_ACCEPT_EMAIL_AUTHENTICATION = email
settings.A2_ACCEPT_PHONE_AUTHENTICATION = phone
new_apps = migration.apply(migrate_to)
LoginPasswordAuthenticator = new_apps.get_model(app, 'LoginPasswordAuthenticator')
authenticator = LoginPasswordAuthenticator.objects.get()
assert authenticator.accept_email_authentication == email
assert authenticator.accept_phone_authentication == phone

View File

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

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

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

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

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

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

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

À relire le code, comme plus bas on a

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

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

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

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

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

View File

@ -23,7 +23,7 @@ from django.urls import reverse
from httmock import HTTMock, remember_called, urlmatch from httmock import HTTMock, remember_called, urlmatch
from authentic2.apps.authenticators.models import LoginPasswordAuthenticator 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 authentic2.utils.misc import send_password_reset_mail
from . import utils 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) 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): def test_send_password_reset_by_sms_code_improperly_configured(
settings.A2_ACCEPT_PHONE_AUTHENTICATION = True app, nomail_user, settings, phone_activated_authn
):
nomail_user.attributes.phone = nomail_user.phone
nomail_user.save()
settings.SMS_URL = 'https://foo.whatever.none/' settings.SMS_URL = 'https://foo.whatever.none/'
assert not SMSCode.objects.count() 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() 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): def test_send_password_reset_by_sms_code(app, nomail_user, settings, phone_activated_authn):
settings.A2_ACCEPT_PHONE_AUTHENTICATION = True nomail_user.attributes.phone = '+33123456789'
nomail_user.save()
settings.SMS_URL = 'https://foo.whatever.none/' settings.SMS_URL = 'https://foo.whatever.none/'
code_length = settings.SMS_CODE_LENGTH 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) app.get(url, status=404)
def test_send_password_reset_by_sms_code_next_url(app, nomail_user, settings): def test_send_password_reset_by_sms_code_nondefault_attribute(app, nomail_user, simple_user, settings):
settings.A2_ACCEPT_PHONE_AUTHENTICATION = True 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/' settings.SMS_URL = 'https://foo.whatever.none/'
resp = app.get('/accounts/consents/').follow() 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 assert "Consent Management" in resp
def test_password_reset_empty_form(app, db, settings): def test_password_reset_empty_form(app, db, settings, phone_activated_authn):
settings.A2_ACCEPT_PHONE_AUTHENTICATION = True
settings.SMS_URL = 'https://foo.whatever.none/' settings.SMS_URL = 'https://foo.whatever.none/'
url = reverse('password_reset') 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): def test_password_reset_both_fields_filled_email_precedence(
settings.A2_ACCEPT_PHONE_AUTHENTICATION = True app, simple_user, settings, mailoutbox, phone_activated_authn
):
simple_user.attributes.phone = '+33123456789'
simple_user.save()
settings.SMS_URL = 'https://foo.whatever.none/' settings.SMS_URL = 'https://foo.whatever.none/'
url = reverse('password_reset') 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() assert not SMSCode.objects.count()
def test_send_password_reset_by_sms_code_erroneous_phone_number(app, nomail_user, settings): def test_send_password_reset_by_sms_code_erroneous_phone_number(
settings.A2_ACCEPT_PHONE_AUTHENTICATION = True app, nomail_user, settings, phone_activated_authn
):
settings.SMS_URL = 'https://foo.whatever.none/' settings.SMS_URL = 'https://foo.whatever.none/'
assert not SMSCode.objects.count() assert not SMSCode.objects.count()

View File

@ -26,6 +26,7 @@ from httmock import HTTMock, remember_called, urlmatch
from authentic2 import models from authentic2 import models
from authentic2.a2_rbac.utils import get_default_ou 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.apps.journal.models import Event
from authentic2.forms.profile import modelform_factory from authentic2.forms.profile import modelform_factory
from authentic2.forms.registration import RegistrationCompletionForm 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 assert 'This password is not strong enough' in resp.text
def test_registration_no_identifier(app, db, settings): def test_registration_no_identifier(app, db, settings, phone_activated_authn):
settings.A2_ACCEPT_PHONE_AUTHENTICATION = True
resp = app.get(reverse('registration_register')) resp = app.get(reverse('registration_register'))
resp = resp.form.submit() resp = resp.form.submit()
assert 'Please provide an email address or a mobile' in resp.text assert 'Please provide an email address or a mobile' in resp.text
def test_registration_erroneous_phone_identifier(app, db, settings): def test_registration_erroneous_phone_identifier(app, db, settings, phone_activated_authn):
settings.A2_ACCEPT_PHONE_AUTHENTICATION = True
resp = app.get(reverse('registration_register')) resp = app.get(reverse('registration_register'))
resp.form.set('phone_1', 'thatsnotquiteit') resp.form.set('phone_1', 'thatsnotquiteit')
resp = resp.form.submit() resp = resp.form.submit()
@ -981,8 +980,7 @@ def sms_service_mock(url, request):
} }
def test_phone_registration_wrong_code(app, db, settings): def test_phone_registration_wrong_code(app, db, settings, phone_activated_authn):
settings.A2_ACCEPT_PHONE_AUTHENTICATION = True
settings.SMS_URL = 'https://foo.whatever.none/' settings.SMS_URL = 'https://foo.whatever.none/'
resp = app.get(reverse('registration_register')) 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.' assert resp.pyquery('li')[0].text_content() == 'Wrong SMS code.'
def test_phone_registration_expired_code(app, db, settings, freezer): def test_phone_registration_expired_code(app, db, settings, freezer, phone_activated_authn):
settings.A2_ACCEPT_PHONE_AUTHENTICATION = True
settings.SMS_URL = 'https://foo.whatever.none/' settings.SMS_URL = 'https://foo.whatever.none/'
resp = app.get(reverse('registration_register')) 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.' assert resp.pyquery('li')[0].text_content() == 'The code has expired.'
def test_phone_registration_cancel(app, db, settings, freezer): def test_phone_registration_cancel(app, db, settings, freezer, phone_activated_authn):
settings.A2_ACCEPT_PHONE_AUTHENTICATION = True
settings.SMS_URL = 'https://foo.whatever.none/' settings.SMS_URL = 'https://foo.whatever.none/'
resp = app.get(reverse('registration_register')) resp = app.get(reverse('registration_register'))
@ -1026,8 +1022,7 @@ def test_phone_registration_cancel(app, db, settings, freezer):
assert not SMSCode.objects.count() assert not SMSCode.objects.count()
def test_phone_registration_improperly_configured(app, db, settings, freezer, caplog): def test_phone_registration_improperly_configured(app, db, settings, freezer, caplog, phone_activated_authn):
settings.A2_ACCEPT_PHONE_AUTHENTICATION = True
settings.SMS_URL = '' settings.SMS_URL = ''
resp = app.get(reverse('registration_register')) 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' assert caplog.records[0].message == 'settings.SMS_URL is not set'
def test_phone_registration_connection_error(app, db, settings, freezer, caplog): def test_phone_registration_connection_error(app, db, settings, freezer, caplog, phone_activated_authn):
settings.A2_ACCEPT_PHONE_AUTHENTICATION = True
settings.SMS_URL = 'https://foo.whatever.none/' settings.SMS_URL = 'https://foo.whatever.none/'
def mocked_requests_connection_error(*args, **kwargs): 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): def test_phone_registration(app, db, settings, phone_activated_authn):
settings.A2_ACCEPT_PHONE_AUTHENTICATION = True
settings.SMS_URL = 'https://foo.whatever.none/' settings.SMS_URL = 'https://foo.whatever.none/'
code_length = settings.SMS_CODE_LENGTH 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 assert "You have just created an account" in resp.text
user = User.objects.get(first_name='John', last_name='Doe') 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): def test_phone_registration_nondefault_attribute(app, db, settings):
settings.A2_ACCEPT_PHONE_AUTHENTICATION = True 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/' settings.SMS_URL = 'https://foo.whatever.none/'
resp = app.get('/accounts/consents/').follow() resp = app.get('/accounts/consents/').follow()
@ -1120,4 +1147,4 @@ def test_phone_registration_redirect_url(app, db, settings):
assert resp.location == '/accounts/consents/' assert resp.location == '/accounts/consents/'
resp.follow() resp.follow()
user = User.objects.get(first_name='John', last_name='Doe') user = User.objects.get(first_name='John', last_name='Doe')
assert user.phone == '+33612345678' assert user.attributes.phone == '+33612345678'

View File

@ -321,10 +321,9 @@ def test_custom_account(settings, app, simple_user):
@pytest.mark.parametrize('view_name', ['registration_register']) # password_lost to be added with #69890 @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') freezer.move_to('2020-01-01')
LoginPasswordAuthenticator.objects.update(sms_ip_ratelimit='10/h', sms_number_ratelimit='3/d') LoginPasswordAuthenticator.objects.update(sms_ip_ratelimit='10/h', sms_number_ratelimit='3/d')
vdeniaud marked this conversation as resolved Outdated

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

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

Oui en effet, j’ai rétabli.

Oui en effet, j’ai rétabli.
settings.A2_ACCEPT_PHONE_AUTHENTICATION = True
settings.SMS_SENDER = 'EO' settings.SMS_SENDER = 'EO'
settings.SMS_URL = 'https://www.example.com/send' 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_0', '33')
response.form.set('phone_1', '0612345678') response.form.set('phone_1', '0612345678')
response = response.form.submit() 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 assert 'try again later' not in response.text
response = app.get(reverse(view_name)) 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() response = response.form.submit()
assert 'try again later' not in response.text 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 = app.get(reverse(view_name))
response.form.set('phone_0', '33') response.form.set('phone_0', '33')
response.form.set('phone_1', '0612345678') 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 assert 'try again later' in response.text
freezer.tick(datetime.timedelta(days=1)) freezer.tick(datetime.timedelta(days=1))