0001-authentication-forms-add-user-phone-as-identifier-69.patch
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 |
- |