diff --git a/testproject/settings.py b/testproject/settings.py index 3c7be5a2..bbde33e5 100644 --- a/testproject/settings.py +++ b/testproject/settings.py @@ -16,7 +16,7 @@ ALLOWED_HOSTS = env.list("ALLOWED_HOSTS", default=["*"]) CORS_ORIGIN_ALLOW_ALL = env.bool("CORS_ORIGIN_ALLOW_ALL", default=False) DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" -STATIC_ROOT= os.path.join(BASE_DIR, 'static/') +STATIC_ROOT = os.path.join(BASE_DIR, "static/") INSTALLED_APPS = [ "django.contrib.admin", diff --git a/testproject/tests/test_add_mfa.py b/testproject/tests/test_add_mfa.py index d8ecb574..ceeb2fa5 100644 --- a/testproject/tests/test_add_mfa.py +++ b/testproject/tests/test_add_mfa.py @@ -1,6 +1,5 @@ import pytest -from django.conf import settings from django.contrib.auth import get_user_model from django.contrib.auth.models import AbstractUser @@ -10,6 +9,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 trench_settings User: AbstractUser = get_user_model() @@ -37,7 +37,7 @@ def test_should_fail_on_add_user_mfa_with_invalid_source_field(active_user: User client = TrenchAPIClient() client.authenticate(user=active_user) secret = create_secret_command() - settings.TRENCH_AUTH["MFA_METHODS"]["email"]["SOURCE_FIELD"] = "email_test" + trench_settings.mfa_methods["email"]["source_field"] = "email_test" response = client.post( path="/auth/email/activate/", @@ -53,7 +53,7 @@ def test_should_fail_on_add_user_mfa_with_invalid_source_field(active_user: User response.data.get("error") == "Field name `email_test` is not valid for model `User`." ) - settings.TRENCH_AUTH["MFA_METHODS"]["email"]["SOURCE_FIELD"] = "email" + trench_settings.mfa_methods["email"]["source_field"] = "email" @flaky diff --git a/testproject/tests/test_backends.py b/testproject/tests/test_backends.py index 981511e3..6aa12a8c 100644 --- a/testproject/tests/test_backends.py +++ b/testproject/tests/test_backends.py @@ -8,6 +8,7 @@ from trench.backends.twilio import TwilioMessageDispatcher from trench.backends.yubikey import YubiKeyMessageDispatcher from trench.exceptions import MissingConfigurationError +from trench.settings import trench_settings User = get_user_model() @@ -16,7 +17,7 @@ @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"] + conf = trench_settings.mfa_methods["sms_twilio"] response = TwilioMessageDispatcher( mfa_method=auth_method, config=conf ).dispatch_message() @@ -26,20 +27,19 @@ 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"] + conf = trench_settings.mfa_methods["sms_api"] response = SMSAPIMessageDispatcher( mfa_method=auth_method, config=conf ).dispatch_message() + print(response.data.get("details")) assert response.data.get("details") == "Authorization failed" @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"][ - "SMSAPI_ACCESS_TOKEN" - ] = "wrong-token" + conf = trench_settings.mfa_methods["sms_api"] + trench_settings.mfa_methods["sms_api"]["smsapi_access_token"] = "wrong-token" response = SMSAPIMessageDispatcher( mfa_method=auth_method, config=conf ).dispatch_message() @@ -49,12 +49,12 @@ 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" + conf = trench_settings.mfa_methods["sms_twilio"] + current_source = trench_settings.mfa_methods["sms_twilio"]["source_field"] + trench_settings.mfa_methods["sms_twilio"]["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 + trench_settings.mfa_methods["sms_twilio"]["source_field"] = current_source @pytest.mark.django_db @@ -62,7 +62,7 @@ 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"] + conf = trench_settings.mfa_methods["app"] response = ApplicationMessageDispatcher( mfa_method=auth_method, config=conf ).dispatch_message() @@ -75,7 +75,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"] + config = trench_settings.mfa_methods["yubi"] auth_method = user.mfa_methods.get(name="yubi") dispatcher = YubiKeyMessageDispatcher(mfa_method=auth_method, config=config) dispatcher.confirm_activation(code) @@ -84,7 +84,7 @@ def test_yubikey_backend(active_user_with_many_otp_methods, settings): @pytest.mark.django_db def test_sms_aws_backend_without_credentials(active_user_with_sms_aws_otp, settings): auth_method = active_user_with_sms_aws_otp.mfa_methods.get(name="sms_aws") - conf = settings.TRENCH_AUTH["MFA_METHODS"]["sms_aws"] + conf = trench_settings.mfa_methods["sms_aws"] response = AWSMessageDispatcher( mfa_method=auth_method, config=conf ).dispatch_message() diff --git a/testproject/tests/test_commands.py b/testproject/tests/test_commands.py index 1c0c7590..d0327c13 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 TrenchSettingsParser from trench.utils import get_mfa_model @@ -23,13 +23,14 @@ def test_remove_backup_code_from_non_existing_method( @pytest.mark.django_db -def test_remove_not_encrypted_code(active_user_with_non_encrypted_backup_codes): +def test_remove_not_encrypted_code( + active_user_with_non_encrypted_backup_codes, settings +): user, codes = active_user_with_non_encrypted_backup_codes - settings = TrenchAPISettings( - user_settings={"ENCRYPT_BACKUP_CODES": False}, defaults=DEFAULTS - ) + settings.TRENCH_AUTH["ENCRYPT_BACKUP_CODES"] = False + trench_settings = TrenchSettingsParser(user_settings=settings).get_settings remove_backup_code_command = RemoveBackupCodeCommand( - mfa_model=get_mfa_model(), settings=settings + mfa_model=get_mfa_model(), settings=trench_settings ).execute code = next(iter(codes)) remove_backup_code_command( diff --git a/testproject/tests/test_exceptions.py b/testproject/tests/test_exceptions.py index 26f7d07d..cef7ebbf 100644 --- a/testproject/tests/test_exceptions.py +++ b/testproject/tests/test_exceptions.py @@ -19,15 +19,15 @@ ProtectedActionValidator, RequestBodyValidator, ) -from trench.settings import DEFAULTS, TrenchAPISettings +from trench.settings import TrenchSettingsParser -def test_method_handler_missing_error(): - settings = TrenchAPISettings( - user_settings={"MFA_METHODS": {"method_without_handler": {}}}, defaults=DEFAULTS - ) +def test_method_handler_missing_error(settings): + settings.TRENCH_AUTH["MFA_METHODS"]["method_without_handler"] = {} + with pytest.raises(MethodHandlerMissingError): - assert settings.MFA_METHODS["method_without_handler"] is None + trench_settings = TrenchSettingsParser(user_settings=settings).get_settings + assert trench_settings.mfa_methods["method_without_handler"] == {} def test_code_missing_error(): diff --git a/testproject/tests/test_second_step_authentication.py b/testproject/tests/test_second_step_authentication.py index aecf3b3b..c11c842c 100644 --- a/testproject/tests/test_second_step_authentication.py +++ b/testproject/tests/test_second_step_authentication.py @@ -20,6 +20,7 @@ ) from trench.exceptions import MFAMethodDoesNotExistError from trench.models import MFAMethod +from trench.settings import trench_settings User = get_user_model() @@ -43,10 +44,8 @@ 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 = trench_settings.mfa_methods["email"]["validity_period"] + trench_settings.mfa_methods["email"]["validity_period"] = 3 mfa_method = active_user_with_email_otp.mfa_methods.first() client = TrenchAPIClient() @@ -69,9 +68,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"][ - "VALIDITY_PERIOD" - ] = ORIGINAL_VALIDITY_PERIOD + trench_settings.mfa_methods["email"]["VALIDITY_PERIOD"] = ORIGINAL_VALIDITY_PERIOD @flaky @@ -601,6 +598,7 @@ def test_yubikey(active_user_with_yubi, offline_yubikey): response = client.authenticate_multi_factor( mfa_method=yubikey_method, user=active_user_with_yubi ) + print(response) assert response.status_code == HTTP_200_OK diff --git a/trench/backends/application.py b/trench/backends/application.py index 879270a3..7e630fe8 100644 --- a/trench/backends/application.py +++ b/trench/backends/application.py @@ -27,5 +27,5 @@ def dispatch_message(self) -> DispatchResponse: def _create_qr_link(self, user: User) -> str: return self._get_otp().provisioning_uri( getattr(user, User.USERNAME_FIELD), - trench_settings.APPLICATION_ISSUER_NAME, + trench_settings.application_issuer_name, ) diff --git a/trench/backends/aws.py b/trench/backends/aws.py index 7d8cfaed..ae1e9e52 100644 --- a/trench/backends/aws.py +++ b/trench/backends/aws.py @@ -1,8 +1,8 @@ from django.utils.translation import gettext_lazy as _ -import logging import boto3 -import botocore.exceptions +import logging +from botocore.exceptions import ClientError, EndpointConnectionError from trench.backends.base import AbstractMessageDispatcher from trench.responses import ( @@ -10,8 +10,8 @@ FailedDispatchResponse, SuccessfulDispatchResponse, ) -from trench.settings import AWS_ACCESS_KEY, AWS_SECRET_KEY, AWS_REGION -from botocore.exceptions import ClientError, EndpointConnectionError +from trench.settings import AWS_ACCESS_KEY, AWS_REGION, AWS_SECRET_KEY + class AWSMessageDispatcher(AbstractMessageDispatcher): _SMS_BODY = _("Your verification code is: ") diff --git a/trench/backends/base.py b/trench/backends/base.py index c32fe1a7..89601648 100644 --- a/trench/backends/base.py +++ b/trench/backends/base.py @@ -82,5 +82,5 @@ def _get_otp(self) -> TOTP: def _get_valid_window(self) -> int: return self._config.get( - VALIDITY_PERIOD, trench_settings.DEFAULT_VALIDITY_PERIOD + VALIDITY_PERIOD, trench_settings.default_validity_period ) diff --git a/trench/backends/basic_mail.py b/trench/backends/basic_mail.py index 1f02e3eb..4f943591 100644 --- a/trench/backends/basic_mail.py +++ b/trench/backends/basic_mail.py @@ -25,7 +25,7 @@ def dispatch_message(self) -> DispatchResponse: email_html_template = self._config[EMAIL_HTML_TEMPLATE] try: send_mail( - subject=self._config.get(EMAIL_SUBJECT), + subject=self._config[EMAIL_SUBJECT], message=get_template(email_plain_template).render(context), html_message=get_template(email_html_template).render(context), from_email=settings.DEFAULT_FROM_EMAIL, diff --git a/trench/backends/provider.py b/trench/backends/provider.py index a7543fa7..a1409bf1 100644 --- a/trench/backends/provider.py +++ b/trench/backends/provider.py @@ -1,3 +1,5 @@ +from rest_framework.settings import perform_import + from trench.backends.base import AbstractMessageDispatcher from trench.models import MFAMethod from trench.query.get_mfa_config_by_name import get_mfa_config_by_name_query @@ -6,5 +8,5 @@ def get_mfa_handler(mfa_method: MFAMethod) -> AbstractMessageDispatcher: conf = get_mfa_config_by_name_query(name=mfa_method.name) - dispatcher = conf[HANDLER] + dispatcher = perform_import(conf[HANDLER], HANDLER) return dispatcher(mfa_method=mfa_method, config=conf) diff --git a/trench/command/create_secret.py b/trench/command/create_secret.py index f1e47b63..51a1ff76 100644 --- a/trench/command/create_secret.py +++ b/trench/command/create_secret.py @@ -1,16 +1,17 @@ from pyotp import random_base32 from typing import Callable -from trench.settings import TrenchAPISettings, trench_settings +from trench.domain.models import TrenchConfig +from trench.settings import trench_settings class CreateSecretCommand: - def __init__(self, generator: Callable, settings: TrenchAPISettings) -> None: + def __init__(self, generator: Callable, settings: TrenchConfig) -> None: self._generator = generator self._settings = settings def execute(self) -> str: - return self._generator(length=self._settings.SECRET_KEY_LENGTH) + return self._generator(length=self._settings.secret_key_length) create_secret_command = CreateSecretCommand( diff --git a/trench/command/generate_backup_codes.py b/trench/command/generate_backup_codes.py index 05d1607f..48a9209d 100644 --- a/trench/command/generate_backup_codes.py +++ b/trench/command/generate_backup_codes.py @@ -11,26 +11,19 @@ def __init__(self, random_string_generator: Callable) -> None: def execute( self, - quantity: int = trench_settings.BACKUP_CODES_QUANTITY, - length: int = trench_settings.BACKUP_CODES_LENGTH, - allowed_chars: str = trench_settings.BACKUP_CODES_CHARACTERS, ) -> Set[str]: """ Generates random encrypted backup codes. - :param quantity: How many codes should be generated - :type quantity: int - :param length: How long codes should be - :type length: int - :param allowed_chars: Characters to create backup codes from - :type allowed_chars: str - :returns: Encrypted backup codes :rtype: set[str] """ return { - self._random_string_generator(length, allowed_chars) - for _ in range(quantity) + self._random_string_generator( + trench_settings.backup_codes_length, + trench_settings.backup_codes_characters, + ) + for _ in range(trench_settings.backup_codes_quantity) } diff --git a/trench/command/remove_backup_code.py b/trench/command/remove_backup_code.py index ca1159cb..21c4c67d 100644 --- a/trench/command/remove_backup_code.py +++ b/trench/command/remove_backup_code.py @@ -2,14 +2,15 @@ from typing import Any, Set, Type +from trench.domain.models import TrenchConfig from trench.exceptions import InvalidCodeError, MFAMethodDoesNotExistError from trench.models import MFAMethod -from trench.settings import TrenchAPISettings, trench_settings +from trench.settings import trench_settings from trench.utils import get_mfa_model class RemoveBackupCodeCommand: - def __init__(self, mfa_model: Type[MFAMethod], settings: TrenchAPISettings) -> None: + def __init__(self, mfa_model: Type[MFAMethod], settings: TrenchConfig) -> None: self._mfa_model = mfa_model self._settings = settings @@ -34,7 +35,7 @@ def execute(self, user_id: Any, method_name: str, code: str) -> None: ) def _remove_code_from_set(self, backup_codes: Set[str], code: str) -> Set[str]: - if not self._settings.ENCRYPT_BACKUP_CODES: + if not self._settings.encrypt_backup_codes: backup_codes.remove(code) return backup_codes for backup_code in backup_codes: diff --git a/trench/command/replace_mfa_method_backup_codes.py b/trench/command/replace_mfa_method_backup_codes.py index ce68d8fe..894eba58 100644 --- a/trench/command/replace_mfa_method_backup_codes.py +++ b/trench/command/replace_mfa_method_backup_codes.py @@ -42,7 +42,7 @@ def execute(self, user_id: int, name: str) -> Set[str]: regenerate_backup_codes_for_mfa_method_command = ( RegenerateBackupCodesForMFAMethodCommand( - requires_encryption=trench_settings.ENCRYPT_BACKUP_CODES, + requires_encryption=trench_settings.encrypt_backup_codes, mfa_model=get_mfa_model(), code_hasher=make_password, codes_generator=generate_backup_codes_command, diff --git a/trench/command/validate_backup_code.py b/trench/command/validate_backup_code.py index b55cd709..ee54ea96 100644 --- a/trench/command/validate_backup_code.py +++ b/trench/command/validate_backup_code.py @@ -2,15 +2,16 @@ from typing import Iterable, Optional -from trench.settings import TrenchAPISettings, trench_settings +from trench.domain.models import TrenchConfig +from trench.settings import trench_settings class ValidateBackupCodeCommand: - def __init__(self, settings: TrenchAPISettings) -> None: + def __init__(self, settings: TrenchConfig) -> None: self._settings = settings def execute(self, value: str, backup_codes: Iterable) -> Optional[str]: - if not self._settings.ENCRYPT_BACKUP_CODES: + if not self._settings.encrypt_backup_codes: return value if value in backup_codes else None for backup_code in backup_codes: if check_password(value, backup_code): diff --git a/trench/domain/__init__.py b/trench/domain/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/trench/domain/models.py b/trench/domain/models.py new file mode 100644 index 00000000..3feff3ca --- /dev/null +++ b/trench/domain/models.py @@ -0,0 +1,86 @@ +from django.utils.translation import gettext_lazy as _ + +import string +from dataclasses import dataclass, field +from typing import Any, Dict, Optional + + +@dataclass(frozen=True) +class MFAMethodConfig: + verbose_name: str + handler: str + validity_period: Optional[int] = None + source_field: Optional[str] = None + + +@dataclass(frozen=True) +class TrenchConfig: + mfa_methods: Dict[str, Any] = field(default_factory=dict) + user_mfa_model: str = "trench.MFAMethod" + user_active_field: str = "is_active" + backup_codes_quantity: int = 5 + backup_codes_length: int = 12 + backup_codes_characters: str = string.ascii_letters + string.digits + secret_key_length: int = 32 + default_validity_period: int = 30 + confirm_disable_with_code: bool = False + confirm_backup_codes_regeneration_with_code: bool = True + allow_backup_codes_regeneration: bool = True + encrypt_backup_codes: bool = True + application_issuer_name: str = "MyApplication" + + +@dataclass(frozen=True) +class MFAMethodConfigAws(MFAMethodConfig): + verbose_name = _("sms_aws") + handler = "trench.backends.aws.AWSMessageDispatcher" + validity_period = 30 + source_field = "phone_number" + aws_access_key = "access_key" + aws_secret_key = "secret_key" + aws_region = "region" + + +@dataclass(frozen=True) +class MFAMethodConfigApp(MFAMethodConfig): + verbose_name = _("app") + validity_period = 30 + handler = "trench.backends.application.ApplicationMessageDispatcher" + uses_third_party_client: bool = True + + +@dataclass(frozen=True) +class MFAMethodConfigEmail(MFAMethodConfig): + verbose_name = _("email") + validity_period = 30 + handler = "trench.backends.basic_mail.SendMailMessageDispatcher" + source_field = "email" + email_subject: str = _("Your verification code") + email_plain_template: str = "trench/backends/email/code.txt" + email_html_template: str = "trench/backends/email/code.html" + + +@dataclass(frozen=True) +class MFAMethodConfigYubi(MFAMethodConfig): + verbose_name = _("yubi") + handler = "trench.backends.yubikey.YubiKeyMessageDispatcher" + yubicloud_client_id: str = "YOUR KEY" + + +@dataclass(frozen=True) +class MFAMethodConfigTwilio(MFAMethodConfig): + verbose_name = _("sms_twilio") + validity_period = 30 + handler = "trench.backends.twilio.TwilioMessageDispatcher" + source_field = "phone_number" + twilio_verified_from_number: str = "" + + +@dataclass(frozen=True) +class MFAMethodConfigSMSAPI(MFAMethodConfig): + verbose_name = _("sms_api") + validity_period = 30 + handler = "trench.backends.sms_api.SMSAPIMessageDispatcher" + source_field = "phone_number" + smsapi_access_token: str = "" + smsapi_from_number: str = "" diff --git a/trench/query/get_mfa_config_by_name.py b/trench/query/get_mfa_config_by_name.py index d8fedfba..18a53b11 100644 --- a/trench/query/get_mfa_config_by_name.py +++ b/trench/query/get_mfa_config_by_name.py @@ -1,16 +1,17 @@ from typing import Any, Dict +from trench.domain.models import TrenchConfig from trench.exceptions import MFAMethodDoesNotExistError -from trench.settings import TrenchAPISettings, trench_settings +from trench.settings import trench_settings class GetMFAConfigByNameQuery: - def __init__(self, settings: TrenchAPISettings) -> None: + def __init__(self, settings: TrenchConfig) -> None: self._settings = settings def execute(self, name: str) -> Dict[str, Any]: try: - return self._settings.MFA_METHODS[name] + return self._settings.mfa_methods[name] except KeyError as cause: raise MFAMethodDoesNotExistError from cause diff --git a/trench/serializers.py b/trench/serializers.py index efb803a1..b32fcaf5 100644 --- a/trench/serializers.py +++ b/trench/serializers.py @@ -79,7 +79,7 @@ def validate_code(self, value: str) -> str: class MFAMethodDeactivationValidator(ProtectedActionValidator): - code = CharField(required=trench_settings.CONFIRM_DISABLE_WITH_CODE) + code = CharField(required=trench_settings.confirm_disable_with_code) @staticmethod def _validate_mfa_method(mfa: MFAMethod) -> None: @@ -100,7 +100,7 @@ def _validate_mfa_method(mfa: MFAMethod) -> None: class MFAMethodBackupCodesGenerationValidator(ProtectedActionValidator): code = CharField( - required=trench_settings.CONFIRM_BACKUP_CODES_REGENERATION_WITH_CODE + required=trench_settings.confirm_backup_codes_regeneration_with_code ) @staticmethod @@ -114,7 +114,7 @@ class MFAMethodCodeSerializer(RequestBodyValidator): @staticmethod def validate_method(value: str) -> str: - if value and value not in trench_settings.MFA_METHODS: + if value and value not in trench_settings.mfa_methods: raise MFAMethodDoesNotExistError() return value diff --git a/trench/settings.py b/trench/settings.py index 885fad5a..8c214a1d 100644 --- a/trench/settings.py +++ b/trench/settings.py @@ -1,122 +1,90 @@ -from django.conf import settings -from django.utils.translation import gettext_lazy as _ +from django.conf import LazySettings, settings +from django.utils.module_loading import import_string -import string -from rest_framework.settings import APISettings, perform_import +from functools import cached_property from typing import Any, Dict +from trench.domain.models import TrenchConfig from trench.exceptions import MethodHandlerMissingError -class TrenchAPISettings(APISettings): - _FIELD_USER_SETTINGS = "_user_settings" +SOURCE_FIELD = "source_field" +HANDLER = "handler" +VALIDITY_PERIOD = "validity_period" +VERBOSE_NAME = "varbose_name" +EMAIL_SUBJECT = "email_subject" +EMAIL_PLAIN_TEMPLATE = "email_plain_template" +EMAIL_HTML_TEMPLATE = "email_html_template" +SMSAPI_ACCESS_TOKEN = "smsapi_access_token" +SMSAPI_FROM_NUMBER = "smsapi_from_number" +TWILIO_VERIFIED_FROM_NUMBER = "twilio_verified_from_number" +YUBICLOUD_CLIENT_ID = "yubicloud_client_id" +AWS_ACCESS_KEY = "aws_access_key" +AWS_SECRET_KEY = "aws_secret_key" +AWS_REGION = "aws_region" +MFA_METHODS_CONFIGS = { + "email": "trench.domain.models.MFAMethodConfigEmail", + "app": "trench.domain.models.MFAMethodConfigApp", + "sms_twilio": "trench.domain.models.MFAMethodConfigTwilio", + "sms_api": "trench.domain.models.MFAMethodConfigSMSAPI", + "sms_aws": "trench.domain.models.MFAMethodConfigAws", + "yubi": "trench.domain.models.MFAMethodConfigYubi", +} + + +class TrenchSettingsParser: _FIELD_TRENCH_AUTH = "TRENCH_AUTH" - _FIELD_BACKUP_CODES_CHARACTERS = "BACKUP_CODES_CHARACTERS" _FIELD_MFA_METHODS = "MFA_METHODS" - _FIELD_HANDLER = "HANDLER" + _FIELD_HANDLER = "handler" - @property - def user_settings(self) -> Dict[str, Any]: - if not hasattr(self, self._FIELD_USER_SETTINGS): - self._user_settings = getattr(settings, self._FIELD_TRENCH_AUTH, {}) - return self._user_settings + def __init__(self, user_settings: LazySettings): + self._user_settings = getattr(user_settings, self._FIELD_TRENCH_AUTH, {}) - def __getattr__(self, attr: str) -> Any: - val = super().__getattr__(attr) - self._validate(attribute=attr, value=val) - return val + @cached_property + def get_settings(self) -> TrenchConfig: + trench_settings_dict = {} - def _validate(self, attribute: str, value: Any) -> None: - if attribute == self._FIELD_MFA_METHODS: - for method_name, method_config in value.items(): - if self._FIELD_HANDLER not in method_config: - raise MethodHandlerMissingError(method_name=method_name) - for k, v in self.defaults[self._FIELD_MFA_METHODS][method_name].items(): - method_config[k] = method_config.get(k, v) - method_config[self._FIELD_HANDLER] = perform_import( - method_config[self._FIELD_HANDLER], self._FIELD_HANDLER - ) - - def __getitem__(self, attr: str) -> Any: - return self.__getattr__(attr) + for field in TrenchConfig.__dataclass_fields__: + field_name = str(field) + if field_name == "mfa_methods": + mfa_methods: Dict[str, Dict[str, Any]] = {} + custom_mfa_methods = self._user_settings.get(field_name.upper(), None) + if custom_mfa_methods: + for ( + mfa_method_name, + mfa_method_values, + ) in custom_mfa_methods.items(): # noqa: E501 + mfa_methods[mfa_method_name] = {} + for ( + mfa_method_value_key, + mfa_method_value, + ) in mfa_method_values.items(): # noqa: E501 + mfa_methods[mfa_method_name][ + mfa_method_value_key.lower() + ] = mfa_method_value # noqa: E501 + if self._FIELD_HANDLER not in mfa_methods[mfa_method_name]: + raise MethodHandlerMissingError(method_name=mfa_method_name) + else: + for ( + mfa_method_name, + mfa_method_config, + ) in MFA_METHODS_CONFIGS.items(): # noqa: E501 + mfa_method = import_string(mfa_method_config) + mfa_methods[mfa_method_name] = {} + for mfa_method_field_name in mfa_method.__dataclass_fields__: + mfa_methods[mfa_method_name][ + mfa_method_field_name + ] = getattr( # noqa: E501 + mfa_method, mfa_method_field_name, None + ) + trench_settings_dict[field_name] = mfa_methods + else: + trench_settings_dict[field_name] = self._user_settings.get( + field_name.upper(), getattr(TrenchConfig, field_name) + ) -SOURCE_FIELD = "SOURCE_FIELD" -HANDLER = "HANDLER" -VALIDITY_PERIOD = "VALIDITY_PERIOD" -VERBOSE_NAME = "VERBOSE_NAME" -EMAIL_SUBJECT = "EMAIL_SUBJECT" -EMAIL_PLAIN_TEMPLATE = "EMAIL_PLAIN_TEMPLATE" -EMAIL_HTML_TEMPLATE = "EMAIL_HTML_TEMPLATE" -SMSAPI_ACCESS_TOKEN = "SMSAPI_ACCESS_TOKEN" -SMSAPI_FROM_NUMBER = "SMSAPI_FROM_NUMBER" -TWILIO_VERIFIED_FROM_NUMBER = "TWILIO_VERIFIED_FROM_NUMBER" -YUBICLOUD_CLIENT_ID = "YUBICLOUD_CLIENT_ID" -AWS_ACCESS_KEY = "AWS_ACCESS_KEY" -AWS_SECRET_KEY = "AWS_SECRET_KEY" -AWS_REGION = "AWS_REGION" + return TrenchConfig(**trench_settings_dict) # type: ignore -DEFAULTS = { - "USER_MFA_MODEL": "trench.MFAMethod", - "USER_ACTIVE_FIELD": "is_active", - "BACKUP_CODES_QUANTITY": 5, - "BACKUP_CODES_LENGTH": 12, # keep (quantity * length) under 200 - "BACKUP_CODES_CHARACTERS": (string.ascii_letters + string.digits), - "SECRET_KEY_LENGTH": 32, - "DEFAULT_VALIDITY_PERIOD": 30, - "CONFIRM_DISABLE_WITH_CODE": False, - "CONFIRM_BACKUP_CODES_REGENERATION_WITH_CODE": True, - "ALLOW_BACKUP_CODES_REGENERATION": True, - "ENCRYPT_BACKUP_CODES": True, - "APPLICATION_ISSUER_NAME": "MyApplication", - "MFA_METHODS": { - "sms_twilio": { - VERBOSE_NAME: _("sms_twilio"), - VALIDITY_PERIOD: 30, - HANDLER: "trench.backends.twilio.TwilioMessageDispatcher", - SOURCE_FIELD: "phone_number", - TWILIO_VERIFIED_FROM_NUMBER: "YOUR TWILIO REGISTERED NUMBER", - }, - "sms_api": { - VERBOSE_NAME: _("sms_api"), - VALIDITY_PERIOD: 30, - HANDLER: "trench.backends.sms_api.SMSAPIMessageDispatcher", - SOURCE_FIELD: "phone_number", - SMSAPI_ACCESS_TOKEN: "YOUR SMSAPI TOKEN", - SMSAPI_FROM_NUMBER: "YOUR REGISTERED NUMBER", - }, - "sms_aws": { - VERBOSE_NAME: _("sms_aws"), - VALIDITY_PERIOD: 30, - HANDLER: "trench.backends.aws.AWSMessageDispatcher", - SOURCE_FIELD: "phone_number", - AWS_ACCESS_KEY: "YOUR AWS ACCESS KEY", - AWS_SECRET_KEY: "YOUR AWS SECRET KEY", - AWS_REGION: "YOUR AWS REGION", - }, - "email": { - VERBOSE_NAME: _("email"), - VALIDITY_PERIOD: 30, - HANDLER: "trench.backends.basic_mail.SendMailMessageDispatcher", - SOURCE_FIELD: "email", - EMAIL_SUBJECT: _("Your verification code"), - EMAIL_PLAIN_TEMPLATE: "trench/backends/email/code.txt", - EMAIL_HTML_TEMPLATE: "trench/backends/email/code.html", - }, - "app": { - VERBOSE_NAME: _("app"), - VALIDITY_PERIOD: 30, - "USES_THIRD_PARTY_CLIENT": True, - HANDLER: "trench.backends.application.ApplicationMessageDispatcher", - }, - "yubi": { - VERBOSE_NAME: _("yubi"), - HANDLER: "trench.backends.yubikey.YubiKeyMessageDispatcher", - YUBICLOUD_CLIENT_ID: "YOUR KEY", - }, - }, -} -trench_settings = TrenchAPISettings( - user_settings=None, defaults=DEFAULTS, import_strings=None -) +trench_settings = TrenchSettingsParser(user_settings=settings).get_settings diff --git a/trench/utils.py b/trench/utils.py index fe22ea8f..a921c0b1 100644 --- a/trench/utils.py +++ b/trench/utils.py @@ -66,11 +66,11 @@ def _make_token_with_timestamp(self, user: User, timestamp: int, **kwargs) -> st def get_mfa_model() -> Type[MFAMethod]: - return apps.get_model(trench_settings.USER_MFA_MODEL) + return apps.get_model(trench_settings.user_mfa_model) def available_method_choices() -> List[Tuple[str, str]]: return [ (method_name, method_config.get(VERBOSE_NAME, _(method_name))) - for method_name, method_config in trench_settings.MFA_METHODS.items() + for method_name, method_config in trench_settings.mfa_methods.items() ] diff --git a/trench/views/base.py b/trench/views/base.py index 53a4dae8..a5a23275 100644 --- a/trench/views/base.py +++ b/trench/views/base.py @@ -110,7 +110,9 @@ def post(request: Request, method: str) -> Response: user = request.user try: if source_field is not None and not hasattr(user, source_field): - raise MFASourceFieldDoesNotExistError(source_field, user.__class__.__name__) + raise MFASourceFieldDoesNotExistError( + source_field, user.__class__.__name__ + ) mfa = create_mfa_method_command( user_id=user.id, @@ -166,7 +168,7 @@ class MFAMethodBackupCodesRegenerationView(APIView): @staticmethod def post(request: Request, method: str) -> Response: - if not trench_settings.ALLOW_BACKUP_CODES_REGENERATION: + if not trench_settings.allow_backup_codes_regeneration: return ErrorResponse(error=_("Backup codes regeneration is not allowed.")) serializer = MFAMethodBackupCodesGenerationValidator( mfa_method_name=method, user=request.user, data=request.data @@ -194,9 +196,9 @@ def get(request: Request) -> Response: method_name for method_name, method_verbose_name in available_method_choices() ], - "confirm_disable_with_code": trench_settings.CONFIRM_DISABLE_WITH_CODE, # noqa - "confirm_regeneration_with_code": trench_settings.CONFIRM_BACKUP_CODES_REGENERATION_WITH_CODE, # noqa - "allow_backup_codes_regeneration": trench_settings.ALLOW_BACKUP_CODES_REGENERATION, # noqa + "confirm_disable_with_code": trench_settings.confirm_disable_with_code, # noqa + "confirm_regeneration_with_code": trench_settings.confirm_backup_codes_regeneration_with_code, # noqa + "allow_backup_codes_regeneration": trench_settings.allow_backup_codes_regeneration, # noqa }, )