authn: let the model backend decide which phone attribute to use (#78046)

This commit is contained in:
Paul Marillonnet 2023-06-19 16:24:26 +02:00
parent eef2e168a7
commit f9b9d9ac88
4 changed files with 176 additions and 1 deletions

View File

@ -19,10 +19,12 @@ 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 get_password_authenticator, parse_phone_number
@ -46,12 +48,19 @@ class ModelBackend(BaseModelBackend):
username_field = 'username'
queries = []
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 password_authenticator.is_phone_authn_active:
# try with the phone number as user identifier
if (pn := parse_phone_number(username)) and is_valid_number(pn):
query = {'phone': format_number(pn, PhoneNumberFormat.E164)}
user_ids = AttributeValue.objects.filter(
multiple=False,
content_type=user_ct,
attribute=password_authenticator.phone_identifier_field,
content=format_number(pn, PhoneNumberFormat.E164),
).values_list('object_id', flat=True)
query = {'id__in': user_ids}
queries.append(models.Q(**query))
if realm is None:

View File

@ -3232,3 +3232,53 @@ def test_api_basic_authz_user_phone_number(app, settings, superuser, phone_activ
# wrong phone number
headers = basic_authorization_header('+33499985644', superuser.username)
app.get('/api/users/', headers=headers, status=401)
def test_api_basic_authz_user_phone_number_nondefault_attribute(app, settings, superuser):
phone, dummy = Attribute.objects.get_or_create(
name='another_phone',
kind='phone_number',
defaults={'label': 'Another phone'},
)
LoginPasswordAuthenticator.objects.update(
accept_phone_authentication=True,
phone_identifier_field=phone,
)
headers = {'Authorization': 'Basic abc'}
app.get('/api/users/', headers=headers, status=401)
headers = basic_authorization_header(superuser)
app.get('/api/users/', headers=headers, status=200)
superuser.attributes.another_phone = '+33499985643'
superuser.save()
# authn valid
headers = basic_authorization_header('+33499985643', superuser.username)
app.get('/api/users/', headers=headers, status=200)
headers = basic_authorization_header('+33499985643 ', superuser.username)
app.get('/api/users/', headers=headers, status=200)
headers = basic_authorization_header('+33-4/99/985643', superuser.username)
app.get('/api/users/', headers=headers, status=200)
headers = basic_authorization_header('0499985643', superuser.username)
app.get('/api/users/', headers=headers, status=200)
# wrong phone number
headers = basic_authorization_header('+33499985644', superuser.username)
app.get('/api/users/', headers=headers, status=401)
# having another known phone does not change anything
superuser.phone = '+33122334455'
superuser.save()
# authn valid
headers = basic_authorization_header('+33499985643', superuser.username)
app.get('/api/users/', headers=headers, status=200)
# wrong phone number
headers = basic_authorization_header('+33499985644', superuser.username)
app.get('/api/users/', headers=headers, status=401)

View File

@ -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
@ -51,6 +53,40 @@ def test_model_backend_phone_number(settings, db, simple_user, nomail_user, ou1,
assert is_user_authenticable(nomail_user)
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()

View File

@ -21,6 +21,7 @@ from django.contrib.auth import get_user_model
from authentic2 import models
from authentic2.apps.authenticators.models import LoginPasswordAuthenticator
from authentic2.apps.journal.models import Event
from authentic2.utils.misc import get_authenticators, get_token_login_url
from .utils import assert_event, login, set_service
@ -44,6 +45,23 @@ 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_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()
@ -54,6 +72,38 @@ def test_success_phone_authn_nomail_user(db, app, nomail_user, settings, phone_a
assert_event('user.logout', user=nomail_user, session=session)
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()
@ -64,6 +114,36 @@ def test_success_phone_authn_simple_user(db, app, simple_user, settings, phone_a
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
app.get('/logout/').form.submit()
assert_event('user.logout', user=simple_user, session=session)
def test_failure(db, app, simple_user):
login(app, simple_user, password='wrong', fail=True)
assert_event('user.login.failure', user=simple_user, username=simple_user.username)