From 999d5a696914dd90b0c6716c42cd750d3deceaa2 Mon Sep 17 00:00:00 2001
From: Paul Marillonnet <pmarillonnet@entrouvert.com>
Date: Mon, 19 Sep 2022 09:52:12 +0200
Subject: [PATCH] authentication/forms: add user phone as identifier (#69221)

---
 src/authentic2/app_settings.py            |  1 +
 src/authentic2/backends/models_backend.py |  6 ++++
 src/authentic2/forms/authentication.py    | 28 +++++++++++++++++++
 src/authentic2/settings.py                |  6 ++++
 tests/conftest.py                         | 12 ++++++++
 tests/test_backends.py                    | 16 +++++++++++
 tests/test_login.py                       | 34 +++++++++++++++++++++++
 tests/test_user_manager.py                |  2 +-
 tests/utils.py                            | 29 +++++++++++++++++--
 9 files changed, 130 insertions(+), 4 deletions(-)

diff --git a/src/authentic2/app_settings.py b/src/authentic2/app_settings.py
index fc22288c9..51d548f0a 100644
--- a/src/authentic2/app_settings.py
+++ b/src/authentic2/app_settings.py
@@ -291,6 +291,7 @@ 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_EMAILS_IP_RATELIMIT=Setting(
         default='10/h', definition='Maximum rate of email sendings triggered by the same IP address.'
     ),
diff --git a/src/authentic2/backends/models_backend.py b/src/authentic2/backends/models_backend.py
index c4b175dc7..8987b388b 100644
--- a/src/authentic2/backends/models_backend.py
+++ b/src/authentic2/backends/models_backend.py
@@ -49,6 +49,12 @@ class ModelBackend(BaseModelBackend):
                 queries.append(models.Q(**{'email__iexact': username}))
         except models.FieldDoesNotExist:
             pass
+        try:
+            if app_settings.A2_ACCEPT_PHONE_AUTHENTICATION and UserModel._meta.get_field('phone'):
+                # try with the phone number as user identifier
+                queries.append(models.Q(**{'phone': username}))
+        except models.FieldDoesNotExist:
+            pass
 
         if realm is None:
             queries.append(models.Q(**{username_field: username}))
diff --git a/src/authentic2/forms/authentication.py b/src/authentic2/forms/authentication.py
index 8be142705..ace87328a 100644
--- a/src/authentic2/forms/authentication.py
+++ b/src/authentic2/forms/authentication.py
@@ -20,6 +20,7 @@ import math
 from django import forms
 from django.conf import settings
 from django.contrib.auth import forms as auth_forms
+from django.contrib.auth import get_user_model
 from django.forms.widgets import Media
 from django.utils import html
 from django.utils.encoding import force_text
@@ -36,6 +37,20 @@ from ..utils import misc as utils_misc
 
 
 class AuthenticationForm(auth_forms.AuthenticationForm):
+    username = auth_forms.UsernameField(
+        widget=forms.TextInput(attrs={'autofocus': True}),
+        required=False,
+    )
+    phone_prefix = forms.ChoiceField(
+        label=_('Geographical zone prefix'),
+        help_text=_('Pick the prefix according to your geographical area.'),
+        choices=((key, value) for key, value in settings.PHONE_PREFIXES.items()),
+    )
+    phone = forms.CharField(
+        label=_('Mobile phone number'),
+        help_text=_('Your mobile phone number if declared in your user account.'),
+        required=False,
+    )
     password = PasswordField(label=_('Password'))
     remember_me = forms.BooleanField(
         initial=False,
@@ -61,6 +76,10 @@ class AuthenticationForm(auth_forms.AuthenticationForm):
             factor=app_settings.A2_LOGIN_EXPONENTIAL_RETRY_TIMEOUT_FACTOR,
         )
 
+        if not app_settings.A2_ACCEPT_PHONE_AUTHENTICATION or not get_user_model()._meta.get_field('phone'):
+            del self.fields['phone_prefix']
+            del self.fields['phone']
+
         if not self.authenticator.remember_me:
             del self.fields['remember_me']
 
@@ -87,6 +106,15 @@ class AuthenticationForm(auth_forms.AuthenticationForm):
         username = self.cleaned_data.get('username')
         password = self.cleaned_data.get('password')
 
+        if app_settings.A2_ACCEPT_PHONE_AUTHENTICATION and get_user_model()._meta.get_field('phone'):
+            phone_prefix = settings.PHONE_PREFIXES[self.cleaned_data.get('phone_prefix')]
+            phone = self.cleaned_data.get('phone')
+            if phone:
+                # Django's ModelBackend only understands a single field as 'username' identifier
+                # for authentication purposes.  In authentic it is already used for authn using the
+                # email address.  Below is the addition of the phone number as authn identifier.
+                self.cleaned_data['username'] = username = phone_prefix + phone
+
         keys = None
         if username and password:
             keys = self.exp_backoff_keys()
diff --git a/src/authentic2/settings.py b/src/authentic2/settings.py
index db8f26b34..34b4ab006 100644
--- a/src/authentic2/settings.py
+++ b/src/authentic2/settings.py
@@ -178,6 +178,12 @@ LOGIN_REDIRECT_URL = '/'
 LOGIN_URL = '/login/'
 LOGOUT_URL = '/logout/'
 
+# Phone prefixes by country for phone number as authentication identifier
+PHONE_PREFIXES = {
+    'fr': '+33',
+    'be': '+32',
+}
+
 # Registration
 ACCOUNT_ACTIVATION_DAYS = 2
 
diff --git a/tests/conftest.py b/tests/conftest.py
index 3d3971a09..b9ebbb5cc 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -110,6 +110,18 @@ def simple_user(db, ou1):
         last_name='Dôe',
         email='user@example.net',
         ou=get_default_ou(),
+        phone='+33123456789',
+    )
+
+
+@pytest.fixture
+def nomail_user(db, ou1):
+    return create_user(
+        username='user',
+        first_name='Jôhn',
+        last_name='Dôe',
+        ou=get_default_ou(),
+        phone='+33123456789',
     )
 
 
diff --git a/tests/test_backends.py b/tests/test_backends.py
index b3e1593d4..9287db3fb 100644
--- a/tests/test_backends.py
+++ b/tests/test_backends.py
@@ -38,3 +38,19 @@ def test_user_filters(settings, db, simple_user, user_ou1, ou1):
     assert not authenticate(username=user_ou1.username, password=user_ou1.username)
     assert is_user_authenticable(simple_user)
     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
+    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
+    # 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)
+    assert is_user_authenticable(simple_user)
diff --git a/tests/test_login.py b/tests/test_login.py
index 0208b263f..551efa665 100644
--- a/tests/test_login.py
+++ b/tests/test_login.py
@@ -36,6 +36,25 @@ def test_success(db, app, simple_user):
     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
+    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):
+    settings.A2_ACCEPT_PHONE_AUTHENTICATION = True
+
+    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)
@@ -44,6 +63,21 @@ 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
+    nomail_user.username = None
+    nomail_user.phone = None
+    nomail_user.save()
+
+    with pytest.raises(AssertionError):
+        login(app, nomail_user)
+        assert_event('user.login.failure', user=nomail_user, username=nomail_user.username)
+
+    with pytest.raises(AssertionError):
+        login(app, nomail_user, phone_authn=True)
+        assert_event('user.login.failure', user=nomail_user, username=nomail_user.username)
+
+
 def test_login_inactive_user(db, app):
     user1 = User.objects.create(username='john.doe')
     user1.set_password('john.doe')
diff --git a/tests/test_user_manager.py b/tests/test_user_manager.py
index f5ad331b1..1f717b614 100644
--- a/tests/test_user_manager.py
+++ b/tests/test_user_manager.py
@@ -1071,7 +1071,7 @@ def test_manager_user_roles_visibility(app, simple_user, admin, ou1, ou2):
     other_user.roles.add(other_role)
     other_user.save()
 
-    login(app, other_user, '/manage/', 'auietsrn')
+    login(app, other_user, '/manage/', password='auietsrn')
     resp = app.get(reverse('a2-manager-user-detail', kwargs={'pk': simple_user.id}))
     assert '/manage/roles/%s/' % role1.pk in resp.text
     assert 'Role 1' in resp.text
diff --git a/tests/utils.py b/tests/utils.py
index 3034dd29c..1375fb250 100644
--- a/tests/utils.py
+++ b/tests/utils.py
@@ -56,7 +56,18 @@ from authentic2.apps.journal.models import Event
 from authentic2.utils import misc as utils_misc
 
 
-def login(app, user, path=None, password=None, remember_me=None, args=None, kwargs=None, fail=False):
+def login(
+    app,
+    user,
+    path=None,
+    login=None,
+    password=None,
+    remember_me=None,
+    phone_authn=False,
+    args=None,
+    kwargs=None,
+    fail=False,
+):
     if path:
         args = args or []
         kwargs = kwargs or {}
@@ -66,8 +77,20 @@ def login(app, user, path=None, password=None, remember_me=None, args=None, kwar
         login_page = app.get(reverse('auth_login'))
     assert login_page.request.path == reverse('auth_login')
     form = login_page.form
-    username = user.username if hasattr(user, 'username') else user
-    form.set('username', username)
+    if not phone_authn:
+        if login:
+            username = login
+        elif hasattr(user, 'username'):
+            username = user.username
+        else:
+            username = user
+        form.set('username', username)
+    else:
+        if login:
+            phone = login
+        else:
+            phone = user.phone
+        form.set('phone', phone)
     # password is supposed to be the same as username
     form.set('password', password or (user.clear_password if hasattr(user, 'clear_password') else username))
     if remember_me is not None:
-- 
2.37.2

