authentication/forms: add user phone as identifier (#69221)
This commit is contained in:
parent
3086948b0e
commit
f7d6895b94
|
@ -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.'
|
||||
),
|
||||
|
|
|
@ -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}))
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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',
|
||||
)
|
||||
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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:
|
||||
|
|
Loading…
Reference in New Issue