diff --git a/doc/upgrade.rst b/doc/upgrade.rst index 4762e58fe..5097873b8 100644 --- a/doc/upgrade.rst +++ b/doc/upgrade.rst @@ -123,6 +123,7 @@ The following modifications must be applied to the :file:`settings.py` file: }, ] + * Add the following variable:: .. sourcecode:: python @@ -141,10 +142,40 @@ The following modifications must be applied to the :file:`settings.py` file: }, -You now have the possibility to customize the url of the new-admin +* 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/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..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) { @@ -31,6 +34,10 @@ _axios.interceptors.response.use( router.push({ name: 'TwoFA' }) return Promise.reject(error) } + if (error.response.status === 429) { + 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/') { return Promise.reject(error) } diff --git a/frontend/src/views/Login.vue b/frontend/src/views/Login.vue index 24fb2d5fe..4141c18eb 100644 --- a/frontend/src/views/Login.vue +++ b/frontend/src/views/Login.vue @@ -94,6 +94,10 @@ export default { 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..a50b26505 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,8 @@ export default { err.response.data.errors.forEach(element => { message += this.$gettext(element) + '
' }) + } else if (err.response.status === 429) { + 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 516991229..e59b05099 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({ + 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 2bcc55089..163632f47 100644 --- a/frontend/src/views/user/PasswordRecoverySmsTotpForm.vue +++ b/frontend/src/views/user/PasswordRecoverySmsTotpForm.vue @@ -82,13 +82,17 @@ 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 => { 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({ + sms_totp: this.$gettext('Too many unsuccessful attempts, please try later.') + }) } }) }, diff --git a/modoboa/admin/api/v1/viewsets.py b/modoboa/admin/api/v1/viewsets.py index a2da94ba5..e79fa97fd 100644 --- a/modoboa/admin/api/v1/viewsets.py +++ b/modoboa/admin/api/v1/viewsets.py @@ -16,6 +16,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 GetThrottleViewsetMixin, PasswordResetRequestThrottle from ... import lib, models from . import serializers @@ -35,7 +36,7 @@ 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, ] @@ -60,7 +61,7 @@ class Meta: fields = ["domain"] -class DomainAliasViewSet(lib_viewsets.RevisionModelMixin, +class DomainAliasViewSet(GetThrottleViewsetMixin, lib_viewsets.RevisionModelMixin, viewsets.ModelViewSet): """ViewSet for DomainAlias.""" @@ -80,13 +81,21 @@ 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") + def get_throttles(self): + + throttles = super().get_throttles() + if self.action == "reset_password": + throttles.append(PasswordResetRequestThrottle()) + + return throttles + def get_serializer_class(self): """Return a serializer.""" action_dict = { @@ -175,7 +184,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. @@ -207,7 +216,7 @@ class Meta: fields = ["mailbox"] -class SenderAddressViewSet(lib_viewsets.RevisionModelMixin, +class SenderAddressViewSet(GetThrottleViewsetMixin, lib_viewsets.RevisionModelMixin, viewsets.ModelViewSet): """View set for SenderAddress model.""" diff --git a/modoboa/admin/api/v2/tests.py b/modoboa/admin/api/v2/tests.py index d1cec2bd2..b4235ad95 100644 --- a/modoboa/admin/api/v2/tests.py +++ b/modoboa/admin/api/v2/tests.py @@ -224,6 +224,7 @@ 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") diff --git a/modoboa/admin/api/v2/viewsets.py b/modoboa/admin/api/v2/viewsets.py index 4b717dda4..f21348c84 100644 --- a/modoboa/admin/api/v2/viewsets.py +++ b/modoboa/admin/api/v2/viewsets.py @@ -16,6 +16,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 GetThrottleViewsetMixin from ... import lib from ... import models @@ -40,7 +41,7 @@ summary="Delete a particular domain" ), ) -class DomainViewSet(lib_viewsets.RevisionModelMixin, +class DomainViewSet(GetThrottleViewsetMixin, lib_viewsets.RevisionModelMixin, mixins.ListModelMixin, mixins.RetrieveModelMixin, mixins.CreateModelMixin, @@ -160,6 +161,7 @@ class AccountViewSet(v1_viewsets.AccountViewSet): filter_backends = (filters.SearchFilter, dj_filters.DjangoFilterBackend) filterset_class = AccountFilterSet + def get_serializer_class(self): if self.action in ["create", "validate", "update", "partial_update"]: return serializers.WritableAccountSerializer @@ -208,7 +210,7 @@ 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, ) @@ -270,7 +272,7 @@ def random_address(self, request, **kwargs): }) -class UserAccountViewSet(viewsets.ViewSet): +class UserAccountViewSet(GetThrottleViewsetMixin, viewsets.ViewSet): """Viewset for current user operations.""" @action(methods=["get", "post"], detail=False) @@ -323,7 +325,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, ) diff --git a/modoboa/core/api/v1/viewsets.py b/modoboa/core/api/v1/viewsets.py index 7bee9079c..7c4bf11f1 100644 --- a/modoboa/core/api/v1/viewsets.py +++ b/modoboa/core/api/v1/viewsets.py @@ -5,14 +5,18 @@ 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 drf_spectacular.utils import extend_schema +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. diff --git a/modoboa/core/api/v2/views.py b/modoboa/core/api/v2/views.py index 7366e3161..f62425ada 100644 --- a/modoboa/core/api/v2/views.py +++ b/modoboa/core/api/v2/views.py @@ -16,6 +16,7 @@ from modoboa.core.password_hashers import get_password_hasher from modoboa.core.utils import check_for_updates +from modoboa.lib.throttle import UserLesserDdosUser, LoginThrottle, PasswordResetApplyThrottle, PasswordResetRequestThrottle, PasswordResetTotpThrottle from modoboa.parameters import tools as param_tools from smtplib import SMTPException @@ -25,9 +26,20 @@ 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.""" + throttle_classes = [LoginThrottle] + def post(self, request, *args, **kwargs): serializer = self.get_serializer(data=request.data) try: @@ -42,6 +54,10 @@ def post(self, request, *args, **kwargs): user = serializer.user login(request, user) + + # Reset login throttle + delete_cache_key(LoginThrottle, self.get_throttles(), request) + logger.info( _("User '%s' successfully logged in"), user.username ) @@ -85,6 +101,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 = [PasswordResetRequestThrottle] + def post(self, request, *args, **kwargs): serializer = serializers.PasswordRecoveryEmailSerializer( data=request.data, context={'request': request}) @@ -96,6 +114,7 @@ def post(self, request, *args, **kwargs): "type": "email", "reason": "Error while sending the email. Please contact an administrator." }, 503) + # Email response return response.Response({"type": "email"}, 200) @@ -114,6 +133,7 @@ def post(self, request, *args, **kwargs): serializer.is_valid(raise_exception=True) except serializers.NoSMSAvailable: return super().post(request, *args, **kwargs) + # SMS response return response.Response({"type": "sms"}, 200) @@ -121,6 +141,8 @@ def post(self, request, *args, **kwargs): class PasswordResetSmsTOTP(APIView): """ Check SMS Totp code. """ + throttle_classes = [PasswordResetTotpThrottle] + def post(self, request, *args, **kwargs): try: if request.data["type"] == "confirm": @@ -140,12 +162,15 @@ def post(self, request, *args, **kwargs): "id": serializer_response[1], "type": "confirm" }) + delete_cache_key(PasswordResetTotpThrottle, self.get_throttles(), request) return response.Response(payload, 200) class PasswordResetConfirmView(APIView): """ Get and set new user password. """ + throttle_classes = [PasswordResetApplyThrottle] + def post(self, request, *args, **kwargs): serializer = serializers.PasswordRecoveryConfirmSerializer( data=request.data) @@ -159,12 +184,15 @@ def post(self, request, *args, **kwargs): data.update({"errors": errors}) return response.Response(data, 400) serializer.save() + delete_cache_key(PasswordResetApplyThrottle, self.get_throttles(), request) return response.Response(status=200) class ComponentsInformationAPIView(APIView): """Retrieve information about installed components.""" + throttle_classes = [UserLesserDdosUser] + @extend_schema(responses=serializers.ModoboaComponentSerializer(many=True)) def get(self, request, *args, **kwargs): status, extensions = check_for_updates() diff --git a/modoboa/core/api/v2/viewsets.py b/modoboa/core/api/v2/viewsets.py index ef8c7b998..24ba9adee 100644 --- a/modoboa/core/api/v2/viewsets.py +++ b/modoboa/core/api/v2/viewsets.py @@ -17,6 +17,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 GetThrottleViewsetMixin from ... import constants from ... import models @@ -148,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,7 +165,7 @@ class LogViewSet(viewsets.ReadOnlyModelViewSet): serializer_class = serializers.LogSerializer -class LanguageViewSet(viewsets.ViewSet): +class LanguageViewSet(GetThrottleViewsetMixin, viewsets.ViewSet): """Language viewset.""" permission_classes = ( diff --git a/modoboa/core/commands/templates/settings.py.tpl b/modoboa/core/commands/templates/settings.py.tpl index 23ec4f89b..22bf058ee 100644 --- a/modoboa/core/commands/templates/settings.py.tpl +++ b/modoboa/core/commands/templates/settings.py.tpl @@ -193,6 +193,15 @@ MEDIA_ROOT = os.path.join(BASE_DIR, 'media') # Rest framework settings 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', diff --git a/modoboa/dnstools/api/v2/viewsets.py b/modoboa/dnstools/api/v2/viewsets.py index 4776e449c..881844d79 100644 --- a/modoboa/dnstools/api/v2/viewsets.py +++ b/modoboa/dnstools/api/v2/viewsets.py @@ -4,11 +4,12 @@ from rest_framework.decorators import action from modoboa.admin import models as admin_models +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, ) diff --git a/modoboa/lib/throttle.py b/modoboa/lib/throttle.py new file mode 100644 index 000000000..c52de265d --- /dev/null +++ b/modoboa/lib/throttle.py @@ -0,0 +1,79 @@ +from rest_framework.throttling import SimpleRateThrottle, UserRateThrottle +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.""" + + 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': hash(resolve(request.path).url_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': hash(resolve(request.path).url_name), + 'ident': ident + } + + +class LoginThrottle(SimpleRateThrottle): + """ Custom throttle to reset the cache counter on success. """ + + scope = 'login' + + 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, None) + 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' + + +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.""" + + throttles = [UserRateThrottle()] + + if self.action in ["list", "retrieve", "validate", "dns_detail", "me", "dns_detail", "applications", "structure"]: + throttles.append(UserLesserDdosUser()) + else: + throttles.append(UserDdosPerView()) + return throttles \ No newline at end of file diff --git a/modoboa/limits/api/v1/viewsets.py b/modoboa/limits/api/v1/viewsets.py index 6e6c6ad3f..2e3da9e5d 100644 --- a/modoboa/limits/api/v1/viewsets.py +++ b/modoboa/limits/api/v1/viewsets.py @@ -6,10 +6,11 @@ from rest_framework.permissions import DjangoModelPermissions, IsAuthenticated from modoboa.core import models as core_models +from modoboa.lib.throttle import GetThrottleViewsetMixin from . import serializers -class ResourcesViewSet( +class ResourcesViewSet(GetThrottleViewsetMixin, mixins.RetrieveModelMixin, mixins.UpdateModelMixin, viewsets.GenericViewSet): diff --git a/modoboa/maillog/api/v2/viewsets.py b/modoboa/maillog/api/v2/viewsets.py index b5bb1be8f..89d1d3c5a 100644 --- a/modoboa/maillog/api/v2/viewsets.py +++ b/modoboa/maillog/api/v2/viewsets.py @@ -9,13 +9,14 @@ from modoboa.admin import models as admin_models from modoboa.lib import pagination +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, ) @@ -51,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] diff --git a/modoboa/parameters/api/v2/viewsets.py b/modoboa/parameters/api/v2/viewsets.py index a6f802f6a..84b087029 100644 --- a/modoboa/parameters/api/v2/viewsets.py +++ b/modoboa/parameters/api/v2/viewsets.py @@ -4,11 +4,13 @@ from rest_framework import response, viewsets from rest_framework.decorators import action +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+" diff --git a/modoboa/relaydomains/api/v1/viewsets.py b/modoboa/relaydomains/api/v1/viewsets.py index 35841af6a..c47a92feb 100644 --- a/modoboa/relaydomains/api/v1/viewsets.py +++ b/modoboa/relaydomains/api/v1/viewsets.py @@ -4,11 +4,12 @@ from rest_framework.permissions import DjangoModelPermissions, IsAuthenticated from modoboa.admin import models as admin_models +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, ] diff --git a/modoboa/transport/api/v2/viewsets.py b/modoboa/transport/api/v2/viewsets.py index 28d2b005f..c8f4b82f0 100644 --- a/modoboa/transport/api/v2/viewsets.py +++ b/modoboa/transport/api/v2/viewsets.py @@ -3,11 +3,13 @@ from drf_spectacular.utils import extend_schema from rest_framework import permissions, response, viewsets +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, ) diff --git a/test_project/test_project/settings.py b/test_project/test_project/settings.py index 3cbab366e..f0d1b63de 100644 --- a/test_project/test_project/settings.py +++ b/test_project/test_project/settings.py @@ -186,6 +186,15 @@ # Rest framework settings REST_FRAMEWORK = { + 'DEFAULT_THROTTLE_RATES': { + 'user': '200/minute', + 'ddos': '10/second', + 'ddos_lesser': '200/minute', + 'login': '10/minute', + 'password_recovery_request': '11/hour', + 'password_recovery_totp_check': '25/hour', + 'password_recovery_apply': '25/hour' + }, 'DEFAULT_AUTHENTICATION_CLASSES': ( 'modoboa.core.drf_authentication.JWTAuthenticationWith2FA', 'rest_framework.authentication.TokenAuthentication',