diff --git a/docs/backends.rst b/docs/backends.rst index 5e2fa770..84c341b9 100644 --- a/docs/backends.rst +++ b/docs/backends.rst @@ -53,7 +53,7 @@ Using Twilio "sms_twilio": { VERBOSE_NAME: _("sms_twilio"), VALIDITY_PERIOD: 30, - HANDLER: "trench.backends.twilio.TwilioMessageDispatcher", + HANDLER: "trench.backends.twilio.TwilioSMSMessageDispatcher", SOURCE_FIELD: "phone_number", TWILIO_VERIFIED_FROM_NUMBER: "+48 123 456 789", }, @@ -86,6 +86,33 @@ Using SMS API :SMSAPI_ACCESS_TOKEN: Access token obtained from `SMS API`_ :SMSAPI_FROM_NUMBER: This will be used as the sender's phone number. +Phone call +********** + +| Phone call backend make call with `Twilio`_ . Credentials can be set in method's specific settings. + +Using Twilio +------------ + +| If you are using Twilio service for calling then you need to set ``TWILIO_ACCOUNT_SID`` and ``TWILIO_AUTH_TOKEN`` environment variables for Twilio API client to be used as credentials. + +.. code-block:: python + + TRENCH_AUTH = { + "MFA_METHODS": { + "call_twilio": { + VERBOSE_NAME: _("call_twilio"), + VALIDITY_PERIOD: 30, + HANDLER: "trench.backends.twilio.TwilioCallMessageDispatcher", + SOURCE_FIELD: "phone_number", + TWILIO_VERIFIED_FROM_NUMBER: "+48 123 456 789", + }, + }, + } + +:SOURCE_FIELD: Defines the field name in your ``AUTH_USER_MODEL`` to be looked up and used as field containing the phone number of the recipient of the OTP code. +:TWILIO_VERIFIED_FROM_NUMBER: This will be used as the sender's phone number. Note: this number must be verified in the Twilio's client panel. + Authentication apps ******************* | This backend returns OTP based QR link to be scanned by apps like Gooogle Authenticator and Authy. diff --git a/testproject/settings.py b/testproject/settings.py index b393c99b..52bf2be7 100644 --- a/testproject/settings.py +++ b/testproject/settings.py @@ -2,6 +2,8 @@ import environ import os +from trench.settings import MfaMethods + root = environ.Path(__file__) - 1 env = environ.Env() @@ -123,17 +125,27 @@ "BACKUP_CODES_QUANTITY": 8, "DEFAULT_VALIDITY_PERIOD": 60, "MFA_METHODS": { - "sms_twilio": { + MfaMethods.CALL_TWILIO.value: { + "VERBOSE_NAME": "call", + "VALIDITY_PERIOD": 60, + "HANDLER": "trench.backends.twilio.TwilioCallMessageDispatcher", + "SOURCE_FIELD": "phone_number", + "TWILIO_VERIFIED_FROM_NUMBER": env( + "TWILIO_VERIFIED_FROM_NUMBER", + default="", + ), + }, + MfaMethods.SMS_TWILIO.value: { "VERBOSE_NAME": "sms", "VALIDITY_PERIOD": 60, - "HANDLER": "trench.backends.twilio.TwilioMessageDispatcher", + "HANDLER": "trench.backends.twilio.TwilioSMSMessageDispatcher", "SOURCE_FIELD": "phone_number", "TWILIO_VERIFIED_FROM_NUMBER": env( "TWILIO_VERIFIED_FROM_NUMBER", default="", ), }, - "sms_api": { + MfaMethods.SMS_API.value: { "VERBOSE_NAME": "sms", "VALIDITY_PERIOD": 60, "HANDLER": "trench.backends.sms_api.SMSAPIMessageDispatcher", @@ -141,7 +153,7 @@ "SMSAPI_ACCESS_TOKEN": "token", "SMSAPI_FROM_NUMBER": "123 456 789", }, - "email": { + MfaMethods.EMAIL.value: { "VERBOSE_NAME": "email", "VALIDITY_PERIOD": 60, "HANDLER": "trench.backends.basic_mail.SendMailMessageDispatcher", @@ -150,13 +162,13 @@ "EMAIL_PLAIN_TEMPLATE": "trench/backends/email/code.txt", "EMAIL_HTML_TEMPLATE": "trench/backends/email/code.html", }, - "app": { + MfaMethods.APP.value: { "VERBOSE_NAME": "app", "VALIDITY_PERIOD": 60, "USES_THIRD_PARTY_CLIENT": True, "HANDLER": "trench.backends.application.ApplicationMessageDispatcher", }, - "yubi": { + MfaMethods.YUBI.value: { "VERBOSE_NAME": "yubi", "HANDLER": "trench.backends.yubikey.YubiKeyMessageDispatcher", "YUBICLOUD_CLIENT_ID": env("YUBICLOUD_CLIENT_ID", default=""), diff --git a/testproject/tests/conftest.py b/testproject/tests/conftest.py index 06b0f6dc..e7754ed6 100644 --- a/testproject/tests/conftest.py +++ b/testproject/tests/conftest.py @@ -14,6 +14,7 @@ from trench.command.create_secret import create_secret_command from trench.command.generate_backup_codes import generate_backup_codes_command from trench.models import MFAMethod as MFAMethodModel +from trench.settings import MfaMethods User = get_user_model() @@ -49,19 +50,17 @@ def mfa_method_creator( @pytest.fixture() def active_user_with_application_otp() -> UserModel: - user, created = User.objects.get_or_create( - username="imhotep", email="imhotep@pyramids.eg" - ) - if created: - user.set_password("secretkey") - user.is_active = True - user.save() - mfa_method_creator(user=user, method_name="app") + user = active_user_with_email(MfaMethods.APP.value) return user @pytest.fixture() def active_user_with_email_otp() -> UserModel: + user = active_user_with_email(MfaMethods.EMAIL.value) + return user + + +def active_user_with_email(method_name: str) -> UserModel: user, created = User.objects.get_or_create( username="imhotep", email="imhotep@pyramids.eg", @@ -70,7 +69,7 @@ def active_user_with_email_otp() -> UserModel: user.set_password("secretkey"), user.is_active = True user.save() - mfa_method_creator(user=user, method_name="email") + mfa_method_creator(user=user, method_name=method_name) return user @@ -83,19 +82,23 @@ def deactivated_user_with_email_otp(active_user_with_email_otp) -> UserModel: @pytest.fixture() def active_user_with_sms_otp() -> UserModel: - user, created = User.objects.get_or_create( - username="imhotep", email="imhotep@pyramids.eg", phone_number="555-555-555" - ) - if created: - user.set_password("secretkey"), - user.is_active = True - user.save() - mfa_method_creator(user=user, method_name="sms_api") + user = active_user_with_phone_number(MfaMethods.SMS_API.value) return user @pytest.fixture() -def active_user_with_twilio_otp() -> UserModel: +def active_user_with_twilio_sms_otp() -> UserModel: + user = active_user_with_phone_number(MfaMethods.SMS_TWILIO.value) + return user + + +@pytest.fixture() +def active_user_with_twilio_call_otp() -> UserModel: + user = active_user_with_phone_number(MfaMethods.CALL_TWILIO.value) + return user + + +def active_user_with_phone_number(method_name: str) -> UserModel: user, created = User.objects.get_or_create( username="imhotep", email="imhotep@pyramids.eg", phone_number="555-555-555" ) @@ -103,7 +106,7 @@ def active_user_with_twilio_otp() -> UserModel: user.set_password("secretkey"), user.is_active = True user.save() - mfa_method_creator(user=user, method_name="sms_twilio") + mfa_method_creator(user=user, method_name=method_name) return user @@ -117,12 +120,18 @@ def active_user_with_email_and_inactive_other_methods_otp() -> UserModel: user.set_password("secretkey"), user.is_active = True user.save() - mfa_method_creator(user=user, method_name="email") + mfa_method_creator(user=user, method_name=MfaMethods.EMAIL.value) mfa_method_creator( - user=user, method_name="sms_twilio", is_primary=False, is_active=False + user=user, + method_name=MfaMethods.SMS_TWILIO.value, + is_primary=False, + is_active=False, ) mfa_method_creator( - user=user, method_name="app", is_primary=False, is_active=False + user=user, + method_name=MfaMethods.APP.value, + is_primary=False, + is_active=False, ) return user @@ -137,12 +146,12 @@ def active_user_with_email_and_active_other_methods_otp() -> UserModel: user.set_password("secretkey"), user.is_active = True user.save() - mfa_method_creator(user=user, method_name="email") + mfa_method_creator(user=user, method_name=MfaMethods.EMAIL.value) mfa_method_creator( - user=user, method_name="sms_twilio", is_primary=False + user=user, method_name=MfaMethods.SMS_TWILIO.value, is_primary=False ) mfa_method_creator( - user=user, method_name="app", is_primary=False + user=user, method_name=MfaMethods.APP.value, is_primary=False ) return user @@ -171,7 +180,9 @@ def active_user_with_backup_codes(encrypt_codes: bool) -> Tuple[UserModel, Set[s user.is_active = True user.save() mfa_method_creator( - user=user, method_name="email", _backup_codes=serialized_backup_codes + user=user, + method_name=MfaMethods.EMAIL.value, + _backup_codes=serialized_backup_codes, ) return user, backup_codes @@ -192,23 +203,25 @@ def active_user_with_many_otp_methods() -> Tuple[UserModel, str]: user.is_active = True user.save() mfa_method_creator( - user=user, method_name="email", _backup_codes=encrypted_backup_codes + user=user, + method_name=MfaMethods.EMAIL.value, + _backup_codes=encrypted_backup_codes, ) mfa_method_creator( user=user, - method_name="sms_twilio", + method_name=MfaMethods.SMS_TWILIO.value, is_primary=False, _backup_codes=encrypted_backup_codes, ) mfa_method_creator( user=user, - method_name="app", + method_name=MfaMethods.APP.value, is_primary=False, _backup_codes=encrypted_backup_codes, ) mfa_method_creator( user=user, - method_name="yubi", + method_name=MfaMethods.YUBI.value, is_primary=False, _backup_codes=encrypted_backup_codes, ) @@ -272,7 +285,7 @@ def active_user_with_yubi() -> UserModel: user.save() mfa_method_creator( user=user, - method_name="yubi", + method_name=MfaMethods.YUBI.value, secret=FAKE_YUBI_SECRET, _backup_codes=encrypted_backup_codes, ) diff --git a/testproject/tests/test_add_mfa.py b/testproject/tests/test_add_mfa.py index 362758ee..3cd47163 100644 --- a/testproject/tests/test_add_mfa.py +++ b/testproject/tests/test_add_mfa.py @@ -7,6 +7,7 @@ from tests.utils import TrenchAPIClient from trench.command.create_otp import create_otp_command from trench.command.create_secret import create_secret_command +from trench.settings import MfaMethods User = get_user_model() @@ -18,7 +19,7 @@ def test_add_user_mfa(active_user): client.authenticate(user=active_user) secret = create_secret_command() response = client.post( - path="/auth/email/activate/", + path=f"/auth/{MfaMethods.EMAIL}/activate/", data={ "secret": secret, "code": create_otp_command(secret=secret, interval=60).now(), diff --git a/testproject/tests/test_backends.py b/testproject/tests/test_backends.py index 182e4c33..78d91cb7 100644 --- a/testproject/tests/test_backends.py +++ b/testproject/tests/test_backends.py @@ -2,21 +2,44 @@ from django.contrib.auth import get_user_model +from tests.conftest import mfa_method_creator from trench.backends.application import ApplicationMessageDispatcher from trench.backends.sms_api import SMSAPIMessageDispatcher -from trench.backends.twilio import TwilioMessageDispatcher +from trench.backends.twilio import ( + TwilioCallMessageDispatcher, + TwilioSMSMessageDispatcher, +) from trench.backends.yubikey import YubiKeyMessageDispatcher from trench.exceptions import MissingConfigurationError +from trench.settings import MfaMethods User = get_user_model() @pytest.mark.django_db -def test_twilio_backend_without_credentials(active_user_with_twilio_otp, settings): - auth_method = active_user_with_twilio_otp.mfa_methods.get(name="sms_twilio") - conf = settings.TRENCH_AUTH["MFA_METHODS"]["sms_twilio"] - response = TwilioMessageDispatcher( +def test_twilio_sms_backend_without_credentials( + active_user_with_twilio_sms_otp, settings +): + auth_method = active_user_with_twilio_sms_otp.mfa_methods.get( + name=MfaMethods.SMS_TWILIO.value + ) + conf = settings.TRENCH_AUTH["MFA_METHODS"][MfaMethods.SMS_TWILIO.value] + response = TwilioSMSMessageDispatcher( + mfa_method=auth_method, config=conf + ).dispatch_message() + assert response.data.get("details")[:23] == "Unable to create record" + + +@pytest.mark.django_db +def test_twilio_call_backend_without_credentials( + active_user_with_twilio_call_otp, settings +): + auth_method = active_user_with_twilio_call_otp.mfa_methods.get( + name=MfaMethods.CALL_TWILIO.value + ) + conf = settings.TRENCH_AUTH["MFA_METHODS"][MfaMethods.CALL_TWILIO.value] + response = TwilioSMSMessageDispatcher( mfa_method=auth_method, config=conf ).dispatch_message() assert response.data.get("details")[:23] == "Unable to create record" @@ -24,8 +47,10 @@ def test_twilio_backend_without_credentials(active_user_with_twilio_otp, setting @pytest.mark.django_db def test_sms_api_backend_without_credentials(active_user_with_sms_otp, settings): - auth_method = active_user_with_sms_otp.mfa_methods.get(name="sms_api") - conf = settings.TRENCH_AUTH["MFA_METHODS"]["sms_api"] + auth_method = active_user_with_sms_otp.mfa_methods.get( + name=MfaMethods.SMS_API.value + ) + conf = settings.TRENCH_AUTH["MFA_METHODS"][MfaMethods.SMS_API.value] response = SMSAPIMessageDispatcher( mfa_method=auth_method, config=conf ).dispatch_message() @@ -34,9 +59,11 @@ def test_sms_api_backend_without_credentials(active_user_with_sms_otp, settings) @pytest.mark.django_db def test_sms_api_backend_with_wrong_credentials(active_user_with_sms_otp, settings): - auth_method = active_user_with_sms_otp.mfa_methods.get(name="sms_api") - conf = settings.TRENCH_AUTH["MFA_METHODS"]["sms_api"] - settings.TRENCH_AUTH["MFA_METHODS"]["sms_api"][ + auth_method = active_user_with_sms_otp.mfa_methods.get( + name=MfaMethods.SMS_API.value + ) + conf = settings.TRENCH_AUTH["MFA_METHODS"][MfaMethods.SMS_API.value] + settings.TRENCH_AUTH["MFA_METHODS"][MfaMethods.SMS_API.value][ "SMSAPI_ACCESS_TOKEN" ] = "wrong-token" response = SMSAPIMessageDispatcher( @@ -46,22 +73,76 @@ def test_sms_api_backend_with_wrong_credentials(active_user_with_sms_otp, settin @pytest.mark.django_db -def test_sms_backend_misconfiguration_error(active_user_with_twilio_otp, settings): - auth_method = active_user_with_twilio_otp.mfa_methods.get(name="sms_twilio") - conf = settings.TRENCH_AUTH["MFA_METHODS"]["sms_twilio"] - current_source = settings.TRENCH_AUTH["MFA_METHODS"]["sms_twilio"]["SOURCE_FIELD"] - settings.TRENCH_AUTH["MFA_METHODS"]["sms_twilio"]["SOURCE_FIELD"] = "invalid.source" +def test_twilio_sms_backend_misconfiguration_error( + active_user_with_twilio_sms_otp, settings +): + auth_method = active_user_with_twilio_sms_otp.mfa_methods.get( + name=MfaMethods.SMS_TWILIO.value + ) + conf = settings.TRENCH_AUTH["MFA_METHODS"][MfaMethods.SMS_TWILIO.value] + current_source = settings.TRENCH_AUTH["MFA_METHODS"][MfaMethods.SMS_TWILIO.value][ + "SOURCE_FIELD" + ] + settings.TRENCH_AUTH["MFA_METHODS"][MfaMethods.SMS_TWILIO.value][ + "SOURCE_FIELD" + ] = "invalid.source" with pytest.raises(MissingConfigurationError): - SMSAPIMessageDispatcher(mfa_method=auth_method, config=conf).dispatch_message() - settings.TRENCH_AUTH["MFA_METHODS"]["sms_twilio"]["SOURCE_FIELD"] = current_source + TwilioSMSMessageDispatcher( + mfa_method=auth_method, config=conf + ).dispatch_message() + settings.TRENCH_AUTH["MFA_METHODS"][MfaMethods.SMS_TWILIO.value][ + "SOURCE_FIELD" + ] = current_source + + +@pytest.mark.django_db +def test_twilio_call_backend_misconfiguration_error( + active_user_with_twilio_call_otp, settings +): + auth_method = active_user_with_twilio_call_otp.mfa_methods.get( + name=MfaMethods.CALL_TWILIO.value + ) + conf = settings.TRENCH_AUTH["MFA_METHODS"][MfaMethods.CALL_TWILIO.value] + current_source = settings.TRENCH_AUTH["MFA_METHODS"][MfaMethods.CALL_TWILIO.value][ + "SOURCE_FIELD" + ] + settings.TRENCH_AUTH["MFA_METHODS"][MfaMethods.CALL_TWILIO.value][ + "SOURCE_FIELD" + ] = "invalid.source" + with pytest.raises(MissingConfigurationError): + TwilioCallMessageDispatcher( + mfa_method=auth_method, config=conf + ).dispatch_message() + settings.TRENCH_AUTH["MFA_METHODS"][MfaMethods.CALL_TWILIO.value][ + "SOURCE_FIELD" + ] = current_source + + +@pytest.mark.django_db +def test_twilio_call_backend(active_user_with_many_otp_methods, settings): + user, code = active_user_with_many_otp_methods + mfa_method_creator( + user=user, method_name=MfaMethods.CALL_TWILIO.value, is_primary=False + ) + config = settings.TRENCH_AUTH["MFA_METHODS"][MfaMethods.CALL_TWILIO.value] + auth_method = user.mfa_methods.get(name=MfaMethods.CALL_TWILIO.value) + response = TwilioCallMessageDispatcher( + mfa_method=auth_method, config=config + ).dispatch_message() + assert ( + response.data.get("details") + == "Unable to create record: The requested resource /2010-04-01/Accounts/TEST/Calls.json was not found" + ) @pytest.mark.django_db def test_application_backend_generating_url_successfully( active_user_with_application_otp, settings ): - auth_method = active_user_with_application_otp.mfa_methods.get(name="app") - conf = settings.TRENCH_AUTH["MFA_METHODS"]["app"] + auth_method = active_user_with_application_otp.mfa_methods.get( + name=MfaMethods.APP.value + ) + conf = settings.TRENCH_AUTH["MFA_METHODS"][MfaMethods.APP.value] response = ApplicationMessageDispatcher( mfa_method=auth_method, config=conf ).dispatch_message() @@ -74,7 +155,7 @@ def test_application_backend_generating_url_successfully( @pytest.mark.django_db def test_yubikey_backend(active_user_with_many_otp_methods, settings): user, code = active_user_with_many_otp_methods - config = settings.TRENCH_AUTH["MFA_METHODS"]["yubi"] - auth_method = user.mfa_methods.get(name="yubi") + config = settings.TRENCH_AUTH["MFA_METHODS"][MfaMethods.YUBI.value] + auth_method = user.mfa_methods.get(name=MfaMethods.YUBI.value) dispatcher = YubiKeyMessageDispatcher(mfa_method=auth_method, config=config) dispatcher.confirm_activation(code) diff --git a/testproject/tests/test_commands.py b/testproject/tests/test_commands.py index fd8d86d2..a1cfc820 100644 --- a/testproject/tests/test_commands.py +++ b/testproject/tests/test_commands.py @@ -6,7 +6,7 @@ remove_backup_code_command, ) from trench.exceptions import MFAMethodDoesNotExistError, MFANotEnabledError -from trench.settings import DEFAULTS, TrenchAPISettings +from trench.settings import DEFAULTS, MfaMethods, TrenchAPISettings from trench.utils import get_mfa_model @@ -34,14 +34,16 @@ def test_remove_not_encrypted_code(active_user_with_non_encrypted_backup_codes): code = next(iter(codes)) remove_backup_code_command( user_id=user.id, - method_name="email", + method_name=MfaMethods.EMAIL.value, code=code, ) @pytest.mark.django_db def test_deactivate_inactive_mfa(active_user_with_application_otp): - mfa_method = active_user_with_application_otp.mfa_methods.get(name="app") + mfa_method = active_user_with_application_otp.mfa_methods.get( + name=MfaMethods.APP.value + ) mfa_method.is_active = False mfa_method.is_primary = False mfa_method.save() @@ -54,7 +56,9 @@ def test_deactivate_inactive_mfa(active_user_with_application_otp): @pytest.mark.django_db def test_deactivate_an_only_mfa_method(active_user_with_application_otp): - mfa_method = active_user_with_application_otp.mfa_methods.get(name="app") + mfa_method = active_user_with_application_otp.mfa_methods.get( + name=MfaMethods.APP.value + ) deactivate_mfa_method_command( user_id=active_user_with_application_otp.id, mfa_method_name=mfa_method.name, @@ -62,8 +66,7 @@ def test_deactivate_an_only_mfa_method(active_user_with_application_otp): mfa_model = get_mfa_model() mfa_method.refresh_from_db() assert mfa_method.is_active is False - assert len( - mfa_model.objects.list_active( - user_id=active_user_with_application_otp.id - ) - ) == 0 + assert ( + len(mfa_model.objects.list_active(user_id=active_user_with_application_otp.id)) + == 0 + ) diff --git a/testproject/tests/test_exceptions.py b/testproject/tests/test_exceptions.py index 26f7d07d..62ae01f0 100644 --- a/testproject/tests/test_exceptions.py +++ b/testproject/tests/test_exceptions.py @@ -19,7 +19,7 @@ ProtectedActionValidator, RequestBodyValidator, ) -from trench.settings import DEFAULTS, TrenchAPISettings +from trench.settings import DEFAULTS, MfaMethods, TrenchAPISettings def test_method_handler_missing_error(): @@ -31,7 +31,9 @@ def test_method_handler_missing_error(): def test_code_missing_error(): - validator = ProtectedActionValidator(mfa_method_name="yubi", user=None) + validator = ProtectedActionValidator( + mfa_method_name=MfaMethods.YUBI.value, user=None + ) with pytest.raises(OTPCodeMissingError): validator.validate_code(value="") @@ -45,14 +47,16 @@ def test_request_body_validator(): def test_protected_action_validator(): - validator = ProtectedActionValidator(mfa_method_name="yubi", user=None) + validator = ProtectedActionValidator( + mfa_method_name=MfaMethods.YUBI.value, user=None + ) with pytest.raises(NotImplementedError): validator._validate_mfa_method(mfa=MFAMethod()) def test_mfa_method_activation_validator(): validator = MFAMethodActivationConfirmationValidator( - mfa_method_name="yubi", user=None + mfa_method_name=MfaMethods.YUBI.value, user=None ) with pytest.raises(MFAMethodAlreadyActiveError): validator._validate_mfa_method(mfa=MFAMethod(is_active=True)) @@ -65,7 +69,7 @@ def test_primary_method_inactive_error( with pytest.raises(MFAPrimaryMethodInactiveError): set_primary_mfa_method_command( user_id=active_user_with_email_and_inactive_other_methods_otp.id, - name="sms_twilio", + name=MfaMethods.SMS_TWILIO.value, ) diff --git a/testproject/tests/test_second_step_authentication.py b/testproject/tests/test_second_step_authentication.py index 5ae8c737..3f56b19a 100644 --- a/testproject/tests/test_second_step_authentication.py +++ b/testproject/tests/test_second_step_authentication.py @@ -19,6 +19,7 @@ ) from trench.exceptions import MFAMethodDoesNotExistError from trench.models import MFAMethod +from trench.settings import MfaMethods User = get_user_model() @@ -33,7 +34,7 @@ def test_mfa_method_manager(active_user): @pytest.mark.django_db def test_mfa_model(active_user_with_email_otp): mfa_method = active_user_with_email_otp.mfa_methods.first() - assert "email" in str(mfa_method) + assert MfaMethods.EMAIL.value in str(mfa_method) mfa_method.backup_codes = ["test1", "test2"] assert mfa_method.backup_codes == ["test1", "test2"] @@ -42,10 +43,10 @@ def test_mfa_model(active_user_with_email_otp): @pytest.mark.django_db def test_custom_validity_period(active_user_with_email_otp, settings): - ORIGINAL_VALIDITY_PERIOD = settings.TRENCH_AUTH["MFA_METHODS"]["email"][ - "VALIDITY_PERIOD" - ] - settings.TRENCH_AUTH["MFA_METHODS"]["email"]["VALIDITY_PERIOD"] = 3 + ORIGINAL_VALIDITY_PERIOD = settings.TRENCH_AUTH["MFA_METHODS"][ + MfaMethods.EMAIL.value + ]["VALIDITY_PERIOD"] + settings.TRENCH_AUTH["MFA_METHODS"][MfaMethods.EMAIL.value]["VALIDITY_PERIOD"] = 3 mfa_method = active_user_with_email_otp.mfa_methods.first() client = TrenchAPIClient() @@ -68,7 +69,7 @@ def test_custom_validity_period(active_user_with_email_otp, settings): ) assert response_second_step.status_code == HTTP_200_OK - settings.TRENCH_AUTH["MFA_METHODS"]["email"][ + settings.TRENCH_AUTH["MFA_METHODS"][MfaMethods.EMAIL.value][ "VALIDITY_PERIOD" ] = ORIGINAL_VALIDITY_PERIOD @@ -139,7 +140,7 @@ def test_second_method_activation(active_user_with_email_otp): assert len(active_user_with_email_otp.mfa_methods.all()) == 1 try: client.post( - path="/auth/sms_twilio/activate/", + path=f"/auth/{MfaMethods.SMS_TWILIO}/activate/", data={"phone_number": "555-555-555"}, format="json", ) @@ -158,13 +159,29 @@ def test_second_method_activation_already_active(active_user_with_email_otp): ) assert len(active_user_with_email_otp.mfa_methods.all()) == 1 response = client.post( - path="/auth/email/activate/", + path=f"/auth/{MfaMethods.EMAIL}/activate/", format="json", ) assert response.status_code == HTTP_400_BAD_REQUEST assert response.data.get("error") == "MFA method already active." +@pytest.mark.django_db +def test_second_method_not_exist(active_user_with_email_otp): + mfa_method = active_user_with_email_otp.mfa_methods.first() + client = TrenchAPIClient() + client.authenticate_multi_factor( + mfa_method=mfa_method, user=active_user_with_email_otp + ) + assert len(active_user_with_email_otp.mfa_methods.all()) == 1 + response = client.post( + path="/auth/not_existing/activate/", + format="json", + ) + assert response.status_code == HTTP_400_BAD_REQUEST + assert response.data.get("error") == "Requested MFA method does not exist." + + @pytest.mark.django_db def test_use_backup_code(active_user_with_encrypted_backup_codes): client = TrenchAPIClient() @@ -187,7 +204,7 @@ def test_activation_otp(active_user): client = TrenchAPIClient() client.authenticate(user=active_user) response = client.post( - path="/auth/email/activate/", + path=f"/auth/{MfaMethods.EMAIL}/activate/", format="json", ) assert response.status_code == HTTP_200_OK @@ -198,12 +215,12 @@ def test_activation_otp_confirm_wrong(active_user): client = TrenchAPIClient() client.authenticate(user=active_user) response = client.post( - path="/auth/email/activate/", + path=f"/auth/{MfaMethods.EMAIL}/activate/", format="json", ) assert response.status_code == HTTP_200_OK response = client.post( - path="/auth/email/activate/confirm/", + path=f"/auth/{MfaMethods.EMAIL}/activate/confirm/", data={"code": "test00"}, format="json", ) @@ -219,7 +236,7 @@ def test_confirm_activation_otp(active_user): # create new MFA method client.post( - path="/auth/email/activate/", + path=f"/auth/{MfaMethods.EMAIL}/activate/", format="json", ) mfa_method = active_user.mfa_methods.first() @@ -227,7 +244,7 @@ def test_confirm_activation_otp(active_user): # activate the newly created MFA method response = client.post( - path="/auth/email/activate/confirm/", + path=f"/auth/{MfaMethods.EMAIL}/activate/confirm/", data={"code": handler.create_code()}, format="json", ) @@ -246,7 +263,7 @@ def test_deactivation_of_an_only_primary_mfa_method(active_user_with_email_otp): mfa_method=mfa_method, user=active_user_with_email_otp ) response = client.post( - path="/auth/email/deactivate/", + path=f"/auth/{MfaMethods.EMAIL}/deactivate/", data={"code": handler.create_code()}, format="json", ) @@ -255,20 +272,19 @@ def test_deactivation_of_an_only_primary_mfa_method(active_user_with_email_otp): @pytest.mark.django_db def test_deactivation_of_an_only_primary_mfa_method_when_other_mfa_inactive( - active_user_with_email_and_inactive_other_methods_otp + active_user_with_email_and_inactive_other_methods_otp, ): client = TrenchAPIClient() mfa_method = ( - active_user_with_email_and_inactive_other_methods_otp.mfa_methods - .first() + active_user_with_email_and_inactive_other_methods_otp.mfa_methods.first() ) handler = get_mfa_handler(mfa_method=mfa_method) client.authenticate_multi_factor( mfa_method=mfa_method, - user=active_user_with_email_and_inactive_other_methods_otp + user=active_user_with_email_and_inactive_other_methods_otp, ) response = client.post( - path="/auth/email/deactivate/", + path=f"/auth/{MfaMethods.EMAIL}/deactivate/", data={"code": handler.create_code()}, format="json", ) @@ -277,7 +293,7 @@ def test_deactivation_of_an_only_primary_mfa_method_when_other_mfa_inactive( @pytest.mark.django_db def test_deactivation_of_primary_mfa_method_when_other_active_mfa_methods( - active_user_with_email_and_active_other_methods_otp + active_user_with_email_and_active_other_methods_otp, ): client = TrenchAPIClient() mfa_method = active_user_with_email_and_active_other_methods_otp.mfa_methods.first() @@ -286,7 +302,7 @@ def test_deactivation_of_primary_mfa_method_when_other_active_mfa_methods( mfa_method=mfa_method, user=active_user_with_email_and_active_other_methods_otp ) response = client.post( - path="/auth/email/deactivate/", + path=f"/auth/{MfaMethods.EMAIL}/deactivate/", data={"code": handler.create_code()}, format="json", ) @@ -324,7 +340,7 @@ def test_deactivation_of_disabled_method( handler = get_mfa_handler(mfa_method=mfa_method) client.authenticate_multi_factor(mfa_method=mfa_method, user=user) response = client.post( - path="/auth/sms_twilio/deactivate/", + path=f"/auth/{MfaMethods.SMS_TWILIO}/deactivate/", data={"code": handler.create_code()}, format="json", ) @@ -342,7 +358,7 @@ def test_change_primary_method(active_user_with_many_otp_methods): response = client.post( path="/auth/mfa/change-primary-method/", data={ - "method": "sms_twilio", + "method": MfaMethods.SMS_TWILIO.value, "code": handler.create_code(), }, format="json", @@ -352,7 +368,7 @@ def test_change_primary_method(active_user_with_many_otp_methods): ).first() assert response.status_code == HTTP_204_NO_CONTENT assert primary_mfa != new_primary_method - assert new_primary_method.name == "sms_twilio" + assert new_primary_method.name == MfaMethods.SMS_TWILIO.value # revert changes new_primary_method.is_primary = False @@ -372,7 +388,7 @@ def test_change_primary_method_with_backup_code( response = client.post( path="/auth/mfa/change-primary-method/", data={ - "method": "sms_twilio", + "method": MfaMethods.SMS_TWILIO.value, "code": backup_code, }, format="json", @@ -382,7 +398,7 @@ def test_change_primary_method_with_backup_code( ).first() assert response.status_code == HTTP_204_NO_CONTENT assert primary_mfa_method != new_primary_method - assert new_primary_method.name == "sms_twilio" + assert new_primary_method.name == MfaMethods.SMS_TWILIO.value # revert changes primary_mfa_method.is_primary = True @@ -400,7 +416,7 @@ def test_change_primary_method_with_invalid_code(active_user_with_many_otp_metho response = client.post( path="/auth/mfa/change-primary-method/", data={ - "method": "sms_twilio", + "method": MfaMethods.SMS_TWILIO.value, "code": "invalid", }, format="json", @@ -422,7 +438,7 @@ def test_change_primary_method_to_inactive(active_user_with_email_otp): response = client.post( path="/auth/mfa/change-primary-method/", data={ - "method": "sms_twilio", + "method": MfaMethods.SMS_TWILIO.value, "code": handler.create_code(), }, format="json", @@ -446,7 +462,7 @@ def test_confirm_activation_otp_with_backup_code( client._update_jwt_from_response(response=response) try: client.post( - path="/auth/sms_twilio/activate/", + path=f"/auth/{MfaMethods.SMS_TWILIO}/activate/", data={"phone_number": "555-555-555"}, format="json", ) @@ -456,10 +472,10 @@ def test_confirm_activation_otp_with_backup_code( pass backup_codes = regenerate_backup_codes_for_mfa_method_command( - user_id=active_user.id, name="sms_twilio" + user_id=active_user.id, name=MfaMethods.SMS_TWILIO.value ) response = client.post( - path="/auth/sms_twilio/activate/confirm/", + path=f"/auth/{MfaMethods.SMS_TWILIO}/activate/confirm/", data={"code": backup_codes.pop()}, format="json", ) @@ -467,7 +483,7 @@ def test_confirm_activation_otp_with_backup_code( assert len(response.data.get("backup_codes")) == 8 # revert changes - active_user.mfa_methods.filter(name="sms_twilio").delete() + active_user.mfa_methods.filter(name=MfaMethods.SMS_TWILIO.value).delete() @pytest.mark.django_db @@ -479,7 +495,7 @@ def test_request_code_for_active_mfa_method(active_user_with_email_otp): ) response = client.post( path="/auth/code/request/", - data={"method": "email"}, + data={"method": MfaMethods.EMAIL.value}, format="json", ) expected_msg = "Email message with MFA code has been sent." @@ -496,7 +512,7 @@ def test_request_code_for_not_inactive_mfa_method(active_user_with_email_otp): ) response = client.post( path="/auth/code/request/", - data={"method": "sms_twilio"}, + data={"method": MfaMethods.SMS_TWILIO.value}, format="json", ) assert response.status_code == HTTP_400_BAD_REQUEST @@ -559,7 +575,9 @@ def test_backup_codes_regeneration_disabled_method( handler = get_mfa_handler(mfa_method=primary_method) client.authenticate_multi_factor(mfa_method=primary_method, user=active_user) - active_user.mfa_methods.filter(name="sms_twilio").update(is_active=False) + active_user.mfa_methods.filter(name=MfaMethods.SMS_TWILIO.value).update( + is_active=False + ) response = client.post( path="/auth/sms_twilio/codes/regenerate/", @@ -570,7 +588,9 @@ def test_backup_codes_regeneration_disabled_method( assert response.data.get("code")[0].code == "not_enabled" # revert changes - active_user.mfa_methods.filter(name="sms_twilio").update(is_active=True) + active_user.mfa_methods.filter(name=MfaMethods.SMS_TWILIO.value).update( + is_active=True + ) @pytest.mark.django_db @@ -607,11 +627,11 @@ def test_confirm_yubikey_activation_with_backup_code( ) client._update_jwt_from_response(response=response) client.post( - path="/auth/yubi/activate/", + path=f"/auth/{MfaMethods.YUBI}/activate/", format="json", ) response = client.post( - path="/auth/yubi/activate/confirm/", + path=f"/auth/{MfaMethods.YUBI}/activate/confirm/", data={ "code": backup_codes.pop(), }, diff --git a/testproject/tests/test_utils.py b/testproject/tests/test_utils.py index f5807a5d..1b4ccc09 100644 --- a/testproject/tests/test_utils.py +++ b/testproject/tests/test_utils.py @@ -4,6 +4,7 @@ from trench.backends.base import AbstractMessageDispatcher from trench.backends.provider import get_mfa_handler from trench.models import MFAMethod +from trench.settings import MfaMethods from trench.utils import UserTokenGenerator @@ -22,7 +23,7 @@ def test_invalid_token(): @pytest.mark.django_db def test_create_qr_link(active_user_with_many_otp_methods): user, _ = active_user_with_many_otp_methods - mfa_method: MFAMethod = user.mfa_methods.filter(name="app").first() + mfa_method: MFAMethod = user.mfa_methods.filter(name=MfaMethods.APP.value).first() handler: ApplicationMessageDispatcher = get_mfa_handler(mfa_method) qr_link = handler._create_qr_link(user=user) assert type(qr_link) == str @@ -49,6 +50,6 @@ def test_validate_code(active_user_with_email_otp): @pytest.mark.django_db def test_validate_code_yubikey(active_user_with_many_otp_methods): active_user, _ = active_user_with_many_otp_methods - yubi_method = active_user.mfa_methods.get(name="yubi") + yubi_method = active_user.mfa_methods.get(name=MfaMethods.YUBI.value) handler = get_mfa_handler(mfa_method=yubi_method) assert handler.validate_code("t" * 44) is False diff --git a/trench/backends/twilio.py b/trench/backends/twilio.py index 897c4332..5e7714ad 100644 --- a/trench/backends/twilio.py +++ b/trench/backends/twilio.py @@ -11,9 +11,10 @@ SuccessfulDispatchResponse, ) from trench.settings import TWILIO_VERIFIED_FROM_NUMBER +from twilio.twiml.voice_response import VoiceResponse -class TwilioMessageDispatcher(AbstractMessageDispatcher): +class TwilioSMSMessageDispatcher(AbstractMessageDispatcher): _SMS_BODY = _("Your verification code is: ") _SUCCESS_DETAILS = _("SMS message with MFA code has been sent.") @@ -29,3 +30,23 @@ def dispatch_message(self) -> DispatchResponse: except TwilioRestException as cause: logging.error(cause, exc_info=True) return FailedDispatchResponse(details=cause.msg) + + +class TwilioCallMessageDispatcher(AbstractMessageDispatcher): + _CALL_MESSAGE = _("Your verification code is: ") + _SUCCESS_DETAILS = _("Pick up phone and get key.") + + def dispatch_message(self) -> DispatchResponse: + try: + response = VoiceResponse() + response.say(self._CALL_MESSAGE + self.create_code()) + client = Client() + client.calls.create( + twiml=str(response), + to=self._to, + from_=self._config.get(TWILIO_VERIFIED_FROM_NUMBER), + ) + return SuccessfulDispatchResponse(details=self._SUCCESS_DETAILS) + except TwilioRestException as cause: + logging.error(cause, exc_info=True) + return FailedDispatchResponse(details=cause.msg) diff --git a/trench/settings.py b/trench/settings.py index 2a1bfc63..6d6ff729 100644 --- a/trench/settings.py +++ b/trench/settings.py @@ -2,6 +2,7 @@ from django.utils.translation import gettext_lazy as _ import string +from enum import Enum from rest_framework.settings import APISettings, perform_import from typing import Any, Dict @@ -41,6 +42,18 @@ def __getitem__(self, attr: str) -> Any: return self.__getattr__(attr) +class MfaMethods(Enum): + CALL_TWILIO = "call_twilio" + SMS_TWILIO = "sms_twilio" + SMS_API = "sms_api" + EMAIL = "email" + APP = "app" + YUBI = "yubi" + + def __str__(self): + return self.value + + SOURCE_FIELD = "SOURCE_FIELD" HANDLER = "HANDLER" VALIDITY_PERIOD = "VALIDITY_PERIOD" @@ -67,14 +80,21 @@ def __getitem__(self, attr: str) -> Any: "ENCRYPT_BACKUP_CODES": True, "APPLICATION_ISSUER_NAME": "MyApplication", "MFA_METHODS": { - "sms_twilio": { + MfaMethods.CALL_TWILIO.value: { + VERBOSE_NAME: _("call_twilio"), + VALIDITY_PERIOD: 30, + HANDLER: "trench.backends.twilio.TwilioCallMessageDispatcher", + SOURCE_FIELD: "phone_number", + TWILIO_VERIFIED_FROM_NUMBER: "YOUR TWILIO REGISTERED NUMBER", + }, + MfaMethods.SMS_TWILIO.value: { VERBOSE_NAME: _("sms_twilio"), VALIDITY_PERIOD: 30, - HANDLER: "trench.backends.twilio.TwilioMessageDispatcher", + HANDLER: "trench.backends.twilio.TwilioSMSMessageDispatcher", SOURCE_FIELD: "phone_number", TWILIO_VERIFIED_FROM_NUMBER: "YOUR TWILIO REGISTERED NUMBER", }, - "sms_api": { + MfaMethods.SMS_API.value: { VERBOSE_NAME: _("sms_api"), VALIDITY_PERIOD: 30, HANDLER: "trench.backends.sms_api.SMSAPIMessageDispatcher", @@ -82,7 +102,7 @@ def __getitem__(self, attr: str) -> Any: SMSAPI_ACCESS_TOKEN: "YOUR SMSAPI TOKEN", SMSAPI_FROM_NUMBER: "YOUR REGISTERED NUMBER", }, - "email": { + MfaMethods.EMAIL.value: { VERBOSE_NAME: _("email"), VALIDITY_PERIOD: 30, HANDLER: "trench.backends.basic_mail.SendMailMessageDispatcher", @@ -91,13 +111,13 @@ def __getitem__(self, attr: str) -> Any: EMAIL_PLAIN_TEMPLATE: "trench/backends/email/code.txt", EMAIL_HTML_TEMPLATE: "trench/backends/email/code.html", }, - "app": { + MfaMethods.APP.value: { VERBOSE_NAME: _("app"), VALIDITY_PERIOD: 30, "USES_THIRD_PARTY_CLIENT": True, HANDLER: "trench.backends.application.ApplicationMessageDispatcher", }, - "yubi": { + MfaMethods.YUBI.value: { VERBOSE_NAME: _("yubi"), HANDLER: "trench.backends.yubikey.YubiKeyMessageDispatcher", YUBICLOUD_CLIENT_ID: "YOUR KEY",