Skip to content

Commit 78c515d

Browse files
make phonenumber plugin really a plugin
1 parent d6d8b63 commit 78c515d

14 files changed

+90
-108
lines changed

tests/settings.py

+2
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,8 @@
9090
DEFAULT_AUTO_FIELD = 'django.db.models.AutoField'
9191

9292
TWO_FACTOR_PATCH_ADMIN = False
93+
TWO_FACTOR_SMS_GATEWAY = 'two_factor.gateways.fake.Fake'
94+
TWO_FACTOR_CALL_GATEWAY = 'two_factor.gateways.fake.Fake'
9395

9496
TWO_FACTOR_WEBAUTHN_RP_NAME = 'Test Server'
9597

tests/test_utils.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -140,8 +140,8 @@ class PhoneUtilsTests(UserMixin, TestCase):
140140
def test_backup_phones(self):
141141
gateway = 'two_factor.gateways.fake.Fake'
142142
user = self.create_user()
143-
user.phonedevice_set.create(name='default', number='+12024561111')
144-
backup = user.phonedevice_set.create(name='backup', number='+12024561111')
143+
user.phonedevice_set.create(name='default', number='+12024561111', method='call')
144+
backup = user.phonedevice_set.create(name='backup', number='+12024561111', method='call')
145145

146146
parameters = [
147147
# with_gateway, with_user, expected_output

tests/test_views_login.py

-5
Original file line numberDiff line numberDiff line change
@@ -282,7 +282,6 @@ def test_throttle_with_generator(self, mock_signal):
282282
@mock.patch('two_factor.views.core.signals.user_verified.send')
283283
@override_settings(
284284
TWO_FACTOR_PHONE_THROTTLE_FACTOR=10,
285-
TWO_FACTOR_SMS_GATEWAY='two_factor.gateways.fake.Fake'
286285
)
287286
def test_throttle_with_phone_sms(self, mock_signal):
288287
with freeze_time("2023-01-01") as frozen_time:
@@ -310,10 +309,6 @@ def test_throttle_with_phone_sms(self, mock_signal):
310309

311310
@mock.patch('two_factor.gateways.fake.Fake')
312311
@mock.patch('two_factor.views.core.signals.user_verified.send')
313-
@override_settings(
314-
TWO_FACTOR_SMS_GATEWAY='two_factor.gateways.fake.Fake',
315-
TWO_FACTOR_CALL_GATEWAY='two_factor.gateways.fake.Fake',
316-
)
317312
def test_with_backup_phone(self, mock_signal, fake):
318313
user = self.create_user()
319314
for no_digits in (6, 8):

tests/test_views_phone.py

+2-6
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
from django.shortcuts import resolve_url
66
from django.template import Context, Template
77
from django.test import TestCase
8-
from django.test.utils import override_settings
98
from django.urls import reverse, reverse_lazy
109
from django_otp.oath import totp
1110
from django_otp.util import random_hex
@@ -23,10 +22,6 @@
2322
from .utils import UserMixin
2423

2524

26-
@override_settings(
27-
TWO_FACTOR_SMS_GATEWAY='two_factor.gateways.fake.Fake',
28-
TWO_FACTOR_CALL_GATEWAY='two_factor.gateways.fake.Fake',
29-
)
3025
class PhoneSetupTest(UserMixin, TestCase):
3126
def setUp(self):
3227
super().setUp()
@@ -157,10 +152,11 @@ def setUp(self):
157152
self.login_user()
158153

159154
def test_delete(self):
155+
self.assertEqual(len(backup_phones(self.user)), 1)
160156
response = self.client.post(reverse('two_factor:phone_delete',
161157
args=[self.backup.pk]))
162158
self.assertRedirects(response, resolve_url(settings.LOGIN_REDIRECT_URL))
163-
self.assertEqual(list(backup_phones(self.user)), [])
159+
self.assertEqual(backup_phones(self.user), [])
164160

165161
def test_cannot_delete_default(self):
166162
response = self.client.post(reverse('two_factor:phone_delete',

tests/test_views_profile.py

+20-39
Original file line numberDiff line numberDiff line change
@@ -6,56 +6,37 @@
66

77

88
class ProfileTest(UserMixin, TestCase):
9-
PHONENUMBER_PLUGIN_NAME = 'two_factor.plugins.phonenumber'
10-
EXPECTED_BASE_CONTEXT_KEYS = {
11-
'default_device',
12-
'default_device_type',
13-
'backup_tokens',
14-
}
15-
EXPECTED_PHONENUMBER_PLUGIN_ADDITIONAL_KEYS = {
16-
'backup_phones',
17-
'available_phone_methods',
18-
}
9+
PHONENUMBER_PLUGIN = 'two_factor.plugins.phonenumber'
1910

2011
def setUp(self):
2112
super().setUp()
2213
self.user = self.create_user()
2314
self.enable_otp()
2415
self.login_user()
2516

26-
@classmethod
27-
def get_installed_apps_list(cls, with_phone_number_plugin=True):
28-
apps = set(settings.INSTALLED_APPS)
29-
if with_phone_number_plugin:
30-
apps.add(cls.PHONENUMBER_PLUGIN_NAME)
31-
else:
32-
apps.remove(cls.PHONENUMBER_PLUGIN_NAME)
33-
return list(apps)
34-
3517
def get_profile(self):
3618
url = reverse('two_factor:profile')
3719
return self.client.get(url)
3820

39-
def test_get_profile_without_phonenumer_plugin_enabled(self):
40-
apps_list = self.get_installed_apps_list(with_phone_number_plugin=False)
41-
with override_settings(INSTALLED_APPS=apps_list):
21+
def test_get_profile_without_phonenumber_plugin_enabled(self):
22+
installed_apps = [app for app in settings.INSTALLED_APPS if app != self.PHONENUMBER_PLUGIN]
23+
24+
with override_settings(INSTALLED_APPS=installed_apps):
25+
from two_factor.plugins.registry import registry
26+
27+
self.assertFalse(registry.get_method('call'))
28+
self.assertFalse(registry.get_method('sms'))
29+
4230
response = self.get_profile()
43-
context_keys = set(response.context.keys())
44-
self.assertTrue(self.EXPECTED_BASE_CONTEXT_KEYS.issubset(context_keys))
45-
# None of the phonenumber related keys are present
46-
self.assertTrue(
47-
self.EXPECTED_PHONENUMBER_PLUGIN_ADDITIONAL_KEYS.isdisjoint(
48-
context_keys
49-
)
50-
)
31+
32+
self.assertTrue(response.context['available_phone_methods'] == [])
5133

5234
def test_get_profile_with_phonenumer_plugin_enabled(self):
53-
apps_list = self.get_installed_apps_list(with_phone_number_plugin=True)
54-
with override_settings(INSTALLED_APPS=apps_list):
55-
response = self.get_profile()
56-
context_keys = set(response.context.keys())
57-
expected_keys = (
58-
self.EXPECTED_BASE_CONTEXT_KEYS
59-
| self.EXPECTED_PHONENUMBER_PLUGIN_ADDITIONAL_KEYS
60-
)
61-
self.assertTrue(expected_keys.issubset(context_keys))
35+
from two_factor.plugins.registry import registry
36+
37+
self.assertTrue(registry.get_method('call'))
38+
self.assertTrue(registry.get_method('sms'))
39+
40+
response = self.get_profile()
41+
available_phone_method_codes = {method.code for method in response.context['available_phone_methods']}
42+
self.assertTrue(available_phone_method_codes == {'call', 'sms'})

tests/test_views_setup.py

+3-9
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
from unittest import mock
44

55
from django.test import TestCase
6-
from django.test.utils import override_settings
76
from django.urls import reverse
87
from django_otp import DEVICE_ID_SESSION_KEY
98
from django_otp.oath import totp
@@ -67,8 +66,6 @@ def test_setup_only_generator_available(self):
6766
self.assertRedirects(response, reverse('two_factor:setup_complete'))
6867
self.assertEqual(1, self.user.totpdevice_set.count())
6968

70-
@override_settings(TWO_FACTOR_CALL_GATEWAY='two_factor.gateways.fake.Fake',
71-
TWO_FACTOR_SMS_GATEWAY='two_factor.gateways.fake.Fake')
7269
def test_setup_generator_with_multi_method(self):
7370
response = self.client.post(
7471
reverse('two_factor:setup'),
@@ -137,7 +134,6 @@ def test_no_phone(self):
137134
self.assertContains(response, 'call')
138135

139136
@mock.patch('two_factor.gateways.fake.Fake')
140-
@override_settings(TWO_FACTOR_CALL_GATEWAY='two_factor.gateways.fake.Fake')
141137
def test_setup_phone_call(self, fake):
142138
response = self._post(data={'setup_view-current_step': 'welcome'})
143139
self.assertContains(response, 'Method:')
@@ -176,7 +172,6 @@ def test_setup_phone_call(self, fake):
176172
self.assertEqual(phones[0].method, 'call')
177173

178174
@mock.patch('two_factor.gateways.fake.Fake')
179-
@override_settings(TWO_FACTOR_SMS_GATEWAY='two_factor.gateways.fake.Fake')
180175
def test_setup_phone_sms(self, fake):
181176
response = self._post(data={'setup_view-current_step': 'welcome'})
182177
self.assertContains(response, 'Method:')
@@ -239,13 +234,12 @@ def test_suggest_backup_number(self):
239234
self.enable_otp()
240235
self.login_user()
241236

242-
with self.settings(TWO_FACTOR_SMS_GATEWAY=None):
237+
with self.settings(TWO_FACTOR_SMS_GATEWAY=None, TWO_FACTOR_CALL_GATEWAY=None):
243238
response = self.client.get(reverse('two_factor:setup_complete'))
244239
self.assertNotContains(response, 'Add Phone Number')
245240

246-
with self.settings(TWO_FACTOR_SMS_GATEWAY='two_factor.gateways.fake.Fake'):
247-
response = self.client.get(reverse('two_factor:setup_complete'))
248-
self.assertContains(response, 'Add Phone Number')
241+
response = self.client.get(reverse('two_factor:setup_complete'))
242+
self.assertContains(response, 'Add Phone Number')
249243

250244
def test_missing_management_data(self):
251245
# missing management data

two_factor/plugins/phonenumber/apps.py

+8-5
Original file line numberDiff line numberDiff line change
@@ -12,19 +12,22 @@ class TwoFactorPhoneNumberConfig(AppConfig):
1212
url_prefix = 'phone'
1313

1414
def ready(self):
15-
register_methods(self, None, None)
16-
setting_changed.connect(register_methods)
15+
update_registered_methods(self, None, None)
16+
setting_changed.connect(update_registered_methods)
1717

1818

19-
def register_methods(sender, setting, value, **kwargs):
19+
def update_registered_methods(sender, setting, value, **kwargs):
2020
# This allows for dynamic registration, typically when testing.
2121
from .method import PhoneCallMethod, SMSMethod
2222

23-
if getattr(settings, 'TWO_FACTOR_CALL_GATEWAY', None):
23+
installed_apps = getattr(settings, 'INSTALLED_APPS') or []
24+
phone_number_app_installed = 'two_factor.plugins.phonenumber' in installed_apps
25+
26+
if phone_number_app_installed and getattr(settings, 'TWO_FACTOR_CALL_GATEWAY', None):
2427
registry.register(PhoneCallMethod())
2528
else:
2629
registry.unregister('call')
27-
if getattr(settings, 'TWO_FACTOR_SMS_GATEWAY', None):
30+
if phone_number_app_installed and getattr(settings, 'TWO_FACTOR_SMS_GATEWAY', None):
2831
registry.register(SMSMethod())
2932
else:
3033
registry.unregister('sms')

two_factor/plugins/phonenumber/forms.py

+8-1
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,16 @@ class Meta:
1515
model = PhoneDevice
1616
fields = ['number', 'method']
1717

18+
@staticmethod
19+
def get_available_choices():
20+
choices = []
21+
for method in get_available_phone_methods():
22+
choices.append((method.code, method.verbose_name))
23+
return choices
24+
1825
def __init__(self, **kwargs):
1926
super().__init__(**kwargs)
20-
self.fields['method'].choices = get_available_phone_methods()
27+
self.fields['method'].choices = self.get_available_choices()
2128

2229

2330
class PhoneNumberForm(forms.ModelForm):

two_factor/plugins/phonenumber/method.py

+11-11
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,17 @@
44

55
from .forms import PhoneNumberForm
66
from .models import PhoneDevice
7-
from .utils import backup_phones, format_phone_number, mask_phone_number
7+
from .utils import format_phone_number, mask_phone_number
88

99

1010
class PhoneMethodBase(MethodBase):
1111
def get_devices(self, user):
12-
return [device for device in backup_phones(user) if device.method == self.code]
12+
return PhoneDevice.objects.filter(user=user, method=self.code)
1313

1414
def recognize_device(self, device):
15-
return isinstance(device, PhoneDevice)
15+
if not isinstance(device, PhoneDevice):
16+
return False
17+
return device.method == self.code
1618

1719
def get_setup_forms(self, *args):
1820
return {self.code: PhoneNumberForm}
@@ -28,23 +30,21 @@ def get_device_from_setup_data(self, request, storage_data, **kwargs):
2830

2931
def get_action(self, device):
3032
number = mask_phone_number(format_phone_number(device.number))
31-
if device.method == 'sms':
32-
return _('Send text message to %s') % number
33-
else:
34-
return _('Call number %s') % number
33+
return self.action % number
3534

3635
def get_verbose_action(self, device):
37-
if device.method == 'sms':
38-
return _('We sent you a text message, please enter the token we sent.')
39-
else:
40-
return _('We are calling your phone right now, please enter the digits you hear.')
36+
return self.verbose_action
4137

4238

4339
class PhoneCallMethod(PhoneMethodBase):
4440
code = 'call'
4541
verbose_name = _('Phone call')
42+
action = _('Call number %s')
43+
verbose_action = _('We are calling your phone right now, please enter the digits you hear.')
4644

4745

4846
class SMSMethod(PhoneMethodBase):
4947
code = 'sms'
5048
verbose_name = _('Text message')
49+
action = _('Send text message to %s')
50+
verbose_action = _('We sent you a text message, please enter the token we sent.')

two_factor/plugins/phonenumber/urls.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,12 @@
44

55
urlpatterns = [
66
path(
7-
'account/two_factor/backup/phone/register/',
7+
'register/',
88
PhoneSetupView.as_view(),
99
name='phone_create',
1010
),
1111
path(
12-
'account/two_factor/backup/phone/unregister/<int:pk>/',
12+
'unregister/<int:pk>/',
1313
PhoneDeleteView.as_view(),
1414
name='phone_delete',
1515
),
+20-18
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,29 @@
11
import re
22

3-
import phonenumbers
4-
from django.conf import settings
5-
from django.utils.translation import gettext_lazy as _
3+
from two_factor.plugins.registry import registry
64

75
phone_mask = re.compile(r'(?<=.{3})[0-9](?=.{2})')
86

97

8+
def get_available_phone_methods():
9+
methods = []
10+
for code in ['sms', 'call']:
11+
if method := registry.get_method(code):
12+
methods.append(method)
13+
14+
return methods
15+
16+
1017
def backup_phones(user):
11-
no_gateways = (
12-
getattr(settings, 'TWO_FACTOR_CALL_GATEWAY', None) is None
13-
and getattr(settings, 'TWO_FACTOR_SMS_GATEWAY', None) is None)
14-
no_user = not user or user.is_anonymous
18+
if not user or user.is_anonymous:
19+
return []
1520

16-
if no_gateways or no_user:
17-
from .models import PhoneDevice
18-
return PhoneDevice.objects.none()
19-
return user.phonedevice_set.filter(name='backup')
21+
phones = []
2022

23+
for method in get_available_phone_methods():
24+
phones += list(method.get_devices(user))
2125

22-
def get_available_phone_methods():
23-
methods = []
24-
if getattr(settings, 'TWO_FACTOR_CALL_GATEWAY', None):
25-
methods.append(('call', _('Phone call')))
26-
if getattr(settings, 'TWO_FACTOR_SMS_GATEWAY', None):
27-
methods.append(('sms', _('Text message')))
28-
return methods
26+
return [phone for phone in phones if phone.name == 'backup']
2927

3028

3129
def mask_phone_number(number):
@@ -39,6 +37,8 @@ def mask_phone_number(number):
3937
:param number: str or phonenumber object
4038
:return: str
4139
"""
40+
import phonenumbers
41+
4242
if isinstance(number, phonenumbers.PhoneNumber):
4343
number = format_phone_number(number)
4444
return phone_mask.sub('*', number)
@@ -50,6 +50,8 @@ def format_phone_number(number):
5050
:param number: str or phonenumber object
5151
:return: str
5252
"""
53+
import phonenumbers
54+
5355
if not isinstance(number, phonenumbers.PhoneNumber):
5456
number = phonenumbers.parse(number)
5557
return phonenumbers.format_number(number, phonenumbers.PhoneNumberFormat.INTERNATIONAL)

two_factor/plugins/phonenumber/views.py

+1
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ def get_device(self, **kwargs):
7272
"""
7373
kwargs = kwargs or {}
7474
kwargs.update(self.storage.validated_step_data.get('setup', {}))
75+
7576
return PhoneDevice(key=self.get_key(), **kwargs)
7677

7778
def get_key(self):

two_factor/plugins/registry.py

+4
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,10 @@ def __init__(self):
7777
self.register(GeneratorMethod())
7878

7979
def register(self, method):
80+
for registered_method in self._methods:
81+
if method.code == registered_method.code:
82+
return
83+
8084
self._methods.append(method)
8185

8286
def unregister(self, code):

0 commit comments

Comments
 (0)