Projet

Général

Profil

0005-authentication-forms-add-user-phone-as-identifier-69.patch

Paul Marillonnet, 21 septembre 2022 09:34

Télécharger (11,4 ko)

Voir les différences:

Subject: [PATCH 5/5] 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    | 20 ++++++++++++-
 src/authentic2/views.py                   |  3 +-
 tests/conftest.py                         | 12 ++++++++
 tests/test_backends.py                    | 16 +++++++++++
 tests/test_login.py                       | 34 +++++++++++++++++++++++
 tests/test_user_manager.py                |  2 +-
 tests/utils.py                            | 30 ++++++++++++++++++--
 9 files changed, 118 insertions(+), 6 deletions(-)
src/authentic2/app_settings.py
291 291
    A2_ACCOUNTS_URL=Setting(default=None, definition='IdP has no account page, redirect to this one.'),
292 292
    A2_CACHE_ENABLED=Setting(default=True, definition='Disable all cache decorators for testing purpose.'),
293 293
    A2_ACCEPT_EMAIL_AUTHENTICATION=Setting(default=True, definition='Enable authentication by email'),
294
    A2_ACCEPT_PHONE_AUTHENTICATION=Setting(default=False, definition='Enable authentication by phone'),
294 295
    A2_EMAILS_IP_RATELIMIT=Setting(
295 296
        default='10/h', definition='Maximum rate of email sendings triggered by the same IP address.'
296 297
    ),
src/authentic2/backends/models_backend.py
49 49
                queries.append(models.Q(**{'email__iexact': username}))
50 50
        except models.FieldDoesNotExist:
51 51
            pass
52
        try:
53
            if app_settings.A2_ACCEPT_PHONE_AUTHENTICATION and UserModel._meta.get_field('phone'):
54
                # try with the phone number as user identifier
55
                queries.append(models.Q(**{'phone': username}))
56
        except models.FieldDoesNotExist:
57
            pass
52 58

  
53 59
        if realm is None:
54 60
            queries.append(models.Q(**{username_field: username}))
src/authentic2/forms/authentication.py
20 20
from django import forms
21 21
from django.conf import settings
22 22
from django.contrib.auth import forms as auth_forms
23
from django.contrib.auth import get_user_model
23 24
from django.forms.widgets import Media
24 25
from django.utils import html
25 26
from django.utils.encoding import force_text
26 27
from django.utils.translation import ugettext
27 28
from django.utils.translation import ugettext_lazy as _
28 29

  
29
from authentic2.forms.fields import PasswordField
30
from authentic2.forms.fields import PasswordField, PhoneField
30 31
from authentic2.utils.lazy import lazy_label
31 32

  
32 33
from .. import app_settings
......
36 37

  
37 38

  
38 39
class AuthenticationForm(auth_forms.AuthenticationForm):
40
    username = auth_forms.UsernameField(
41
        widget=forms.TextInput(attrs={'autofocus': True}),
42
        required=False,
43
    )
44
    phone = PhoneField(
45
        label=_('Phone number'),
46
        help_text=_('Your mobile phone number if declared in your user account.'),
47
    )
39 48
    password = PasswordField(label=_('Password'))
40 49
    remember_me = forms.BooleanField(
41 50
        initial=False,
......
61 70
            factor=app_settings.A2_LOGIN_EXPONENTIAL_RETRY_TIMEOUT_FACTOR,
62 71
        )
63 72

  
73
        if not app_settings.A2_ACCEPT_PHONE_AUTHENTICATION or not get_user_model()._meta.get_field('phone'):
74
            del self.fields['phone']
75

  
64 76
        if not self.authenticator.remember_me:
65 77
            del self.fields['remember_me']
66 78

  
......
87 99
        username = self.cleaned_data.get('username')
88 100
        password = self.cleaned_data.get('password')
89 101

  
102
        if app_settings.A2_ACCEPT_PHONE_AUTHENTICATION and get_user_model()._meta.get_field('phone'):
103
            # Django's ModelBackend only understands a single field as 'username' identifier
104
            # for authentication purposes.  In authentic it is already used for authn using the
105
            # email address.  Below is the addition of the phone number as authn identifier.
106
            self.cleaned_data['username'] = username = self.cleaned_data.get('phone')
107

  
90 108
        keys = None
91 109
        if username and password:
92 110
            keys = self.exp_backoff_keys()
src/authentic2/views.py
739 739

  
740 740
            return response
741 741
        else:
742
            username = form.cleaned_data.get('username', '').strip()
742
            username = form.cleaned_data.get('username') or ''
743
            username = username.strip()
743 744
            if request.failed_logins:
744 745
                for user, failure_data in request.failed_logins.items():
745 746
                    request.journal.record(
tests/conftest.py
110 110
        last_name='Dôe',
111 111
        email='user@example.net',
112 112
        ou=get_default_ou(),
113
        phone='+33123456789',
114
    )
115

  
116

  
117
@pytest.fixture
118
def nomail_user(db, ou1):
119
    return create_user(
120
        username='user',
121
        first_name='Jôhn',
122
        last_name='Dôe',
123
        ou=get_default_ou(),
124
        phone='+33123456789',
113 125
    )
114 126

  
115 127

  
tests/test_backends.py
38 38
    assert not authenticate(username=user_ou1.username, password=user_ou1.username)
39 39
    assert is_user_authenticable(simple_user)
40 40
    assert not is_user_authenticable(user_ou1)
41

  
42

  
43
def test_model_backend_phone_number(settings, db, simple_user, nomail_user, ou1):
44
    settings.A2_ACCEPT_PHONE_AUTHENTICATION = True
45
    assert authenticate(username=simple_user.phone, password=simple_user.username)
46
    assert is_user_authenticable(simple_user)
47
    assert authenticate(username=nomail_user.phone, password=nomail_user.username)
48
    assert is_user_authenticable(nomail_user)
49

  
50

  
51
def test_model_backend_phone_number_email(settings, db, simple_user):
52
    settings.A2_ACCEPT_PHONE_AUTHENTICATION = True
53
    # user with both phone number and username can authenticate in two different ways
54
    assert authenticate(username=simple_user.username, password=simple_user.username)
55
    assert authenticate(username=simple_user.phone, password=simple_user.username)
56
    assert is_user_authenticable(simple_user)
tests/test_login.py
36 36
    assert_event('user.logout', user=simple_user, session=session)
37 37

  
38 38

  
39
def test_success_phone_authn_nomail_user(db, app, nomail_user, settings):
40
    settings.A2_ACCEPT_PHONE_AUTHENTICATION = True
41
    login(app, nomail_user, login='123456789', phone_authn=True)
42
    assert_event('user.login', user=nomail_user, session=app.session, how='password-on-https')
43
    session = app.session
44
    app.get('/logout/').form.submit()
45
    assert_event('user.logout', user=nomail_user, session=session)
46

  
47

  
48
def test_success_phone_authn_simple_user(db, app, simple_user, settings):
49
    settings.A2_ACCEPT_PHONE_AUTHENTICATION = True
50

  
51
    login(app, simple_user, login='123456789', phone_authn=True)
52
    assert_event('user.login', user=simple_user, session=app.session, how='password-on-https')
53
    session = app.session
54
    app.get('/logout/').form.submit()
55
    assert_event('user.logout', user=simple_user, session=session)
56

  
57

  
39 58
def test_failure(db, app, simple_user):
40 59
    login(app, simple_user, password='wrong', fail=True)
41 60
    assert_event('user.login.failure', user=simple_user, username=simple_user.username)
......
44 63
    assert_event('user.login.failure', username='noone')
45 64

  
46 65

  
66
def test_failure_no_means_of_authentication(db, app, nomail_user, settings):
67
    settings.A2_ACCEPT_PHONE_AUTHENTICATION = True
68
    nomail_user.username = None
69
    nomail_user.phone = None
70
    nomail_user.save()
71

  
72
    with pytest.raises(AssertionError):
73
        login(app, nomail_user)
74
        assert_event('user.login.failure', user=nomail_user, username=nomail_user.username)
75

  
76
    with pytest.raises(AssertionError):
77
        login(app, nomail_user, phone_authn=True)
78
        assert_event('user.login.failure', user=nomail_user, username=nomail_user.username)
79

  
80

  
47 81
def test_login_inactive_user(db, app):
48 82
    user1 = User.objects.create(username='john.doe')
49 83
    user1.set_password('john.doe')
tests/test_user_manager.py
1071 1071
    other_user.roles.add(other_role)
1072 1072
    other_user.save()
1073 1073

  
1074
    login(app, other_user, '/manage/', 'auietsrn')
1074
    login(app, other_user, '/manage/', password='auietsrn')
1075 1075
    resp = app.get(reverse('a2-manager-user-detail', kwargs={'pk': simple_user.id}))
1076 1076
    assert '/manage/roles/%s/' % role1.pk in resp.text
1077 1077
    assert 'Role 1' in resp.text
tests/utils.py
55 55
from authentic2.utils import misc as utils_misc
56 56

  
57 57

  
58
def login(app, user, path=None, password=None, remember_me=None, args=None, kwargs=None, fail=False):
58
def login(
59
    app,
60
    user,
61
    path=None,
62
    login=None,
63
    password=None,
64
    remember_me=None,
65
    phone_authn=False,
66
    args=None,
67
    kwargs=None,
68
    fail=False,
69
):
59 70
    if path:
60 71
        args = args or []
61 72
        kwargs = kwargs or {}
......
65 76
        login_page = app.get(reverse('auth_login'))
66 77
    assert login_page.request.path == reverse('auth_login')
67 78
    form = login_page.form
68
    username = user.username if hasattr(user, 'username') else user
69
    form.set('username', username)
79
    if not phone_authn:
80
        if login:
81
            username = login
82
        elif hasattr(user, 'username'):
83
            username = user.username
84
        else:
85
            username = user
86
        form.set('username', username)
87
    else:
88
        if login:
89
            phone = login
90
        else:
91
            phone = user.phone
92
        form.set('phone_0', '33')
93
        form.set('phone_1', phone)
70 94
    # password is supposed to be the same as username
71 95
    form.set('password', password or (user.clear_password if hasattr(user, 'clear_password') else username))
72 96
    if remember_me is not None:
73
-