Projet

Général

Profil

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

Paul Marillonnet, 20 septembre 2022 10:38

Télécharger (11,4 ko)

Voir les différences:

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(-)
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
......
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_prefix = forms.ChoiceField(
45
        label=_('Geographical zone prefix'),
46
        help_text=_('Pick the prefix according to your geographical area.'),
47
        choices=((key, value) for key, value in settings.PHONE_PREFIXES.items()),
48
    )
49
    phone = forms.CharField(
50
        label=_('Mobile phone number'),
51
        help_text=_('Your mobile phone number if declared in your user account.'),
52
        required=False,
53
    )
39 54
    password = PasswordField(label=_('Password'))
40 55
    remember_me = forms.BooleanField(
41 56
        initial=False,
......
61 76
            factor=app_settings.A2_LOGIN_EXPONENTIAL_RETRY_TIMEOUT_FACTOR,
62 77
        )
63 78

  
79
        if not app_settings.A2_ACCEPT_PHONE_AUTHENTICATION or not get_user_model()._meta.get_field('phone'):
80
            del self.fields['phone_prefix']
81
            del self.fields['phone']
82

  
64 83
        if not self.authenticator.remember_me:
65 84
            del self.fields['remember_me']
66 85

  
......
87 106
        username = self.cleaned_data.get('username')
88 107
        password = self.cleaned_data.get('password')
89 108

  
109
        if app_settings.A2_ACCEPT_PHONE_AUTHENTICATION and get_user_model()._meta.get_field('phone'):
110
            phone_prefix = settings.PHONE_PREFIXES[self.cleaned_data.get('phone_prefix')]
111
            phone = self.cleaned_data.get('phone')
112
            if phone:
113
                # Django's ModelBackend only understands a single field as 'username' identifier
114
                # for authentication purposes.  In authentic it is already used for authn using the
115
                # email address.  Below is the addition of the phone number as authn identifier.
116
                self.cleaned_data['username'] = username = phone_prefix + phone
117

  
90 118
        keys = None
91 119
        if username and password:
92 120
            keys = self.exp_backoff_keys()
src/authentic2/settings.py
178 178
LOGIN_URL = '/login/'
179 179
LOGOUT_URL = '/logout/'
180 180

  
181
# Phone prefixes by country for phone number as authentication identifier
182
PHONE_PREFIXES = {
183
    'fr': '+33',
184
    'be': '+32',
185
}
186

  
181 187
# Registration
182 188
ACCOUNT_ACTIVATION_DAYS = 2
183 189

  
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
56 56
from authentic2.utils import misc as utils_misc
57 57

  
58 58

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