Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add whatsapp method #679

Open
wants to merge 12 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .bumpversion.cfg
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
[bumpversion]
current_version = 1.15.5
current_version = 1.16
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you remove the commits that change the version string? This is something that's done at release time.

commit = True
tag = True
tag_name = {new_version}
Expand Down
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
# Changelog

## 1.15.6
### Changed
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be

## Unreleased
### Added

- Added WhatsApp as a token method through Twilio
- Added the ability to modify the token input size
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think modifying the token input size should be a part of this PR


## 1.15.5
### Fixed
- Include transitively replaced migrations in phonenumber migration.
Expand Down
2 changes: 1 addition & 1 deletion docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@
#

# The full version, including alpha/beta/rc tags.
release = '1.15.5'
release = '1.16'
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you remove the commits that change the version string? This is something that's done at release time.


# The short X.Y version.
version = '.'.join(release.split('.')[0:2])
Expand Down
36 changes: 36 additions & 0 deletions docs/configuration.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
----------------------

Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/installation.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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.
]
Expand Down
5 changes: 5 additions & 0 deletions example/gateways.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
7 changes: 6 additions & 1 deletion example/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 2 additions & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

setup(
name='django-two-factor-auth',
version='1.15.5',
version='1.16',
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you remove the commits that change the version string? This is something that's done at release time.

description='Complete Two-Factor Authentication for Django',
long_description=open('README.rst', encoding='utf-8').read(),
author='Bouke Haarsma',
Expand All @@ -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'],
Expand Down
25 changes: 19 additions & 6 deletions tests/test_gateways.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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)

54 changes: 54 additions & 0 deletions tests/test_views_setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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')
15 changes: 9 additions & 6 deletions two_factor/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's not clear that we need this


class MethodForm(forms.Form):
method = forms.ChoiceField(label=_("Method"),
widget=forms.RadioSelect)

def __init__(self, **kwargs):
super().__init__(**kwargs)

Expand All @@ -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;'})
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This won't work if a site is using CSP and disallows inline styles

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You are correct, I will remove this functionality from this pr.

error_messages = {
'invalid_token': _('Entered token is not valid.'),
'invalid_token': _('Entered code is not valid.'),
}

def __init__(self, device, **kwargs):
Expand All @@ -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.'),
Expand Down Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions two_factor/gateways/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
35 changes: 35 additions & 0 deletions two_factor/gateways/twilio/gateway.py
Original file line number Diff line number Diff line change
Expand Up @@ -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/
"""

Expand Down Expand Up @@ -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):
Expand Down
6 changes: 5 additions & 1 deletion two_factor/plugins/phonenumber/apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand All @@ -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')
6 changes: 6 additions & 0 deletions two_factor/plugins/phonenumber/method.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.')
Loading