diff --git a/api/app/settings/common.py b/api/app/settings/common.py index 996e16c97ca6..38bc38ebb1f8 100644 --- a/api/app/settings/common.py +++ b/api/app/settings/common.py @@ -127,7 +127,7 @@ "api_keys", "features.feature_external_resources", # 2FA - "trench", + "custom_auth.mfa.trench", # health check plugins "health_check", "health_check.db", @@ -750,7 +750,6 @@ } TRENCH_AUTH = { - "FROM_EMAIL": DEFAULT_FROM_EMAIL, "BACKUP_CODES_QUANTITY": 5, "BACKUP_CODES_LENGTH": 10, # keep (quantity * length) under 200 "BACKUP_CODES_CHARACTERS": ( @@ -759,6 +758,7 @@ "DEFAULT_VALIDITY_PERIOD": 30, "CONFIRM_BACKUP_CODES_REGENERATION_WITH_CODE": True, "APPLICATION_ISSUER_NAME": "app.bullet-train.io", + "ENCRYPT_BACKUP_CODES": True, "MFA_METHODS": { "app": { "VERBOSE_NAME": "TOTP App", diff --git a/api/custom_auth/mfa/backends/application.py b/api/custom_auth/mfa/backends/application.py index 3d81db4d4000..7a6db0df57a2 100644 --- a/api/custom_auth/mfa/backends/application.py +++ b/api/custom_auth/mfa/backends/application.py @@ -1,8 +1,28 @@ -from trench.backends.application import ApplicationBackend +from typing import Any, Dict +from django.conf import settings +from pyotp import TOTP +from rest_framework.response import Response + +from custom_auth.mfa.trench.models import MFAMethod + + +class CustomApplicationBackend: + def __init__(self, mfa_method: MFAMethod, config: Dict[str, Any]) -> None: + self._mfa_method = mfa_method + self._config = config + self._totp = TOTP(self._mfa_method.secret) -class CustomApplicationBackend(ApplicationBackend): def dispatch_message(self): - original_message = super(CustomApplicationBackend, self).dispatch_message() - data = {**original_message, "secret": self.obj.secret} - return data + qr_link = self._totp.provisioning_uri( + self._mfa_method.user.email, settings.TRENCH_AUTH["APPLICATION_ISSUER_NAME"] + ) + data = { + "qr_link": qr_link, + "secret": self._mfa_method.secret, + } + return Response(data) + + def validate_code(self, code: str) -> bool: + validity_period = settings.TRENCH_AUTH["MFA_METHODS"]["app"]["VALIDITY_PERIOD"] + return self._totp.verify(otp=code, valid_window=int(validity_period / 20)) diff --git a/api/custom_auth/mfa/trench/__init__.py b/api/custom_auth/mfa/trench/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/api/custom_auth/mfa/trench/admin.py b/api/custom_auth/mfa/trench/admin.py new file mode 100644 index 000000000000..aaf617bb7714 --- /dev/null +++ b/api/custom_auth/mfa/trench/admin.py @@ -0,0 +1,8 @@ +from django.contrib import admin + +from custom_auth.mfa.trench.models import MFAMethod + + +@admin.register(MFAMethod) +class MFAMethodAdmin(admin.ModelAdmin): + pass diff --git a/api/custom_auth/mfa/trench/apps.py b/api/custom_auth/mfa/trench/apps.py new file mode 100644 index 000000000000..9d65b184cdfd --- /dev/null +++ b/api/custom_auth/mfa/trench/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class TrenchConfig(AppConfig): + name = "custom_auth.mfa.trench" + verbose_name = "django-trench" diff --git a/api/custom_auth/mfa/trench/backends/__init__.py b/api/custom_auth/mfa/trench/backends/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/api/custom_auth/mfa/trench/command/__init__.py b/api/custom_auth/mfa/trench/command/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/api/custom_auth/mfa/trench/command/activate_mfa_method.py b/api/custom_auth/mfa/trench/command/activate_mfa_method.py new file mode 100644 index 000000000000..e357f4525a55 --- /dev/null +++ b/api/custom_auth/mfa/trench/command/activate_mfa_method.py @@ -0,0 +1,37 @@ +from typing import Callable, Set, Type + +from custom_auth.mfa.trench.command.generate_backup_codes import ( + generate_backup_codes_command, +) +from custom_auth.mfa.trench.command.replace_mfa_method_backup_codes import ( + regenerate_backup_codes_for_mfa_method_command, +) +from custom_auth.mfa.trench.models import MFAMethod +from custom_auth.mfa.trench.utils import get_mfa_model + + +class ActivateMFAMethodCommand: + def __init__( + self, mfa_model: Type[MFAMethod], backup_codes_generator: Callable + ) -> None: + self._mfa_model = mfa_model + self._backup_codes_generator = backup_codes_generator + + def execute(self, user_id: int, name: str, code: str) -> Set[str]: + self._mfa_model.objects.filter(user_id=user_id, name=name).update( + is_active=True, + is_primary=not self._mfa_model.objects.primary_exists(user_id=user_id), + ) + + backup_codes = regenerate_backup_codes_for_mfa_method_command( + user_id=user_id, + name=name, + ) + + return backup_codes + + +activate_mfa_method_command = ActivateMFAMethodCommand( + mfa_model=get_mfa_model(), + backup_codes_generator=generate_backup_codes_command, +).execute diff --git a/api/custom_auth/mfa/trench/command/authenticate_second_factor.py b/api/custom_auth/mfa/trench/command/authenticate_second_factor.py new file mode 100644 index 000000000000..35912eb5cd4f --- /dev/null +++ b/api/custom_auth/mfa/trench/command/authenticate_second_factor.py @@ -0,0 +1,36 @@ +from custom_auth.mfa.trench.command.remove_backup_code import ( + remove_backup_code_command, +) +from custom_auth.mfa.trench.command.validate_backup_code import ( + validate_backup_code_command, +) +from custom_auth.mfa.trench.exceptions import ( + InvalidCodeError, + InvalidTokenError, +) +from custom_auth.mfa.trench.models import MFAMethod +from custom_auth.mfa.trench.utils import get_mfa_handler, user_token_generator +from users.models import FFAdminUser + + +def is_authenticated(user_id: int, code: str) -> None: + for auth_method in MFAMethod.objects.list_active(user_id=user_id): + validated_backup_code = validate_backup_code_command( + value=code, backup_codes=auth_method.backup_codes + ) + if get_mfa_handler(mfa_method=auth_method).validate_code(code=code): + return + if validated_backup_code: + remove_backup_code_command( + user_id=auth_method.user_id, method_name=auth_method.name, code=code + ) + return + raise InvalidCodeError() + + +def authenticate_second_step_command(code: str, ephemeral_token: str) -> FFAdminUser: + user = user_token_generator.check_token(user=None, token=ephemeral_token) + if user is None: + raise InvalidTokenError() + is_authenticated(user_id=user.id, code=code) + return user diff --git a/api/custom_auth/mfa/trench/command/create_mfa_method.py b/api/custom_auth/mfa/trench/command/create_mfa_method.py new file mode 100644 index 000000000000..1d966e5a1d11 --- /dev/null +++ b/api/custom_auth/mfa/trench/command/create_mfa_method.py @@ -0,0 +1,30 @@ +from typing import Callable, Type + +from custom_auth.mfa.trench.command.create_secret import create_secret_command +from custom_auth.mfa.trench.exceptions import MFAMethodAlreadyActiveError +from custom_auth.mfa.trench.models import MFAMethod +from custom_auth.mfa.trench.utils import get_mfa_model + + +class CreateMFAMethodCommand: + def __init__(self, secret_generator: Callable, mfa_model: Type[MFAMethod]) -> None: + self._mfa_model = mfa_model + self._create_secret = secret_generator + + def execute(self, user_id: int, name: str) -> MFAMethod: + mfa, created = self._mfa_model.objects.get_or_create( + user_id=user_id, + name=name, + defaults={ + "secret": self._create_secret, + "is_active": False, + }, + ) + if not created and mfa.is_active: + raise MFAMethodAlreadyActiveError() + return mfa + + +create_mfa_method_command = CreateMFAMethodCommand( + secret_generator=create_secret_command, mfa_model=get_mfa_model() +).execute diff --git a/api/custom_auth/mfa/trench/command/create_secret.py b/api/custom_auth/mfa/trench/command/create_secret.py new file mode 100644 index 000000000000..ea2669432a8d --- /dev/null +++ b/api/custom_auth/mfa/trench/command/create_secret.py @@ -0,0 +1,7 @@ +from django.conf import settings +from pyotp import random_base32 + + +def create_secret_command() -> str: + generator = random_base32 + return generator(length=settings.TRENCH_AUTH["SECRET_KEY_LENGTH"]) diff --git a/api/custom_auth/mfa/trench/command/deactivate_mfa_method.py b/api/custom_auth/mfa/trench/command/deactivate_mfa_method.py new file mode 100644 index 000000000000..af7422d57d99 --- /dev/null +++ b/api/custom_auth/mfa/trench/command/deactivate_mfa_method.py @@ -0,0 +1,27 @@ +from typing import Type + +from django.db.transaction import atomic + +from custom_auth.mfa.trench.exceptions import MFANotEnabledError +from custom_auth.mfa.trench.models import MFAMethod +from custom_auth.mfa.trench.utils import get_mfa_model + + +class DeactivateMFAMethodCommand: + def __init__(self, mfa_model: Type[MFAMethod]) -> None: + self._mfa_model = mfa_model + + @atomic + def execute(self, mfa_method_name: str, user_id: int) -> None: + mfa = self._mfa_model.objects.get_by_name(user_id=user_id, name=mfa_method_name) + if not mfa.is_active: + raise MFANotEnabledError() + + self._mfa_model.objects.filter(user_id=user_id, name=mfa_method_name).update( + is_active=False, is_primary=False + ) + + +deactivate_mfa_method_command = DeactivateMFAMethodCommand( + mfa_model=get_mfa_model() +).execute diff --git a/api/custom_auth/mfa/trench/command/generate_backup_codes.py b/api/custom_auth/mfa/trench/command/generate_backup_codes.py new file mode 100644 index 000000000000..fa71c97e89c3 --- /dev/null +++ b/api/custom_auth/mfa/trench/command/generate_backup_codes.py @@ -0,0 +1,38 @@ +from typing import Callable, Set + +from django.conf import settings +from django.utils.crypto import get_random_string + + +class GenerateBackupCodesCommand: + def __init__(self, random_string_generator: Callable) -> None: + self._random_string_generator = random_string_generator + + def execute( + self, + quantity: int = settings.TRENCH_AUTH["BACKUP_CODES_QUANTITY"], + length: int = settings.TRENCH_AUTH["BACKUP_CODES_LENGTH"], + allowed_chars: str = settings.TRENCH_AUTH["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) + } + + +generate_backup_codes_command = GenerateBackupCodesCommand( + random_string_generator=get_random_string, +).execute diff --git a/api/custom_auth/mfa/trench/command/remove_backup_code.py b/api/custom_auth/mfa/trench/command/remove_backup_code.py new file mode 100644 index 000000000000..d8d58f11bbe5 --- /dev/null +++ b/api/custom_auth/mfa/trench/command/remove_backup_code.py @@ -0,0 +1,29 @@ +from typing import Any, Optional, Set + +from django.contrib.auth.hashers import check_password + +from custom_auth.mfa.trench.models import MFAMethod + + +def remove_backup_code_command(user_id: Any, method_name: str, code: str) -> None: + serialized_codes = ( + MFAMethod.objects.filter(user_id=user_id, name=method_name) + .values_list("_backup_codes", flat=True) + .first() + ) + codes = MFAMethod._BACKUP_CODES_DELIMITER.join( + _remove_code_from_set( + backup_codes=set(serialized_codes.split(MFAMethod._BACKUP_CODES_DELIMITER)), + code=code, + ) + ) + MFAMethod.objects.filter(user_id=user_id, name=method_name).update( + _backup_codes=codes + ) + + +def _remove_code_from_set(backup_codes: Set[str], code: str) -> Optional[Set[str]]: + for backup_code in backup_codes: + if check_password(code, backup_code): + backup_codes.remove(backup_code) + return backup_codes diff --git a/api/custom_auth/mfa/trench/command/replace_mfa_method_backup_codes.py b/api/custom_auth/mfa/trench/command/replace_mfa_method_backup_codes.py new file mode 100644 index 000000000000..27564bc3c46d --- /dev/null +++ b/api/custom_auth/mfa/trench/command/replace_mfa_method_backup_codes.py @@ -0,0 +1,39 @@ +from typing import Callable, Set, Type + +from django.contrib.auth.hashers import make_password + +from custom_auth.mfa.trench.command.generate_backup_codes import ( + generate_backup_codes_command, +) +from custom_auth.mfa.trench.models import MFAMethod +from custom_auth.mfa.trench.utils import get_mfa_model + + +class RegenerateBackupCodesForMFAMethodCommand: + def __init__( + self, + mfa_model: Type[MFAMethod], + code_hasher: Callable, + codes_generator: Callable, + ) -> None: + self._mfa_model = mfa_model + self._code_hasher = code_hasher + self._codes_generator = codes_generator + + def execute(self, user_id: int, name: str) -> Set[str]: + backup_codes = self._codes_generator() + self._mfa_model.objects.filter(user_id=user_id, name=name).update( + _backup_codes=MFAMethod._BACKUP_CODES_DELIMITER.join( + [self._code_hasher(backup_code) for backup_code in backup_codes] + ), + ) + return backup_codes + + +regenerate_backup_codes_for_mfa_method_command = ( + RegenerateBackupCodesForMFAMethodCommand( + mfa_model=get_mfa_model(), + code_hasher=make_password, + codes_generator=generate_backup_codes_command, + ).execute +) diff --git a/api/custom_auth/mfa/trench/command/validate_backup_code.py b/api/custom_auth/mfa/trench/command/validate_backup_code.py new file mode 100644 index 000000000000..bee5bfc56c0a --- /dev/null +++ b/api/custom_auth/mfa/trench/command/validate_backup_code.py @@ -0,0 +1,10 @@ +from typing import Iterable, Optional + +from django.contrib.auth.hashers import check_password + + +def validate_backup_code_command(value: str, backup_codes: Iterable) -> Optional[str]: + for backup_code in backup_codes: + if check_password(value, backup_code): + return backup_code + return None diff --git a/api/custom_auth/mfa/trench/exceptions.py b/api/custom_auth/mfa/trench/exceptions.py new file mode 100644 index 000000000000..aa6b3916e7e9 --- /dev/null +++ b/api/custom_auth/mfa/trench/exceptions.py @@ -0,0 +1,46 @@ +from django.utils.translation import gettext_lazy as _ +from rest_framework.serializers import ValidationError + + +class MFAValidationError(ValidationError): + def __str__(self) -> str: + return ", ".join(detail for detail in self.detail) + + +class CodeInvalidOrExpiredError(MFAValidationError): + def __init__(self) -> None: + super().__init__( + detail=_("Code invalid or expired."), + code="code_invalid_or_expired", + ) + + +class MFAMethodDoesNotExistError(MFAValidationError): + def __init__(self) -> None: + super().__init__( + detail=_("Requested MFA method does not exist."), + code="mfa_method_does_not_exist", + ) + + +class MFAMethodAlreadyActiveError(MFAValidationError): + def __init__(self) -> None: + super().__init__( + detail=_("MFA method already active."), + code="method_already_active", + ) + + +class MFANotEnabledError(MFAValidationError): + def __init__(self) -> None: + super().__init__(detail=_("2FA is not enabled."), code="not_enabled") + + +class InvalidTokenError(MFAValidationError): + def __init__(self) -> None: + super().__init__(detail=_("Invalid or expired token."), code="invalid_token") + + +class InvalidCodeError(MFAValidationError): + def __init__(self) -> None: + super().__init__(detail=_("Invalid or expired code."), code="invalid_code") diff --git a/api/custom_auth/mfa/trench/migrations/0001_initial.py b/api/custom_auth/mfa/trench/migrations/0001_initial.py new file mode 100644 index 000000000000..cb6e1239ef69 --- /dev/null +++ b/api/custom_auth/mfa/trench/migrations/0001_initial.py @@ -0,0 +1,68 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.15 on 2018-10-12 11:34 +from __future__ import unicode_literals + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="MFAMethod", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "name", + models.CharField(max_length=255, verbose_name="name"), + ), + ( + "secret", + models.CharField(max_length=20, verbose_name="secret"), + ), + ( + "is_primary", + models.BooleanField(default=False, verbose_name="is primary"), + ), + ( + "is_active", + models.BooleanField(default=False, verbose_name="is active"), + ), + ( + "backup_codes", + models.CharField( + blank=True, max_length=255, verbose_name="backup codes" + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="mfa", + to=settings.AUTH_USER_MODEL, + verbose_name="user", + ), + ), + ], + options={ + "verbose_name": "MFA Method", + "verbose_name_plural": "MFA Methods", + }, + ), + ] diff --git a/api/custom_auth/mfa/trench/migrations/0002_auto_20190111_1403.py b/api/custom_auth/mfa/trench/migrations/0002_auto_20190111_1403.py new file mode 100644 index 000000000000..a95dc501fa5b --- /dev/null +++ b/api/custom_auth/mfa/trench/migrations/0002_auto_20190111_1403.py @@ -0,0 +1,32 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.15 on 2019-01-11 14:03 +from __future__ import unicode_literals + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("trench", "0001_initial"), + ] + + operations = [ + migrations.RenameField( + model_name="mfamethod", + old_name="backup_codes", + new_name="_backup_codes", + ), + migrations.AlterField( + model_name="mfamethod", + name="user", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="mfa_methods", + to=settings.AUTH_USER_MODEL, + verbose_name="user", + ), + ), + ] diff --git a/api/custom_auth/mfa/trench/migrations/0003_auto_20190213_2330.py b/api/custom_auth/mfa/trench/migrations/0003_auto_20190213_2330.py new file mode 100644 index 000000000000..39699eb4374f --- /dev/null +++ b/api/custom_auth/mfa/trench/migrations/0003_auto_20190213_2330.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.18 on 2019-02-13 23:30 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("trench", "0002_auto_20190111_1403"), + ] + + operations = [ + migrations.AlterField( + model_name="mfamethod", + name="secret", + field=models.CharField(max_length=255, verbose_name="secret"), + ), + migrations.AlterField( + model_name="mfamethod", + name="_backup_codes", + field=models.TextField(blank=True, verbose_name="backup codes"), + ), + ] diff --git a/api/custom_auth/mfa/trench/migrations/__init__.py b/api/custom_auth/mfa/trench/migrations/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/api/custom_auth/mfa/trench/models.py b/api/custom_auth/mfa/trench/models.py new file mode 100644 index 000000000000..ee6d7580f882 --- /dev/null +++ b/api/custom_auth/mfa/trench/models.py @@ -0,0 +1,61 @@ +from typing import Any, Iterable + +from django.conf import settings +from django.db.models import ( + CASCADE, + BooleanField, + CharField, + ForeignKey, + Manager, + Model, + QuerySet, + TextField, +) + +from custom_auth.mfa.trench.exceptions import MFAMethodDoesNotExistError + + +class MFAUserMethodManager(Manager): + def get_by_name(self, user_id: Any, name: str) -> "MFAMethod": + try: + return self.get(user_id=user_id, name=name) + except self.model.DoesNotExist: + raise MFAMethodDoesNotExistError() + + def get_primary_active(self, user_id: Any) -> "MFAMethod": + try: + return self.get(user_id=user_id, is_primary=True, is_active=True) + except self.model.DoesNotExist: + raise MFAMethodDoesNotExistError() + + def list_active(self, user_id: Any) -> QuerySet: + return self.filter(user_id=user_id, is_active=True) + + def primary_exists(self, user_id: Any) -> bool: + return self.filter(user_id=user_id, is_primary=True).exists() + + +class MFAMethod(Model): + _BACKUP_CODES_DELIMITER = "," + + user = ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=CASCADE, + verbose_name="user", + related_name="mfa_methods", + ) + name = CharField("name", max_length=255) + secret = CharField("secret", max_length=255) + is_primary = BooleanField("is primary", default=False) + is_active = BooleanField("is active", default=False) + _backup_codes = TextField("backup codes", blank=True) + + class Meta: + verbose_name = "MFA Method" + verbose_name_plural = "MFA Methods" + + objects = MFAUserMethodManager() + + @property + def backup_codes(self) -> Iterable[str]: + return self._backup_codes.split(self._BACKUP_CODES_DELIMITER) diff --git a/api/custom_auth/mfa/trench/responses.py b/api/custom_auth/mfa/trench/responses.py new file mode 100644 index 000000000000..ba3a7da0bc31 --- /dev/null +++ b/api/custom_auth/mfa/trench/responses.py @@ -0,0 +1,23 @@ +from rest_framework.response import Response +from rest_framework.status import HTTP_400_BAD_REQUEST + +from custom_auth.mfa.trench.exceptions import MFAValidationError + + +class DispatchResponse(Response): + _FIELD_DETAILS = "details" + + +class ErrorResponse(Response): + _FIELD_ERROR = "error" + + def __init__( + self, + error: MFAValidationError, + status: str = HTTP_400_BAD_REQUEST, + *args, + **kwargs, + ) -> None: + super().__init__( + data={self._FIELD_ERROR: str(error)}, status=status, *args, **kwargs + ) diff --git a/api/custom_auth/mfa/trench/serializers.py b/api/custom_auth/mfa/trench/serializers.py new file mode 100644 index 000000000000..60561b85dbac --- /dev/null +++ b/api/custom_auth/mfa/trench/serializers.py @@ -0,0 +1,52 @@ +from django.contrib.auth import get_user_model +from django.contrib.auth.models import AbstractUser +from rest_framework.fields import CharField +from rest_framework.serializers import ModelSerializer, Serializer + +from custom_auth.mfa.trench.exceptions import ( + CodeInvalidOrExpiredError, + MFAMethodAlreadyActiveError, +) +from custom_auth.mfa.trench.models import MFAMethod +from custom_auth.mfa.trench.utils import get_mfa_handler, get_mfa_model + +User: AbstractUser = get_user_model() + + +class MFAMethodActivationConfirmationValidator(Serializer): + code = CharField() + + def __init__(self, mfa_method_name: str, user: User, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + self._user = user + self._mfa_method_name = mfa_method_name + + def validate_code(self, value: str) -> str: + mfa_model = get_mfa_model() + mfa = mfa_model.objects.get_by_name( + user_id=self._user.id, name=self._mfa_method_name + ) + self._validate_mfa_method(mfa) + + handler = get_mfa_handler(mfa) + + if handler.validate_code(value): + return value + + raise CodeInvalidOrExpiredError() + + @staticmethod + def _validate_mfa_method(mfa: MFAMethod) -> None: + if mfa.is_active: + raise MFAMethodAlreadyActiveError() + + +class CodeLoginSerializer(Serializer): + ephemeral_token = CharField() + code = CharField() + + +class UserMFAMethodSerializer(ModelSerializer): + class Meta: + model = get_mfa_model() + fields = ("name", "is_primary") diff --git a/api/custom_auth/mfa/trench/urls/__init__.py b/api/custom_auth/mfa/trench/urls/__init__.py new file mode 100644 index 000000000000..23983a23f331 --- /dev/null +++ b/api/custom_auth/mfa/trench/urls/__init__.py @@ -0,0 +1 @@ +from custom_auth.mfa.trench.urls.base import urlpatterns # noqa diff --git a/api/custom_auth/mfa/trench/urls/base.py b/api/custom_auth/mfa/trench/urls/base.py new file mode 100644 index 000000000000..8cc2cfc08760 --- /dev/null +++ b/api/custom_auth/mfa/trench/urls/base.py @@ -0,0 +1,35 @@ +from django.urls import path + +__all__ = [ + "urlpatterns", +] + +from custom_auth.mfa.trench.views import ( + ListUserActiveMFAMethods, + MFAMethodActivationView, + MFAMethodConfirmActivationView, + MFAMethodDeactivationView, +) + +urlpatterns = ( + path( + "/activate/", + MFAMethodActivationView.as_view(), + name="mfa-activate", + ), + path( + "/activate/confirm/", + MFAMethodConfirmActivationView.as_view(), + name="mfa-activate-confirm", + ), + path( + "/deactivate/", + MFAMethodDeactivationView.as_view(), + name="mfa-deactivate", + ), + path( + "mfa/user-active-methods/", + ListUserActiveMFAMethods.as_view(), + name="mfa-list-user-active-methods", + ), +) diff --git a/api/custom_auth/mfa/trench/utils.py b/api/custom_auth/mfa/trench/utils.py new file mode 100644 index 000000000000..b192b0b63909 --- /dev/null +++ b/api/custom_auth/mfa/trench/utils.py @@ -0,0 +1,70 @@ +from datetime import datetime +from typing import Optional, Type + +from django.conf import settings +from django.contrib.auth import get_user_model +from django.contrib.auth.models import AbstractUser +from django.contrib.auth.tokens import PasswordResetTokenGenerator +from django.utils.crypto import constant_time_compare, salted_hmac +from django.utils.http import base36_to_int, int_to_base36 + +from custom_auth.mfa.backends.application import CustomApplicationBackend +from custom_auth.mfa.trench.models import MFAMethod + +User: AbstractUser = get_user_model() + + +class UserTokenGenerator(PasswordResetTokenGenerator): + """ + Custom token generator: + - user pk in token + - expires after 15 minutes + - longer hash (40 instead of 20) + """ + + KEY_SALT = "django.contrib.auth.tokens.PasswordResetTokenGenerator" + SECRET = settings.SECRET_KEY + EXPIRY_TIME = 60 * 15 + + def make_token(self, user: User) -> str: + return self._make_token_with_timestamp(user, int(datetime.now().timestamp())) + + def check_token(self, user: User, token: str) -> Optional[User]: + user_model = get_user_model() + try: + token = str(token) + user_pk, ts_b36, token_hash = token.rsplit("-", 2) + ts = base36_to_int(ts_b36) + user = user_model._default_manager.get(pk=user_pk) + except (ValueError, TypeError, user_model.DoesNotExist): + return None + + if (datetime.now().timestamp() - ts) > self.EXPIRY_TIME: + return None # pragma: no cover + + if not constant_time_compare(self._make_token_with_timestamp(user, ts), token): + return None # pragma: no cover + + return user + + def _make_token_with_timestamp(self, user: User, timestamp: int, **kwargs) -> str: + ts_b36 = int_to_base36(timestamp) + token_hash = salted_hmac( + self.KEY_SALT, + self._make_hash_value(user, timestamp), + secret=self.SECRET, + ).hexdigest() + return f"{user.pk}-{ts_b36}-{token_hash}" + + +user_token_generator = UserTokenGenerator() + + +def get_mfa_model() -> Type[MFAMethod]: + return MFAMethod + + +def get_mfa_handler(mfa_method: MFAMethod) -> CustomApplicationBackend: + conf = settings.TRENCH_AUTH["MFA_METHODS"]["app"] + mfa_handler = CustomApplicationBackend(mfa_method=mfa_method, config=conf) + return mfa_handler diff --git a/api/custom_auth/mfa/trench/views/__init__.py b/api/custom_auth/mfa/trench/views/__init__.py new file mode 100644 index 000000000000..90081727ecac --- /dev/null +++ b/api/custom_auth/mfa/trench/views/__init__.py @@ -0,0 +1 @@ +from custom_auth.mfa.trench.views.base import * # noqa diff --git a/api/custom_auth/mfa/trench/views/base.py b/api/custom_auth/mfa/trench/views/base.py new file mode 100644 index 000000000000..97fde0d359ad --- /dev/null +++ b/api/custom_auth/mfa/trench/views/base.py @@ -0,0 +1,87 @@ +from django.contrib.auth import get_user_model +from django.contrib.auth.models import AbstractUser +from rest_framework import status +from rest_framework.permissions import IsAuthenticated +from rest_framework.request import Request +from rest_framework.response import Response +from rest_framework.status import HTTP_204_NO_CONTENT, HTTP_400_BAD_REQUEST +from rest_framework.views import APIView + +from custom_auth.mfa.trench.command.activate_mfa_method import ( + activate_mfa_method_command, +) +from custom_auth.mfa.trench.command.create_mfa_method import ( + create_mfa_method_command, +) +from custom_auth.mfa.trench.command.deactivate_mfa_method import ( + deactivate_mfa_method_command, +) +from custom_auth.mfa.trench.exceptions import MFAValidationError +from custom_auth.mfa.trench.models import MFAMethod +from custom_auth.mfa.trench.responses import ErrorResponse +from custom_auth.mfa.trench.serializers import ( + MFAMethodActivationConfirmationValidator, + UserMFAMethodSerializer, +) +from custom_auth.mfa.trench.utils import get_mfa_handler + +User: AbstractUser = get_user_model() + + +class MFAMethodActivationView(APIView): + permission_classes = (IsAuthenticated,) + + @staticmethod + def post(request: Request, method: str) -> Response: + if method != "app": + return Response(status=status.HTTP_404_NOT_FOUND) + try: + user = request.user + mfa = create_mfa_method_command( + user_id=user.id, + name=method, + ) + except MFAValidationError as cause: + return ErrorResponse(error=cause) + return get_mfa_handler(mfa_method=mfa).dispatch_message() + + +class MFAMethodConfirmActivationView(APIView): + permission_classes = (IsAuthenticated,) + + @staticmethod + def post(request: Request, method: str) -> Response: + serializer = MFAMethodActivationConfirmationValidator( + mfa_method_name=method, user=request.user, data=request.data + ) + if not serializer.is_valid(): + return Response(status=HTTP_400_BAD_REQUEST, data=serializer.errors) + backup_codes = activate_mfa_method_command( + user_id=request.user.id, + name=method, + code=serializer.validated_data["code"], + ) + return Response({"backup_codes": backup_codes}) + + +class MFAMethodDeactivationView(APIView): + permission_classes = (IsAuthenticated,) + + @staticmethod + def post(request: Request, method: str) -> Response: + try: + deactivate_mfa_method_command( + mfa_method_name=method, user_id=request.user.id + ) + return Response(status=HTTP_204_NO_CONTENT) + except MFAValidationError as cause: + return ErrorResponse(error=cause) + + +class ListUserActiveMFAMethods(APIView): + permission_classes = (IsAuthenticated,) + + def get(self, request, *args, **kwargs): + active_mfa_methods = MFAMethod.objects.filter(user=request.user, is_active=True) + serializer = UserMFAMethodSerializer(active_mfa_methods, many=True) + return Response(serializer.data) diff --git a/api/custom_auth/urls.py b/api/custom_auth/urls.py index 054b0a449423..54995ad5def7 100644 --- a/api/custom_auth/urls.py +++ b/api/custom_auth/urls.py @@ -1,4 +1,5 @@ from django.urls import include, path +from djoser.views import TokenDestroyView from rest_framework.routers import DefaultRouter from custom_auth.views import ( @@ -25,12 +26,12 @@ CustomAuthTokenLoginWithMFACode.as_view(), name="mfa-authtoken-login-code", ), + path("logout/", TokenDestroyView.as_view(), name="authtoken-logout"), path("", include(ffadmin_user_router.urls)), path("token/", delete_token, name="delete-token"), # NOTE: endpoints provided by `djoser.urls` # are deprecated and will be removed in the next Major release path("", include("djoser.urls")), - path("", include("trench.urls")), # MFA - path("", include("trench.urls.djoser")), # override necessary urls for MFA auth + path("", include("custom_auth.mfa.trench.urls")), # MFA path("oauth/", include("custom_auth.oauth.urls")), ] diff --git a/api/custom_auth/views.py b/api/custom_auth/views.py index 211a224fdc44..97381120fd93 100644 --- a/api/custom_auth/views.py +++ b/api/custom_auth/views.py @@ -1,25 +1,35 @@ +from django.conf import settings from django.contrib.auth import user_logged_out from django.utils.decorators import method_decorator -from djoser.views import UserViewSet +from djoser.views import TokenCreateView, UserViewSet from drf_yasg.utils import swagger_auto_schema from rest_framework import status from rest_framework.authtoken.models import Token from rest_framework.decorators import action, api_view, permission_classes from rest_framework.permissions import IsAuthenticated +from rest_framework.request import Request from rest_framework.response import Response from rest_framework.throttling import ScopedRateThrottle -from trench.views.authtoken import ( - AuthTokenLoginOrRequestMFACode, - AuthTokenLoginWithMFACode, -) +from custom_auth.mfa.backends.application import CustomApplicationBackend +from custom_auth.mfa.trench.command.authenticate_second_factor import ( + authenticate_second_step_command, +) +from custom_auth.mfa.trench.exceptions import ( + MFAMethodDoesNotExistError, + MFAValidationError, +) +from custom_auth.mfa.trench.models import MFAMethod +from custom_auth.mfa.trench.responses import ErrorResponse +from custom_auth.mfa.trench.serializers import CodeLoginSerializer +from custom_auth.mfa.trench.utils import user_token_generator from custom_auth.serializers import CustomUserDelete from users.constants import DEFAULT_DELETE_ORPHAN_ORGANISATIONS_VALUE from .models import UserPasswordResetRequest -class CustomAuthTokenLoginOrRequestMFACode(AuthTokenLoginOrRequestMFACode): +class CustomAuthTokenLoginOrRequestMFACode(TokenCreateView): """ Class to handle throttling for login requests """ @@ -27,8 +37,27 @@ class CustomAuthTokenLoginOrRequestMFACode(AuthTokenLoginOrRequestMFACode): throttle_classes = [ScopedRateThrottle] throttle_scope = "login" + def post(self, request: Request) -> Response: + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + user = serializer.user + try: + mfa_model = MFAMethod + mfa_method = mfa_model.objects.get_primary_active(user_id=user.id) + conf = settings.TRENCH_AUTH["MFA_METHODS"]["app"] + mfa_handler = CustomApplicationBackend(mfa_method=mfa_method, config=conf) + mfa_handler.dispatch_message() + return Response( + data={ + "ephemeral_token": user_token_generator.make_token(user), + "method": mfa_method.name, + } + ) + except MFAMethodDoesNotExistError: + return self._action(serializer) + -class CustomAuthTokenLoginWithMFACode(AuthTokenLoginWithMFACode): +class CustomAuthTokenLoginWithMFACode(TokenCreateView): """ Override class to add throttling """ @@ -36,6 +65,19 @@ class CustomAuthTokenLoginWithMFACode(AuthTokenLoginWithMFACode): throttle_classes = [ScopedRateThrottle] throttle_scope = "mfa_code" + def post(self, request: Request) -> Response: + serializer = CodeLoginSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + try: + user = authenticate_second_step_command( + code=serializer.validated_data["code"], + ephemeral_token=serializer.validated_data["ephemeral_token"], + ) + serializer.user = user + return self._action(serializer) + except MFAValidationError as cause: + return ErrorResponse(error=cause, status=status.HTTP_401_UNAUTHORIZED) + @api_view(["DELETE"]) @permission_classes([IsAuthenticated]) diff --git a/api/features/feature_external_resources/views.py b/api/features/feature_external_resources/views.py index f539877c31e8..c9636bba1132 100644 --- a/api/features/feature_external_resources/views.py +++ b/api/features/feature_external_resources/views.py @@ -1,9 +1,5 @@ -import re - -from django.db.utils import IntegrityError from django.shortcuts import get_object_or_404 from rest_framework import status, viewsets -from rest_framework.exceptions import ValidationError from rest_framework.response import Response from features.models import Feature @@ -60,7 +56,6 @@ def create(self, request, *args, **kwargs): ) or not hasattr(feature.project, "github_project") ): - return Response( data={ "detail": "This Project doesn't have a valid GitHub integration configuration" @@ -68,18 +63,8 @@ def create(self, request, *args, **kwargs): content_type="application/json", status=status.HTTP_400_BAD_REQUEST, ) - - try: - return super().create(request, *args, **kwargs) - - except IntegrityError as e: - if re.search(r"Key \(feature_id, url\)", str(e)) and re.search( - r"already exists.$", str(e) - ): - raise ValidationError( - detail="Duplication error. The feature already has this resource URI" - ) + return super().create(request, *args, **kwargs) def perform_update(self, serializer): - external_resource_id = int(self.kwargs["id"]) + external_resource_id = int(self.kwargs["pk"]) serializer.save(id=external_resource_id) diff --git a/api/poetry.lock b/api/poetry.lock index 4beb5af72b9f..1d4b4999d013 100644 --- a/api/poetry.lock +++ b/api/poetry.lock @@ -1,128 +1,5 @@ # This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. -[[package]] -name = "aiohttp" -version = "3.9.4" -description = "Async http client/server framework (asyncio)" -optional = false -python-versions = ">=3.8" -files = [ - {file = "aiohttp-3.9.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:76d32588ef7e4a3f3adff1956a0ba96faabbdee58f2407c122dd45aa6e34f372"}, - {file = "aiohttp-3.9.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:56181093c10dbc6ceb8a29dfeea1e815e1dfdc020169203d87fd8d37616f73f9"}, - {file = "aiohttp-3.9.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c7a5b676d3c65e88b3aca41816bf72831898fcd73f0cbb2680e9d88e819d1e4d"}, - {file = "aiohttp-3.9.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d1df528a85fb404899d4207a8d9934cfd6be626e30e5d3a5544a83dbae6d8a7e"}, - {file = "aiohttp-3.9.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f595db1bceabd71c82e92df212dd9525a8a2c6947d39e3c994c4f27d2fe15b11"}, - {file = "aiohttp-3.9.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9c0b09d76e5a4caac3d27752027fbd43dc987b95f3748fad2b924a03fe8632ad"}, - {file = "aiohttp-3.9.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:689eb4356649ec9535b3686200b231876fb4cab4aca54e3bece71d37f50c1d13"}, - {file = "aiohttp-3.9.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a3666cf4182efdb44d73602379a66f5fdfd5da0db5e4520f0ac0dcca644a3497"}, - {file = "aiohttp-3.9.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:b65b0f8747b013570eea2f75726046fa54fa8e0c5db60f3b98dd5d161052004a"}, - {file = "aiohttp-3.9.4-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:a1885d2470955f70dfdd33a02e1749613c5a9c5ab855f6db38e0b9389453dce7"}, - {file = "aiohttp-3.9.4-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:0593822dcdb9483d41f12041ff7c90d4d1033ec0e880bcfaf102919b715f47f1"}, - {file = "aiohttp-3.9.4-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:47f6eb74e1ecb5e19a78f4a4228aa24df7fbab3b62d4a625d3f41194a08bd54f"}, - {file = "aiohttp-3.9.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c8b04a3dbd54de6ccb7604242fe3ad67f2f3ca558f2d33fe19d4b08d90701a89"}, - {file = "aiohttp-3.9.4-cp310-cp310-win32.whl", hash = "sha256:8a78dfb198a328bfb38e4308ca8167028920fb747ddcf086ce706fbdd23b2926"}, - {file = "aiohttp-3.9.4-cp310-cp310-win_amd64.whl", hash = "sha256:e78da6b55275987cbc89141a1d8e75f5070e577c482dd48bd9123a76a96f0bbb"}, - {file = "aiohttp-3.9.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c111b3c69060d2bafc446917534150fd049e7aedd6cbf21ba526a5a97b4402a5"}, - {file = "aiohttp-3.9.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:efbdd51872cf170093998c87ccdf3cb5993add3559341a8e5708bcb311934c94"}, - {file = "aiohttp-3.9.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7bfdb41dc6e85d8535b00d73947548a748e9534e8e4fddd2638109ff3fb081df"}, - {file = "aiohttp-3.9.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2bd9d334412961125e9f68d5b73c1d0ab9ea3f74a58a475e6b119f5293eee7ba"}, - {file = "aiohttp-3.9.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:35d78076736f4a668d57ade00c65d30a8ce28719d8a42471b2a06ccd1a2e3063"}, - {file = "aiohttp-3.9.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:824dff4f9f4d0f59d0fa3577932ee9a20e09edec8a2f813e1d6b9f89ced8293f"}, - {file = "aiohttp-3.9.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:52b8b4e06fc15519019e128abedaeb56412b106ab88b3c452188ca47a25c4093"}, - {file = "aiohttp-3.9.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eae569fb1e7559d4f3919965617bb39f9e753967fae55ce13454bec2d1c54f09"}, - {file = "aiohttp-3.9.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:69b97aa5792428f321f72aeb2f118e56893371f27e0b7d05750bcad06fc42ca1"}, - {file = "aiohttp-3.9.4-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:4d79aad0ad4b980663316f26d9a492e8fab2af77c69c0f33780a56843ad2f89e"}, - {file = "aiohttp-3.9.4-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:d6577140cd7db19e430661e4b2653680194ea8c22c994bc65b7a19d8ec834403"}, - {file = "aiohttp-3.9.4-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:9860d455847cd98eb67897f5957b7cd69fbcb436dd3f06099230f16a66e66f79"}, - {file = "aiohttp-3.9.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:69ff36d3f8f5652994e08bd22f093e11cfd0444cea310f92e01b45a4e46b624e"}, - {file = "aiohttp-3.9.4-cp311-cp311-win32.whl", hash = "sha256:e27d3b5ed2c2013bce66ad67ee57cbf614288bda8cdf426c8d8fe548316f1b5f"}, - {file = "aiohttp-3.9.4-cp311-cp311-win_amd64.whl", hash = "sha256:d6a67e26daa686a6fbdb600a9af8619c80a332556245fa8e86c747d226ab1a1e"}, - {file = "aiohttp-3.9.4-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:c5ff8ff44825736a4065d8544b43b43ee4c6dd1530f3a08e6c0578a813b0aa35"}, - {file = "aiohttp-3.9.4-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:d12a244627eba4e9dc52cbf924edef905ddd6cafc6513849b4876076a6f38b0e"}, - {file = "aiohttp-3.9.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:dcad56c8d8348e7e468899d2fb3b309b9bc59d94e6db08710555f7436156097f"}, - {file = "aiohttp-3.9.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f7e69a7fd4b5ce419238388e55abd220336bd32212c673ceabc57ccf3d05b55"}, - {file = "aiohttp-3.9.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c4870cb049f10d7680c239b55428916d84158798eb8f353e74fa2c98980dcc0b"}, - {file = "aiohttp-3.9.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3b2feaf1b7031ede1bc0880cec4b0776fd347259a723d625357bb4b82f62687b"}, - {file = "aiohttp-3.9.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:939393e8c3f0a5bcd33ef7ace67680c318dc2ae406f15e381c0054dd658397de"}, - {file = "aiohttp-3.9.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7d2334e387b2adcc944680bebcf412743f2caf4eeebd550f67249c1c3696be04"}, - {file = "aiohttp-3.9.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:e0198ea897680e480845ec0ffc5a14e8b694e25b3f104f63676d55bf76a82f1a"}, - {file = "aiohttp-3.9.4-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:e40d2cd22914d67c84824045861a5bb0fb46586b15dfe4f046c7495bf08306b2"}, - {file = "aiohttp-3.9.4-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:aba80e77c227f4234aa34a5ff2b6ff30c5d6a827a91d22ff6b999de9175d71bd"}, - {file = "aiohttp-3.9.4-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:fb68dc73bc8ac322d2e392a59a9e396c4f35cb6fdbdd749e139d1d6c985f2527"}, - {file = "aiohttp-3.9.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:f3460a92638dce7e47062cf088d6e7663adb135e936cb117be88d5e6c48c9d53"}, - {file = "aiohttp-3.9.4-cp312-cp312-win32.whl", hash = "sha256:32dc814ddbb254f6170bca198fe307920f6c1308a5492f049f7f63554b88ef36"}, - {file = "aiohttp-3.9.4-cp312-cp312-win_amd64.whl", hash = "sha256:63f41a909d182d2b78fe3abef557fcc14da50c7852f70ae3be60e83ff64edba5"}, - {file = "aiohttp-3.9.4-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:c3770365675f6be220032f6609a8fbad994d6dcf3ef7dbcf295c7ee70884c9af"}, - {file = "aiohttp-3.9.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:305edae1dea368ce09bcb858cf5a63a064f3bff4767dec6fa60a0cc0e805a1d3"}, - {file = "aiohttp-3.9.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:6f121900131d116e4a93b55ab0d12ad72573f967b100e49086e496a9b24523ea"}, - {file = "aiohttp-3.9.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b71e614c1ae35c3d62a293b19eface83d5e4d194e3eb2fabb10059d33e6e8cbf"}, - {file = "aiohttp-3.9.4-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:419f009fa4cfde4d16a7fc070d64f36d70a8d35a90d71aa27670bba2be4fd039"}, - {file = "aiohttp-3.9.4-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7b39476ee69cfe64061fd77a73bf692c40021f8547cda617a3466530ef63f947"}, - {file = "aiohttp-3.9.4-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b33f34c9c7decdb2ab99c74be6443942b730b56d9c5ee48fb7df2c86492f293c"}, - {file = "aiohttp-3.9.4-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c78700130ce2dcebb1a8103202ae795be2fa8c9351d0dd22338fe3dac74847d9"}, - {file = "aiohttp-3.9.4-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:268ba22d917655d1259af2d5659072b7dc11b4e1dc2cb9662fdd867d75afc6a4"}, - {file = "aiohttp-3.9.4-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:17e7c051f53a0d2ebf33013a9cbf020bb4e098c4bc5bce6f7b0c962108d97eab"}, - {file = "aiohttp-3.9.4-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:7be99f4abb008cb38e144f85f515598f4c2c8932bf11b65add0ff59c9c876d99"}, - {file = "aiohttp-3.9.4-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:d58a54d6ff08d2547656356eea8572b224e6f9bbc0cf55fa9966bcaac4ddfb10"}, - {file = "aiohttp-3.9.4-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:7673a76772bda15d0d10d1aa881b7911d0580c980dbd16e59d7ba1422b2d83cd"}, - {file = "aiohttp-3.9.4-cp38-cp38-win32.whl", hash = "sha256:e4370dda04dc8951012f30e1ce7956a0a226ac0714a7b6c389fb2f43f22a250e"}, - {file = "aiohttp-3.9.4-cp38-cp38-win_amd64.whl", hash = "sha256:eb30c4510a691bb87081192a394fb661860e75ca3896c01c6d186febe7c88530"}, - {file = "aiohttp-3.9.4-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:84e90494db7df3be5e056f91412f9fa9e611fbe8ce4aaef70647297f5943b276"}, - {file = "aiohttp-3.9.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7d4845f8501ab28ebfdbeab980a50a273b415cf69e96e4e674d43d86a464df9d"}, - {file = "aiohttp-3.9.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:69046cd9a2a17245c4ce3c1f1a4ff8c70c7701ef222fce3d1d8435f09042bba1"}, - {file = "aiohttp-3.9.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b73a06bafc8dcc508420db43b4dd5850e41e69de99009d0351c4f3007960019"}, - {file = "aiohttp-3.9.4-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:418bb0038dfafeac923823c2e63226179976c76f981a2aaad0ad5d51f2229bca"}, - {file = "aiohttp-3.9.4-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:71a8f241456b6c2668374d5d28398f8e8cdae4cce568aaea54e0f39359cd928d"}, - {file = "aiohttp-3.9.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:935c369bf8acc2dc26f6eeb5222768aa7c62917c3554f7215f2ead7386b33748"}, - {file = "aiohttp-3.9.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:74e4e48c8752d14ecfb36d2ebb3d76d614320570e14de0a3aa7a726ff150a03c"}, - {file = "aiohttp-3.9.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:916b0417aeddf2c8c61291238ce25286f391a6acb6f28005dd9ce282bd6311b6"}, - {file = "aiohttp-3.9.4-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:9b6787b6d0b3518b2ee4cbeadd24a507756ee703adbac1ab6dc7c4434b8c572a"}, - {file = "aiohttp-3.9.4-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:221204dbda5ef350e8db6287937621cf75e85778b296c9c52260b522231940ed"}, - {file = "aiohttp-3.9.4-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:10afd99b8251022ddf81eaed1d90f5a988e349ee7d779eb429fb07b670751e8c"}, - {file = "aiohttp-3.9.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:2506d9f7a9b91033201be9ffe7d89c6a54150b0578803cce5cb84a943d075bc3"}, - {file = "aiohttp-3.9.4-cp39-cp39-win32.whl", hash = "sha256:e571fdd9efd65e86c6af2f332e0e95dad259bfe6beb5d15b3c3eca3a6eb5d87b"}, - {file = "aiohttp-3.9.4-cp39-cp39-win_amd64.whl", hash = "sha256:7d29dd5319d20aa3b7749719ac9685fbd926f71ac8c77b2477272725f882072d"}, - {file = "aiohttp-3.9.4.tar.gz", hash = "sha256:6ff71ede6d9a5a58cfb7b6fffc83ab5d4a63138276c771ac91ceaaddf5459644"}, -] - -[package.dependencies] -aiosignal = ">=1.1.2" -attrs = ">=17.3.0" -frozenlist = ">=1.1.1" -multidict = ">=4.5,<7.0" -yarl = ">=1.0,<2.0" - -[package.extras] -speedups = ["Brotli", "aiodns", "brotlicffi"] - -[[package]] -name = "aiohttp-retry" -version = "2.8.3" -description = "Simple retry client for aiohttp" -optional = false -python-versions = ">=3.7" -files = [ - {file = "aiohttp_retry-2.8.3-py3-none-any.whl", hash = "sha256:3aeeead8f6afe48272db93ced9440cf4eda8b6fd7ee2abb25357b7eb28525b45"}, - {file = "aiohttp_retry-2.8.3.tar.gz", hash = "sha256:9a8e637e31682ad36e1ff9f8bcba912fcfc7d7041722bc901a4b948da4d71ea9"}, -] - -[package.dependencies] -aiohttp = "*" - -[[package]] -name = "aiosignal" -version = "1.3.1" -description = "aiosignal: a list of registered asynchronous callbacks" -optional = false -python-versions = ">=3.7" -files = [ - {file = "aiosignal-1.3.1-py3-none-any.whl", hash = "sha256:f8376fb07dd1e86a584e4fcdec80b36b7f81aac666ebc724e2c090300dd83b17"}, - {file = "aiosignal-1.3.1.tar.gz", hash = "sha256:54cd96e15e1649b75d6c87526a6ff0b6c1b0dd3459f43d9ca11d48c339b68cfc"}, -] - -[package.dependencies] -frozenlist = ">=1.1.0" - [[package]] name = "annotated-types" version = "0.6.0" @@ -1209,35 +1086,19 @@ files = [ [package.dependencies] typing_extensions = ">=3.6,<5" -[[package]] -name = "django-trench" -version = "0.2.3" -description = "REST Multi-factor authentication package for Django" -optional = false -python-versions = "*" -files = [ - {file = "django-trench-0.2.3.tar.gz", hash = "sha256:63e189a057c45198d178ea79337e690250b484fcd8ff2057c9fd4b3699639853"}, -] - -[package.dependencies] -pyotp = ">=2.2.6" -smsapi-client = ">=2.2.5" -twilio = ">=6.18.1" -yubico-client = ">=1.10.0" - [[package]] name = "djangorestframework" -version = "3.12.4" +version = "3.15.1" description = "Web APIs for Django, made easy." optional = false -python-versions = ">=3.5" +python-versions = ">=3.6" files = [ - {file = "djangorestframework-3.12.4-py3-none-any.whl", hash = "sha256:6d1d59f623a5ad0509fe0d6bfe93cbdfe17b8116ebc8eda86d45f6e16e819aaf"}, - {file = "djangorestframework-3.12.4.tar.gz", hash = "sha256:f747949a8ddac876e879190df194b925c177cdeb725a099db1460872f7c0a7f2"}, + {file = "djangorestframework-3.15.1-py3-none-any.whl", hash = "sha256:3ccc0475bce968608cf30d07fb17d8e52d1d7fc8bfe779c905463200750cbca6"}, + {file = "djangorestframework-3.15.1.tar.gz", hash = "sha256:f88fad74183dfc7144b2756d0d2ac716ea5b4c7c9840995ac3bfd8ec034333c1"}, ] [package.dependencies] -django = ">=2.2" +django = ">=3.0" [[package]] name = "djangorestframework-api-key" @@ -1310,13 +1171,13 @@ test = ["cryptography", "pytest", "pytest-cov", "pytest-django", "pytest-xdist", [[package]] name = "djoser" -version = "2.2.0" +version = "2.2.2" description = "REST implementation of Django authentication system." optional = false python-versions = ">=3.8,<4.0" files = [ - {file = "djoser-2.2.0-py3-none-any.whl", hash = "sha256:7b24718cdc51b4294b0abcf6bf0ead11aa3ca83652e351dfb04b7b8b15afa3b0"}, - {file = "djoser-2.2.0.tar.gz", hash = "sha256:4aa48502df870c8b5f07109ad4a749cc881c37bb5efa85cf5462ea695a0dca8c"}, + {file = "djoser-2.2.2-py3-none-any.whl", hash = "sha256:efb91ad61e4d5b8d664db029b5947df9d34078289ef2680a1ab665e047144b74"}, + {file = "djoser-2.2.2.tar.gz", hash = "sha256:9deb831a1c8781ceff325699e1407b4e1be8b4588e87071621d88ba31c09349f"}, ] [package.dependencies] @@ -1597,76 +1458,6 @@ files = [ [package.dependencies] python-dateutil = ">=2.7" -[[package]] -name = "frozenlist" -version = "1.4.0" -description = "A list-like structure which implements collections.abc.MutableSequence" -optional = false -python-versions = ">=3.8" -files = [ - {file = "frozenlist-1.4.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:764226ceef3125e53ea2cb275000e309c0aa5464d43bd72abd661e27fffc26ab"}, - {file = "frozenlist-1.4.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d6484756b12f40003c6128bfcc3fa9f0d49a687e171186c2d85ec82e3758c559"}, - {file = "frozenlist-1.4.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9ac08e601308e41eb533f232dbf6b7e4cea762f9f84f6357136eed926c15d12c"}, - {file = "frozenlist-1.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d081f13b095d74b67d550de04df1c756831f3b83dc9881c38985834387487f1b"}, - {file = "frozenlist-1.4.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:71932b597f9895f011f47f17d6428252fc728ba2ae6024e13c3398a087c2cdea"}, - {file = "frozenlist-1.4.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:981b9ab5a0a3178ff413bca62526bb784249421c24ad7381e39d67981be2c326"}, - {file = "frozenlist-1.4.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e41f3de4df3e80de75845d3e743b3f1c4c8613c3997a912dbf0229fc61a8b963"}, - {file = "frozenlist-1.4.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6918d49b1f90821e93069682c06ffde41829c346c66b721e65a5c62b4bab0300"}, - {file = "frozenlist-1.4.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:0e5c8764c7829343d919cc2dfc587a8db01c4f70a4ebbc49abde5d4b158b007b"}, - {file = "frozenlist-1.4.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:8d0edd6b1c7fb94922bf569c9b092ee187a83f03fb1a63076e7774b60f9481a8"}, - {file = "frozenlist-1.4.0-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:e29cda763f752553fa14c68fb2195150bfab22b352572cb36c43c47bedba70eb"}, - {file = "frozenlist-1.4.0-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:0c7c1b47859ee2cac3846fde1c1dc0f15da6cec5a0e5c72d101e0f83dcb67ff9"}, - {file = "frozenlist-1.4.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:901289d524fdd571be1c7be054f48b1f88ce8dddcbdf1ec698b27d4b8b9e5d62"}, - {file = "frozenlist-1.4.0-cp310-cp310-win32.whl", hash = "sha256:1a0848b52815006ea6596c395f87449f693dc419061cc21e970f139d466dc0a0"}, - {file = "frozenlist-1.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:b206646d176a007466358aa21d85cd8600a415c67c9bd15403336c331a10d956"}, - {file = "frozenlist-1.4.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:de343e75f40e972bae1ef6090267f8260c1446a1695e77096db6cfa25e759a95"}, - {file = "frozenlist-1.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ad2a9eb6d9839ae241701d0918f54c51365a51407fd80f6b8289e2dfca977cc3"}, - {file = "frozenlist-1.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:bd7bd3b3830247580de99c99ea2a01416dfc3c34471ca1298bccabf86d0ff4dc"}, - {file = "frozenlist-1.4.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bdf1847068c362f16b353163391210269e4f0569a3c166bc6a9f74ccbfc7e839"}, - {file = "frozenlist-1.4.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:38461d02d66de17455072c9ba981d35f1d2a73024bee7790ac2f9e361ef1cd0c"}, - {file = "frozenlist-1.4.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d5a32087d720c608f42caed0ef36d2b3ea61a9d09ee59a5142d6070da9041b8f"}, - {file = "frozenlist-1.4.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dd65632acaf0d47608190a71bfe46b209719bf2beb59507db08ccdbe712f969b"}, - {file = "frozenlist-1.4.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:261b9f5d17cac914531331ff1b1d452125bf5daa05faf73b71d935485b0c510b"}, - {file = "frozenlist-1.4.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:b89ac9768b82205936771f8d2eb3ce88503b1556324c9f903e7156669f521472"}, - {file = "frozenlist-1.4.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:008eb8b31b3ea6896da16c38c1b136cb9fec9e249e77f6211d479db79a4eaf01"}, - {file = "frozenlist-1.4.0-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:e74b0506fa5aa5598ac6a975a12aa8928cbb58e1f5ac8360792ef15de1aa848f"}, - {file = "frozenlist-1.4.0-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:490132667476f6781b4c9458298b0c1cddf237488abd228b0b3650e5ecba7467"}, - {file = "frozenlist-1.4.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:76d4711f6f6d08551a7e9ef28c722f4a50dd0fc204c56b4bcd95c6cc05ce6fbb"}, - {file = "frozenlist-1.4.0-cp311-cp311-win32.whl", hash = "sha256:a02eb8ab2b8f200179b5f62b59757685ae9987996ae549ccf30f983f40602431"}, - {file = "frozenlist-1.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:515e1abc578dd3b275d6a5114030b1330ba044ffba03f94091842852f806f1c1"}, - {file = "frozenlist-1.4.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:f0ed05f5079c708fe74bf9027e95125334b6978bf07fd5ab923e9e55e5fbb9d3"}, - {file = "frozenlist-1.4.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:ca265542ca427bf97aed183c1676e2a9c66942e822b14dc6e5f42e038f92a503"}, - {file = "frozenlist-1.4.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:491e014f5c43656da08958808588cc6c016847b4360e327a62cb308c791bd2d9"}, - {file = "frozenlist-1.4.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:17ae5cd0f333f94f2e03aaf140bb762c64783935cc764ff9c82dff626089bebf"}, - {file = "frozenlist-1.4.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1e78fb68cf9c1a6aa4a9a12e960a5c9dfbdb89b3695197aa7064705662515de2"}, - {file = "frozenlist-1.4.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d5655a942f5f5d2c9ed93d72148226d75369b4f6952680211972a33e59b1dfdc"}, - {file = "frozenlist-1.4.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c11b0746f5d946fecf750428a95f3e9ebe792c1ee3b1e96eeba145dc631a9672"}, - {file = "frozenlist-1.4.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e66d2a64d44d50d2543405fb183a21f76b3b5fd16f130f5c99187c3fb4e64919"}, - {file = "frozenlist-1.4.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:88f7bc0fcca81f985f78dd0fa68d2c75abf8272b1f5c323ea4a01a4d7a614efc"}, - {file = "frozenlist-1.4.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:5833593c25ac59ede40ed4de6d67eb42928cca97f26feea219f21d0ed0959b79"}, - {file = "frozenlist-1.4.0-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:fec520865f42e5c7f050c2a79038897b1c7d1595e907a9e08e3353293ffc948e"}, - {file = "frozenlist-1.4.0-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:b826d97e4276750beca7c8f0f1a4938892697a6bcd8ec8217b3312dad6982781"}, - {file = "frozenlist-1.4.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:ceb6ec0a10c65540421e20ebd29083c50e6d1143278746a4ef6bcf6153171eb8"}, - {file = "frozenlist-1.4.0-cp38-cp38-win32.whl", hash = "sha256:2b8bcf994563466db019fab287ff390fffbfdb4f905fc77bc1c1d604b1c689cc"}, - {file = "frozenlist-1.4.0-cp38-cp38-win_amd64.whl", hash = "sha256:a6c8097e01886188e5be3e6b14e94ab365f384736aa1fca6a0b9e35bd4a30bc7"}, - {file = "frozenlist-1.4.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:6c38721585f285203e4b4132a352eb3daa19121a035f3182e08e437cface44bf"}, - {file = "frozenlist-1.4.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a0c6da9aee33ff0b1a451e867da0c1f47408112b3391dd43133838339e410963"}, - {file = "frozenlist-1.4.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:93ea75c050c5bb3d98016b4ba2497851eadf0ac154d88a67d7a6816206f6fa7f"}, - {file = "frozenlist-1.4.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f61e2dc5ad442c52b4887f1fdc112f97caeff4d9e6ebe78879364ac59f1663e1"}, - {file = "frozenlist-1.4.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aa384489fefeb62321b238e64c07ef48398fe80f9e1e6afeff22e140e0850eef"}, - {file = "frozenlist-1.4.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:10ff5faaa22786315ef57097a279b833ecab1a0bfb07d604c9cbb1c4cdc2ed87"}, - {file = "frozenlist-1.4.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:007df07a6e3eb3e33e9a1fe6a9db7af152bbd8a185f9aaa6ece10a3529e3e1c6"}, - {file = "frozenlist-1.4.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f4f399d28478d1f604c2ff9119907af9726aed73680e5ed1ca634d377abb087"}, - {file = "frozenlist-1.4.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:c5374b80521d3d3f2ec5572e05adc94601985cc526fb276d0c8574a6d749f1b3"}, - {file = "frozenlist-1.4.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:ce31ae3e19f3c902de379cf1323d90c649425b86de7bbdf82871b8a2a0615f3d"}, - {file = "frozenlist-1.4.0-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:7211ef110a9194b6042449431e08c4d80c0481e5891e58d429df5899690511c2"}, - {file = "frozenlist-1.4.0-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:556de4430ce324c836789fa4560ca62d1591d2538b8ceb0b4f68fb7b2384a27a"}, - {file = "frozenlist-1.4.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:7645a8e814a3ee34a89c4a372011dcd817964ce8cb273c8ed6119d706e9613e3"}, - {file = "frozenlist-1.4.0-cp39-cp39-win32.whl", hash = "sha256:19488c57c12d4e8095a922f328df3f179c820c212940a498623ed39160bc3c2f"}, - {file = "frozenlist-1.4.0-cp39-cp39-win_amd64.whl", hash = "sha256:6221d84d463fb110bdd7619b69cb43878a11d51cbb9394ae3105d082d5199167"}, - {file = "frozenlist-1.4.0.tar.gz", hash = "sha256:09163bdf0b2907454042edb19f887c6d33806adc71fbd54afc14908bfdc22251"}, -] - [[package]] name = "genson" version = "1.2.2" @@ -2571,105 +2362,6 @@ portalocker = [ {version = ">=1.6,<3", markers = "python_version >= \"3.5\" and platform_system == \"Windows\""}, ] -[[package]] -name = "multidict" -version = "6.0.5" -description = "multidict implementation" -optional = false -python-versions = ">=3.7" -files = [ - {file = "multidict-6.0.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:228b644ae063c10e7f324ab1ab6b548bdf6f8b47f3ec234fef1093bc2735e5f9"}, - {file = "multidict-6.0.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:896ebdcf62683551312c30e20614305f53125750803b614e9e6ce74a96232604"}, - {file = "multidict-6.0.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:411bf8515f3be9813d06004cac41ccf7d1cd46dfe233705933dd163b60e37600"}, - {file = "multidict-6.0.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1d147090048129ce3c453f0292e7697d333db95e52616b3793922945804a433c"}, - {file = "multidict-6.0.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:215ed703caf15f578dca76ee6f6b21b7603791ae090fbf1ef9d865571039ade5"}, - {file = "multidict-6.0.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c6390cf87ff6234643428991b7359b5f59cc15155695deb4eda5c777d2b880f"}, - {file = "multidict-6.0.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21fd81c4ebdb4f214161be351eb5bcf385426bf023041da2fd9e60681f3cebae"}, - {file = "multidict-6.0.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3cc2ad10255f903656017363cd59436f2111443a76f996584d1077e43ee51182"}, - {file = "multidict-6.0.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:6939c95381e003f54cd4c5516740faba40cf5ad3eeff460c3ad1d3e0ea2549bf"}, - {file = "multidict-6.0.5-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:220dd781e3f7af2c2c1053da9fa96d9cf3072ca58f057f4c5adaaa1cab8fc442"}, - {file = "multidict-6.0.5-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:766c8f7511df26d9f11cd3a8be623e59cca73d44643abab3f8c8c07620524e4a"}, - {file = "multidict-6.0.5-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:fe5d7785250541f7f5019ab9cba2c71169dc7d74d0f45253f8313f436458a4ef"}, - {file = "multidict-6.0.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c1c1496e73051918fcd4f58ff2e0f2f3066d1c76a0c6aeffd9b45d53243702cc"}, - {file = "multidict-6.0.5-cp310-cp310-win32.whl", hash = "sha256:7afcdd1fc07befad18ec4523a782cde4e93e0a2bf71239894b8d61ee578c1319"}, - {file = "multidict-6.0.5-cp310-cp310-win_amd64.whl", hash = "sha256:99f60d34c048c5c2fabc766108c103612344c46e35d4ed9ae0673d33c8fb26e8"}, - {file = "multidict-6.0.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:f285e862d2f153a70586579c15c44656f888806ed0e5b56b64489afe4a2dbfba"}, - {file = "multidict-6.0.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:53689bb4e102200a4fafa9de9c7c3c212ab40a7ab2c8e474491914d2305f187e"}, - {file = "multidict-6.0.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:612d1156111ae11d14afaf3a0669ebf6c170dbb735e510a7438ffe2369a847fd"}, - {file = "multidict-6.0.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7be7047bd08accdb7487737631d25735c9a04327911de89ff1b26b81745bd4e3"}, - {file = "multidict-6.0.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de170c7b4fe6859beb8926e84f7d7d6c693dfe8e27372ce3b76f01c46e489fcf"}, - {file = "multidict-6.0.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04bde7a7b3de05732a4eb39c94574db1ec99abb56162d6c520ad26f83267de29"}, - {file = "multidict-6.0.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:85f67aed7bb647f93e7520633d8f51d3cbc6ab96957c71272b286b2f30dc70ed"}, - {file = "multidict-6.0.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:425bf820055005bfc8aa9a0b99ccb52cc2f4070153e34b701acc98d201693733"}, - {file = "multidict-6.0.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:d3eb1ceec286eba8220c26f3b0096cf189aea7057b6e7b7a2e60ed36b373b77f"}, - {file = "multidict-6.0.5-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:7901c05ead4b3fb75113fb1dd33eb1253c6d3ee37ce93305acd9d38e0b5f21a4"}, - {file = "multidict-6.0.5-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:e0e79d91e71b9867c73323a3444724d496c037e578a0e1755ae159ba14f4f3d1"}, - {file = "multidict-6.0.5-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:29bfeb0dff5cb5fdab2023a7a9947b3b4af63e9c47cae2a10ad58394b517fddc"}, - {file = "multidict-6.0.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e030047e85cbcedbfc073f71836d62dd5dadfbe7531cae27789ff66bc551bd5e"}, - {file = "multidict-6.0.5-cp311-cp311-win32.whl", hash = "sha256:2f4848aa3baa109e6ab81fe2006c77ed4d3cd1e0ac2c1fbddb7b1277c168788c"}, - {file = "multidict-6.0.5-cp311-cp311-win_amd64.whl", hash = "sha256:2faa5ae9376faba05f630d7e5e6be05be22913782b927b19d12b8145968a85ea"}, - {file = "multidict-6.0.5-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:51d035609b86722963404f711db441cf7134f1889107fb171a970c9701f92e1e"}, - {file = "multidict-6.0.5-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:cbebcd5bcaf1eaf302617c114aa67569dd3f090dd0ce8ba9e35e9985b41ac35b"}, - {file = "multidict-6.0.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2ffc42c922dbfddb4a4c3b438eb056828719f07608af27d163191cb3e3aa6cc5"}, - {file = "multidict-6.0.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ceb3b7e6a0135e092de86110c5a74e46bda4bd4fbfeeb3a3bcec79c0f861e450"}, - {file = "multidict-6.0.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:79660376075cfd4b2c80f295528aa6beb2058fd289f4c9252f986751a4cd0496"}, - {file = "multidict-6.0.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e4428b29611e989719874670fd152b6625500ad6c686d464e99f5aaeeaca175a"}, - {file = "multidict-6.0.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d84a5c3a5f7ce6db1f999fb9438f686bc2e09d38143f2d93d8406ed2dd6b9226"}, - {file = "multidict-6.0.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:76c0de87358b192de7ea9649beb392f107dcad9ad27276324c24c91774ca5271"}, - {file = "multidict-6.0.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:79a6d2ba910adb2cbafc95dad936f8b9386e77c84c35bc0add315b856d7c3abb"}, - {file = "multidict-6.0.5-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:92d16a3e275e38293623ebf639c471d3e03bb20b8ebb845237e0d3664914caef"}, - {file = "multidict-6.0.5-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:fb616be3538599e797a2017cccca78e354c767165e8858ab5116813146041a24"}, - {file = "multidict-6.0.5-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:14c2976aa9038c2629efa2c148022ed5eb4cb939e15ec7aace7ca932f48f9ba6"}, - {file = "multidict-6.0.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:435a0984199d81ca178b9ae2c26ec3d49692d20ee29bc4c11a2a8d4514c67eda"}, - {file = "multidict-6.0.5-cp312-cp312-win32.whl", hash = "sha256:9fe7b0653ba3d9d65cbe7698cca585bf0f8c83dbbcc710db9c90f478e175f2d5"}, - {file = "multidict-6.0.5-cp312-cp312-win_amd64.whl", hash = "sha256:01265f5e40f5a17f8241d52656ed27192be03bfa8764d88e8220141d1e4b3556"}, - {file = "multidict-6.0.5-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:19fe01cea168585ba0f678cad6f58133db2aa14eccaf22f88e4a6dccadfad8b3"}, - {file = "multidict-6.0.5-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6bf7a982604375a8d49b6cc1b781c1747f243d91b81035a9b43a2126c04766f5"}, - {file = "multidict-6.0.5-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:107c0cdefe028703fb5dafe640a409cb146d44a6ae201e55b35a4af8e95457dd"}, - {file = "multidict-6.0.5-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:403c0911cd5d5791605808b942c88a8155c2592e05332d2bf78f18697a5fa15e"}, - {file = "multidict-6.0.5-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aeaf541ddbad8311a87dd695ed9642401131ea39ad7bc8cf3ef3967fd093b626"}, - {file = "multidict-6.0.5-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e4972624066095e52b569e02b5ca97dbd7a7ddd4294bf4e7247d52635630dd83"}, - {file = "multidict-6.0.5-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:d946b0a9eb8aaa590df1fe082cee553ceab173e6cb5b03239716338629c50c7a"}, - {file = "multidict-6.0.5-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:b55358304d7a73d7bdf5de62494aaf70bd33015831ffd98bc498b433dfe5b10c"}, - {file = "multidict-6.0.5-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:a3145cb08d8625b2d3fee1b2d596a8766352979c9bffe5d7833e0503d0f0b5e5"}, - {file = "multidict-6.0.5-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:d65f25da8e248202bd47445cec78e0025c0fe7582b23ec69c3b27a640dd7a8e3"}, - {file = "multidict-6.0.5-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:c9bf56195c6bbd293340ea82eafd0071cb3d450c703d2c93afb89f93b8386ccc"}, - {file = "multidict-6.0.5-cp37-cp37m-win32.whl", hash = "sha256:69db76c09796b313331bb7048229e3bee7928eb62bab5e071e9f7fcc4879caee"}, - {file = "multidict-6.0.5-cp37-cp37m-win_amd64.whl", hash = "sha256:fce28b3c8a81b6b36dfac9feb1de115bab619b3c13905b419ec71d03a3fc1423"}, - {file = "multidict-6.0.5-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:76f067f5121dcecf0d63a67f29080b26c43c71a98b10c701b0677e4a065fbd54"}, - {file = "multidict-6.0.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:b82cc8ace10ab5bd93235dfaab2021c70637005e1ac787031f4d1da63d493c1d"}, - {file = "multidict-6.0.5-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:5cb241881eefd96b46f89b1a056187ea8e9ba14ab88ba632e68d7a2ecb7aadf7"}, - {file = "multidict-6.0.5-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8e94e6912639a02ce173341ff62cc1201232ab86b8a8fcc05572741a5dc7d93"}, - {file = "multidict-6.0.5-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:09a892e4a9fb47331da06948690ae38eaa2426de97b4ccbfafbdcbe5c8f37ff8"}, - {file = "multidict-6.0.5-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:55205d03e8a598cfc688c71ca8ea5f66447164efff8869517f175ea632c7cb7b"}, - {file = "multidict-6.0.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:37b15024f864916b4951adb95d3a80c9431299080341ab9544ed148091b53f50"}, - {file = "multidict-6.0.5-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f2a1dee728b52b33eebff5072817176c172050d44d67befd681609b4746e1c2e"}, - {file = "multidict-6.0.5-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:edd08e6f2f1a390bf137080507e44ccc086353c8e98c657e666c017718561b89"}, - {file = "multidict-6.0.5-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:60d698e8179a42ec85172d12f50b1668254628425a6bd611aba022257cac1386"}, - {file = "multidict-6.0.5-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:3d25f19500588cbc47dc19081d78131c32637c25804df8414463ec908631e453"}, - {file = "multidict-6.0.5-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:4cc0ef8b962ac7a5e62b9e826bd0cd5040e7d401bc45a6835910ed699037a461"}, - {file = "multidict-6.0.5-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:eca2e9d0cc5a889850e9bbd68e98314ada174ff6ccd1129500103df7a94a7a44"}, - {file = "multidict-6.0.5-cp38-cp38-win32.whl", hash = "sha256:4a6a4f196f08c58c59e0b8ef8ec441d12aee4125a7d4f4fef000ccb22f8d7241"}, - {file = "multidict-6.0.5-cp38-cp38-win_amd64.whl", hash = "sha256:0275e35209c27a3f7951e1ce7aaf93ce0d163b28948444bec61dd7badc6d3f8c"}, - {file = "multidict-6.0.5-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:e7be68734bd8c9a513f2b0cfd508802d6609da068f40dc57d4e3494cefc92929"}, - {file = "multidict-6.0.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:1d9ea7a7e779d7a3561aade7d596649fbecfa5c08a7674b11b423783217933f9"}, - {file = "multidict-6.0.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ea1456df2a27c73ce51120fa2f519f1bea2f4a03a917f4a43c8707cf4cbbae1a"}, - {file = "multidict-6.0.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cf590b134eb70629e350691ecca88eac3e3b8b3c86992042fb82e3cb1830d5e1"}, - {file = "multidict-6.0.5-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5c0631926c4f58e9a5ccce555ad7747d9a9f8b10619621f22f9635f069f6233e"}, - {file = "multidict-6.0.5-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dce1c6912ab9ff5f179eaf6efe7365c1f425ed690b03341911bf4939ef2f3046"}, - {file = "multidict-6.0.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0868d64af83169e4d4152ec612637a543f7a336e4a307b119e98042e852ad9c"}, - {file = "multidict-6.0.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:141b43360bfd3bdd75f15ed811850763555a251e38b2405967f8e25fb43f7d40"}, - {file = "multidict-6.0.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:7df704ca8cf4a073334e0427ae2345323613e4df18cc224f647f251e5e75a527"}, - {file = "multidict-6.0.5-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:6214c5a5571802c33f80e6c84713b2c79e024995b9c5897f794b43e714daeec9"}, - {file = "multidict-6.0.5-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:cd6c8fca38178e12c00418de737aef1261576bd1b6e8c6134d3e729a4e858b38"}, - {file = "multidict-6.0.5-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:e02021f87a5b6932fa6ce916ca004c4d441509d33bbdbeca70d05dff5e9d2479"}, - {file = "multidict-6.0.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ebd8d160f91a764652d3e51ce0d2956b38efe37c9231cd82cfc0bed2e40b581c"}, - {file = "multidict-6.0.5-cp39-cp39-win32.whl", hash = "sha256:04da1bb8c8dbadf2a18a452639771951c662c5ad03aefe4884775454be322c9b"}, - {file = "multidict-6.0.5-cp39-cp39-win_amd64.whl", hash = "sha256:d6f6d4f185481c9669b9447bf9d9cf3b95a0e9df9d169bbc17e363b7d5487755"}, - {file = "multidict-6.0.5-py3-none-any.whl", hash = "sha256:0d63c74e3d7ab26de115c49bffc92cc77ed23395303d496eae515d4204a625e7"}, - {file = "multidict-6.0.5.tar.gz", hash = "sha256:f7e301075edaf50500f0b341543c41194d8df3ae5caf4702f2095f3ca73dd8da"}, -] - [[package]] name = "mypy-boto3-dynamodb" version = "1.33.0" @@ -4419,20 +4111,6 @@ files = [ optional = ["SQLAlchemy (>=1,<2)", "aiodns (>1.0)", "aiohttp (>=3.7.3,<4)", "boto3 (<=2)", "websocket-client (>=1,<2)", "websockets (>=9.1,<10)"] testing = ["Flask (>=1,<2)", "Flask-Sockets (>=0.2,<1)", "Werkzeug (<2)", "black (==21.7b0)", "boto3 (<=2)", "codecov (>=2,<3)", "databases (>=0.3)", "flake8 (>=3,<4)", "moto (<2)", "psutil (>=5,<6)", "pytest (>=5.4,<6)", "pytest-asyncio (<1)", "pytest-cov (>=2,<3)"] -[[package]] -name = "smsapi-client" -version = "2.7.0" -description = "SmsAPI client" -optional = false -python-versions = "*" -files = [ - {file = "smsapi-client-2.7.0.tar.gz", hash = "sha256:9de0932faaaf0c36fd279a11b5054f3ca24cf6bf58be3235316c15346ddcce82"}, - {file = "smsapi_client-2.7.0-py2.py3-none-any.whl", hash = "sha256:fd101101ed74fde0f24e663398f3648dd5a56954ec9576d509da087641f73da8"}, -] - -[package.dependencies] -requests = "*" - [[package]] name = "social-auth-app-django" version = "5.4.1" @@ -4523,24 +4201,6 @@ files = [ {file = "tomlkit-0.12.1.tar.gz", hash = "sha256:38e1ff8edb991273ec9f6181244a6a391ac30e9f5098e7535640ea6be97a7c86"}, ] -[[package]] -name = "twilio" -version = "8.5.0" -description = "Twilio API client and TwiML generator" -optional = false -python-versions = ">=3.7.0" -files = [ - {file = "twilio-8.5.0-py2.py3-none-any.whl", hash = "sha256:a6fdea2252cb7a8a47b5750d58abe1888bba9777482bac8e9bc3be47970facc7"}, - {file = "twilio-8.5.0.tar.gz", hash = "sha256:f55da9b485f9070aef09836e56230d0e6fd83811d2e6668f20d9057dd3668143"}, -] - -[package.dependencies] -aiohttp = ">=3.8.4" -aiohttp-retry = ">=2.8.3" -PyJWT = ">=2.0.0,<3.0.0" -pytz = "*" -requests = ">=2.0.0" - [[package]] name = "types-toml" version = "0.10.8.7" @@ -4815,108 +4475,7 @@ files = [ {file = "xmltodict-0.13.0.tar.gz", hash = "sha256:341595a488e3e01a85a9d8911d8912fd922ede5fecc4dce437eb4b6c8d037e56"}, ] -[[package]] -name = "yarl" -version = "1.9.2" -description = "Yet another URL library" -optional = false -python-versions = ">=3.7" -files = [ - {file = "yarl-1.9.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:8c2ad583743d16ddbdf6bb14b5cd76bf43b0d0006e918809d5d4ddf7bde8dd82"}, - {file = "yarl-1.9.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:82aa6264b36c50acfb2424ad5ca537a2060ab6de158a5bd2a72a032cc75b9eb8"}, - {file = "yarl-1.9.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c0c77533b5ed4bcc38e943178ccae29b9bcf48ffd1063f5821192f23a1bd27b9"}, - {file = "yarl-1.9.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ee4afac41415d52d53a9833ebae7e32b344be72835bbb589018c9e938045a560"}, - {file = "yarl-1.9.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9bf345c3a4f5ba7f766430f97f9cc1320786f19584acc7086491f45524a551ac"}, - {file = "yarl-1.9.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2a96c19c52ff442a808c105901d0bdfd2e28575b3d5f82e2f5fd67e20dc5f4ea"}, - {file = "yarl-1.9.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:891c0e3ec5ec881541f6c5113d8df0315ce5440e244a716b95f2525b7b9f3608"}, - {file = "yarl-1.9.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c3a53ba34a636a256d767c086ceb111358876e1fb6b50dfc4d3f4951d40133d5"}, - {file = "yarl-1.9.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:566185e8ebc0898b11f8026447eacd02e46226716229cea8db37496c8cdd26e0"}, - {file = "yarl-1.9.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:2b0738fb871812722a0ac2154be1f049c6223b9f6f22eec352996b69775b36d4"}, - {file = "yarl-1.9.2-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:32f1d071b3f362c80f1a7d322bfd7b2d11e33d2adf395cc1dd4df36c9c243095"}, - {file = "yarl-1.9.2-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:e9fdc7ac0d42bc3ea78818557fab03af6181e076a2944f43c38684b4b6bed8e3"}, - {file = "yarl-1.9.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:56ff08ab5df8429901ebdc5d15941b59f6253393cb5da07b4170beefcf1b2528"}, - {file = "yarl-1.9.2-cp310-cp310-win32.whl", hash = "sha256:8ea48e0a2f931064469bdabca50c2f578b565fc446f302a79ba6cc0ee7f384d3"}, - {file = "yarl-1.9.2-cp310-cp310-win_amd64.whl", hash = "sha256:50f33040f3836e912ed16d212f6cc1efb3231a8a60526a407aeb66c1c1956dde"}, - {file = "yarl-1.9.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:646d663eb2232d7909e6601f1a9107e66f9791f290a1b3dc7057818fe44fc2b6"}, - {file = "yarl-1.9.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:aff634b15beff8902d1f918012fc2a42e0dbae6f469fce134c8a0dc51ca423bb"}, - {file = "yarl-1.9.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a83503934c6273806aed765035716216cc9ab4e0364f7f066227e1aaea90b8d0"}, - {file = "yarl-1.9.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b25322201585c69abc7b0e89e72790469f7dad90d26754717f3310bfe30331c2"}, - {file = "yarl-1.9.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:22a94666751778629f1ec4280b08eb11815783c63f52092a5953faf73be24191"}, - {file = "yarl-1.9.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8ec53a0ea2a80c5cd1ab397925f94bff59222aa3cf9c6da938ce05c9ec20428d"}, - {file = "yarl-1.9.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:159d81f22d7a43e6eabc36d7194cb53f2f15f498dbbfa8edc8a3239350f59fe7"}, - {file = "yarl-1.9.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:832b7e711027c114d79dffb92576acd1bd2decc467dec60e1cac96912602d0e6"}, - {file = "yarl-1.9.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:95d2ecefbcf4e744ea952d073c6922e72ee650ffc79028eb1e320e732898d7e8"}, - {file = "yarl-1.9.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:d4e2c6d555e77b37288eaf45b8f60f0737c9efa3452c6c44626a5455aeb250b9"}, - {file = "yarl-1.9.2-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:783185c75c12a017cc345015ea359cc801c3b29a2966c2655cd12b233bf5a2be"}, - {file = "yarl-1.9.2-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:b8cc1863402472f16c600e3e93d542b7e7542a540f95c30afd472e8e549fc3f7"}, - {file = "yarl-1.9.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:822b30a0f22e588b32d3120f6d41e4ed021806418b4c9f0bc3048b8c8cb3f92a"}, - {file = "yarl-1.9.2-cp311-cp311-win32.whl", hash = "sha256:a60347f234c2212a9f0361955007fcf4033a75bf600a33c88a0a8e91af77c0e8"}, - {file = "yarl-1.9.2-cp311-cp311-win_amd64.whl", hash = "sha256:be6b3fdec5c62f2a67cb3f8c6dbf56bbf3f61c0f046f84645cd1ca73532ea051"}, - {file = "yarl-1.9.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:38a3928ae37558bc1b559f67410df446d1fbfa87318b124bf5032c31e3447b74"}, - {file = "yarl-1.9.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ac9bb4c5ce3975aeac288cfcb5061ce60e0d14d92209e780c93954076c7c4367"}, - {file = "yarl-1.9.2-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3da8a678ca8b96c8606bbb8bfacd99a12ad5dd288bc6f7979baddd62f71c63ef"}, - {file = "yarl-1.9.2-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:13414591ff516e04fcdee8dc051c13fd3db13b673c7a4cb1350e6b2ad9639ad3"}, - {file = "yarl-1.9.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bf74d08542c3a9ea97bb8f343d4fcbd4d8f91bba5ec9d5d7f792dbe727f88938"}, - {file = "yarl-1.9.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6e7221580dc1db478464cfeef9b03b95c5852cc22894e418562997df0d074ccc"}, - {file = "yarl-1.9.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:494053246b119b041960ddcd20fd76224149cfea8ed8777b687358727911dd33"}, - {file = "yarl-1.9.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:52a25809fcbecfc63ac9ba0c0fb586f90837f5425edfd1ec9f3372b119585e45"}, - {file = "yarl-1.9.2-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:e65610c5792870d45d7b68c677681376fcf9cc1c289f23e8e8b39c1485384185"}, - {file = "yarl-1.9.2-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:1b1bba902cba32cdec51fca038fd53f8beee88b77efc373968d1ed021024cc04"}, - {file = "yarl-1.9.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:662e6016409828ee910f5d9602a2729a8a57d74b163c89a837de3fea050c7582"}, - {file = "yarl-1.9.2-cp37-cp37m-win32.whl", hash = "sha256:f364d3480bffd3aa566e886587eaca7c8c04d74f6e8933f3f2c996b7f09bee1b"}, - {file = "yarl-1.9.2-cp37-cp37m-win_amd64.whl", hash = "sha256:6a5883464143ab3ae9ba68daae8e7c5c95b969462bbe42e2464d60e7e2698368"}, - {file = "yarl-1.9.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:5610f80cf43b6202e2c33ba3ec2ee0a2884f8f423c8f4f62906731d876ef4fac"}, - {file = "yarl-1.9.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:b9a4e67ad7b646cd6f0938c7ebfd60e481b7410f574c560e455e938d2da8e0f4"}, - {file = "yarl-1.9.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:83fcc480d7549ccebe9415d96d9263e2d4226798c37ebd18c930fce43dfb9574"}, - {file = "yarl-1.9.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5fcd436ea16fee7d4207c045b1e340020e58a2597301cfbcfdbe5abd2356c2fb"}, - {file = "yarl-1.9.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:84e0b1599334b1e1478db01b756e55937d4614f8654311eb26012091be109d59"}, - {file = "yarl-1.9.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3458a24e4ea3fd8930e934c129b676c27452e4ebda80fbe47b56d8c6c7a63a9e"}, - {file = "yarl-1.9.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:838162460b3a08987546e881a2bfa573960bb559dfa739e7800ceeec92e64417"}, - {file = "yarl-1.9.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f4e2d08f07a3d7d3e12549052eb5ad3eab1c349c53ac51c209a0e5991bbada78"}, - {file = "yarl-1.9.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:de119f56f3c5f0e2fb4dee508531a32b069a5f2c6e827b272d1e0ff5ac040333"}, - {file = "yarl-1.9.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:149ddea5abf329752ea5051b61bd6c1d979e13fbf122d3a1f9f0c8be6cb6f63c"}, - {file = "yarl-1.9.2-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:674ca19cbee4a82c9f54e0d1eee28116e63bc6fd1e96c43031d11cbab8b2afd5"}, - {file = "yarl-1.9.2-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:9b3152f2f5677b997ae6c804b73da05a39daa6a9e85a512e0e6823d81cdad7cc"}, - {file = "yarl-1.9.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:5415d5a4b080dc9612b1b63cba008db84e908b95848369aa1da3686ae27b6d2b"}, - {file = "yarl-1.9.2-cp38-cp38-win32.whl", hash = "sha256:f7a3d8146575e08c29ed1cd287068e6d02f1c7bdff8970db96683b9591b86ee7"}, - {file = "yarl-1.9.2-cp38-cp38-win_amd64.whl", hash = "sha256:63c48f6cef34e6319a74c727376e95626f84ea091f92c0250a98e53e62c77c72"}, - {file = "yarl-1.9.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:75df5ef94c3fdc393c6b19d80e6ef1ecc9ae2f4263c09cacb178d871c02a5ba9"}, - {file = "yarl-1.9.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:c027a6e96ef77d401d8d5a5c8d6bc478e8042f1e448272e8d9752cb0aff8b5c8"}, - {file = "yarl-1.9.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f3b078dbe227f79be488ffcfc7a9edb3409d018e0952cf13f15fd6512847f3f7"}, - {file = "yarl-1.9.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:59723a029760079b7d991a401386390c4be5bfec1e7dd83e25a6a0881859e716"}, - {file = "yarl-1.9.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b03917871bf859a81ccb180c9a2e6c1e04d2f6a51d953e6a5cdd70c93d4e5a2a"}, - {file = "yarl-1.9.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c1012fa63eb6c032f3ce5d2171c267992ae0c00b9e164efe4d73db818465fac3"}, - {file = "yarl-1.9.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a74dcbfe780e62f4b5a062714576f16c2f3493a0394e555ab141bf0d746bb955"}, - {file = "yarl-1.9.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8c56986609b057b4839968ba901944af91b8e92f1725d1a2d77cbac6972b9ed1"}, - {file = "yarl-1.9.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:2c315df3293cd521033533d242d15eab26583360b58f7ee5d9565f15fee1bef4"}, - {file = "yarl-1.9.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:b7232f8dfbd225d57340e441d8caf8652a6acd06b389ea2d3222b8bc89cbfca6"}, - {file = "yarl-1.9.2-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:53338749febd28935d55b41bf0bcc79d634881195a39f6b2f767870b72514caf"}, - {file = "yarl-1.9.2-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:066c163aec9d3d073dc9ffe5dd3ad05069bcb03fcaab8d221290ba99f9f69ee3"}, - {file = "yarl-1.9.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:8288d7cd28f8119b07dd49b7230d6b4562f9b61ee9a4ab02221060d21136be80"}, - {file = "yarl-1.9.2-cp39-cp39-win32.whl", hash = "sha256:b124e2a6d223b65ba8768d5706d103280914d61f5cae3afbc50fc3dfcc016623"}, - {file = "yarl-1.9.2-cp39-cp39-win_amd64.whl", hash = "sha256:61016e7d582bc46a5378ffdd02cd0314fb8ba52f40f9cf4d9a5e7dbef88dee18"}, - {file = "yarl-1.9.2.tar.gz", hash = "sha256:04ab9d4b9f587c06d801c2abfe9317b77cdf996c65a90d5e84ecc45010823571"}, -] - -[package.dependencies] -idna = ">=2.0" -multidict = ">=4.0" - -[[package]] -name = "yubico-client" -version = "1.13.0" -description = "Library for verifying Yubikey One Time Passwords (OTPs)" -optional = false -python-versions = "*" -files = [ - {file = "yubico-client-1.13.0.tar.gz", hash = "sha256:e3b86cd2a123105edfacad40551c7b26e9c1193d81ffe168ee704ebfd3d11162"}, - {file = "yubico_client-1.13.0-py2.py3-none-any.whl", hash = "sha256:59d818661f638e3f041fae44ba2c0569e4eb2a17865fa7cc9ad6577185c4d185"}, -] - -[package.dependencies] -requests = ">=2.7,<3.0" - [metadata] lock-version = "2.0" python-versions = ">=3.11, <3.13" -content-hash = "63aafc526f6ef0df22c431ba8f12880a7ad15ec00c052c514f79742651971032" +content-hash = "5e1a10d25dfba65b6f275786cd40158cf377d0eab2af331c5f4d8f9dc88d233b" diff --git a/api/pyproject.toml b/api/pyproject.toml index d3d99d21748b..e1b0e104de53 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -60,7 +60,7 @@ segment-analytics-python = "~2.2.3" backoff = "~2.2.1" appdirs = "~1.4.4" django-cors-headers = "~3.5.0" -djangorestframework = "~3.12.1" +djangorestframework = "~3.15.1" gunicorn = "~22.0.0" pyparsing = "~2.4.7" requests = "~2.32.0" @@ -99,8 +99,7 @@ pymemcache = "~4.0.0" google-re2 = "^1.0" django-softdelete = "~0.10.5" simplejson = "~3.19.1" -djoser = "~2.2.0" -django-trench = "~0.2.3" +djoser = "~2.2.2" django-storages = "~1.10.1" django-environ = "~0.4.5" influxdb-client = "~1.28.0" @@ -115,6 +114,7 @@ django-redis = "^5.4.0" pygithub = "2.1.1" hubspot-api-client = "^8.2.1" djangorestframework-dataclasses = "^1.3.1" +pyotp = "^2.9.0" [tool.poetry.group.auth-controller] optional = true diff --git a/api/tests/integration/custom_auth/end_to_end/test_custom_auth_integration.py b/api/tests/integration/custom_auth/end_to_end/test_custom_auth_integration.py index 75ea643eea44..c52d5c5c569c 100644 --- a/api/tests/integration/custom_auth/end_to_end/test_custom_auth_integration.py +++ b/api/tests/integration/custom_auth/end_to_end/test_custom_auth_integration.py @@ -245,7 +245,8 @@ def test_login_workflow_with_mfa_enabled( confirm_mfa_method_response = api_client.post( confirm_mfa_method_url, data=confirm_mfa_data ) - assert confirm_mfa_method_response + assert confirm_mfa_method_response.status_code == status.HTTP_200_OK + backup_codes = confirm_mfa_method_response.json()["backup_codes"] # now login should return an ephemeral token rather than a token login_data = {"email": email, "password": password} @@ -262,6 +263,19 @@ def test_login_workflow_with_mfa_enabled( assert login_confirm_response.status_code == status.HTTP_200_OK key = login_confirm_response.json()["key"] + # Login with backup code should also work + api_client.logout() + login_response = api_client.post(login_url, data=login_data) + assert login_response.status_code == status.HTTP_200_OK + ephemeral_token = login_response.json()["ephemeral_token"] + confirm_login_data = { + "ephemeral_token": ephemeral_token, + "code": backup_codes[0], + } + login_confirm_response = api_client.post(login_confirm_url, data=confirm_login_data) + assert login_confirm_response.status_code == status.HTTP_200_OK + key = login_confirm_response.json()["key"] + # and verify that we can use the token to access the API api_client.credentials(HTTP_AUTHORIZATION=f"Token {key}") current_user_url = reverse("api-v1:custom_auth:ffadminuser-me") diff --git a/api/tests/unit/custom_auth/mfa/trench/__init__.py b/api/tests/unit/custom_auth/mfa/trench/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/api/tests/unit/custom_auth/mfa/trench/conftest.py b/api/tests/unit/custom_auth/mfa/trench/conftest.py new file mode 100644 index 000000000000..a56b3bd81c87 --- /dev/null +++ b/api/tests/unit/custom_auth/mfa/trench/conftest.py @@ -0,0 +1,30 @@ +import pytest + +from custom_auth.mfa.trench.command.create_secret import create_secret_command +from custom_auth.mfa.trench.command.replace_mfa_method_backup_codes import ( + regenerate_backup_codes_for_mfa_method_command, +) +from custom_auth.mfa.trench.models import MFAMethod +from users.models import FFAdminUser + + +@pytest.fixture() +def mfa_app_method(admin_user: FFAdminUser) -> MFAMethod: + mfa = MFAMethod.objects.create( + user=admin_user, + name="app", + secret=create_secret_command(), + is_active=True, + is_primary=True, + ) + # Generate backup codes + regenerate_backup_codes_for_mfa_method_command(admin_user.id, mfa.name) + return mfa + + +@pytest.fixture() +def deactivated_mfa_app_method(mfa_app_method: MFAMethod) -> MFAMethod: + mfa_app_method.is_active = False + mfa_app_method.is_primary = False + mfa_app_method.save() + return mfa_app_method diff --git a/api/tests/unit/custom_auth/mfa/trench/test_views.py b/api/tests/unit/custom_auth/mfa/trench/test_views.py new file mode 100644 index 000000000000..ad5e61e63d45 --- /dev/null +++ b/api/tests/unit/custom_auth/mfa/trench/test_views.py @@ -0,0 +1,222 @@ +import pyotp +from django.urls import reverse +from rest_framework import status +from rest_framework.test import APIClient + +from custom_auth.mfa.trench.models import MFAMethod +from users.models import FFAdminUser + + +def test_list_user_active_methods(admin_client: APIClient, mfa_app_method: MFAMethod): + # Given + url = reverse("api-v1:custom_auth:mfa-list-user-active-methods") + + # When + response = admin_client.get(url) + + # Then + assert response.status_code == status.HTTP_200_OK + assert response.json() == [ + {"name": mfa_app_method.name, "is_primary": mfa_app_method.is_primary} + ] + + +def test_deactivate_user_active_method( + admin_client: APIClient, mfa_app_method: MFAMethod +): + # Given + url = reverse("api-v1:custom_auth:mfa-deactivate", args=[mfa_app_method.name]) + + # When + response = admin_client.post(url) + + # Then + assert response.status_code == status.HTTP_204_NO_CONTENT + mfa_app_method.refresh_from_db() + assert mfa_app_method.is_active is False + + +def test_deactivate_already_deactivated_mfa_returns_400( + admin_client: APIClient, mfa_app_method: MFAMethod +): + # Given + mfa_app_method.is_active = False + mfa_app_method.is_primary = False + mfa_app_method.save() + + url = reverse("api-v1:custom_auth:mfa-deactivate", args=[mfa_app_method.name]) + + # When + response = admin_client.post(url) + + # Then + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.json()["error"] == "2FA is not enabled." + + +def test_activate_wrong_method_returns_404(admin_client: APIClient): + # Given + url = reverse("api-v1:custom_auth:mfa-activate", kwargs={"method": "wrong_method"}) + + # When + response = admin_client.post(url) + + # Then + assert response.status_code == status.HTTP_404_NOT_FOUND + + +def test_activate_mfa_with_existing_mfa_returns_400( + admin_client: APIClient, mfa_app_method: MFAMethod +): + # Given + url = reverse("api-v1:custom_auth:mfa-activate", kwargs={"method": "app"}) + + # When + response = admin_client.post(url) + + # Then + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.json()["error"] == "MFA method already active." + + +def test_activate_confirm_with_wrong_method_returns_400( + admin_client: APIClient, mfa_app_method: MFAMethod +): + # Given + totp = pyotp.TOTP(mfa_app_method.secret) + url = reverse( + "api-v1:custom_auth:mfa-activate-confirm", kwargs={"method": "wrong_method"} + ) + + # When + data = {"code": totp.now()} + response = admin_client.post(url, data=data) + + # Then + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.json() == {"code": ["Requested MFA method does not exist."]} + + +def test_activate_confirm_already_active_mfa_returns_400( + admin_client: APIClient, mfa_app_method: MFAMethod +): + # Given + totp = pyotp.TOTP(mfa_app_method.secret) + url = reverse("api-v1:custom_auth:mfa-activate-confirm", kwargs={"method": "app"}) + + # When + data = {"code": totp.now()} + response = admin_client.post(url, data=data) + + # Then + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.json() == {"code": ["MFA method already active."]} + + +def test_re_activate_confirm_deactive_mfa_creates_new_backup_codes( + admin_client: APIClient, deactivated_mfa_app_method: MFAMethod +): + # Given + existing_backup_codes = deactivated_mfa_app_method + totp = pyotp.TOTP(deactivated_mfa_app_method.secret) + url = reverse("api-v1:custom_auth:mfa-activate-confirm", kwargs={"method": "app"}) + + # When + data = {"code": totp.now()} + response = admin_client.post(url, data=data) + + # Then + assert response.status_code == status.HTTP_200_OK + new_backup_codes = response.json()["backup_codes"] + for code in existing_backup_codes.backup_codes: + assert code not in new_backup_codes + + +def test_activate_confirm_mfa_for_different_user_retuns_400( + staff_client: APIClient, deactivated_mfa_app_method: MFAMethod +): + # Given + totp = pyotp.TOTP(deactivated_mfa_app_method.secret) + url = reverse("api-v1:custom_auth:mfa-activate-confirm", kwargs={"method": "app"}) + + # When + data = {"code": totp.now()} + response = staff_client.post(url, data=data) + + # Then + assert response.status_code == status.HTTP_400_BAD_REQUEST + + +def test_activate_confirm_without_code_returns_400( + admin_client: APIClient, mfa_app_method: MFAMethod +): + # Given + url = reverse( + "api-v1:custom_auth:mfa-activate-confirm", + kwargs={"method": mfa_app_method.name}, + ) + + # When + response = admin_client.post(url) + + # Then + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.json() == {"code": ["This field is required."]} + + +def test_activate_confirm_with_wrong_code_returns_400( + admin_client: APIClient, mfa_app_method: MFAMethod +): + # Given + mfa_app_method.is_active = False + mfa_app_method.is_primary = False + mfa_app_method.save() + + url = reverse( + "api-v1:custom_auth:mfa-activate-confirm", + kwargs={"method": mfa_app_method.name}, + ) + data = {"code": "wrong_code"} + # When + response = admin_client.post(url, data=data) + + # Then + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.json() == {"code": ["Code invalid or expired."]} + + +def test_login_with_invalid_mfa_code_returns_401( + api_client: APIClient, admin_user: FFAdminUser, mfa_app_method: MFAMethod +): + # Given + login_url = reverse("api-v1:custom_auth:custom-mfa-authtoken-login") + data = {"email": admin_user.email, "password": "password"} + + login_response = api_client.post(login_url, data=data) + + ephemeral_token = login_response.json()["ephemeral_token"] + confirm_login_data = {"ephemeral_token": ephemeral_token, "code": "wrong_code"} + login_confirm_url = reverse("api-v1:custom_auth:mfa-authtoken-login-code") + + # When + login_confirm_response = api_client.post(login_confirm_url, data=confirm_login_data) + + # Then + assert login_confirm_response.status_code == status.HTTP_401_UNAUTHORIZED + assert login_confirm_response.json() == {"error": "Invalid or expired code."} + + +def test_login_with_invalid_mfa_token_returns_401( + api_client: APIClient, mfa_app_method: MFAMethod +): + # Given + totp = pyotp.TOTP(mfa_app_method.secret) + data = {"ephemeral_token": "wrong_token", "code": totp.now()} + url = reverse("api-v1:custom_auth:mfa-authtoken-login-code") + + # When + login_confirm_response = api_client.post(url, data=data) + + # Then + assert login_confirm_response.status_code == status.HTTP_401_UNAUTHORIZED + assert login_confirm_response.json() == {"error": "Invalid or expired token."} diff --git a/api/tests/unit/features/test_unit_feature_external_resources_views.py b/api/tests/unit/features/test_unit_feature_external_resources_views.py index 504d28989e25..901454b6add6 100644 --- a/api/tests/unit/features/test_unit_feature_external_resources_views.py +++ b/api/tests/unit/features/test_unit_feature_external_resources_views.py @@ -277,11 +277,44 @@ def test_cannot_create_feature_external_resource_due_to_unique_constraint( # Then assert response.status_code == status.HTTP_400_BAD_REQUEST assert ( - "Duplication error. The feature already has this resource URI" - in response.json()[0] + response.json()["non_field_errors"][0] + == "The fields feature, url must make a unique set." ) +def test_update_feature_external_resource( + admin_client_new: APIClient, + feature: Feature, + feature_external_resource: FeatureExternalResource, + project: Project, + github_configuration: GithubConfiguration, + github_repository: GithubRepository, + post_request_mock: MagicMock, + mocker: MockerFixture, +) -> None: + # Given + mock_generate_token = mocker.patch( + "integrations.github.client.generate_token", + ) + mock_generate_token.return_value = "mocked_token" + mock_generate_token.return_value = "mocked_token" + feature_external_resource_data = { + "type": "GITHUB_ISSUE", + "url": "https://github.com/userexample/example-project-repo/issues/12", + "feature": feature.id, + } + url = reverse( + "api-v1:projects:feature-external-resources-detail", + args=[project.id, feature.id, feature_external_resource.id], + ) + # When + response = admin_client_new.put(url, data=feature_external_resource_data) + + # Then + assert response.status_code == status.HTTP_200_OK + assert response.json()["url"] == feature_external_resource_data["url"] + + def test_delete_feature_external_resource( admin_client_new: APIClient, feature: Feature,