From 955b5016fbea3a6ff817fff18fcca611a328969f Mon Sep 17 00:00:00 2001 From: Spitap Date: Fri, 27 Jan 2023 15:14:59 +0100 Subject: [PATCH 01/14] Api throttling --- modoboa/core/api/v2/views.py | 8 ++++++++ modoboa/core/commands/templates/settings.py.tpl | 11 +++++++++++ test_project/test_project/settings.py | 11 +++++++++++ 3 files changed, 30 insertions(+) diff --git a/modoboa/core/api/v2/views.py b/modoboa/core/api/v2/views.py index 7366e3161..5e3a62464 100644 --- a/modoboa/core/api/v2/views.py +++ b/modoboa/core/api/v2/views.py @@ -28,6 +28,8 @@ class TokenObtainPairView(jwt_views.TokenObtainPairView): """We overwrite this view to deal with password scheme update.""" + throttle_classes = ["AnonRateThrottle"] + def post(self, request, *args, **kwargs): serializer = self.get_serializer(data=request.data) try: @@ -84,6 +86,8 @@ class EmailPasswordResetView(APIView): """ An Api View which provides a method to request a password reset token based on an e-mail address. """ + + throttle_scope = 'password_recovery_request' def post(self, request, *args, **kwargs): serializer = serializers.PasswordRecoveryEmailSerializer( @@ -121,6 +125,8 @@ def post(self, request, *args, **kwargs): class PasswordResetSmsTOTP(APIView): """ Check SMS Totp code. """ + throttle_scope = 'password_recovery_totp_check' + def post(self, request, *args, **kwargs): try: if request.data["type"] == "confirm": @@ -146,6 +152,8 @@ def post(self, request, *args, **kwargs): class PasswordResetConfirmView(APIView): """ Get and set new user password. """ + throttle_scope = 'password_recovery_apply' + def post(self, request, *args, **kwargs): serializer = serializers.PasswordRecoveryConfirmSerializer( data=request.data) diff --git a/modoboa/core/commands/templates/settings.py.tpl b/modoboa/core/commands/templates/settings.py.tpl index d9455bcbf..9f9ab4b9c 100644 --- a/modoboa/core/commands/templates/settings.py.tpl +++ b/modoboa/core/commands/templates/settings.py.tpl @@ -193,6 +193,17 @@ MEDIA_ROOT = os.path.join(BASE_DIR, 'media') # Rest framework settings REST_FRAMEWORK = { + 'DEFAULT_THROTTLE_CLASSES': [ + 'rest_framework.throttling.AnonRateThrottle', + 'rest_framework.throttling.ScopedRateThrottle' + ], + 'DEFAULT_THROTTLE_RATES': { + 'anon': '100/hour', + 'login': '5/hour', + 'password_recovery_request': '6/hour', + 'password_recovery_totp_check': '20/hour', + 'password_recovery_apply': '20/hour' + }, 'DEFAULT_AUTHENTICATION_CLASSES': ( 'modoboa.core.drf_authentication.JWTAuthenticationWith2FA', 'rest_framework.authentication.TokenAuthentication', diff --git a/test_project/test_project/settings.py b/test_project/test_project/settings.py index 3cbab366e..b0b09770b 100644 --- a/test_project/test_project/settings.py +++ b/test_project/test_project/settings.py @@ -186,6 +186,17 @@ # Rest framework settings REST_FRAMEWORK = { + 'DEFAULT_THROTTLE_CLASSES': [ + 'rest_framework.throttling.AnonRateThrottle', + 'rest_framework.throttling.ScopedRateThrottle' + ], + 'DEFAULT_THROTTLE_RATES': { + 'anon': '100/hour', + 'login': '5/hour', + 'password_recovery_request': '6/hour', + 'password_recovery_totp_check': '20/hour', + 'password_recovery_apply': '20/hour' + }, 'DEFAULT_AUTHENTICATION_CLASSES': ( 'modoboa.core.drf_authentication.JWTAuthenticationWith2FA', 'rest_framework.authentication.TokenAuthentication', From 0d2f3d05a36da20b88bc02d30e2d0bfd1722abb8 Mon Sep 17 00:00:00 2001 From: Spitap Date: Fri, 27 Jan 2023 16:27:52 +0100 Subject: [PATCH 02/14] Added throttling to v2 api routes --- modoboa/admin/api/v2/viewsets.py | 10 +++++++- modoboa/core/api/v2/views.py | 7 ++++-- .../core/commands/templates/settings.py.tpl | 10 +++++--- modoboa/dnstools/api/v2/viewsets.py | 2 ++ modoboa/lib/throttle.py | 23 +++++++++++++++++++ modoboa/maillog/api/v2/viewsets.py | 3 +++ modoboa/parameters/api/v2/viewsets.py | 3 +++ modoboa/transport/api/v2/viewsets.py | 3 +++ test_project/test_project/settings.py | 8 +++++-- 9 files changed, 61 insertions(+), 8 deletions(-) create mode 100644 modoboa/lib/throttle.py diff --git a/modoboa/admin/api/v2/viewsets.py b/modoboa/admin/api/v2/viewsets.py index 4b717dda4..569733c57 100644 --- a/modoboa/admin/api/v2/viewsets.py +++ b/modoboa/admin/api/v2/viewsets.py @@ -9,13 +9,14 @@ from rest_framework import ( filters, mixins, parsers, pagination, permissions, response, status, viewsets ) -from rest_framework.decorators import action +from rest_framework.decorators import action, throttle_classes from rest_framework.exceptions import PermissionDenied 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 UserDosThrottle from ... import lib from ... import models @@ -51,6 +52,7 @@ class DomainViewSet(lib_viewsets.RevisionModelMixin, permission_classes = ( permissions.IsAuthenticated, permissions.DjangoModelPermissions, ) + throttle_classes = [UserDosThrottle] def get_queryset(self): """Filter queryset based on current user.""" @@ -159,6 +161,7 @@ class AccountViewSet(v1_viewsets.AccountViewSet): filter_backends = (filters.SearchFilter, dj_filters.DjangoFilterBackend) filterset_class = AccountFilterSet + throttle_classes = [UserDosThrottle] def get_serializer_class(self): if self.action in ["create", "validate", "update", "partial_update"]: @@ -213,6 +216,7 @@ class IdentityViewSet(viewsets.ViewSet): permission_classes = (permissions.IsAuthenticated, ) serializer_class = None + throttle_classes = [UserDosThrottle] def list(self, request, **kwargs): """Return all identities.""" @@ -252,6 +256,7 @@ class AliasViewSet(v1_viewsets.AliasViewSet): """Viewset for Alias.""" serializer_class = serializers.AliasSerializer + throttle_classes = [UserDosThrottle] @action(methods=["post"], detail=False) def validate(self, request, **kwargs): @@ -273,6 +278,8 @@ def random_address(self, request, **kwargs): class UserAccountViewSet(viewsets.ViewSet): """Viewset for current user operations.""" + throttle_classes = [UserDosThrottle] + @action(methods=["get", "post"], detail=False) def forward(self, request, **kwargs): """Get or define user forward.""" @@ -334,6 +341,7 @@ class AlarmViewSet(viewsets.ReadOnlyModelViewSet): ) search_fields = ["domain__name", "title"] serializer_class = serializers.AlarmSerializer + throttle_classes = [UserDosThrottle] def get_queryset(self): return models.Alarm.objects.select_related("domain").filter( diff --git a/modoboa/core/api/v2/views.py b/modoboa/core/api/v2/views.py index 5e3a62464..cbc186869 100644 --- a/modoboa/core/api/v2/views.py +++ b/modoboa/core/api/v2/views.py @@ -14,6 +14,7 @@ from rest_framework_simplejwt.exceptions import InvalidToken, TokenError from rest_framework.views import APIView +from modoboa.lib.throttle import UserDosThrottle from modoboa.core.password_hashers import get_password_hasher from modoboa.core.utils import check_for_updates from modoboa.parameters import tools as param_tools @@ -28,7 +29,7 @@ class TokenObtainPairView(jwt_views.TokenObtainPairView): """We overwrite this view to deal with password scheme update.""" - throttle_classes = ["AnonRateThrottle"] + throttle_scope = "login" def post(self, request, *args, **kwargs): serializer = self.get_serializer(data=request.data) @@ -86,7 +87,7 @@ class EmailPasswordResetView(APIView): """ An Api View which provides a method to request a password reset token based on an e-mail address. """ - + throttle_scope = 'password_recovery_request' def post(self, request, *args, **kwargs): @@ -173,6 +174,8 @@ def post(self, request, *args, **kwargs): class ComponentsInformationAPIView(APIView): """Retrieve information about installed components.""" + throttle_classes = [UserDosThrottle] + @extend_schema(responses=serializers.ModoboaComponentSerializer(many=True)) def get(self, request, *args, **kwargs): status, extensions = check_for_updates() diff --git a/modoboa/core/commands/templates/settings.py.tpl b/modoboa/core/commands/templates/settings.py.tpl index 9f9ab4b9c..8676049fb 100644 --- a/modoboa/core/commands/templates/settings.py.tpl +++ b/modoboa/core/commands/templates/settings.py.tpl @@ -194,12 +194,16 @@ MEDIA_ROOT = os.path.join(BASE_DIR, 'media') REST_FRAMEWORK = { 'DEFAULT_THROTTLE_CLASSES': [ - 'rest_framework.throttling.AnonRateThrottle', - 'rest_framework.throttling.ScopedRateThrottle' + 'rest_framework.throttling.AnonRateThrottle', + 'rest_framework.throttling.ScopedRateThrottle', + 'modoboa.lib.throttle.UserDosThrottle', + 'modoboa.lib.throttle.AnonDosThrottle' ], 'DEFAULT_THROTTLE_RATES': { 'anon': '100/hour', - 'login': '5/hour', + 'login': '3/minute', + 'ddos': '100/minute', + 'anon_ddos': '10/minute', 'password_recovery_request': '6/hour', 'password_recovery_totp_check': '20/hour', 'password_recovery_apply': '20/hour' diff --git a/modoboa/dnstools/api/v2/viewsets.py b/modoboa/dnstools/api/v2/viewsets.py index 4776e449c..1faad46fa 100644 --- a/modoboa/dnstools/api/v2/viewsets.py +++ b/modoboa/dnstools/api/v2/viewsets.py @@ -4,6 +4,7 @@ from rest_framework.decorators import action from modoboa.admin import models as admin_models +from modoboa.lib.throttle import UserDosThrottle from . import serializers @@ -12,6 +13,7 @@ class DNSViewSet(viewsets.GenericViewSet): """A viewset to provide extra routes related to DNS information.""" permission_classes = (permissions.IsAuthenticated, ) + throttle_classes = [UserDosThrottle] def get_queryset(self): """Filter queryset based on current user.""" diff --git a/modoboa/lib/throttle.py b/modoboa/lib/throttle.py new file mode 100644 index 000000000..b5142219c --- /dev/null +++ b/modoboa/lib/throttle.py @@ -0,0 +1,23 @@ +from rest_framework.throttling import UserRateThrottle, AnonRateThrottle + +class UserDosThrottle(UserRateThrottle): + """Custom Throttle class for rest_framework. The throttling is applied on a per view basis for authentificated users.""" + + scope = "ddos" + + def get_cache_key(self, request, view): + return "throttle_{viewid}_{indent}".format( + viewid=id(view), + indent=self.get_indent(request) + ) + +class AnonDosThrottle(AnonRateThrottle): + """Custom Throttle class for rest_framework. The throttling is applied on a per view basis for visitors.""" + + scope = "anon_ddos" + + def get_cache_key(self, request, view): + return "throttle_{viewid}_{indent}".format( + viewid=id(view), + indent=self.get_indent(request) + ) diff --git a/modoboa/maillog/api/v2/viewsets.py b/modoboa/maillog/api/v2/viewsets.py index b5bb1be8f..79ec23203 100644 --- a/modoboa/maillog/api/v2/viewsets.py +++ b/modoboa/maillog/api/v2/viewsets.py @@ -9,6 +9,7 @@ from modoboa.admin import models as admin_models from modoboa.lib import pagination +from modoboa.lib.throttle import UserDosThrottle from ... import models from ... import signals @@ -19,6 +20,7 @@ class StatisticsViewSet(viewsets.ViewSet): """A viewset to provide extra route related to mail statistics.""" permission_classes = (permissions.IsAuthenticated, ) + throttle_classes = [UserDosThrottle] @extend_schema( parameters=[serializers.StatisticsInputSerializer], @@ -61,6 +63,7 @@ class MaillogViewSet(viewsets.ReadOnlyModelViewSet): permissions = (permissions.IsAuthenticated, ) search_fields = ["queue_id", "sender", "rcpt", "original_rcpt", "status"] serializer_class = serializers.MaillogSerializer + throttle_classes = [UserDosThrottle] 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 a6f802f6a..a08dbd478 100644 --- a/modoboa/parameters/api/v2/viewsets.py +++ b/modoboa/parameters/api/v2/viewsets.py @@ -4,6 +4,8 @@ from rest_framework import response, viewsets from rest_framework.decorators import action +from modoboa.lib.throttle import UserDosThrottle + from . import serializers from ... import tools @@ -13,6 +15,7 @@ class ParametersViewSet(viewsets.ViewSet): lookup_value_regex = r"\w+" serializer_class = None + throttle_classes = [UserDosThrottle] @extend_schema(responses=serializers.ApplicationSerializer(many=True)) @action(methods=["get"], detail=False) diff --git a/modoboa/transport/api/v2/viewsets.py b/modoboa/transport/api/v2/viewsets.py index 28d2b005f..5ba820454 100644 --- a/modoboa/transport/api/v2/viewsets.py +++ b/modoboa/transport/api/v2/viewsets.py @@ -3,6 +3,8 @@ from drf_spectacular.utils import extend_schema from rest_framework import permissions, response, viewsets +from modoboa.lib.throttle import UserDosThrottle + from . import serializers from ... import backends @@ -11,6 +13,7 @@ class TransportViewSet(viewsets.ViewSet): """Viewset for Transport.""" permissions = (permissions.IsAuthenticated, ) + throttle_classes = [UserDosThrottle] @extend_schema( responses={200: serializers.TransportBackendSerializer} diff --git a/test_project/test_project/settings.py b/test_project/test_project/settings.py index b0b09770b..76f67e156 100644 --- a/test_project/test_project/settings.py +++ b/test_project/test_project/settings.py @@ -188,11 +188,15 @@ REST_FRAMEWORK = { 'DEFAULT_THROTTLE_CLASSES': [ 'rest_framework.throttling.AnonRateThrottle', - 'rest_framework.throttling.ScopedRateThrottle' + 'rest_framework.throttling.ScopedRateThrottle', + 'modoboa.lib.throttle.UserDosThrottle', + 'modoboa.lib.throttle.AnonDosThrottle' ], 'DEFAULT_THROTTLE_RATES': { 'anon': '100/hour', - 'login': '5/hour', + 'login': '3/minute', + 'ddos': '100/minute', + 'anon_ddos': '10/minute', 'password_recovery_request': '6/hour', 'password_recovery_totp_check': '20/hour', 'password_recovery_apply': '20/hour' From 2e9f43d3a77d7412974be71c632e257daa36834a Mon Sep 17 00:00:00 2001 From: Spitap Date: Fri, 27 Jan 2023 16:55:45 +0100 Subject: [PATCH 03/14] Improved custom throttling --- .../core/commands/templates/settings.py.tpl | 4 +-- modoboa/lib/throttle.py | 26 +++++++------------ test_project/test_project/settings.py | 4 +-- 3 files changed, 12 insertions(+), 22 deletions(-) diff --git a/modoboa/core/commands/templates/settings.py.tpl b/modoboa/core/commands/templates/settings.py.tpl index 8676049fb..d554da21a 100644 --- a/modoboa/core/commands/templates/settings.py.tpl +++ b/modoboa/core/commands/templates/settings.py.tpl @@ -196,14 +196,12 @@ REST_FRAMEWORK = { 'DEFAULT_THROTTLE_CLASSES': [ 'rest_framework.throttling.AnonRateThrottle', 'rest_framework.throttling.ScopedRateThrottle', - 'modoboa.lib.throttle.UserDosThrottle', - 'modoboa.lib.throttle.AnonDosThrottle' + 'modoboa.lib.throttle.UserDosThrottle' ], 'DEFAULT_THROTTLE_RATES': { 'anon': '100/hour', 'login': '3/minute', 'ddos': '100/minute', - 'anon_ddos': '10/minute', 'password_recovery_request': '6/hour', 'password_recovery_totp_check': '20/hour', 'password_recovery_apply': '20/hour' diff --git a/modoboa/lib/throttle.py b/modoboa/lib/throttle.py index b5142219c..03c8cf7c2 100644 --- a/modoboa/lib/throttle.py +++ b/modoboa/lib/throttle.py @@ -1,23 +1,17 @@ -from rest_framework.throttling import UserRateThrottle, AnonRateThrottle +from rest_framework.throttling import SimpleRateThrottle -class UserDosThrottle(UserRateThrottle): +class UserDosThrottle(SimpleRateThrottle): """Custom Throttle class for rest_framework. The throttling is applied on a per view basis for authentificated users.""" scope = "ddos" def get_cache_key(self, request, view): - return "throttle_{viewid}_{indent}".format( - viewid=id(view), - indent=self.get_indent(request) - ) + if request.user and request.user.is_authenticated: + ident = request.user.pk + else: + ident = self.get_ident(request) -class AnonDosThrottle(AnonRateThrottle): - """Custom Throttle class for rest_framework. The throttling is applied on a per view basis for visitors.""" - - scope = "anon_ddos" - - def get_cache_key(self, request, view): - return "throttle_{viewid}_{indent}".format( - viewid=id(view), - indent=self.get_indent(request) - ) + return self.cache_format % { + 'scope': id(view), + 'ident': ident + } \ No newline at end of file diff --git a/test_project/test_project/settings.py b/test_project/test_project/settings.py index 76f67e156..e120e2e95 100644 --- a/test_project/test_project/settings.py +++ b/test_project/test_project/settings.py @@ -189,14 +189,12 @@ 'DEFAULT_THROTTLE_CLASSES': [ 'rest_framework.throttling.AnonRateThrottle', 'rest_framework.throttling.ScopedRateThrottle', - 'modoboa.lib.throttle.UserDosThrottle', - 'modoboa.lib.throttle.AnonDosThrottle' + 'modoboa.lib.throttle.UserDosThrottle' ], 'DEFAULT_THROTTLE_RATES': { 'anon': '100/hour', 'login': '3/minute', 'ddos': '100/minute', - 'anon_ddos': '10/minute', 'password_recovery_request': '6/hour', 'password_recovery_totp_check': '20/hour', 'password_recovery_apply': '20/hour' From 126f3a7ea5ce31844090a586d20e765f9bc269bd Mon Sep 17 00:00:00 2001 From: Spitap Date: Fri, 27 Jan 2023 17:15:42 +0100 Subject: [PATCH 04/14] Tried to create test --- modoboa/admin/api/v2/tests.py | 4 ++++ modoboa/lib/throttle.py | 2 +- test_project/test_project/settings.py | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/modoboa/admin/api/v2/tests.py b/modoboa/admin/api/v2/tests.py index d1cec2bd2..36a8afc97 100644 --- a/modoboa/admin/api/v2/tests.py +++ b/modoboa/admin/api/v2/tests.py @@ -224,10 +224,14 @@ def test_create_with_bad_password(self): self.assertIn("password", resp.json()) def test_validate(self): + """Test validate and throttling.""" data = {"username": "toto@test.com"} url = reverse("v2:account-validate") resp = self.client.post(url, data, format="json") self.assertEqual(resp.status_code, 204) + for _ in range(500): + resp = self.client.post(url, data, format="json") + self.assertEqual(resp.status_code, 429) def test_random_password(self): url = reverse("v2:account-random-password") diff --git a/modoboa/lib/throttle.py b/modoboa/lib/throttle.py index 03c8cf7c2..32af019ec 100644 --- a/modoboa/lib/throttle.py +++ b/modoboa/lib/throttle.py @@ -12,6 +12,6 @@ def get_cache_key(self, request, view): ident = self.get_ident(request) return self.cache_format % { - 'scope': id(view), + 'scope': id(view.action), 'ident': ident } \ No newline at end of file diff --git a/test_project/test_project/settings.py b/test_project/test_project/settings.py index e120e2e95..2d26a543f 100644 --- a/test_project/test_project/settings.py +++ b/test_project/test_project/settings.py @@ -194,7 +194,7 @@ 'DEFAULT_THROTTLE_RATES': { 'anon': '100/hour', 'login': '3/minute', - 'ddos': '100/minute', + 'ddos': '300/minute', 'password_recovery_request': '6/hour', 'password_recovery_totp_check': '20/hour', 'password_recovery_apply': '20/hour' From bde07163b0bcbb40eda9955abc7c42d650aa7732 Mon Sep 17 00:00:00 2001 From: Spitap Date: Fri, 27 Jan 2023 17:41:32 +0100 Subject: [PATCH 05/14] Fixed default throttle class --- modoboa/admin/api/v2/viewsets.py | 16 +++++++-------- modoboa/core/api/v2/views.py | 13 ++++++++---- .../core/commands/templates/settings.py.tpl | 5 ----- modoboa/dnstools/api/v2/viewsets.py | 4 ++-- modoboa/lib/throttle.py | 20 +++++++++++++++++-- modoboa/maillog/api/v2/viewsets.py | 6 +++--- modoboa/parameters/api/v2/viewsets.py | 4 ++-- modoboa/transport/api/v2/viewsets.py | 4 ++-- test_project/test_project/settings.py | 5 ----- 9 files changed, 44 insertions(+), 33 deletions(-) diff --git a/modoboa/admin/api/v2/viewsets.py b/modoboa/admin/api/v2/viewsets.py index 569733c57..c80c6aadf 100644 --- a/modoboa/admin/api/v2/viewsets.py +++ b/modoboa/admin/api/v2/viewsets.py @@ -9,14 +9,14 @@ from rest_framework import ( filters, mixins, parsers, pagination, permissions, response, status, viewsets ) -from rest_framework.decorators import action, throttle_classes +from rest_framework.decorators import action from rest_framework.exceptions import PermissionDenied 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 UserDosThrottle +from modoboa.lib.throttle import UserDosThrottleViewset from ... import lib from ... import models @@ -52,7 +52,7 @@ class DomainViewSet(lib_viewsets.RevisionModelMixin, permission_classes = ( permissions.IsAuthenticated, permissions.DjangoModelPermissions, ) - throttle_classes = [UserDosThrottle] + throttle_classes = [UserDosThrottleViewset] def get_queryset(self): """Filter queryset based on current user.""" @@ -161,7 +161,7 @@ class AccountViewSet(v1_viewsets.AccountViewSet): filter_backends = (filters.SearchFilter, dj_filters.DjangoFilterBackend) filterset_class = AccountFilterSet - throttle_classes = [UserDosThrottle] + throttle_classes = [UserDosThrottleViewset] def get_serializer_class(self): if self.action in ["create", "validate", "update", "partial_update"]: @@ -216,7 +216,7 @@ class IdentityViewSet(viewsets.ViewSet): permission_classes = (permissions.IsAuthenticated, ) serializer_class = None - throttle_classes = [UserDosThrottle] + throttle_classes = [UserDosThrottleViewset] def list(self, request, **kwargs): """Return all identities.""" @@ -256,7 +256,7 @@ class AliasViewSet(v1_viewsets.AliasViewSet): """Viewset for Alias.""" serializer_class = serializers.AliasSerializer - throttle_classes = [UserDosThrottle] + throttle_classes = [UserDosThrottleViewset] @action(methods=["post"], detail=False) def validate(self, request, **kwargs): @@ -278,7 +278,7 @@ def random_address(self, request, **kwargs): class UserAccountViewSet(viewsets.ViewSet): """Viewset for current user operations.""" - throttle_classes = [UserDosThrottle] + throttle_classes = [UserDosThrottleViewset] @action(methods=["get", "post"], detail=False) def forward(self, request, **kwargs): @@ -341,7 +341,7 @@ class AlarmViewSet(viewsets.ReadOnlyModelViewSet): ) search_fields = ["domain__name", "title"] serializer_class = serializers.AlarmSerializer - throttle_classes = [UserDosThrottle] + throttle_classes = [UserDosThrottleViewset] def get_queryset(self): return models.Alarm.objects.select_related("domain").filter( diff --git a/modoboa/core/api/v2/views.py b/modoboa/core/api/v2/views.py index cbc186869..a4c3b841c 100644 --- a/modoboa/core/api/v2/views.py +++ b/modoboa/core/api/v2/views.py @@ -12,9 +12,10 @@ 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.lib.throttle import UserDosThrottle +from modoboa.lib.throttle import UserDosThrottleView from modoboa.core.password_hashers import get_password_hasher from modoboa.core.utils import check_for_updates from modoboa.parameters import tools as param_tools @@ -28,7 +29,8 @@ class TokenObtainPairView(jwt_views.TokenObtainPairView): """We overwrite this view to deal with password scheme update.""" - + + throttle_classes = [ScopedRateThrottle] throttle_scope = "login" def post(self, request, *args, **kwargs): @@ -87,7 +89,8 @@ class EmailPasswordResetView(APIView): """ An Api View which provides a method to request a password reset token based on an e-mail address. """ - + + throttle_classes = [ScopedRateThrottle] throttle_scope = 'password_recovery_request' def post(self, request, *args, **kwargs): @@ -126,6 +129,7 @@ def post(self, request, *args, **kwargs): class PasswordResetSmsTOTP(APIView): """ Check SMS Totp code. """ + throttle_classes = [ScopedRateThrottle] throttle_scope = 'password_recovery_totp_check' def post(self, request, *args, **kwargs): @@ -153,6 +157,7 @@ def post(self, request, *args, **kwargs): class PasswordResetConfirmView(APIView): """ Get and set new user password. """ + throttle_classes = [ScopedRateThrottle] throttle_scope = 'password_recovery_apply' def post(self, request, *args, **kwargs): @@ -174,7 +179,7 @@ def post(self, request, *args, **kwargs): class ComponentsInformationAPIView(APIView): """Retrieve information about installed components.""" - throttle_classes = [UserDosThrottle] + throttle_classes = [UserDosThrottleView] @extend_schema(responses=serializers.ModoboaComponentSerializer(many=True)) def get(self, request, *args, **kwargs): diff --git a/modoboa/core/commands/templates/settings.py.tpl b/modoboa/core/commands/templates/settings.py.tpl index d554da21a..3c4b10721 100644 --- a/modoboa/core/commands/templates/settings.py.tpl +++ b/modoboa/core/commands/templates/settings.py.tpl @@ -193,11 +193,6 @@ MEDIA_ROOT = os.path.join(BASE_DIR, 'media') # Rest framework settings REST_FRAMEWORK = { - 'DEFAULT_THROTTLE_CLASSES': [ - 'rest_framework.throttling.AnonRateThrottle', - 'rest_framework.throttling.ScopedRateThrottle', - 'modoboa.lib.throttle.UserDosThrottle' - ], 'DEFAULT_THROTTLE_RATES': { 'anon': '100/hour', 'login': '3/minute', diff --git a/modoboa/dnstools/api/v2/viewsets.py b/modoboa/dnstools/api/v2/viewsets.py index 1faad46fa..a66dd93d3 100644 --- a/modoboa/dnstools/api/v2/viewsets.py +++ b/modoboa/dnstools/api/v2/viewsets.py @@ -4,7 +4,7 @@ from rest_framework.decorators import action from modoboa.admin import models as admin_models -from modoboa.lib.throttle import UserDosThrottle +from modoboa.lib.throttle import UserDosThrottleViewset from . import serializers @@ -13,7 +13,7 @@ class DNSViewSet(viewsets.GenericViewSet): """A viewset to provide extra routes related to DNS information.""" permission_classes = (permissions.IsAuthenticated, ) - throttle_classes = [UserDosThrottle] + throttle_classes = [UserDosThrottleViewset] def get_queryset(self): """Filter queryset based on current user.""" diff --git a/modoboa/lib/throttle.py b/modoboa/lib/throttle.py index 32af019ec..7e7c277b1 100644 --- a/modoboa/lib/throttle.py +++ b/modoboa/lib/throttle.py @@ -1,6 +1,6 @@ from rest_framework.throttling import SimpleRateThrottle -class UserDosThrottle(SimpleRateThrottle): +class UserDosThrottleViewset(SimpleRateThrottle): """Custom Throttle class for rest_framework. The throttling is applied on a per view basis for authentificated users.""" scope = "ddos" @@ -12,6 +12,22 @@ def get_cache_key(self, request, view): ident = self.get_ident(request) return self.cache_format % { - 'scope': id(view.action), + 'scope': hash(f"{view.basename}_{view.name}"), + 'ident': ident + } + +class UserDosThrottleView(SimpleRateThrottle): + """Custom Throttle class for rest_framework. The throttling is applied on a per view basis for authentificated users.""" + + scope = "ddos" + + def get_cache_key(self, request, view): + if request.user and request.user.is_authenticated: + ident = request.user.pk + else: + ident = self.get_ident(request) + + return self.cache_format % { + 'scope': id(view), 'ident': ident } \ No newline at end of file diff --git a/modoboa/maillog/api/v2/viewsets.py b/modoboa/maillog/api/v2/viewsets.py index 79ec23203..c3fed34d3 100644 --- a/modoboa/maillog/api/v2/viewsets.py +++ b/modoboa/maillog/api/v2/viewsets.py @@ -9,7 +9,7 @@ from modoboa.admin import models as admin_models from modoboa.lib import pagination -from modoboa.lib.throttle import UserDosThrottle +from modoboa.lib.throttle import UserDosThrottleViewset from ... import models from ... import signals @@ -20,7 +20,7 @@ class StatisticsViewSet(viewsets.ViewSet): """A viewset to provide extra route related to mail statistics.""" permission_classes = (permissions.IsAuthenticated, ) - throttle_classes = [UserDosThrottle] + throttle_classes = [UserDosThrottleViewset] @extend_schema( parameters=[serializers.StatisticsInputSerializer], @@ -63,7 +63,7 @@ class MaillogViewSet(viewsets.ReadOnlyModelViewSet): permissions = (permissions.IsAuthenticated, ) search_fields = ["queue_id", "sender", "rcpt", "original_rcpt", "status"] serializer_class = serializers.MaillogSerializer - throttle_classes = [UserDosThrottle] + throttle_classes = [UserDosThrottleViewset] 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 a08dbd478..fd92c5d9c 100644 --- a/modoboa/parameters/api/v2/viewsets.py +++ b/modoboa/parameters/api/v2/viewsets.py @@ -4,7 +4,7 @@ from rest_framework import response, viewsets from rest_framework.decorators import action -from modoboa.lib.throttle import UserDosThrottle +from modoboa.lib.throttle import UserDosThrottleViewset from . import serializers from ... import tools @@ -15,7 +15,7 @@ class ParametersViewSet(viewsets.ViewSet): lookup_value_regex = r"\w+" serializer_class = None - throttle_classes = [UserDosThrottle] + throttle_classes = [UserDosThrottleViewset] @extend_schema(responses=serializers.ApplicationSerializer(many=True)) @action(methods=["get"], detail=False) diff --git a/modoboa/transport/api/v2/viewsets.py b/modoboa/transport/api/v2/viewsets.py index 5ba820454..4be4d7594 100644 --- a/modoboa/transport/api/v2/viewsets.py +++ b/modoboa/transport/api/v2/viewsets.py @@ -3,7 +3,7 @@ from drf_spectacular.utils import extend_schema from rest_framework import permissions, response, viewsets -from modoboa.lib.throttle import UserDosThrottle +from modoboa.lib.throttle import UserDosThrottleViewset from . import serializers from ... import backends @@ -13,7 +13,7 @@ class TransportViewSet(viewsets.ViewSet): """Viewset for Transport.""" permissions = (permissions.IsAuthenticated, ) - throttle_classes = [UserDosThrottle] + throttle_classes = [UserDosThrottleViewset] @extend_schema( responses={200: serializers.TransportBackendSerializer} diff --git a/test_project/test_project/settings.py b/test_project/test_project/settings.py index 2d26a543f..9e4359669 100644 --- a/test_project/test_project/settings.py +++ b/test_project/test_project/settings.py @@ -186,11 +186,6 @@ # Rest framework settings REST_FRAMEWORK = { - 'DEFAULT_THROTTLE_CLASSES': [ - 'rest_framework.throttling.AnonRateThrottle', - 'rest_framework.throttling.ScopedRateThrottle', - 'modoboa.lib.throttle.UserDosThrottle' - ], 'DEFAULT_THROTTLE_RATES': { 'anon': '100/hour', 'login': '3/minute', From cbfac68b975b5ee156996e5b4911289ef893bab5 Mon Sep 17 00:00:00 2001 From: Spitap Date: Fri, 27 Jan 2023 22:03:09 +0100 Subject: [PATCH 06/14] Make throttling more strict --- modoboa/admin/api/v1/viewsets.py | 7 +++++++ modoboa/admin/api/v2/viewsets.py | 14 ++++++------- modoboa/core/api/v1/viewsets.py | 6 ++++++ modoboa/core/api/v2/views.py | 9 ++++----- modoboa/core/api/v2/viewsets.py | 2 ++ .../core/commands/templates/settings.py.tpl | 5 ++--- modoboa/dnstools/api/v2/viewsets.py | 5 +++-- modoboa/lib/throttle.py | 20 ++----------------- modoboa/limits/api/v1/viewsets.py | 3 +++ modoboa/maillog/api/v2/viewsets.py | 7 ++++--- modoboa/parameters/api/v2/viewsets.py | 5 +++-- modoboa/relaydomains/api/v1/viewsets.py | 3 +++ modoboa/transport/api/v2/viewsets.py | 5 +++-- 13 files changed, 49 insertions(+), 42 deletions(-) diff --git a/modoboa/admin/api/v1/viewsets.py b/modoboa/admin/api/v1/viewsets.py index a2da94ba5..aa9faaad6 100644 --- a/modoboa/admin/api/v1/viewsets.py +++ b/modoboa/admin/api/v1/viewsets.py @@ -11,11 +11,13 @@ from rest_framework.exceptions import ParseError from rest_framework.permissions import DjangoModelPermissions, IsAuthenticated from rest_framework.response import Response +from rest_framework.throttling import UserRateThrottle 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 ... import lib, models from . import serializers @@ -40,6 +42,7 @@ class DomainViewSet(lib_viewsets.RevisionModelMixin, viewsets.ModelViewSet): permission_classes = [IsAuthenticated, DjangoModelPermissions, ] serializer_class = serializers.DomainSerializer + throttle_classes = [UserDdosPerView, UserRateThrottle] def get_queryset(self): """Filter queryset based on current user.""" @@ -69,6 +72,7 @@ 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.""" @@ -86,6 +90,7 @@ class AccountViewSet(lib_viewsets.RevisionModelMixin, viewsets.ModelViewSet): filter_backends = (filters.SearchFilter, ) permission_classes = [IsAuthenticated, DjangoModelPermissions, ] search_fields = ("^first_name", "^last_name", "^email") + throttle_classes = [UserDdosPerView, UserRateThrottle] def get_serializer_class(self): """Return a serializer.""" @@ -183,6 +188,7 @@ 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.""" @@ -215,6 +221,7 @@ 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 c80c6aadf..59a2d7222 100644 --- a/modoboa/admin/api/v2/viewsets.py +++ b/modoboa/admin/api/v2/viewsets.py @@ -11,12 +11,13 @@ ) from rest_framework.decorators import action from rest_framework.exceptions import PermissionDenied +from rest_framework.throttling import UserRateThrottle 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 UserDosThrottleViewset +from modoboa.lib.throttle import UserDdosPerView from ... import lib from ... import models @@ -52,7 +53,7 @@ class DomainViewSet(lib_viewsets.RevisionModelMixin, permission_classes = ( permissions.IsAuthenticated, permissions.DjangoModelPermissions, ) - throttle_classes = [UserDosThrottleViewset] + throttle_classes = [UserDdosPerView, UserRateThrottle] def get_queryset(self): """Filter queryset based on current user.""" @@ -161,7 +162,6 @@ class AccountViewSet(v1_viewsets.AccountViewSet): filter_backends = (filters.SearchFilter, dj_filters.DjangoFilterBackend) filterset_class = AccountFilterSet - throttle_classes = [UserDosThrottleViewset] def get_serializer_class(self): if self.action in ["create", "validate", "update", "partial_update"]: @@ -216,7 +216,7 @@ class IdentityViewSet(viewsets.ViewSet): permission_classes = (permissions.IsAuthenticated, ) serializer_class = None - throttle_classes = [UserDosThrottleViewset] + throttle_classes = [UserDdosPerView, UserRateThrottle] def list(self, request, **kwargs): """Return all identities.""" @@ -256,7 +256,7 @@ class AliasViewSet(v1_viewsets.AliasViewSet): """Viewset for Alias.""" serializer_class = serializers.AliasSerializer - throttle_classes = [UserDosThrottleViewset] + throttle_classes = [UserDdosPerView, UserRateThrottle] @action(methods=["post"], detail=False) def validate(self, request, **kwargs): @@ -278,7 +278,7 @@ def random_address(self, request, **kwargs): class UserAccountViewSet(viewsets.ViewSet): """Viewset for current user operations.""" - throttle_classes = [UserDosThrottleViewset] + throttle_classes = [UserDdosPerView, UserRateThrottle] @action(methods=["get", "post"], detail=False) def forward(self, request, **kwargs): @@ -341,7 +341,7 @@ class AlarmViewSet(viewsets.ReadOnlyModelViewSet): ) search_fields = ["domain__name", "title"] serializer_class = serializers.AlarmSerializer - throttle_classes = [UserDosThrottleViewset] + 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 7bee9079c..223dc11e4 100644 --- a/modoboa/core/api/v1/viewsets.py +++ b/modoboa/core/api/v1/viewsets.py @@ -5,10 +5,15 @@ import django_otp from django_otp.plugins.otp_static.models import StaticDevice, StaticToken from django_otp.plugins.otp_totp.models import TOTPDevice + from rest_framework import permissions, response, viewsets from rest_framework.decorators import action +from rest_framework.throttling import UserRateThrottle + from drf_spectacular.utils import extend_schema +from modoboa.lib.throttle import UserDdosPerView + from . import serializers @@ -20,6 +25,7 @@ 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 a4c3b841c..39b891f78 100644 --- a/modoboa/core/api/v2/views.py +++ b/modoboa/core/api/v2/views.py @@ -12,10 +12,9 @@ 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.throttling import ScopedRateThrottle, UserRateThrottle from rest_framework.views import APIView -from modoboa.lib.throttle import UserDosThrottleView from modoboa.core.password_hashers import get_password_hasher from modoboa.core.utils import check_for_updates from modoboa.parameters import tools as param_tools @@ -29,7 +28,7 @@ class TokenObtainPairView(jwt_views.TokenObtainPairView): """We overwrite this view to deal with password scheme update.""" - + throttle_classes = [ScopedRateThrottle] throttle_scope = "login" @@ -89,7 +88,7 @@ class EmailPasswordResetView(APIView): """ An Api View which provides a method to request a password reset token based on an e-mail address. """ - + throttle_classes = [ScopedRateThrottle] throttle_scope = 'password_recovery_request' @@ -179,7 +178,7 @@ def post(self, request, *args, **kwargs): class ComponentsInformationAPIView(APIView): """Retrieve information about installed components.""" - throttle_classes = [UserDosThrottleView] + throttle_classes = [UserRateThrottle] @extend_schema(responses=serializers.ModoboaComponentSerializer(many=True)) def get(self, request, *args, **kwargs): diff --git a/modoboa/core/api/v2/viewsets.py b/modoboa/core/api/v2/viewsets.py index ef8c7b998..cdfb2bb78 100644 --- a/modoboa/core/api/v2/viewsets.py +++ b/modoboa/core/api/v2/viewsets.py @@ -11,6 +11,7 @@ from rest_framework.authtoken.models import Token from rest_framework.decorators import action from rest_framework.exceptions import PermissionDenied +from rest_framework.throttling import UserRateThrottle from rest_framework_simplejwt.tokens import RefreshToken from modoboa.admin.api.v1 import serializers as admin_v1_serializers @@ -162,6 +163,7 @@ class LogViewSet(viewsets.ReadOnlyModelViewSet): queryset = models.Log.objects.all() search_fields = ["logger", "level", "message"] serializer_class = serializers.LogSerializer + throttle_classes = [UserRateThrottle] class LanguageViewSet(viewsets.ViewSet): diff --git a/modoboa/core/commands/templates/settings.py.tpl b/modoboa/core/commands/templates/settings.py.tpl index 3c4b10721..b2f68ef77 100644 --- a/modoboa/core/commands/templates/settings.py.tpl +++ b/modoboa/core/commands/templates/settings.py.tpl @@ -194,9 +194,8 @@ MEDIA_ROOT = os.path.join(BASE_DIR, 'media') REST_FRAMEWORK = { 'DEFAULT_THROTTLE_RATES': { - 'anon': '100/hour', - 'login': '3/minute', - 'ddos': '100/minute', + 'user': '100/minute', + 'ddos': '1/second', 'password_recovery_request': '6/hour', 'password_recovery_totp_check': '20/hour', 'password_recovery_apply': '20/hour' diff --git a/modoboa/dnstools/api/v2/viewsets.py b/modoboa/dnstools/api/v2/viewsets.py index a66dd93d3..f06eeac90 100644 --- a/modoboa/dnstools/api/v2/viewsets.py +++ b/modoboa/dnstools/api/v2/viewsets.py @@ -2,9 +2,10 @@ 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 UserDosThrottleViewset +from modoboa.lib.throttle import UserDdosPerView from . import serializers @@ -13,7 +14,7 @@ class DNSViewSet(viewsets.GenericViewSet): """A viewset to provide extra routes related to DNS information.""" permission_classes = (permissions.IsAuthenticated, ) - throttle_classes = [UserDosThrottleViewset] + 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 7e7c277b1..af6a90a61 100644 --- a/modoboa/lib/throttle.py +++ b/modoboa/lib/throttle.py @@ -1,9 +1,9 @@ from rest_framework.throttling import SimpleRateThrottle -class UserDosThrottleViewset(SimpleRateThrottle): +class UserDdosPerView(SimpleRateThrottle): """Custom Throttle class for rest_framework. The throttling is applied on a per view basis for authentificated users.""" - scope = "ddos" + scope = 'ddos' def get_cache_key(self, request, view): if request.user and request.user.is_authenticated: @@ -15,19 +15,3 @@ def get_cache_key(self, request, view): 'scope': hash(f"{view.basename}_{view.name}"), 'ident': ident } - -class UserDosThrottleView(SimpleRateThrottle): - """Custom Throttle class for rest_framework. The throttling is applied on a per view basis for authentificated users.""" - - scope = "ddos" - - def get_cache_key(self, request, view): - if request.user and request.user.is_authenticated: - ident = request.user.pk - else: - ident = self.get_ident(request) - - return self.cache_format % { - 'scope': id(view), - 'ident': ident - } \ No newline at end of file diff --git a/modoboa/limits/api/v1/viewsets.py b/modoboa/limits/api/v1/viewsets.py index 6e6c6ad3f..c335b4843 100644 --- a/modoboa/limits/api/v1/viewsets.py +++ b/modoboa/limits/api/v1/viewsets.py @@ -4,8 +4,10 @@ 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 UserDdosPerView from . import serializers @@ -17,6 +19,7 @@ class ResourcesViewSet( permission_classes = [IsAuthenticated, DjangoModelPermissions] serializer_class = serializers.ResourcesSerializer + throttle_classes = [UserDdosPerView, 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 c3fed34d3..4db0ec142 100644 --- a/modoboa/maillog/api/v2/viewsets.py +++ b/modoboa/maillog/api/v2/viewsets.py @@ -6,10 +6,11 @@ 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 UserDosThrottleViewset +from modoboa.lib.throttle import UserDdosPerView from ... import models from ... import signals @@ -20,7 +21,7 @@ class StatisticsViewSet(viewsets.ViewSet): """A viewset to provide extra route related to mail statistics.""" permission_classes = (permissions.IsAuthenticated, ) - throttle_classes = [UserDosThrottleViewset] + throttle_classes = [UserDdosPerView, UserRateThrottle] @extend_schema( parameters=[serializers.StatisticsInputSerializer], @@ -63,7 +64,7 @@ class MaillogViewSet(viewsets.ReadOnlyModelViewSet): permissions = (permissions.IsAuthenticated, ) search_fields = ["queue_id", "sender", "rcpt", "original_rcpt", "status"] serializer_class = serializers.MaillogSerializer - throttle_classes = [UserDosThrottleViewset] + 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 fd92c5d9c..aa23b1043 100644 --- a/modoboa/parameters/api/v2/viewsets.py +++ b/modoboa/parameters/api/v2/viewsets.py @@ -3,8 +3,9 @@ from drf_spectacular.utils import extend_schema, OpenApiParameter from rest_framework import response, viewsets from rest_framework.decorators import action +from rest_framework.throttling import UserRateThrottle -from modoboa.lib.throttle import UserDosThrottleViewset +from modoboa.lib.throttle import UserDdosPerView from . import serializers from ... import tools @@ -15,7 +16,7 @@ class ParametersViewSet(viewsets.ViewSet): lookup_value_regex = r"\w+" serializer_class = None - throttle_classes = [UserDosThrottleViewset] + 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 35841af6a..ba98260b9 100644 --- a/modoboa/relaydomains/api/v1/viewsets.py +++ b/modoboa/relaydomains/api/v1/viewsets.py @@ -2,8 +2,10 @@ from rest_framework import viewsets 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 UserDdosPerView from modoboa.lib.viewsets import RevisionModelMixin from . import serializers @@ -13,6 +15,7 @@ class RelayDomainViewSet(RevisionModelMixin, viewsets.ModelViewSet): permission_classes = [IsAuthenticated, DjangoModelPermissions, ] serializer_class = serializers.RelayDomainSerializer + throttle_classes = [UserDdosPerView, UserRateThrottle] 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 4be4d7594..11325045a 100644 --- a/modoboa/transport/api/v2/viewsets.py +++ b/modoboa/transport/api/v2/viewsets.py @@ -2,8 +2,9 @@ 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 UserDosThrottleViewset +from modoboa.lib.throttle import UserDdosPerView from . import serializers from ... import backends @@ -13,7 +14,7 @@ class TransportViewSet(viewsets.ViewSet): """Viewset for Transport.""" permissions = (permissions.IsAuthenticated, ) - throttle_classes = [UserDosThrottleViewset] + throttle_classes = [UserDdosPerView, UserDdosPerView] @extend_schema( responses={200: serializers.TransportBackendSerializer} From bee1342f4d9ee2db481b43d971256c65e39bfbcf Mon Sep 17 00:00:00 2001 From: Spitap Date: Wed, 1 Feb 2023 12:16:44 +0100 Subject: [PATCH 07/14] fix --- modoboa/admin/api/v2/viewsets.py | 31 ++++++++++++++++--- modoboa/core/api/v2/views.py | 5 +-- modoboa/core/api/v2/viewsets.py | 8 +++++ .../core/commands/templates/settings.py.tpl | 8 +++-- modoboa/lib/throttle.py | 16 ++++++++++ modoboa/relaydomains/api/v1/viewsets.py | 9 ++++-- test_project/test_project/settings.py | 11 ++++--- 7 files changed, 72 insertions(+), 16 deletions(-) diff --git a/modoboa/admin/api/v2/viewsets.py b/modoboa/admin/api/v2/viewsets.py index 59a2d7222..53288a4cd 100644 --- a/modoboa/admin/api/v2/viewsets.py +++ b/modoboa/admin/api/v2/viewsets.py @@ -17,7 +17,7 @@ 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 +from modoboa.lib.throttle import UserDdosPerView, UserLesserDdosUser from ... import lib from ... import models @@ -53,7 +53,14 @@ class DomainViewSet(lib_viewsets.RevisionModelMixin, permission_classes = ( permissions.IsAuthenticated, permissions.DjangoModelPermissions, ) - throttle_classes = [UserDdosPerView, UserRateThrottle] + 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.""" @@ -163,6 +170,14 @@ 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"]: return serializers.WritableAccountSerializer @@ -216,7 +231,15 @@ class IdentityViewSet(viewsets.ViewSet): permission_classes = (permissions.IsAuthenticated, ) serializer_class = None - throttle_classes = [UserDdosPerView, UserRateThrottle] + 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.""" @@ -256,7 +279,7 @@ class AliasViewSet(v1_viewsets.AliasViewSet): """Viewset for Alias.""" serializer_class = serializers.AliasSerializer - throttle_classes = [UserDdosPerView, UserRateThrottle] + throttle_classes = [UserDdosPerView, UserLesserDdosUser] @action(methods=["post"], detail=False) def validate(self, request, **kwargs): diff --git a/modoboa/core/api/v2/views.py b/modoboa/core/api/v2/views.py index 39b891f78..1ae5bab27 100644 --- a/modoboa/core/api/v2/views.py +++ b/modoboa/core/api/v2/views.py @@ -12,11 +12,12 @@ 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, UserRateThrottle +from rest_framework.throttling import ScopedRateThrottle from rest_framework.views import APIView from modoboa.core.password_hashers import get_password_hasher from modoboa.core.utils import check_for_updates +from modoboa.lib.throttle import UserLesserDdosUser from modoboa.parameters import tools as param_tools from smtplib import SMTPException @@ -178,7 +179,7 @@ def post(self, request, *args, **kwargs): class ComponentsInformationAPIView(APIView): """Retrieve information about installed components.""" - throttle_classes = [UserRateThrottle] + throttle_classes = [UserLesserDdosUser] @extend_schema(responses=serializers.ModoboaComponentSerializer(many=True)) def get(self, request, *args, **kwargs): diff --git a/modoboa/core/api/v2/viewsets.py b/modoboa/core/api/v2/viewsets.py index cdfb2bb78..55d195d14 100644 --- a/modoboa/core/api/v2/viewsets.py +++ b/modoboa/core/api/v2/viewsets.py @@ -18,6 +18,7 @@ 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 ... import constants from ... import models @@ -27,6 +28,12 @@ class AccountViewSet(core_v1_viewsets.AccountViewSet): """Account viewset.""" + def get_throttles(self): + if self.action in ['me']: + self.throttle_classes.append(UserLesserDdosUser) + return super().get_throttles() + + @extend_schema(responses=admin_v1_serializers.AccountSerializer) @action(methods=["get"], detail=False) def me(self, request): @@ -172,6 +179,7 @@ class LanguageViewSet(viewsets.ViewSet): permission_classes = ( permissions.IsAuthenticated, ) + throttle_classes = [UserRateThrottle] def list(self, request, *args, **kwargs): languages = [ diff --git a/modoboa/core/commands/templates/settings.py.tpl b/modoboa/core/commands/templates/settings.py.tpl index b2f68ef77..6f755c168 100644 --- a/modoboa/core/commands/templates/settings.py.tpl +++ b/modoboa/core/commands/templates/settings.py.tpl @@ -194,11 +194,13 @@ MEDIA_ROOT = os.path.join(BASE_DIR, 'media') REST_FRAMEWORK = { 'DEFAULT_THROTTLE_RATES': { - 'user': '100/minute', - 'ddos': '1/second', + 'user': '300/minute', + 'ddos': '5/second', + 'ddos_lesser': '10/second', + 'login': '5/minute', 'password_recovery_request': '6/hour', 'password_recovery_totp_check': '20/hour', - 'password_recovery_apply': '20/hour' + 'password_recovery_apply': '40/hour' }, 'DEFAULT_AUTHENTICATION_CLASSES': ( 'modoboa.core.drf_authentication.JWTAuthenticationWith2FA', diff --git a/modoboa/lib/throttle.py b/modoboa/lib/throttle.py index af6a90a61..e77d797db 100644 --- a/modoboa/lib/throttle.py +++ b/modoboa/lib/throttle.py @@ -15,3 +15,19 @@ def get_cache_key(self, request, view): 'scope': hash(f"{view.basename}_{view.name}"), 'ident': ident } + +class UserLesserDdosUser(SimpleRateThrottle): + """Custom Throttle class for rest_framework. The throttling is applied on a per view basis for authentificated users.""" + + scope = 'ddos_lesser' + + def get_cache_key(self, request, view): + if request.user and request.user.is_authenticated: + ident = request.user.pk + else: + ident = self.get_ident(request) + + return self.cache_format % { + 'scope': self.scope, + 'ident': ident + } diff --git a/modoboa/relaydomains/api/v1/viewsets.py b/modoboa/relaydomains/api/v1/viewsets.py index ba98260b9..a1804f926 100644 --- a/modoboa/relaydomains/api/v1/viewsets.py +++ b/modoboa/relaydomains/api/v1/viewsets.py @@ -5,7 +5,7 @@ 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 UserLesserDdosUser from modoboa.lib.viewsets import RevisionModelMixin from . import serializers @@ -15,7 +15,12 @@ class RelayDomainViewSet(RevisionModelMixin, viewsets.ModelViewSet): permission_classes = [IsAuthenticated, DjangoModelPermissions, ] serializer_class = serializers.RelayDomainSerializer - throttle_classes = [UserDdosPerView, UserRateThrottle] + + def get_throttles(self): + if self.action: + self.throttle_classes.append(UserLesserDdosUser) + return super().get_throttles() + def get_queryset(self): """Filter queryset based on current user.""" diff --git a/test_project/test_project/settings.py b/test_project/test_project/settings.py index 9e4359669..5a3d7b63e 100644 --- a/test_project/test_project/settings.py +++ b/test_project/test_project/settings.py @@ -187,12 +187,13 @@ REST_FRAMEWORK = { 'DEFAULT_THROTTLE_RATES': { - 'anon': '100/hour', - 'login': '3/minute', - 'ddos': '300/minute', + 'user': '400/minute', + 'ddos': '100/second', + 'ddos_lesser': '300/minute', + 'login': '10/minute', 'password_recovery_request': '6/hour', - 'password_recovery_totp_check': '20/hour', - 'password_recovery_apply': '20/hour' + 'password_recovery_totp_check': '40/hour', + 'password_recovery_apply': '40/hour' }, 'DEFAULT_AUTHENTICATION_CLASSES': ( 'modoboa.core.drf_authentication.JWTAuthenticationWith2FA', From b707ae292c9b98dc63e763f1652f9621d72cbcb7 Mon Sep 17 00:00:00 2001 From: Spitap Date: Wed, 1 Feb 2023 14:09:28 +0100 Subject: [PATCH 08/14] use of url for the throttle scope of ddos --- modoboa/admin/api/v2/tests.py | 3 --- modoboa/lib/throttle.py | 6 ++---- modoboa/limits/api/v1/viewsets.py | 4 ++-- modoboa/relaydomains/api/v1/viewsets.py | 7 +------ 4 files changed, 5 insertions(+), 15 deletions(-) diff --git a/modoboa/admin/api/v2/tests.py b/modoboa/admin/api/v2/tests.py index 36a8afc97..b4235ad95 100644 --- a/modoboa/admin/api/v2/tests.py +++ b/modoboa/admin/api/v2/tests.py @@ -229,9 +229,6 @@ def test_validate(self): url = reverse("v2:account-validate") resp = self.client.post(url, data, format="json") self.assertEqual(resp.status_code, 204) - for _ in range(500): - resp = self.client.post(url, data, format="json") - self.assertEqual(resp.status_code, 429) def test_random_password(self): url = reverse("v2:account-random-password") diff --git a/modoboa/lib/throttle.py b/modoboa/lib/throttle.py index e77d797db..1bea8c580 100644 --- a/modoboa/lib/throttle.py +++ b/modoboa/lib/throttle.py @@ -10,9 +10,8 @@ def get_cache_key(self, request, view): ident = request.user.pk else: ident = self.get_ident(request) - return self.cache_format % { - 'scope': hash(f"{view.basename}_{view.name}"), + 'scope': hash(request.path), 'ident': ident } @@ -26,8 +25,7 @@ def get_cache_key(self, request, view): ident = request.user.pk else: ident = self.get_ident(request) - return self.cache_format % { - 'scope': self.scope, + 'scope': hash(request.path), 'ident': ident } diff --git a/modoboa/limits/api/v1/viewsets.py b/modoboa/limits/api/v1/viewsets.py index c335b4843..053352575 100644 --- a/modoboa/limits/api/v1/viewsets.py +++ b/modoboa/limits/api/v1/viewsets.py @@ -7,7 +7,7 @@ from rest_framework.throttling import UserRateThrottle from modoboa.core import models as core_models -from modoboa.lib.throttle import UserDdosPerView +from modoboa.lib.throttle import UserLesserDdosUser from . import serializers @@ -19,7 +19,7 @@ class ResourcesViewSet( permission_classes = [IsAuthenticated, DjangoModelPermissions] serializer_class = serializers.ResourcesSerializer - throttle_classes = [UserDdosPerView, UserRateThrottle] + throttle_classes = [UserLesserDdosUser, UserRateThrottle] def get_queryset(self): """Filter queryset based on current user.""" diff --git a/modoboa/relaydomains/api/v1/viewsets.py b/modoboa/relaydomains/api/v1/viewsets.py index a1804f926..0bf7a81ab 100644 --- a/modoboa/relaydomains/api/v1/viewsets.py +++ b/modoboa/relaydomains/api/v1/viewsets.py @@ -15,12 +15,7 @@ class RelayDomainViewSet(RevisionModelMixin, viewsets.ModelViewSet): permission_classes = [IsAuthenticated, DjangoModelPermissions, ] serializer_class = serializers.RelayDomainSerializer - - def get_throttles(self): - if self.action: - self.throttle_classes.append(UserLesserDdosUser) - return super().get_throttles() - + throttle_classes = [UserLesserDdosUser] def get_queryset(self): """Filter queryset based on current user.""" From fdbf30bf4ce4084ee83a077db14faa8642942d13 Mon Sep 17 00:00:00 2001 From: Spitap Date: Mon, 6 Feb 2023 10:48:06 +0100 Subject: [PATCH 09/14] better throttling, doc updated --- doc/upgrade.rst | 32 ++++++++++++++++ modoboa/core/api/v2/views.py | 23 +++++++----- modoboa/core/api/v2/viewsets.py | 4 ++ .../core/commands/templates/settings.py.tpl | 10 ++--- modoboa/lib/throttle.py | 37 +++++++++++++++++-- 5 files changed, 89 insertions(+), 17 deletions(-) diff --git a/doc/upgrade.rst b/doc/upgrade.rst index 6433b3b80..a283cdc96 100644 --- a/doc/upgrade.rst +++ b/doc/upgrade.rst @@ -123,9 +123,41 @@ Specific instructions * You now have the possibility to customize the url of the new-admin interface. + To do so please head up to :ref:`the custom configuration chapter ` (advanced user). +* Add ``DEFAULT_THROTTLE_RATES`` to ``REST_FRAMEWORK``: + +.. sourcecode:: python + + REST_FRAMEWORK = { + 'DEFAULT_THROTTLE_RATES': { + 'user': '300/minute', + 'ddos': '5/second', + 'ddos_lesser': '200/minute', + 'login': '10/minute', + 'password_recovery_request': '12/hour', + 'password_recovery_totp_check': '25/hour', + 'password_recovery_apply': '25/hour' + }, + 'DEFAULT_AUTHENTICATION_CLASSES': ( + 'modoboa.core.drf_authentication.JWTAuthenticationWith2FA', + 'rest_framework.authentication.TokenAuthentication', + 'rest_framework.authentication.SessionAuthentication', + ), + 'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema', + 'DEFAULT_VERSIONING_CLASS': 'rest_framework.versioning.NamespaceVersioning', + } + +* You can edit the ``DEFAULT_THROTTLE_RATES`` to whatever value suits you. + - `user` is for every endpoint, it is per user or per ip if not logged. + - `ddos` is per api endpoint and per user or per ip if not logged. + - `ddos_lesser` is for per api endpoint and per user or per ip if not logged. This is for api endpoint that are lighter. + - `login` the number of time an ip can attempt to log. The counter will reset on login success. + - `password_` is for the recovery, it is divided per step in the recovery process. + + 2.0.3 ===== diff --git a/modoboa/core/api/v2/views.py b/modoboa/core/api/v2/views.py index 1ae5bab27..845dd1fc2 100644 --- a/modoboa/core/api/v2/views.py +++ b/modoboa/core/api/v2/views.py @@ -17,7 +17,7 @@ from modoboa.core.password_hashers import get_password_hasher from modoboa.core.utils import check_for_updates -from modoboa.lib.throttle import UserLesserDdosUser +from modoboa.lib.throttle import UserLesserDdosUser, LoginThrottle, PasswordResetApplyThrottle, PasswordResetRequestThrottle, PasswordResetTotpThrottle from modoboa.parameters import tools as param_tools from smtplib import SMTPException @@ -30,8 +30,7 @@ class TokenObtainPairView(jwt_views.TokenObtainPairView): """We overwrite this view to deal with password scheme update.""" - throttle_classes = [ScopedRateThrottle] - throttle_scope = "login" + throttle_classes = [LoginThrottle] def post(self, request, *args, **kwargs): serializer = self.get_serializer(data=request.data) @@ -47,6 +46,10 @@ def post(self, request, *args, **kwargs): user = serializer.user login(request, user) + + # Reset login throttle + self.get_throttles()[0].reset_cache(request) + logger.info( _("User '%s' successfully logged in"), user.username ) @@ -90,8 +93,7 @@ class EmailPasswordResetView(APIView): An Api View which provides a method to request a password reset token based on an e-mail address. """ - throttle_classes = [ScopedRateThrottle] - throttle_scope = 'password_recovery_request' + throttle_classes = [PasswordResetRequestThrottle] def post(self, request, *args, **kwargs): serializer = serializers.PasswordRecoveryEmailSerializer( @@ -104,6 +106,8 @@ def post(self, request, *args, **kwargs): "type": "email", "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) @@ -123,14 +127,14 @@ def post(self, request, *args, **kwargs): except serializers.NoSMSAvailable: return super().post(request, *args, **kwargs) # SMS response + self.get_throttles()[0].reset_cache(request) return response.Response({"type": "sms"}, 200) class PasswordResetSmsTOTP(APIView): """ Check SMS Totp code. """ - throttle_classes = [ScopedRateThrottle] - throttle_scope = 'password_recovery_totp_check' + throttle_classes = [PasswordResetTotpThrottle] def post(self, request, *args, **kwargs): try: @@ -151,14 +155,14 @@ def post(self, request, *args, **kwargs): "id": serializer_response[1], "type": "confirm" }) + self.get_throttles()[0].reset_cache(request) return response.Response(payload, 200) class PasswordResetConfirmView(APIView): """ Get and set new user password. """ - throttle_classes = [ScopedRateThrottle] - throttle_scope = 'password_recovery_apply' + throttle_classes = [PasswordResetApplyThrottle] def post(self, request, *args, **kwargs): serializer = serializers.PasswordRecoveryConfirmSerializer( @@ -173,6 +177,7 @@ def post(self, request, *args, **kwargs): data.update({"errors": errors}) return response.Response(data, 400) serializer.save() + self.get_throttles()[0].reset_cache(request) return response.Response(status=200) diff --git a/modoboa/core/api/v2/viewsets.py b/modoboa/core/api/v2/viewsets.py index 55d195d14..11211eb3d 100644 --- a/modoboa/core/api/v2/viewsets.py +++ b/modoboa/core/api/v2/viewsets.py @@ -28,9 +28,13 @@ 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() diff --git a/modoboa/core/commands/templates/settings.py.tpl b/modoboa/core/commands/templates/settings.py.tpl index 6f755c168..8dfeee005 100644 --- a/modoboa/core/commands/templates/settings.py.tpl +++ b/modoboa/core/commands/templates/settings.py.tpl @@ -196,11 +196,11 @@ REST_FRAMEWORK = { 'DEFAULT_THROTTLE_RATES': { 'user': '300/minute', 'ddos': '5/second', - 'ddos_lesser': '10/second', - 'login': '5/minute', - 'password_recovery_request': '6/hour', - 'password_recovery_totp_check': '20/hour', - 'password_recovery_apply': '40/hour' + 'ddos_lesser': '200/minute', + 'login': '10/minute', + 'password_recovery_request': '12/hour', + 'password_recovery_totp_check': '25/hour', + 'password_recovery_apply': '25/hour' }, 'DEFAULT_AUTHENTICATION_CLASSES': ( 'modoboa.core.drf_authentication.JWTAuthenticationWith2FA', diff --git a/modoboa/lib/throttle.py b/modoboa/lib/throttle.py index 1bea8c580..23e58681a 100644 --- a/modoboa/lib/throttle.py +++ b/modoboa/lib/throttle.py @@ -1,4 +1,5 @@ -from rest_framework.throttling import SimpleRateThrottle +from rest_framework.throttling import SimpleRateThrottle, AnonRateThrottle +from django.urls import resolve class UserDdosPerView(SimpleRateThrottle): """Custom Throttle class for rest_framework. The throttling is applied on a per view basis for authentificated users.""" @@ -11,7 +12,7 @@ def get_cache_key(self, request, view): else: ident = self.get_ident(request) return self.cache_format % { - 'scope': hash(request.path), + 'scope': hash(resolve(request.path).url_name), 'ident': ident } @@ -26,6 +27,36 @@ def get_cache_key(self, request, view): else: ident = self.get_ident(request) return self.cache_format % { - 'scope': hash(request.path), + 'scope': hash(resolve(request.path).url_name), 'ident': ident } + +class LoginThrottle(SimpleRateThrottle): + + scope = 'login' + + def get_cache_key(self, request, view = None): + 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.cache.delete(self.key) + + +class PasswordResetRequestThrottle(LoginThrottle): + + scope = 'password_recovery_request' + + +class PasswordResetTotpThrottle(LoginThrottle): + + scope = 'password_recovery_totp_check' + + +class PasswordResetApplyThrottle(LoginThrottle): + + scope = 'password_recovery_apply' + From 5cf2976013790fadbf2b0dd33262cfb07b1c14d0 Mon Sep 17 00:00:00 2001 From: Spitap Date: Mon, 6 Feb 2023 11:09:35 +0100 Subject: [PATCH 10/14] Added comment --- modoboa/lib/throttle.py | 1 + 1 file changed, 1 insertion(+) diff --git a/modoboa/lib/throttle.py b/modoboa/lib/throttle.py index 23e58681a..b6cb131a6 100644 --- a/modoboa/lib/throttle.py +++ b/modoboa/lib/throttle.py @@ -32,6 +32,7 @@ def get_cache_key(self, request, view): } class LoginThrottle(SimpleRateThrottle): + """ Custom throttle to reset the cache counter on success. """ scope = 'login' From 8c0fb3d4fff31bc0e2a3162327415d1ae889db82 Mon Sep 17 00:00:00 2001 From: Spitap Date: Tue, 7 Feb 2023 12:47:58 +0100 Subject: [PATCH 11/14] 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 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 @@ 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 Meta: 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 @@ def get_renderer_context(self): 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 @@ def reset_password(self, request): 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 Meta: 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.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 @@ 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 @@ def delete(self, request, **kwargs): 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 @@ def random_address(self, request, **kwargs): }) -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 @@ def forward(self, request, **kwargs): 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 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 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 @@ 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 @@ def post(self, request, *args, **kwargs): 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 @@ def post(self, request, *args, **kwargs): "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 @@ def post(self, request, *args, **kwargs): 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 @@ def post(self, request, *args, **kwargs): "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 @@ def post(self, request, *args, **kwargs): 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.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 @@ 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 @@ def get_cache_key(self, request, view): '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 @@ def get_cache_key(self, request, view): '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 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 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 @@ def list(self, request, **kwargs): 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.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.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} From b79dffada8faf312b775d980c127ac402bc2864e Mon Sep 17 00:00:00 2001 From: Spitap Date: Wed, 8 Feb 2023 14:08:16 +0100 Subject: [PATCH 12/14] fixed test --- frontend/src/api/repository.js | 3 +-- frontend/src/views/Login.vue | 1 + modoboa/admin/api/v1/viewsets.py | 9 ++++----- modoboa/admin/api/v2/viewsets.py | 3 +-- modoboa/core/api/v1/viewsets.py | 1 - modoboa/core/api/v2/viewsets.py | 9 +++------ modoboa/lib/throttle.py | 10 +++++----- modoboa/parameters/api/v2/viewsets.py | 1 - modoboa/relaydomains/api/v1/viewsets.py | 1 - test_project/test_project/settings.py | 12 ++++++------ 10 files changed, 21 insertions(+), 29 deletions(-) diff --git a/frontend/src/api/repository.js b/frontend/src/api/repository.js index 56118ddf4..d4b0f9187 100644 --- a/frontend/src/api/repository.js +++ b/frontend/src/api/repository.js @@ -3,7 +3,6 @@ import Cookies from 'js-cookie' import router from '../router' import store from '../store' -import app from '../App.vue' const _axios = axios.create() @@ -33,7 +32,7 @@ _axios.interceptors.response.use( return Promise.reject(error) } if (error.response.status === 429) { - app.showNotification() + store.showNotification() return Promise.reject(error) } if (error.response.status !== 401 || router.currentRoute.path === '/login/') { diff --git a/frontend/src/views/Login.vue b/frontend/src/views/Login.vue index 24fb2d5fe..be4d4759c 100644 --- a/frontend/src/views/Login.vue +++ b/frontend/src/views/Login.vue @@ -89,6 +89,7 @@ export default { this.$router.push({ name: 'DomainList' }) }) }).catch(err => { + console.error(err) this.loading = false if (err.response.status === 401) { this.$refs.observer.setErrors({ diff --git a/modoboa/admin/api/v1/viewsets.py b/modoboa/admin/api/v1/viewsets.py index 211c2b241..e79fa97fd 100644 --- a/modoboa/admin/api/v1/viewsets.py +++ b/modoboa/admin/api/v1/viewsets.py @@ -11,13 +11,12 @@ from rest_framework.exceptions import ParseError from rest_framework.permissions import DjangoModelPermissions, IsAuthenticated from rest_framework.response import Response -from rest_framework.throttling import UserRateThrottle 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, GetThrottleViewsetMixin, PasswordResetRequestThrottle +from modoboa.lib.throttle import GetThrottleViewsetMixin, PasswordResetRequestThrottle from ... import lib, models from . import serializers @@ -91,11 +90,11 @@ class AccountViewSet(GetThrottleViewsetMixin, lib_viewsets.RevisionModelMixin, v def get_throttles(self): - throttle_classes = super().get_throttles() + throttles = super().get_throttles() if self.action == "reset_password": - throttle_classes.append(PasswordResetRequestThrottle()) + throttles.append(PasswordResetRequestThrottle()) - return throttle_classes + return throttles def get_serializer_class(self): """Return a serializer.""" diff --git a/modoboa/admin/api/v2/viewsets.py b/modoboa/admin/api/v2/viewsets.py index 667c72d10..f21348c84 100644 --- a/modoboa/admin/api/v2/viewsets.py +++ b/modoboa/admin/api/v2/viewsets.py @@ -11,13 +11,12 @@ ) from rest_framework.decorators import action from rest_framework.exceptions import PermissionDenied -from rest_framework.throttling import UserRateThrottle 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, GetThrottleViewsetMixin +from modoboa.lib.throttle import GetThrottleViewsetMixin from ... import lib from ... import models diff --git a/modoboa/core/api/v1/viewsets.py b/modoboa/core/api/v1/viewsets.py index 1b791e24c..7c4bf11f1 100644 --- a/modoboa/core/api/v1/viewsets.py +++ b/modoboa/core/api/v1/viewsets.py @@ -8,7 +8,6 @@ from rest_framework import permissions, response, viewsets from rest_framework.decorators import action -from rest_framework.throttling import UserRateThrottle from drf_spectacular.utils import extend_schema diff --git a/modoboa/core/api/v2/viewsets.py b/modoboa/core/api/v2/viewsets.py index b8fd3d090..24ba9adee 100644 --- a/modoboa/core/api/v2/viewsets.py +++ b/modoboa/core/api/v2/viewsets.py @@ -11,14 +11,13 @@ from rest_framework.authtoken.models import Token from rest_framework.decorators import action from rest_framework.exceptions import PermissionDenied -from rest_framework.throttling import UserRateThrottle from rest_framework_simplejwt.tokens import RefreshToken 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, GetThrottleViewsetMixin +from modoboa.lib.throttle import GetThrottleViewsetMixin from ... import constants from ... import models @@ -150,7 +149,7 @@ def tfa_setup_check(self, request): }) -class LogViewSet(viewsets.ReadOnlyModelViewSet): +class LogViewSet(GetThrottleViewsetMixin, viewsets.ReadOnlyModelViewSet): """Log viewset.""" filter_backends = [filters.OrderingFilter, filters.SearchFilter] @@ -164,16 +163,14 @@ class LogViewSet(viewsets.ReadOnlyModelViewSet): queryset = models.Log.objects.all() search_fields = ["logger", "level", "message"] serializer_class = serializers.LogSerializer - throttle_classes = [UserRateThrottle] -class LanguageViewSet(viewsets.ViewSet): +class LanguageViewSet(GetThrottleViewsetMixin, viewsets.ViewSet): """Language viewset.""" permission_classes = ( permissions.IsAuthenticated, ) - throttle_classes = [UserRateThrottle] def list(self, request, *args, **kwargs): languages = [ diff --git a/modoboa/lib/throttle.py b/modoboa/lib/throttle.py index 02fabebfd..c52de265d 100644 --- a/modoboa/lib/throttle.py +++ b/modoboa/lib/throttle.py @@ -70,10 +70,10 @@ class GetThrottleViewsetMixin(): def get_throttles(self): """Give lesser_ddos to GET type actions and ddos to others.""" - throttle_classes = [UserRateThrottle()] + throttles = [UserRateThrottle()] - if self.action in ["list", "retrieve", "validate", "dns_detail", "me", "dns_detail"]: - throttle_classes.append(UserLesserDdosUser()) + if self.action in ["list", "retrieve", "validate", "dns_detail", "me", "dns_detail", "applications", "structure"]: + throttles.append(UserLesserDdosUser()) else: - throttle_classes.append(UserDdosPerView()) - return throttle_classes \ No newline at end of file + throttles.append(UserDdosPerView()) + return throttles \ No newline at end of file diff --git a/modoboa/parameters/api/v2/viewsets.py b/modoboa/parameters/api/v2/viewsets.py index 97a22d518..84b087029 100644 --- a/modoboa/parameters/api/v2/viewsets.py +++ b/modoboa/parameters/api/v2/viewsets.py @@ -3,7 +3,6 @@ from drf_spectacular.utils import extend_schema, OpenApiParameter from rest_framework import response, viewsets from rest_framework.decorators import action -from rest_framework.throttling import UserRateThrottle from modoboa.lib.throttle import GetThrottleViewsetMixin diff --git a/modoboa/relaydomains/api/v1/viewsets.py b/modoboa/relaydomains/api/v1/viewsets.py index d6b552276..c47a92feb 100644 --- a/modoboa/relaydomains/api/v1/viewsets.py +++ b/modoboa/relaydomains/api/v1/viewsets.py @@ -2,7 +2,6 @@ from rest_framework import viewsets 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 GetThrottleViewsetMixin diff --git a/test_project/test_project/settings.py b/test_project/test_project/settings.py index 5a3d7b63e..f0d1b63de 100644 --- a/test_project/test_project/settings.py +++ b/test_project/test_project/settings.py @@ -187,13 +187,13 @@ REST_FRAMEWORK = { 'DEFAULT_THROTTLE_RATES': { - 'user': '400/minute', - 'ddos': '100/second', - 'ddos_lesser': '300/minute', + 'user': '200/minute', + 'ddos': '10/second', + 'ddos_lesser': '200/minute', 'login': '10/minute', - 'password_recovery_request': '6/hour', - 'password_recovery_totp_check': '40/hour', - 'password_recovery_apply': '40/hour' + 'password_recovery_request': '11/hour', + 'password_recovery_totp_check': '25/hour', + 'password_recovery_apply': '25/hour' }, 'DEFAULT_AUTHENTICATION_CLASSES': ( 'modoboa.core.drf_authentication.JWTAuthenticationWith2FA', From 2fd15745c903c81d19bd8bb8c006a0bb8e60420c Mon Sep 17 00:00:00 2001 From: Spitap Date: Thu, 9 Feb 2023 10:45:20 +0100 Subject: [PATCH 13/14] Fixed UX --- frontend/src/api/repository.js | 5 ++++- frontend/src/views/Login.vue | 5 ++++- frontend/src/views/user/PasswordRecoveryChangeForm.vue | 5 ++++- frontend/src/views/user/PasswordRecoveryForm.vue | 4 ++++ frontend/src/views/user/PasswordRecoverySmsTotpForm.vue | 4 ++++ 5 files changed, 20 insertions(+), 3 deletions(-) diff --git a/frontend/src/api/repository.js b/frontend/src/api/repository.js index d4b0f9187..d353dc1bd 100644 --- a/frontend/src/api/repository.js +++ b/frontend/src/api/repository.js @@ -3,8 +3,11 @@ import Cookies from 'js-cookie' import router from '../router' import store from '../store' +import { translate } from 'vue-gettext' +import { bus } from '@/main' const _axios = axios.create() +const { gettext: $gettext } = translate _axios.interceptors.request.use( function (config) { @@ -32,7 +35,7 @@ _axios.interceptors.response.use( return Promise.reject(error) } if (error.response.status === 429) { - store.showNotification() + bus.$emit('notification', { msg: $gettext('You are throttled, please try later.'), type: 'error' }) return Promise.reject(error) } if (error.response.status !== 401 || router.currentRoute.path === '/login/') { diff --git a/frontend/src/views/Login.vue b/frontend/src/views/Login.vue index be4d4759c..4141c18eb 100644 --- a/frontend/src/views/Login.vue +++ b/frontend/src/views/Login.vue @@ -89,12 +89,15 @@ export default { this.$router.push({ name: 'DomainList' }) }) }).catch(err => { - console.error(err) this.loading = false if (err.response.status === 401) { this.$refs.observer.setErrors({ password: this.$gettext('Invalid username and/or password') }) + } else if (err.response.status === 429) { + this.$refs.observer.setErrors({ + password: this.$gettext('Too many unsuccessful attempts, please try later.') + }) } }) } diff --git a/frontend/src/views/user/PasswordRecoveryChangeForm.vue b/frontend/src/views/user/PasswordRecoveryChangeForm.vue index 3055b9d13..2204bca68 100644 --- a/frontend/src/views/user/PasswordRecoveryChangeForm.vue +++ b/frontend/src/views/user/PasswordRecoveryChangeForm.vue @@ -81,7 +81,6 @@ export default { } else { const decodedId = atob(this.$route.params.id) if (!/^\d+$/.test(decodedId)) { - console.error('Received ID is invalid') this.$router.push({ name: 'PasswordRecoveryForm' }) } else { this.id = this.$route.params.id @@ -119,6 +118,10 @@ export default { err.response.data.errors.forEach(element => { message += this.$gettext(element) + '
' }) + } else if (err.response.status === 429) { + this.$refs.observer.setErrors({ + password: this.$gettext('Too many unsuccessful attempts, please try later.') + }) } this.password_validation_error = message }) diff --git a/frontend/src/views/user/PasswordRecoveryForm.vue b/frontend/src/views/user/PasswordRecoveryForm.vue index 516991229..d29c47490 100644 --- a/frontend/src/views/user/PasswordRecoveryForm.vue +++ b/frontend/src/views/user/PasswordRecoveryForm.vue @@ -110,6 +110,10 @@ export default { } } else if (err.response.status === 503 && err.response.data.type === 'email') { this.showDialog('Error', err.response.data.reason, true) + } else if (err.response.status === 429) { + this.$refs.observer.setErrors({ + password: this.$gettext('Too many unsuccessful attempts, please try later.') + }) } }) } diff --git a/frontend/src/views/user/PasswordRecoverySmsTotpForm.vue b/frontend/src/views/user/PasswordRecoverySmsTotpForm.vue index 2bcc55089..616f0b642 100644 --- a/frontend/src/views/user/PasswordRecoverySmsTotpForm.vue +++ b/frontend/src/views/user/PasswordRecoverySmsTotpForm.vue @@ -89,6 +89,10 @@ export default { if (err.response.status === 400) { this.loading = false this.showErrorDialog(this.$t('User seems wrong, return to login or restart reset the process?')) + } else if (err.response.status === 429) { + this.$refs.observer.setErrors({ + password: this.$gettext('Too many unsuccessful attempts, please try later.') + }) } }) }, From d0ec656f091df3b46f071b1649eb278d4e913e7b Mon Sep 17 00:00:00 2001 From: Spitap Date: Thu, 9 Feb 2023 10:52:25 +0100 Subject: [PATCH 14/14] fixed typo --- frontend/src/views/user/PasswordRecoveryChangeForm.vue | 4 +--- frontend/src/views/user/PasswordRecoveryForm.vue | 2 +- frontend/src/views/user/PasswordRecoverySmsTotpForm.vue | 4 ++-- 3 files changed, 4 insertions(+), 6 deletions(-) diff --git a/frontend/src/views/user/PasswordRecoveryChangeForm.vue b/frontend/src/views/user/PasswordRecoveryChangeForm.vue index 2204bca68..a50b26505 100644 --- a/frontend/src/views/user/PasswordRecoveryChangeForm.vue +++ b/frontend/src/views/user/PasswordRecoveryChangeForm.vue @@ -119,9 +119,7 @@ export default { message += this.$gettext(element) + '
' }) } else if (err.response.status === 429) { - this.$refs.observer.setErrors({ - password: this.$gettext('Too many unsuccessful attempts, please try later.') - }) + message = this.$gettext('Too many unsuccessful attempts, please try later.') } this.password_validation_error = message }) diff --git a/frontend/src/views/user/PasswordRecoveryForm.vue b/frontend/src/views/user/PasswordRecoveryForm.vue index d29c47490..e59b05099 100644 --- a/frontend/src/views/user/PasswordRecoveryForm.vue +++ b/frontend/src/views/user/PasswordRecoveryForm.vue @@ -112,7 +112,7 @@ export default { this.showDialog('Error', err.response.data.reason, true) } else if (err.response.status === 429) { this.$refs.observer.setErrors({ - password: this.$gettext('Too many unsuccessful attempts, please try later.') + email: this.$gettext('Too many unsuccessful attempts, please try later.') }) } }) diff --git a/frontend/src/views/user/PasswordRecoverySmsTotpForm.vue b/frontend/src/views/user/PasswordRecoverySmsTotpForm.vue index 616f0b642..163632f47 100644 --- a/frontend/src/views/user/PasswordRecoverySmsTotpForm.vue +++ b/frontend/src/views/user/PasswordRecoverySmsTotpForm.vue @@ -82,7 +82,7 @@ export default { this.loading = false if (resp.status === 200) { this.$refs.observer.setErrors({ - password_confirmed: this.$gettext('TOTP resent.') + sms_totp: this.$gettext('TOTP resent.') }) } }).catch(err => { @@ -91,7 +91,7 @@ export default { this.showErrorDialog(this.$t('User seems wrong, return to login or restart reset the process?')) } else if (err.response.status === 429) { this.$refs.observer.setErrors({ - password: this.$gettext('Too many unsuccessful attempts, please try later.') + sms_totp: this.$gettext('Too many unsuccessful attempts, please try later.') }) } })