From 4c6cf56b6dd5e4f858756d15f741cf537ce6e766 Mon Sep 17 00:00:00 2001 From: Michel Baran Date: Wed, 14 Jun 2023 20:15:56 -0400 Subject: [PATCH 1/2] Cambios en el Example --- example/requirements.txt | 30 ++++++++++++++++++++++++++++++ example/views.py | 1 + 2 files changed, 31 insertions(+) create mode 100644 example/requirements.txt diff --git a/example/requirements.txt b/example/requirements.txt new file mode 100644 index 000000000..e0803c321 --- /dev/null +++ b/example/requirements.txt @@ -0,0 +1,30 @@ +aiohttp==3.8.4 +aiohttp-retry==2.8.3 +aiosignal==1.3.1 +asgiref==3.7.2 +async-timeout==4.0.2 +attrs==23.1.0 +backports.zoneinfo==0.2.1 +certifi==2023.5.7 +charset-normalizer==3.1.0 +Django==4.2.2 +django-bootstrap-form==3.4 +django-debug-toolbar==4.1.0 +django-formtools==2.4.1 +django-otp==1.2.1 +django-phonenumber-field==6.4.0 +django-user-sessions==2.0.0 +frozenlist==1.3.3 +idna==3.4 +multidict==6.0.4 +phonenumbers==8.13.13 +PyJWT==2.7.0 +pypng==0.20220715.0 +pytz==2023.3 +qrcode==7.4.2 +requests==2.31.0 +sqlparse==0.4.4 +twilio==8.2.2 +typing_extensions==4.6.3 +urllib3==2.0.2 +yarl==1.9.2 diff --git a/example/views.py b/example/views.py index 365ed6710..a88f89b6b 100644 --- a/example/views.py +++ b/example/views.py @@ -32,4 +32,5 @@ def get_context_data(self, **kwargs): @class_view_decorator(never_cache) class ExampleSecretView(OTPRequiredMixin, TemplateView): +#class ExampleSecretView( TemplateView): template_name = 'secret.html' From f00a0e5c9a7538087dfa296e4d25829be38d74e0 Mon Sep 17 00:00:00 2001 From: Michel Baran Date: Wed, 14 Jun 2023 21:34:39 -0400 Subject: [PATCH 2/2] Move SMS and PhoneCall Test to PlugIn Section --- tests/test_utils.py | 60 ---- .../plugins/phonenumber/tests/__init__.py | 0 .../plugins/phonenumber/tests/test_utils.py | 118 ++++++++ .../phonenumber/tests/test_validators.py | 32 +++ .../phonenumber/tests/test_views_phone.py | 257 ++++++++++++++++++ 5 files changed, 407 insertions(+), 60 deletions(-) create mode 100644 two_factor/plugins/phonenumber/tests/__init__.py create mode 100644 two_factor/plugins/phonenumber/tests/test_utils.py create mode 100644 two_factor/plugins/phonenumber/tests/test_validators.py create mode 100644 two_factor/plugins/phonenumber/tests/test_views_phone.py diff --git a/tests/test_utils.py b/tests/test_utils.py index 64762e98e..ee1ea869c 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,16 +1,10 @@ from unittest import mock from urllib.parse import parse_qsl, urlparse - from django.contrib.auth.hashers import make_password from django.test import TestCase, override_settings from django_otp.util import random_hex -from phonenumber_field.phonenumber import PhoneNumber from two_factor.plugins.email.utils import mask_email -from two_factor.plugins.phonenumber.models import PhoneDevice -from two_factor.plugins.phonenumber.utils import ( - backup_phones, format_phone_number, mask_phone_number, -) from two_factor.utils import ( USER_DEFAULT_DEVICE_ATTR_NAME, default_device, get_otpauth_url, totp_digits, @@ -18,7 +12,6 @@ from two_factor.views.utils import ( get_remember_device_cookie, validate_remember_device_cookie, ) - from .utils import UserMixin @@ -27,18 +20,6 @@ def test_default_device(self): user = self.create_user() self.assertEqual(default_device(user), None) - user.phonedevice_set.create(name='backup', number='+12024561111') - self.assertEqual(default_device(user), None) - - default = user.phonedevice_set.create(name='default', number='+12024561111') - self.assertEqual(default_device(user).pk, default.pk) - self.assertEqual(getattr(user, USER_DEFAULT_DEVICE_ATTR_NAME).pk, default.pk) - - # double check we're actually caching - PhoneDevice.objects.all().delete() - self.assertEqual(default_device(user).pk, default.pk) - self.assertEqual(getattr(user, USER_DEFAULT_DEVICE_ATTR_NAME).pk, default.pk) - def test_get_otpauth_url(self): for num_digits in (6, 8): self.assertEqualUrl( @@ -135,47 +116,6 @@ def test_wrong_device_hash(self): ) self.assertFalse(validation_result) - -class PhoneUtilsTests(UserMixin, TestCase): - def test_backup_phones(self): - gateway = 'two_factor.gateways.fake.Fake' - user = self.create_user() - user.phonedevice_set.create(name='default', number='+12024561111') - backup = user.phonedevice_set.create(name='backup', number='+12024561111') - - parameters = [ - # with_gateway, with_user, expected_output - (True, True, [backup.pk]), - (True, False, []), - (False, True, []), - (False, False, []) - ] - - for with_gateway, with_user, expected_output in parameters: - gateway_param = gateway if with_gateway else None - user_param = user if with_user else None - - with self.subTest(with_gateway=with_gateway, with_user=with_user), \ - self.settings(TWO_FACTOR_CALL_GATEWAY=gateway_param): - - phone_pks = [phone.pk for phone in backup_phones(user_param)] - self.assertEqual(phone_pks, expected_output) - - def test_mask_phone_number(self): - self.assertEqual(mask_phone_number('+41 524 204 242'), '+41 *** *** *42') - self.assertEqual( - mask_phone_number(PhoneNumber.from_string('+41524204242')), - '+41 ** *** ** 42' - ) - - def test_format_phone_number(self): - self.assertEqual(format_phone_number('+41524204242'), '+41 52 420 42 42') - self.assertEqual( - format_phone_number(PhoneNumber.from_string('+41524204242')), - '+41 52 420 42 42' - ) - - class EmailUtilsTests(TestCase): def test_mask_email(self): self.assertEqual(mask_email('bouke@example.com'), 'b***e@example.com') diff --git a/two_factor/plugins/phonenumber/tests/__init__.py b/two_factor/plugins/phonenumber/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/two_factor/plugins/phonenumber/tests/test_utils.py b/two_factor/plugins/phonenumber/tests/test_utils.py new file mode 100644 index 000000000..c1bcbeb8e --- /dev/null +++ b/two_factor/plugins/phonenumber/tests/test_utils.py @@ -0,0 +1,118 @@ +from unittest import mock +from urllib.parse import parse_qsl, urlparse + +from django.contrib.auth.hashers import make_password +from django.test import TestCase, override_settings +from django_otp.util import random_hex +from phonenumber_field.phonenumber import PhoneNumber + +from two_factor.plugins.phonenumber.models import PhoneDevice +from two_factor.plugins.phonenumber.utils import ( + backup_phones, format_phone_number, mask_phone_number, +) +from two_factor.utils import ( + USER_DEFAULT_DEVICE_ATTR_NAME, default_device, get_otpauth_url, + totp_digits, +) +from two_factor.views.utils import ( + get_remember_device_cookie, validate_remember_device_cookie, +) + +from tests.utils import UserMixin + + +class UtilsTest(UserMixin, TestCase): + def test_default_device(self): + user = self.create_user() + self.assertEqual(default_device(user), None) + + user.phonedevice_set.create(name='backup', number='+12024561111') + self.assertEqual(default_device(user), None) + + default = user.phonedevice_set.create(name='default', number='+12024561111') + self.assertEqual(default_device(user).pk, default.pk) + self.assertEqual(getattr(user, USER_DEFAULT_DEVICE_ATTR_NAME).pk, default.pk) + + # double check we're actually caching + PhoneDevice.objects.all().delete() + self.assertEqual(default_device(user).pk, default.pk) + self.assertEqual(getattr(user, USER_DEFAULT_DEVICE_ATTR_NAME).pk, default.pk) + + def assertEqualUrl(self, lhs, rhs): + """ + Asserts whether the URLs are canonically equal. + """ + lhs = urlparse(lhs) + rhs = urlparse(rhs) + self.assertEqual(lhs.scheme, rhs.scheme) + self.assertEqual(lhs.netloc, rhs.netloc) + self.assertEqual(lhs.path, rhs.path) + self.assertEqual(lhs.fragment, rhs.fragment) + + # We used parse_qs before, but as query parameter order became + # significant with Microsoft Authenticator and possibly other + # authenticator apps, we've switched to parse_qsl. + self.assertEqual(parse_qsl(lhs.query), parse_qsl(rhs.query)) + + def test_get_totp_digits(self): + # test that the default is 6 if TWO_FACTOR_TOTP_DIGITS is not set + self.assertEqual(totp_digits(), 6) + + for no_digits in (6, 8): + with self.settings(TWO_FACTOR_TOTP_DIGITS=no_digits): + self.assertEqual(totp_digits(), no_digits) + + def test_wrong_device_hash(self): + user = mock.Mock() + user.pk = 123 + user.password = make_password("xx") + + cookie_value = get_remember_device_cookie( + user=user, otp_device_id="SomeModel/33" + ) + validation_result = validate_remember_device_cookie( + cookie=cookie_value, + user=user, + otp_device_id="SomeModel/34", + ) + self.assertFalse(validation_result) + + +class PhoneUtilsTests(UserMixin, TestCase): + def test_backup_phones(self): + gateway = 'two_factor.gateways.fake.Fake' + user = self.create_user() + user.phonedevice_set.create(name='default', number='+12024561111') + backup = user.phonedevice_set.create(name='backup', number='+12024561111') + + parameters = [ + # with_gateway, with_user, expected_output + (True, True, [backup.pk]), + (True, False, []), + (False, True, []), + (False, False, []) + ] + + for with_gateway, with_user, expected_output in parameters: + gateway_param = gateway if with_gateway else None + user_param = user if with_user else None + + with self.subTest(with_gateway=with_gateway, with_user=with_user), \ + self.settings(TWO_FACTOR_CALL_GATEWAY=gateway_param): + + phone_pks = [phone.pk for phone in backup_phones(user_param)] + self.assertEqual(phone_pks, expected_output) + + def test_mask_phone_number(self): + self.assertEqual(mask_phone_number('+41 524 204 242'), '+41 *** *** *42') + self.assertEqual( + mask_phone_number(PhoneNumber.from_string('+41524204242')), + '+41 ** *** ** 42' + ) + + def test_format_phone_number(self): + self.assertEqual(format_phone_number('+41524204242'), '+41 52 420 42 42') + self.assertEqual( + format_phone_number(PhoneNumber.from_string('+41524204242')), + '+41 52 420 42 42' + ) \ No newline at end of file diff --git a/two_factor/plugins/phonenumber/tests/test_validators.py b/two_factor/plugins/phonenumber/tests/test_validators.py new file mode 100644 index 000000000..8b182fb0f --- /dev/null +++ b/two_factor/plugins/phonenumber/tests/test_validators.py @@ -0,0 +1,32 @@ +from django import forms +from django.test import TestCase + +from two_factor.plugins.phonenumber.validators import ( + validate_international_phonenumber, +) + + +class ValidatorsTest(TestCase): + def test_phone_number_validator_on_form_valid(self): + class TestForm(forms.Form): + number = forms.CharField(validators=[validate_international_phonenumber]) + + form = TestForm({ + 'number': '+31101234567', + }) + + self.assertTrue(form.is_valid()) + + def test_phone_number_validator_on_form_invalid(self): + class TestForm(forms.Form): + number = forms.CharField(validators=[validate_international_phonenumber]) + + form = TestForm({ + 'number': '+3110123456', + }) + + self.assertFalse(form.is_valid()) + self.assertIn('number', form.errors) + + self.assertEqual(form.errors['number'], + [str(validate_international_phonenumber.message)]) diff --git a/two_factor/plugins/phonenumber/tests/test_views_phone.py b/two_factor/plugins/phonenumber/tests/test_views_phone.py new file mode 100644 index 000000000..3e778509f --- /dev/null +++ b/two_factor/plugins/phonenumber/tests/test_views_phone.py @@ -0,0 +1,257 @@ +from unittest import mock + +from django.conf import settings +from django.core.exceptions import ValidationError +from django.shortcuts import resolve_url +from django.template import Context, Template +from django.test import TestCase +from django.test.utils import override_settings +from django.urls import reverse, reverse_lazy +from django_otp.oath import totp +from django_otp.util import random_hex + +from two_factor.plugins.phonenumber.models import PhoneDevice +from two_factor.plugins.phonenumber.utils import backup_phones +from two_factor.plugins.phonenumber.validators import ( + validate_international_phonenumber, +) +from two_factor.plugins.phonenumber.views import ( + PhoneDeleteView, PhoneSetupView, +) + +from tests.utils import UserMixin + + +@override_settings( + TWO_FACTOR_SMS_GATEWAY='two_factor.gateways.fake.Fake', + TWO_FACTOR_CALL_GATEWAY='two_factor.gateways.fake.Fake', +) +class PhoneSetupTest(UserMixin, TestCase): + def setUp(self): + super().setUp() + self.user = self.create_user() + self.enable_otp() + self.login_user() + + def test_form(self): + response = self.client.get(reverse('two_factor:phone_create')) + self.assertContains(response, 'Number:') + + # When no methods are configured, redirect to login. + with self.settings(TWO_FACTOR_SMS_GATEWAY=None, TWO_FACTOR_CALL_GATEWAY=None): + response = self.client.get(reverse('two_factor:phone_create')) + self.assertRedirects(response, reverse(settings.LOGIN_REDIRECT_URL)) + + def _post(self, data=None): + return self.client.post(reverse('two_factor:phone_create'), data=data) + + @mock.patch('two_factor.gateways.fake.Fake') + def test_setup(self, fake): + response = self._post({'phone_setup_view-current_step': 'setup', + 'setup-number': '', + 'setup-method': ''}) + self.assertEqual(response.context_data['wizard']['form'].errors, + {'method': ['This field is required.'], + 'number': ['This field is required.']}) + + response = self._post({'phone_setup_view-current_step': 'setup', + 'setup-number': '+31101234567', + 'setup-method': 'call'}) + self.assertContains(response, 'We\'ve sent a token to your phone') + self.assertContains(response, 'autofocus="autofocus"') + self.assertContains(response, 'inputmode="numeric"') + self.assertContains(response, 'autocomplete="one-time-code"') + device = response.context_data['wizard']['form'].device + fake.return_value.make_call.assert_called_with( + device=mock.ANY, token='%06d' % totp(device.bin_key)) + + args, kwargs = fake.return_value.make_call.call_args + submitted_device = kwargs['device'] + self.assertEqual(submitted_device.number, device.number) + self.assertEqual(submitted_device.key, device.key) + self.assertEqual(submitted_device.method, device.method) + + response = self._post({'phone_setup_view-current_step': 'validation', + 'validation-token': '123456'}) + self.assertEqual(response.context_data['wizard']['form'].errors, + {'token': ['Entered token is not valid.']}) + + response = self._post({'phone_setup_view-current_step': 'validation', + 'validation-token': totp(device.bin_key)}) + self.assertRedirects(response, resolve_url(settings.LOGIN_REDIRECT_URL)) + phones = self.user.phonedevice_set.all() + self.assertEqual(len(phones), 1) + self.assertEqual(phones[0].name, 'backup') + self.assertEqual(phones[0].number.as_e164, '+31101234567') + self.assertEqual(phones[0].key, device.key) + + @mock.patch('two_factor.gateways.fake.Fake') + def test_number_validation(self, fake): + response = self._post({'phone_setup_view-current_step': 'setup', + 'setup-number': '123', + 'setup-method': 'call'}) + self.assertEqual( + response.context_data['wizard']['form'].errors, + {'number': [str(validate_international_phonenumber.message)]}) + + @mock.patch('formtools.wizard.views.WizardView.get_context_data') + def test_success_url_as_url(self, get_context_data): + url = '/account/two_factor/' + view = PhoneSetupView() + view.success_url = url + + def return_kwargs(form, **kwargs): + return kwargs + get_context_data.side_effect = return_kwargs + + context = view.get_context_data(None) + self.assertIn('cancel_url', context) + self.assertEqual(url, context['cancel_url']) + + @mock.patch('formtools.wizard.views.WizardView.get_context_data') + def test_success_url_as_named_url(self, get_context_data): + url_name = 'two_factor:profile' + url = reverse(url_name) + view = PhoneSetupView() + view.success_url = url_name + + def return_kwargs(form, **kwargs): + return kwargs + get_context_data.side_effect = return_kwargs + + context = view.get_context_data(None) + self.assertIn('cancel_url', context) + self.assertEqual(url, context['cancel_url']) + + @mock.patch('formtools.wizard.views.WizardView.get_context_data') + def test_success_url_as_reverse_lazy(self, get_context_data): + url_name = 'two_factor:profile' + url = reverse(url_name) + view = PhoneSetupView() + view.success_url = reverse_lazy(url_name) + + def return_kwargs(form, **kwargs): + return kwargs + get_context_data.side_effect = return_kwargs + + context = view.get_context_data(None) + self.assertIn('cancel_url', context) + self.assertEqual(url, context['cancel_url']) + + def test_missing_management_data(self): + # missing management data + response = self._post({'setup-number': '123', + 'setup-method': 'call'}) + + # view should return HTTP 400 Bad Request + self.assertEqual(response.status_code, 400) + + +class PhoneDeleteTest(UserMixin, TestCase): + def setUp(self): + super().setUp() + self.user = self.create_user() + self.backup = self.user.phonedevice_set.create(name='backup', method='sms', number='+12024561111') + self.default = self.user.phonedevice_set.create(name='default', method='call', number='+12024561111') + self.login_user() + + def test_delete(self): + response = self.client.post(reverse('two_factor:phone_delete', + args=[self.backup.pk])) + self.assertRedirects(response, resolve_url(settings.LOGIN_REDIRECT_URL)) + self.assertEqual(list(backup_phones(self.user)), []) + + def test_cannot_delete_default(self): + response = self.client.post(reverse('two_factor:phone_delete', + args=[self.default.pk])) + self.assertContains(response, 'was not found', status_code=404) + + def test_success_url_as_url(self): + url = '/account/two_factor/' + view = PhoneDeleteView() + view.success_url = url + self.assertEqual(view.get_success_url(), url) + + def test_success_url_as_named_url(self): + url_name = 'two_factor:profile' + url = reverse(url_name) + view = PhoneDeleteView() + view.success_url = url_name + self.assertEqual(view.get_success_url(), url) + + def test_success_url_as_reverse_lazy(self): + url_name = 'two_factor:profile' + url = reverse(url_name) + view = PhoneDeleteView() + view.success_url = reverse_lazy(url_name) + self.assertEqual(view.get_success_url(), url) + + +class PhoneDeviceTest(UserMixin, TestCase): + def test_clean(self): + device = PhoneDevice(key='xyz', method='sms') + with self.assertRaises(ValidationError) as ctxt: + device.full_clean() + # The 'b' prefix might be a bug to be solved in django_otp. + self.assertIn("b'xyz' is not valid hex-encoded data.", str(ctxt.exception)) + + def test_verify(self): + for no_digits in (6, 8): + with self.settings(TWO_FACTOR_TOTP_DIGITS=no_digits): + device = PhoneDevice(key=random_hex()) + self.assertFalse(device.verify_token(-1)) + self.assertFalse(device.verify_token('foobar')) + self.assertTrue(device.verify_token(totp(device.bin_key, digits=no_digits))) + + def test_verify_token_as_string(self): + """ + The field used to read the token may be a CharField, + so the PhoneDevice must be able to validate tokens + read as strings + """ + for no_digits in (6, 8): + with self.settings(TWO_FACTOR_TOTP_DIGITS=no_digits): + device = PhoneDevice(key=random_hex()) + self.assertTrue(device.verify_token(str(totp(device.bin_key, digits=no_digits)))) + + def test_unicode(self): + device = PhoneDevice(name='unknown') + self.assertEqual('unknown (None)', str(device)) + + device.user = self.create_user() + self.assertEqual('unknown (bouke@example.com)', str(device)) + + def test_template_tags(self): + def render_template(string, context=None): + context = context or {} + context = Context(context) + return Template(string).render(context) + + rendered = render_template( + '{% load phonenumber %}' + '{{ number|format_phone_number }}', + context={'number': '+41524204242'} + ) + self.assertEqual(rendered, '+41 52 420 42 42') + + rendered = render_template( + '{% load phonenumber %}' + '{{ number|mask_phone_number }}', + context={'number': '+41524204242'} + ) + self.assertEqual(rendered, '+41*******42') + + device1 = PhoneDevice(method='sms', number='+12024561111') + device2 = PhoneDevice(method='call', number='+12024561112') + rendered = render_template( + '{% load phonenumber %}' + '{{ device|device_action }}', + context={'device': device1} + ) + self.assertEqual(rendered, 'Send text message to +1 ***-***-**11') + rendered = render_template( + '{% load phonenumber %}' + '{{ device|device_action }}', + context={'device': device2} + ) + self.assertEqual(rendered, 'Call number +1 ***-***-**12')