From 8c0fb3d4fff31bc0e2a3162327415d1ae889db82 Mon Sep 17 00:00:00 2001 From: Spitap Date: Tue, 7 Feb 2023 12:47:58 +0100 Subject: [PATCH] Better throttle, attempt to have proper UX --- frontend/src/App.vue | 8 ++++-- frontend/src/api/repository.js | 5 ++++ modoboa/admin/api/v1/viewsets.py | 25 +++++++++------- modoboa/admin/api/v2/viewsets.py | 38 ++++--------------------- modoboa/core/api/v1/viewsets.py | 5 ++-- modoboa/core/api/v2/views.py | 19 +++++++++---- modoboa/core/api/v2/viewsets.py | 12 +------- modoboa/dnstools/api/v2/viewsets.py | 6 ++-- modoboa/lib/throttle.py | 22 ++++++++++++-- modoboa/limits/api/v1/viewsets.py | 6 ++-- modoboa/maillog/api/v2/viewsets.py | 9 ++---- modoboa/parameters/api/v2/viewsets.py | 5 ++-- modoboa/relaydomains/api/v1/viewsets.py | 5 ++-- modoboa/transport/api/v2/viewsets.py | 6 ++-- 14 files changed, 77 insertions(+), 94 deletions(-) diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 3af13a0f5..640da0f5f 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -60,9 +60,11 @@ export default { }), methods: { showNotification (options) { - this.notification = options.msg - this.notificationColor = (options.type) ? options.type : 'success' - this.snackbar = true + if (this.isAuthenticated) { + this.notification = options.msg + this.notificationColor = (options.type) ? options.type : 'success' + this.snackbar = true + } } } } diff --git a/frontend/src/api/repository.js b/frontend/src/api/repository.js index d5c821288..56118ddf4 100644 --- a/frontend/src/api/repository.js +++ b/frontend/src/api/repository.js @@ -3,6 +3,7 @@ import Cookies from 'js-cookie' import router from '../router' import store from '../store' +import app from '../App.vue' const _axios = axios.create() @@ -31,6 +32,10 @@ _axios.interceptors.response.use( router.push({ name: 'TwoFA' }) return Promise.reject(error) } + if (error.response.status === 429) { + app.showNotification() + return Promise.reject(error) + } if (error.response.status !== 401 || router.currentRoute.path === '/login/') { return Promise.reject(error) } diff --git a/modoboa/admin/api/v1/viewsets.py b/modoboa/admin/api/v1/viewsets.py index aa9faaad6..211c2b241 100644 --- a/modoboa/admin/api/v1/viewsets.py +++ b/modoboa/admin/api/v1/viewsets.py @@ -17,7 +17,7 @@ from modoboa.core import models as core_models from modoboa.core import sms_backends from modoboa.lib import renderers as lib_renderers from modoboa.lib import viewsets as lib_viewsets -from modoboa.lib.throttle import UserDdosPerView +from modoboa.lib.throttle import UserDdosPerView, GetThrottleViewsetMixin, PasswordResetRequestThrottle from ... import lib, models from . import serializers @@ -37,12 +37,11 @@ from . import serializers summary="Create a new domain" ) ) -class DomainViewSet(lib_viewsets.RevisionModelMixin, viewsets.ModelViewSet): +class DomainViewSet(GetThrottleViewsetMixin, lib_viewsets.RevisionModelMixin, viewsets.ModelViewSet): """Domain viewset.""" permission_classes = [IsAuthenticated, DjangoModelPermissions, ] serializer_class = serializers.DomainSerializer - throttle_classes = [UserDdosPerView, UserRateThrottle] def get_queryset(self): """Filter queryset based on current user.""" @@ -63,7 +62,7 @@ class DomainAliasFilterSet(dj_filters.FilterSet): fields = ["domain"] -class DomainAliasViewSet(lib_viewsets.RevisionModelMixin, +class DomainAliasViewSet(GetThrottleViewsetMixin, lib_viewsets.RevisionModelMixin, viewsets.ModelViewSet): """ViewSet for DomainAlias.""" @@ -72,7 +71,6 @@ class DomainAliasViewSet(lib_viewsets.RevisionModelMixin, permission_classes = [IsAuthenticated, DjangoModelPermissions, ] renderer_classes = (renderers.JSONRenderer, lib_renderers.CSVRenderer) serializer_class = serializers.DomainAliasSerializer - throttle_classes = [UserDdosPerView, UserRateThrottle] def get_queryset(self): """Filter queryset based on current user.""" @@ -84,13 +82,20 @@ class DomainAliasViewSet(lib_viewsets.RevisionModelMixin, return context -class AccountViewSet(lib_viewsets.RevisionModelMixin, viewsets.ModelViewSet): +class AccountViewSet(GetThrottleViewsetMixin, lib_viewsets.RevisionModelMixin, viewsets.ModelViewSet): """ViewSet for User/Mailbox.""" filter_backends = (filters.SearchFilter, ) permission_classes = [IsAuthenticated, DjangoModelPermissions, ] search_fields = ("^first_name", "^last_name", "^email") - throttle_classes = [UserDdosPerView, UserRateThrottle] + + def get_throttles(self): + + throttle_classes = super().get_throttles() + if self.action == "reset_password": + throttle_classes.append(PasswordResetRequestThrottle()) + + return throttle_classes def get_serializer_class(self): """Return a serializer.""" @@ -180,7 +185,7 @@ class AccountViewSet(lib_viewsets.RevisionModelMixin, viewsets.ModelViewSet): return Response(body) -class AliasViewSet(lib_viewsets.RevisionModelMixin, viewsets.ModelViewSet): +class AliasViewSet(GetThrottleViewsetMixin, lib_viewsets.RevisionModelMixin, viewsets.ModelViewSet): """ create: Create a new alias instance. @@ -188,7 +193,6 @@ class AliasViewSet(lib_viewsets.RevisionModelMixin, viewsets.ModelViewSet): permission_classes = [IsAuthenticated, DjangoModelPermissions, ] serializer_class = serializers.AliasSerializer - throttle_classes = [UserDdosPerView, UserRateThrottle] def get_queryset(self): """Filter queryset based on current user.""" @@ -213,7 +217,7 @@ class SenderAddressFilterSet(dj_filters.FilterSet): fields = ["mailbox"] -class SenderAddressViewSet(lib_viewsets.RevisionModelMixin, +class SenderAddressViewSet(GetThrottleViewsetMixin, lib_viewsets.RevisionModelMixin, viewsets.ModelViewSet): """View set for SenderAddress model.""" @@ -221,7 +225,6 @@ class SenderAddressViewSet(lib_viewsets.RevisionModelMixin, filterset_class = SenderAddressFilterSet permission_classes = [IsAuthenticated, DjangoModelPermissions, ] serializer_class = serializers.SenderAddressSerializer - throttle_classes = [UserDdosPerView, UserRateThrottle] def get_queryset(self): """Filter queryset based on current user.""" diff --git a/modoboa/admin/api/v2/viewsets.py b/modoboa/admin/api/v2/viewsets.py index 53288a4cd..667c72d10 100644 --- a/modoboa/admin/api/v2/viewsets.py +++ b/modoboa/admin/api/v2/viewsets.py @@ -17,7 +17,7 @@ from modoboa.admin.api.v1 import viewsets as v1_viewsets from modoboa.core import models as core_models from modoboa.lib import renderers as lib_renderers from modoboa.lib import viewsets as lib_viewsets -from modoboa.lib.throttle import UserDdosPerView, UserLesserDdosUser +from modoboa.lib.throttle import UserDdosPerView, UserLesserDdosUser, GetThrottleViewsetMixin from ... import lib from ... import models @@ -42,7 +42,7 @@ from . import serializers summary="Delete a particular domain" ), ) -class DomainViewSet(lib_viewsets.RevisionModelMixin, +class DomainViewSet(GetThrottleViewsetMixin, lib_viewsets.RevisionModelMixin, mixins.ListModelMixin, mixins.RetrieveModelMixin, mixins.CreateModelMixin, @@ -53,14 +53,6 @@ class DomainViewSet(lib_viewsets.RevisionModelMixin, permission_classes = ( permissions.IsAuthenticated, permissions.DjangoModelPermissions, ) - throttle_classes = [UserDdosPerView] - - def get_throttles(self): - if self.action in ['administrators']: - self.throttle_classes.append(UserLesserDdosUser) - else: - self.throttle_classes.append(UserDdosPerView) - return super().get_throttles() def get_queryset(self): """Filter queryset based on current user.""" @@ -170,13 +162,6 @@ class AccountViewSet(v1_viewsets.AccountViewSet): filter_backends = (filters.SearchFilter, dj_filters.DjangoFilterBackend) filterset_class = AccountFilterSet - def get_throttles(self): - if self.action in ['validate']: - self.throttle_classes.append(UserLesserDdosUser) - else: - self.throttle_classes.append(UserDdosPerView) - return super().get_throttles() - def get_serializer_class(self): if self.action in ["create", "validate", "update", "partial_update"]: @@ -226,20 +211,11 @@ class AccountViewSet(v1_viewsets.AccountViewSet): return response.Response(status=status.HTTP_204_NO_CONTENT) -class IdentityViewSet(viewsets.ViewSet): +class IdentityViewSet(GetThrottleViewsetMixin, viewsets.ViewSet): """Viewset for identities.""" permission_classes = (permissions.IsAuthenticated, ) serializer_class = None - throttle_classes = [UserDdosPerView] - - def get_throttles(self): - if self.action in ['list']: - self.throttle_classes.append(UserLesserDdosUser) - else: - self.throttle_classes.append(UserDdosPerView) - return super().get_throttles() - def list(self, request, **kwargs): """Return all identities.""" @@ -279,7 +255,6 @@ class AliasViewSet(v1_viewsets.AliasViewSet): """Viewset for Alias.""" serializer_class = serializers.AliasSerializer - throttle_classes = [UserDdosPerView, UserLesserDdosUser] @action(methods=["post"], detail=False) def validate(self, request, **kwargs): @@ -298,11 +273,9 @@ class AliasViewSet(v1_viewsets.AliasViewSet): }) -class UserAccountViewSet(viewsets.ViewSet): +class UserAccountViewSet(GetThrottleViewsetMixin, viewsets.ViewSet): """Viewset for current user operations.""" - throttle_classes = [UserDdosPerView, UserRateThrottle] - @action(methods=["get", "post"], detail=False) def forward(self, request, **kwargs): """Get or define user forward.""" @@ -353,7 +326,7 @@ class UserAccountViewSet(viewsets.ViewSet): return response.Response(serializer.validated_data) -class AlarmViewSet(viewsets.ReadOnlyModelViewSet): +class AlarmViewSet(GetThrottleViewsetMixin, viewsets.ReadOnlyModelViewSet): """Viewset for Alarm.""" filter_backends = (filters.OrderingFilter, filters.SearchFilter, ) @@ -364,7 +337,6 @@ class AlarmViewSet(viewsets.ReadOnlyModelViewSet): ) search_fields = ["domain__name", "title"] serializer_class = serializers.AlarmSerializer - throttle_classes = [UserDdosPerView, UserRateThrottle] def get_queryset(self): return models.Alarm.objects.select_related("domain").filter( diff --git a/modoboa/core/api/v1/viewsets.py b/modoboa/core/api/v1/viewsets.py index 223dc11e4..1b791e24c 100644 --- a/modoboa/core/api/v1/viewsets.py +++ b/modoboa/core/api/v1/viewsets.py @@ -12,12 +12,12 @@ from rest_framework.throttling import UserRateThrottle from drf_spectacular.utils import extend_schema -from modoboa.lib.throttle import UserDdosPerView +from modoboa.lib.throttle import GetThrottleViewsetMixin from . import serializers -class AccountViewSet(viewsets.ViewSet): +class AccountViewSet(GetThrottleViewsetMixin, viewsets.ViewSet): """Account viewset. Contains endpoints used to manipulate current user's account. @@ -25,7 +25,6 @@ class AccountViewSet(viewsets.ViewSet): permission_classes = (permissions.IsAuthenticated, ) serializer_class = None - throttle_classes = [UserDdosPerView, UserRateThrottle] @action(methods=["post"], detail=False, url_path="tfa/setup") def tfa_setup(self, request): diff --git a/modoboa/core/api/v2/views.py b/modoboa/core/api/v2/views.py index 845dd1fc2..f62425ada 100644 --- a/modoboa/core/api/v2/views.py +++ b/modoboa/core/api/v2/views.py @@ -12,7 +12,6 @@ from drf_spectacular.utils import extend_schema from rest_framework import response, status from rest_framework_simplejwt import views as jwt_views from rest_framework_simplejwt.exceptions import InvalidToken, TokenError -from rest_framework.throttling import ScopedRateThrottle from rest_framework.views import APIView from modoboa.core.password_hashers import get_password_hasher @@ -27,6 +26,15 @@ from . import serializers logger = logging.getLogger("modoboa.auth") +def delete_cache_key(class_target, throttles, request): + """Attempt to delete cache key from throttling on login/password reset success.""" + + for throttle in throttles: + if type(throttle) == class_target: + throttle.reset_cache(request) + return + + class TokenObtainPairView(jwt_views.TokenObtainPairView): """We overwrite this view to deal with password scheme update.""" @@ -48,7 +56,7 @@ class TokenObtainPairView(jwt_views.TokenObtainPairView): login(request, user) # Reset login throttle - self.get_throttles()[0].reset_cache(request) + delete_cache_key(LoginThrottle, self.get_throttles(), request) logger.info( _("User '%s' successfully logged in"), user.username @@ -107,7 +115,6 @@ class EmailPasswordResetView(APIView): "reason": "Error while sending the email. Please contact an administrator." }, 503) - self.get_throttles()[0].reset_cache(request) # Email response return response.Response({"type": "email"}, 200) @@ -126,8 +133,8 @@ class DefaultPasswordResetView(EmailPasswordResetView): serializer.is_valid(raise_exception=True) except serializers.NoSMSAvailable: return super().post(request, *args, **kwargs) + # SMS response - self.get_throttles()[0].reset_cache(request) return response.Response({"type": "sms"}, 200) @@ -155,7 +162,7 @@ class PasswordResetSmsTOTP(APIView): "id": serializer_response[1], "type": "confirm" }) - self.get_throttles()[0].reset_cache(request) + delete_cache_key(PasswordResetTotpThrottle, self.get_throttles(), request) return response.Response(payload, 200) @@ -177,7 +184,7 @@ class PasswordResetConfirmView(APIView): data.update({"errors": errors}) return response.Response(data, 400) serializer.save() - self.get_throttles()[0].reset_cache(request) + delete_cache_key(PasswordResetApplyThrottle, self.get_throttles(), request) return response.Response(status=200) diff --git a/modoboa/core/api/v2/viewsets.py b/modoboa/core/api/v2/viewsets.py index 11211eb3d..b8fd3d090 100644 --- a/modoboa/core/api/v2/viewsets.py +++ b/modoboa/core/api/v2/viewsets.py @@ -18,7 +18,7 @@ from modoboa.admin.api.v1 import serializers as admin_v1_serializers from modoboa.core.api.v1 import serializers as core_v1_serializers from modoboa.core.api.v1 import viewsets as core_v1_viewsets from modoboa.lib import pagination -from modoboa.lib.throttle import UserLesserDdosUser, UserDdosPerView +from modoboa.lib.throttle import UserLesserDdosUser, UserDdosPerView, GetThrottleViewsetMixin from ... import constants from ... import models @@ -28,16 +28,6 @@ from . import serializers class AccountViewSet(core_v1_viewsets.AccountViewSet): """Account viewset.""" - throttle_classes = [UserRateThrottle] - - def get_throttles(self): - if self.action in ['me']: - self.throttle_classes.append(UserLesserDdosUser) - else: - self.throttle_classes.append(UserDdosPerView) - return super().get_throttles() - - @extend_schema(responses=admin_v1_serializers.AccountSerializer) @action(methods=["get"], detail=False) def me(self, request): diff --git a/modoboa/dnstools/api/v2/viewsets.py b/modoboa/dnstools/api/v2/viewsets.py index f06eeac90..881844d79 100644 --- a/modoboa/dnstools/api/v2/viewsets.py +++ b/modoboa/dnstools/api/v2/viewsets.py @@ -2,19 +2,17 @@ from rest_framework import permissions, response, viewsets from rest_framework.decorators import action -from rest_framework.throttling import UserRateThrottle from modoboa.admin import models as admin_models -from modoboa.lib.throttle import UserDdosPerView +from modoboa.lib.throttle import GetThrottleViewsetMixin from . import serializers -class DNSViewSet(viewsets.GenericViewSet): +class DNSViewSet(GetThrottleViewsetMixin, viewsets.GenericViewSet): """A viewset to provide extra routes related to DNS information.""" permission_classes = (permissions.IsAuthenticated, ) - throttle_classes = [UserDdosPerView, UserRateThrottle] def get_queryset(self): """Filter queryset based on current user.""" diff --git a/modoboa/lib/throttle.py b/modoboa/lib/throttle.py index b6cb131a6..02fabebfd 100644 --- a/modoboa/lib/throttle.py +++ b/modoboa/lib/throttle.py @@ -1,4 +1,4 @@ -from rest_framework.throttling import SimpleRateThrottle, AnonRateThrottle +from rest_framework.throttling import SimpleRateThrottle, UserRateThrottle from django.urls import resolve class UserDdosPerView(SimpleRateThrottle): @@ -16,6 +16,7 @@ class UserDdosPerView(SimpleRateThrottle): 'ident': ident } + class UserLesserDdosUser(SimpleRateThrottle): """Custom Throttle class for rest_framework. The throttling is applied on a per view basis for authentificated users.""" @@ -31,19 +32,20 @@ class UserLesserDdosUser(SimpleRateThrottle): 'ident': ident } + class LoginThrottle(SimpleRateThrottle): """ Custom throttle to reset the cache counter on success. """ scope = 'login' - def get_cache_key(self, request, view = None): + def get_cache_key(self, request, view): return self.cache_format % { 'scope': self.scope, 'ident': self.get_ident(request) } def reset_cache(self, request): - self.key = self.get_cache_key(request) + self.key = self.get_cache_key(request, None) self.cache.delete(self.key) @@ -61,3 +63,17 @@ class PasswordResetApplyThrottle(LoginThrottle): scope = 'password_recovery_apply' + +class GetThrottleViewsetMixin(): + """Override default get_throttle behaviour to assign throttle classes to different actions.""" + + def get_throttles(self): + """Give lesser_ddos to GET type actions and ddos to others.""" + + throttle_classes = [UserRateThrottle()] + + if self.action in ["list", "retrieve", "validate", "dns_detail", "me", "dns_detail"]: + throttle_classes.append(UserLesserDdosUser()) + else: + throttle_classes.append(UserDdosPerView()) + return throttle_classes \ No newline at end of file diff --git a/modoboa/limits/api/v1/viewsets.py b/modoboa/limits/api/v1/viewsets.py index 053352575..2e3da9e5d 100644 --- a/modoboa/limits/api/v1/viewsets.py +++ b/modoboa/limits/api/v1/viewsets.py @@ -4,14 +4,13 @@ from django.contrib.contenttypes.models import ContentType from rest_framework import mixins, viewsets from rest_framework.permissions import DjangoModelPermissions, IsAuthenticated -from rest_framework.throttling import UserRateThrottle from modoboa.core import models as core_models -from modoboa.lib.throttle import UserLesserDdosUser +from modoboa.lib.throttle import GetThrottleViewsetMixin from . import serializers -class ResourcesViewSet( +class ResourcesViewSet(GetThrottleViewsetMixin, mixins.RetrieveModelMixin, mixins.UpdateModelMixin, viewsets.GenericViewSet): @@ -19,7 +18,6 @@ class ResourcesViewSet( permission_classes = [IsAuthenticated, DjangoModelPermissions] serializer_class = serializers.ResourcesSerializer - throttle_classes = [UserLesserDdosUser, UserRateThrottle] def get_queryset(self): """Filter queryset based on current user.""" diff --git a/modoboa/maillog/api/v2/viewsets.py b/modoboa/maillog/api/v2/viewsets.py index 4db0ec142..89d1d3c5a 100644 --- a/modoboa/maillog/api/v2/viewsets.py +++ b/modoboa/maillog/api/v2/viewsets.py @@ -6,22 +6,20 @@ from django.db.models import Q from drf_spectacular.utils import extend_schema from rest_framework import filters, permissions, response, viewsets -from rest_framework.throttling import UserRateThrottle from modoboa.admin import models as admin_models from modoboa.lib import pagination -from modoboa.lib.throttle import UserDdosPerView +from modoboa.lib.throttle import GetThrottleViewsetMixin from ... import models from ... import signals from . import serializers -class StatisticsViewSet(viewsets.ViewSet): +class StatisticsViewSet(GetThrottleViewsetMixin,viewsets.ViewSet): """A viewset to provide extra route related to mail statistics.""" permission_classes = (permissions.IsAuthenticated, ) - throttle_classes = [UserDdosPerView, UserRateThrottle] @extend_schema( parameters=[serializers.StatisticsInputSerializer], @@ -54,7 +52,7 @@ class StatisticsViewSet(viewsets.ViewSet): return response.Response({"graphs": graphs}) -class MaillogViewSet(viewsets.ReadOnlyModelViewSet): +class MaillogViewSet(GetThrottleViewsetMixin, viewsets.ReadOnlyModelViewSet): """Simple viewset to access message log.""" filter_backends = [filters.OrderingFilter, filters.SearchFilter] @@ -64,7 +62,6 @@ class MaillogViewSet(viewsets.ReadOnlyModelViewSet): permissions = (permissions.IsAuthenticated, ) search_fields = ["queue_id", "sender", "rcpt", "original_rcpt", "status"] serializer_class = serializers.MaillogSerializer - throttle_classes = [UserDdosPerView, UserRateThrottle] def get_queryset(self): """Filter queryset based on current user.""" diff --git a/modoboa/parameters/api/v2/viewsets.py b/modoboa/parameters/api/v2/viewsets.py index aa23b1043..97a22d518 100644 --- a/modoboa/parameters/api/v2/viewsets.py +++ b/modoboa/parameters/api/v2/viewsets.py @@ -5,18 +5,17 @@ from rest_framework import response, viewsets from rest_framework.decorators import action from rest_framework.throttling import UserRateThrottle -from modoboa.lib.throttle import UserDdosPerView +from modoboa.lib.throttle import GetThrottleViewsetMixin from . import serializers from ... import tools -class ParametersViewSet(viewsets.ViewSet): +class ParametersViewSet(GetThrottleViewsetMixin, viewsets.ViewSet): """Parameter viewset.""" lookup_value_regex = r"\w+" serializer_class = None - throttle_classes = [UserDdosPerView, UserRateThrottle] @extend_schema(responses=serializers.ApplicationSerializer(many=True)) @action(methods=["get"], detail=False) diff --git a/modoboa/relaydomains/api/v1/viewsets.py b/modoboa/relaydomains/api/v1/viewsets.py index 0bf7a81ab..d6b552276 100644 --- a/modoboa/relaydomains/api/v1/viewsets.py +++ b/modoboa/relaydomains/api/v1/viewsets.py @@ -5,17 +5,16 @@ from rest_framework.permissions import DjangoModelPermissions, IsAuthenticated from rest_framework.throttling import UserRateThrottle from modoboa.admin import models as admin_models -from modoboa.lib.throttle import UserLesserDdosUser +from modoboa.lib.throttle import GetThrottleViewsetMixin from modoboa.lib.viewsets import RevisionModelMixin from . import serializers -class RelayDomainViewSet(RevisionModelMixin, viewsets.ModelViewSet): +class RelayDomainViewSet(GetThrottleViewsetMixin, RevisionModelMixin, viewsets.ModelViewSet): """RelayDomain viewset.""" permission_classes = [IsAuthenticated, DjangoModelPermissions, ] serializer_class = serializers.RelayDomainSerializer - throttle_classes = [UserLesserDdosUser] def get_queryset(self): """Filter queryset based on current user.""" diff --git a/modoboa/transport/api/v2/viewsets.py b/modoboa/transport/api/v2/viewsets.py index 11325045a..c8f4b82f0 100644 --- a/modoboa/transport/api/v2/viewsets.py +++ b/modoboa/transport/api/v2/viewsets.py @@ -2,19 +2,17 @@ from drf_spectacular.utils import extend_schema from rest_framework import permissions, response, viewsets -from rest_framework.throttling import UserRateThrottle -from modoboa.lib.throttle import UserDdosPerView +from modoboa.lib.throttle import GetThrottleViewsetMixin from . import serializers from ... import backends -class TransportViewSet(viewsets.ViewSet): +class TransportViewSet(GetThrottleViewsetMixin, viewsets.ViewSet): """Viewset for Transport.""" permissions = (permissions.IsAuthenticated, ) - throttle_classes = [UserDdosPerView, UserDdosPerView] @extend_schema( responses={200: serializers.TransportBackendSerializer}