authentication/forms: add user phone as identifier (#69221)

This commit is contained in:
Paul Marillonnet 2022-09-19 09:52:12 +02:00
parent 3086948b0e
commit f7d6895b94
9 changed files with 118 additions and 6 deletions

View File

@ -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.'
),

View File

@ -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}))

View File

@ -20,13 +20,14 @@ 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_str
from django.utils.translation import gettext
from django.utils.translation import gettext_lazy as _
from authentic2.forms.fields import PasswordField
from authentic2.forms.fields import PasswordField, PhoneField
from authentic2.utils.lazy import lazy_label
from .. import app_settings
@ -36,6 +37,14 @@ 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 = PhoneField(
label=_('Phone number'),
help_text=_('Your mobile phone number if declared in your user account.'),
)
password = PasswordField(label=_('Password'))
remember_me = forms.BooleanField(
initial=False,
@ -61,6 +70,9 @@ 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']
if not self.authenticator.remember_me:
del self.fields['remember_me']
@ -87,6 +99,12 @@ 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'):
# 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 = self.cleaned_data.get('phone')
keys = None
if username and password:
keys = self.exp_backoff_keys()

View File

@ -739,7 +739,8 @@ def login_password_login(request, authenticator, *args, **kwargs):
return response
else:
username = form.cleaned_data.get('username', '').strip()
username = form.cleaned_data.get('username') or ''
username = username.strip()
if request.failed_logins:
for user, failure_data in request.failed_logins.items():
request.journal.record(

View File

@ -111,6 +111,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',
)

View File

@ -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)

View File

@ -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')

View File

@ -1070,7 +1070,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

View File

@ -55,7 +55,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 {}
@ -65,8 +76,21 @@ 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.forms['login-password-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_0', '33')
form.set('phone_1', 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: