diff --git a/.bumpversion.cfg b/.bumpversion.cfg index dae4be2f4..32255096c 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 1.15.5 +current_version = 1.16 commit = True tag = True tag_name = {new_version} diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f670f994..e1c70639b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## 1.15.6 +### Changed +- Added WhatsApp as a token method through Twilio +- Added the ability to modify the token input size + ## 1.15.5 ### Fixed - Include transitively replaced migrations in phonenumber migration. diff --git a/docs/conf.py b/docs/conf.py index df684b9e6..3cf0f9a9e 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -59,7 +59,7 @@ # # The full version, including alpha/beta/rc tags. -release = '1.15.5' +release = '1.16' # The short X.Y version. version = '.'.join(release.split('.')[0:2]) diff --git a/docs/configuration.rst b/docs/configuration.rst index 92ef7fb59..072c65007 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -62,6 +62,16 @@ General Settings indefinitely in a state of having entered their password successfully but not having passed two factor authentication. Set to ``0`` to disable. +``TWO_FACTOR_INPUT_WIDTH`` (default ``80``) + The size of the input field width for the token. This is used by the default + templates to set the size of the input field. Set to ``None`` to not set the + size. + +``TWO_FACTOR_INPUT_HEIGHT`` (default ``10``) + The size of the input field height for the token. This is used by the default + templates to set the size of the input field. Set to ``None`` to not set the + size. + Phone-related settings ---------------------- @@ -87,6 +97,32 @@ setting. Then, you may want to configure the following settings: * ``'two_factor.gateways.fake.Fake'`` for development, recording tokens to the default logger. +``TWO_FACTOR_WHATSAPP_GATEWAY`` (default: ``None``) + Which gateway to use for sending WhatsApp messages. Should be set to a module or + object providing a ``send_whatsapp`` method. Most likely can be set to ``TWO_FACTOR_SMS_GATEWAY``. + Currently two gateways are bundled: + + * ``'two_factor.gateways.twilio.gateway.Twilio'`` for sending real WhatsApp messages using + Twilio_. + * ``'two_factor.gateways.fake.Fake'`` for development, recording tokens to the + default logger. + +``WHATSAPP_APPROVED_MESSAGE`` (default: ``{{ token }} is your OTP code``) + The freeform message to be sent to the user via WhatsApp. + **This message needs to a templated message approved by WhatsApp**. + The token variable will be replaced with the actual token. The token variable + is placed at the beginning of the message by default. Default example + ``123456 is your OTP code``. You can customize the message excluding the token. + You can customize the placement of the token variable + by setting ``PLACE_TOKEN_AT_END_OF_MESSAGE``. Due to the specificity in WhatsApp + message templates, any translations should be done in the Twilio console. + + Note: WhatsApp does not allow sending messages to users who have not initiated a conversation with the business + account. You can read more about this in the `WhatsApp Business API documentation`_. + +``PLACE_TOKEN_AT_END_OF_MESSAGE`` (default: `False`) + Moves the token variable to the end of the message. Default example ``Your OTP code is 123456``. + ``PHONENUMBER_DEFAULT_REGION`` (default: ``None``) The default region for parsing phone numbers. If your application's primary audience is a certain country, setting the region to that country allows diff --git a/docs/installation.rst b/docs/installation.rst index 0fe990576..7f5be4f4c 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -35,7 +35,7 @@ Add the following apps to the ``INSTALLED_APPS``: 'django_otp.plugins.otp_totp', 'django_otp.plugins.otp_email', # <- if you want email capability. 'two_factor', - 'two_factor.plugins.phonenumber', # <- if you want phone number capability. + 'two_factor.plugins.phonenumber', # <- if you want phone number capability (sms / whatsapp / call). 'two_factor.plugins.email', # <- if you want email capability. 'two_factor.plugins.yubikey', # <- for yubikey capability. ] diff --git a/example/gateways.py b/example/gateways.py index d62c22c71..d156978aa 100644 --- a/example/gateways.py +++ b/example/gateways.py @@ -15,6 +15,11 @@ def make_call(cls, device, token): def send_sms(cls, device, token): cls._add_message(_('Fake SMS to %(number)s: "Your token is: %(token)s"'), device, token) + + @classmethod + def send_whatsapp(cls, device, token): + cls._add_message(_('Fake WhatsApp to %(number)s: "Your token is: %(token)s"'), + device, token) @classmethod def _add_message(cls, message, device, token): diff --git a/example/settings.py b/example/settings.py index bb00ab32f..44d91c0c9 100644 --- a/example/settings.py +++ b/example/settings.py @@ -97,8 +97,13 @@ } TWO_FACTOR_CALL_GATEWAY = 'example.gateways.Messages' -TWO_FACTOR_SMS_GATEWAY = 'example.gateways.Messages' +TWO_FACTOR_SMS_GATEWAY = "two_factor.gateways.twilio.gateway.Twilio" PHONENUMBER_DEFAULT_REGION = 'NL' +TWO_FACTOR_WHATSAPP_GATEWAY = TWO_FACTOR_SMS_GATEWAY +TWO_FACTOR_INPUT_WIDTH = 200 +TWO_FACTOR_INPUT_HEIGHT = 50 +PLACE_TOKEN_AT_END_OF_MESSAGE = False +WHATSAPP_APPROVED_MESSAGE = "is your verification code for The Example App." TWO_FACTOR_REMEMBER_COOKIE_AGE = 120 # Set to 2 minute for testing diff --git a/setup.py b/setup.py index a7ba3e8f8..eb81ba60a 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ setup( name='django-two-factor-auth', - version='1.15.5', + version='1.16', description='Complete Two-Factor Authentication for Django', long_description=open('README.rst', encoding='utf-8').read(), author='Bouke Haarsma', @@ -21,6 +21,7 @@ extras_require={ 'call': ['twilio>=6.0'], 'sms': ['twilio>=6.0'], + 'whatsapp': ['twilio>=6.0'], 'webauthn': ['webauthn>=1.11.0,<1.99'], 'yubikey': ['django-otp-yubikey'], 'phonenumbers': ['phonenumbers>=7.0.9,<8.99'], diff --git a/tests/test_gateways.py b/tests/test_gateways.py index 97142c2e8..0a803949c 100644 --- a/tests/test_gateways.py +++ b/tests/test_gateways.py @@ -85,17 +85,19 @@ def test_gateway(self, client): url='http://testserver/twilio/inbound/two_factor/%s/?locale=en-us' % code) twilio.send_sms( - device=Mock(number=PhoneNumber.from_string('+123')), - token=code + + # test whatsapp message + twilio.send_whatsapp( + device=Mock(number=PhoneNumber.from_string(f"whatsapp:+123")), token=code + ) ) client.return_value.messages.create.assert_called_with( - to='+123', + to="whatsapp:+123", body=render_to_string( - 'two_factor/twilio/sms_message.html', - {'token': code} + "two_factor/twilio/whatsapp_message.html", {"token": code} ), - from_='+456' + from_="whatsapp:+456", ) client.return_value.calls.create.reset_mock() @@ -143,6 +145,16 @@ def test_messaging_sid(self, client): messaging_service_sid='ID' ) + # Sending a WhatsApp message should originate from the messaging service SID + twilio.send_whatsapp(device=device, token=code) + client.return_value.messages.create.assert_called_with( + to="whatsapp:+123", + body=render_to_string( + "two_factor/twilio/whatsapp_message.html", {"token": code} + ), + messaging_service_sid="ID", + ) + @override_settings( TWILIO_ACCOUNT_SID='SID', TWILIO_AUTH_TOKEN='TOKEN', @@ -179,3 +191,4 @@ def test_gateway(self, logger): fake.send_sms(device=Mock(number=PhoneNumber.from_string('+123')), token=code) logger.info.assert_called_with( 'Fake SMS to %s: "Your token is: %s"', '+123', code) + diff --git a/tests/test_views_setup.py b/tests/test_views_setup.py index 497fe371b..6b0b9a96b 100644 --- a/tests/test_views_setup.py +++ b/tests/test_views_setup.py @@ -214,6 +214,7 @@ def test_setup_phone_sms(self, fake): self.assertEqual(phones[0].number.as_e164, '+31101234567') self.assertEqual(phones[0].method, 'sms') + def test_already_setup(self): self.enable_otp() self.login_user() @@ -247,9 +248,62 @@ def test_suggest_backup_number(self): response = self.client.get(reverse('two_factor:setup_complete')) self.assertContains(response, 'Add Phone Number') + with self.settings(TWO_FACTOR_WHATSAPP_GATEWAY='two_factor.gateways.fake.Fake'): + response = self.client.get(reverse('two_factor:setup_complete')) + self.assertContains(response, 'Add Phone Number') + def test_missing_management_data(self): # missing management data response = self._post({'validation-token': '666'}) # view should return HTTP 400 Bad Request self.assertEqual(response.status_code, 400) + +@mock.patch('two_factor.gateways.fake.Fake') +@override_settings(TWO_FACTOR_WHATSAPP_GATEWAY='two_factor.gateways.fake.Fake') +class TestSetupWhatsApp(TestCase): + def _post(self, data): + # This method should simulate posting data to your 2FA setup view + # Implement this based on your view logic + pass + + def test_setup_whatsapp_message(self, whatsapp): + # Step 1: Start the setup process + response = self._post(data={'setup_view-current_step': 'welcome'}) + self.assertContains(response, 'Method:') + + # Step 2: Select WhatsApp method + response = self._post(data={'setup_view-current_step': 'method', + 'method-method': 'wa'}) + self.assertContains(response, 'Number:') + + # Step 3: Enter phone number + response = self._post(data={'setup_view-current_step': 'wa', + 'wa-number': '+31101234567'}) + self.assertContains(response, 'Token:') + self.assertContains(response, 'We sent you a WhatsApp message') + + # Check that the token was sent via WhatsApp + self.assertEqual( + whatsapp.return_value.method_calls, + [mock.call.send_message(device=mock.ANY, token=mock.ANY)] + ) + + # Step 4: Validate token + response = self._post(data={'setup_view-current_step': 'validation', + 'validation-token': '123456'}) + self.assertEqual(response.context_data['wizard']['form'].errors, + {'token': ['Entered token is not valid.']}) + + # Submitting correct token + token = whatsapp.return_value.send_message.call_args[1]['token'] + response = self._post(data={'setup_view-current_step': 'validation', + 'validation-token': token}) + self.assertRedirects(response, reverse('two_factor:setup_complete')) + + # Verify WhatsApp device creation + devices = self.user.whatsappdevice_set.all() + self.assertEqual(len(devices), 1) + self.assertEqual(devices[0].name, 'default') + self.assertEqual(devices[0].number.as_e164, '+31101234567') + self.assertEqual(devices[0].method, 'wa') \ No newline at end of file diff --git a/two_factor/forms.py b/two_factor/forms.py index 12072a6a7..9b3ecce54 100644 --- a/two_factor/forms.py +++ b/two_factor/forms.py @@ -11,11 +11,12 @@ from .plugins.registry import registry from .utils import totp_digits +TWO_FACTOR_INPUT_WIDTH = getattr(settings, 'TWO_FACTOR_INPUT_WIDTH', 100) +TWO_FACTOR_INPUT_HEIGHT = getattr(settings, 'TWO_FACTOR_INPUT_HEIGHT', 30) class MethodForm(forms.Form): method = forms.ChoiceField(label=_("Method"), widget=forms.RadioSelect) - def __init__(self, **kwargs): super().__init__(**kwargs) @@ -27,13 +28,13 @@ def __init__(self, **kwargs): class DeviceValidationForm(forms.Form): - token = forms.IntegerField(label=_("Token"), min_value=1, max_value=int('9' * totp_digits())) - + token = forms.IntegerField(label=_("Code"), min_value=1, max_value=int('9' * totp_digits())) token.widget.attrs.update({'autofocus': 'autofocus', 'inputmode': 'numeric', - 'autocomplete': 'one-time-code'}) + 'autocomplete': 'one-time-code', + 'style': f'width:{TWO_FACTOR_INPUT_WIDTH}px; height:{TWO_FACTOR_INPUT_HEIGHT}px;'}) error_messages = { - 'invalid_token': _('Entered token is not valid.'), + 'invalid_token': _('Entered code is not valid.'), } def __init__(self, device, **kwargs): @@ -52,7 +53,8 @@ class TOTPDeviceForm(forms.Form): token.widget.attrs.update({'autofocus': 'autofocus', 'inputmode': 'numeric', - 'autocomplete': 'one-time-code'}) + 'autocomplete': 'one-time-code', + 'style': f'width:{TWO_FACTOR_INPUT_WIDTH}px; height:{TWO_FACTOR_INPUT_HEIGHT}px;'}) error_messages = { 'invalid_token': _('Entered token is not valid.'), @@ -114,6 +116,7 @@ class AuthenticationTokenForm(OTPAuthenticationFormMixin, forms.Form): 'autofocus': 'autofocus', 'pattern': '[0-9]*', # hint to show numeric keyboard for on-screen keyboards 'autocomplete': 'one-time-code', + 'style': f'width:{TWO_FACTOR_INPUT_WIDTH}px; height:{TWO_FACTOR_INPUT_HEIGHT}px;' }) # Our authentication form has an additional submit button to go to the diff --git a/two_factor/gateways/__init__.py b/two_factor/gateways/__init__.py index 17ed5fa68..62a2a5a82 100644 --- a/two_factor/gateways/__init__.py +++ b/two_factor/gateways/__init__.py @@ -14,3 +14,8 @@ def make_call(device, token): def send_sms(device, token): gateway = get_gateway_class(getattr(settings, 'TWO_FACTOR_SMS_GATEWAY'))() gateway.send_sms(device=device, token=token) + + +def send_whatsapp(device, token): + gateway = get_gateway_class(getattr(settings, "TWO_FACTOR_WHATSAPP_GATEWAY"))() + gateway.send_whatsapp(device=device, token=token) diff --git a/two_factor/gateways/twilio/gateway.py b/two_factor/gateways/twilio/gateway.py index 3affd74d9..fc34a0732 100644 --- a/two_factor/gateways/twilio/gateway.py +++ b/two_factor/gateways/twilio/gateway.py @@ -39,6 +39,15 @@ class Twilio: phone numbers and choose one depending on the destination country. When left empty the ``TWILIO_CALLER_ID`` will be used as sender ID. + ``TWILIO_CALLER_ID_WHATSAPP`` + Should be set to a verified phone number. Twilio_ differentiates between + numbers verified for making phone calls and sending whatsapp/sms messages. + + ``TWILIO_MESSAGING_SERVICE_SID_WHATSAPP`` + Can be set to a Twilio Messaging Service for WhatsApp. This service can wrap multiple + phone numbers and choose one depending on the destination country. + When left empty the ``TWILIO_CALLER_ID_WHATSAPP`` will be used as sender ID. + .. _Twilio: http://www.twilio.com/ """ @@ -78,6 +87,32 @@ def send_sms(self, device, token): self.client.messages.create(**send_kwargs) + def send_whatsapp(self, device, token): + """ + send whatsapp using template 'two_factor/twilio/sms_message.html' + """ + PLACE_TOKEN_AT_END_OF_MESSAGE = getattr(settings, 'PLACE_TOKEN_AT_END_OF_MESSAGE', False) + + if PLACE_TOKEN_AT_END_OF_MESSAGE: + whatsapp_approved_message = f"{getattr(settings, 'WHATSAPP_APPROVED_MESSAGE', 'Your OTP code is')} {token}" + else: + whatsapp_approved_message = f"{token} {getattr(settings, 'WHATSAPP_APPROVED_MESSAGE', 'is your OTP code.')}" + + body = whatsapp_approved_message + send_kwargs = { + 'to': f"whatsapp:{device.number.as_e164}", + 'body': body + } + messaging_service_sid = getattr(settings, 'TWILIO_MESSAGING_SERVICE_SID_WHATSAPP', None) + if messaging_service_sid is not None: + send_kwargs['messaging_service_sid'] = messaging_service_sid + else: + send_kwargs['from_'] = ( + f"whatsapp:{getattr(settings, 'TWILIO_CALLER_ID_WHATSAPP', settings.TWILIO_CALLER_ID)}", + ) + + self.client.messages.create(**send_kwargs) + def validate_voice_locale(locale): with translation.override(locale): diff --git a/two_factor/plugins/phonenumber/apps.py b/two_factor/plugins/phonenumber/apps.py index dc28d9f3d..e8a9d7e5e 100644 --- a/two_factor/plugins/phonenumber/apps.py +++ b/two_factor/plugins/phonenumber/apps.py @@ -18,7 +18,7 @@ def ready(self): def register_methods(sender, setting, value, **kwargs): # This allows for dynamic registration, typically when testing. - from .method import PhoneCallMethod, SMSMethod + from .method import PhoneCallMethod, SMSMethod, WhatsAppMethod if getattr(settings, 'TWO_FACTOR_CALL_GATEWAY', None): registry.register(PhoneCallMethod()) @@ -28,3 +28,7 @@ def register_methods(sender, setting, value, **kwargs): registry.register(SMSMethod()) else: registry.unregister('sms') + if getattr(settings, 'TWO_FACTOR_WHATSAPP_GATEWAY', None): + registry.register(WhatsAppMethod()) + else: + registry.unregister('wa') diff --git a/two_factor/plugins/phonenumber/method.py b/two_factor/plugins/phonenumber/method.py index 98edf2668..d89a8e4e6 100644 --- a/two_factor/plugins/phonenumber/method.py +++ b/two_factor/plugins/phonenumber/method.py @@ -46,3 +46,9 @@ class SMSMethod(PhoneMethodBase): verbose_name = _('Text message') action = _('Send text message to %s') verbose_action = _('We sent you a text message, please enter the token we sent.') + +class WhatsAppMethod(PhoneMethodBase): + code = 'wa' + verbose_name = _('WhatsApp message') + action = _('Send WhatsApp message to %s') + verbose_action = _('We sent you a WhatsApp message, please enter the token we sent.') diff --git a/two_factor/plugins/phonenumber/migrations/0002_alter_phonedevice_method.py b/two_factor/plugins/phonenumber/migrations/0002_alter_phonedevice_method.py new file mode 100644 index 000000000..36c693c65 --- /dev/null +++ b/two_factor/plugins/phonenumber/migrations/0002_alter_phonedevice_method.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.7 on 2023-11-02 06:55 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('phonenumber', '0001_squashed_0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='phonedevice', + name='method', + field=models.CharField(choices=[('call', 'Phone Call'), ('sms', 'Text Message'), ('wa', 'WhatsApp Message')], max_length=4, verbose_name='method'), + ), + ] diff --git a/two_factor/plugins/phonenumber/models.py b/two_factor/plugins/phonenumber/models.py index 92d6c2b7a..fa2e9340a 100644 --- a/two_factor/plugins/phonenumber/models.py +++ b/two_factor/plugins/phonenumber/models.py @@ -8,11 +8,13 @@ from django_otp.util import hex_validator, random_hex from phonenumber_field.modelfields import PhoneNumberField -from two_factor.gateways import make_call, send_sms +from two_factor.gateways import make_call, send_sms, send_whatsapp +WHATSAPP = 'wa' PHONE_METHODS = ( ('call', _('Phone Call')), ('sms', _('Text Message')), + (WHATSAPP, _('WhatsApp Message')), ) @@ -94,6 +96,8 @@ def generate_challenge(self): token = str(totp(self.bin_key, digits=no_digits)).zfill(no_digits) if self.method == 'call': make_call(device=self, token=token) + elif self.method == WHATSAPP: + send_whatsapp(device=self, token=token) else: send_sms(device=self, token=token) diff --git a/two_factor/plugins/phonenumber/templatetags/phonenumber.py b/two_factor/plugins/phonenumber/templatetags/phonenumber.py index fc05abf3f..b1fe61aab 100644 --- a/two_factor/plugins/phonenumber/templatetags/phonenumber.py +++ b/two_factor/plugins/phonenumber/templatetags/phonenumber.py @@ -39,6 +39,8 @@ def device_action(device): number = mask_phone_number_utils(format_phone_number_utils(device.number)) if device.method == 'sms': return _('Send text message to %s') % number + elif device.method == 'wa': + return _('Send WhatsApp message to %s') % number elif device.method == 'call': return _('Call number %s') % number raise NotImplementedError('Unknown method: %s' % device.method) diff --git a/two_factor/plugins/phonenumber/tests/test_method.py b/two_factor/plugins/phonenumber/tests/test_method.py index 997609b83..6da77d2a5 100644 --- a/two_factor/plugins/phonenumber/tests/test_method.py +++ b/two_factor/plugins/phonenumber/tests/test_method.py @@ -1,7 +1,7 @@ from django.test import TestCase from tests.utils import UserMixin -from two_factor.plugins.phonenumber.method import PhoneCallMethod, SMSMethod +from two_factor.plugins.phonenumber.method import PhoneCallMethod, SMSMethod, WhatsAppMethod class PhoneMethodBaseTestMixin(UserMixin): @@ -22,3 +22,7 @@ class PhoneCallMethodTest(PhoneMethodBaseTestMixin, TestCase): class SMSMethodTest(PhoneMethodBaseTestMixin, TestCase): method = SMSMethod() + + +class WhatsAppMethodTest(PhoneMethodBaseTestMixin, TestCase): + method = WhatsAppMethod() \ No newline at end of file diff --git a/two_factor/plugins/phonenumber/utils.py b/two_factor/plugins/phonenumber/utils.py index 9a8ea164e..485de7a29 100644 --- a/two_factor/plugins/phonenumber/utils.py +++ b/two_factor/plugins/phonenumber/utils.py @@ -10,6 +10,7 @@ def backup_phones(user): no_gateways = ( getattr(settings, 'TWO_FACTOR_CALL_GATEWAY', None) is None + and getattr(settings, 'TWO_FACTOR_WHATSAPP_GATEWAY', None) is None and getattr(settings, 'TWO_FACTOR_SMS_GATEWAY', None) is None) no_user = not user or user.is_anonymous @@ -25,6 +26,8 @@ def get_available_phone_methods(): methods.append(('call', _('Phone call'))) if getattr(settings, 'TWO_FACTOR_SMS_GATEWAY', None): methods.append(('sms', _('Text message'))) + if getattr(settings, 'TWO_FACTOR_WHATSAPP_GATEWAY', None): + methods.append(('wa', _('WhatsApp message'))) return methods diff --git a/two_factor/templates/two_factor/core/setup.html b/two_factor/templates/two_factor/core/setup.html index 1596fee00..5531e3dce 100644 --- a/two_factor/templates/two_factor/core/setup.html +++ b/two_factor/templates/two_factor/core/setup.html @@ -28,6 +28,10 @@

{% block title %}{% trans "Enable Two-Factor Authentication" %}{% endblock %

{% blocktrans trimmed %}Please enter the phone number you wish to receive the text messages on. This number will be validated in the next step. {% endblocktrans %}

+ {% elif wizard.steps.current == 'wa' %} +

{% blocktrans trimmed %}Please enter the phone number you wish to receive the + whatsapp messages on. This number will be validated in the next step. + {% endblocktrans %}

{% elif wizard.steps.current == 'call' %}

{% blocktrans trimmed %}Please enter the phone number you wish to be called on. This number will be validated in the next step. {% endblocktrans %}

@@ -39,6 +43,9 @@

{% block title %}{% trans "Enable Two-Factor Authentication" %}{% endblock % {% elif device.method == 'sms' %}

{% blocktrans trimmed %}We sent you a text message, please enter the tokens we sent.{% endblocktrans %}

+ {% elif device.method == 'wa' %} +

{% blocktrans trimmed %}We sent you a whatsapp message, please enter the tokens we + sent.{% endblocktrans %}

{% endif %} {% else %}