Skip to content

Commit

Permalink
Merge pull request #2767 from modoboa/api-throttling
Browse files Browse the repository at this point in the history
Api throttling for rest framework
  • Loading branch information
tonioo committed Feb 10, 2023
2 parents 650e4d0 + d0ec656 commit 47d17ac
Show file tree
Hide file tree
Showing 22 changed files with 228 additions and 25 deletions.
33 changes: 32 additions & 1 deletion doc/upgrade.rst
Expand Up @@ -123,6 +123,7 @@ The following modifications must be applied to the :file:`settings.py` file:
},
]


* Add the following variable::

.. sourcecode:: python
Expand All @@ -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 <customization>` (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
=====
Expand Down
8 changes: 5 additions & 3 deletions frontend/src/App.vue
Expand Up @@ -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
}
}
}
}
Expand Down
7 changes: 7 additions & 0 deletions frontend/src/api/repository.js
Expand Up @@ -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) {
Expand All @@ -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)
}
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/views/Login.vue
Expand Up @@ -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.')
})
}
})
}
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/views/user/PasswordRecoveryChangeForm.vue
Expand Up @@ -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
Expand Down Expand Up @@ -119,6 +118,8 @@ export default {
err.response.data.errors.forEach(element => {
message += this.$gettext(element) + '<br>'
})
} else if (err.response.status === 429) {
message = this.$gettext('Too many unsuccessful attempts, please try later.')
}
this.password_validation_error = message
})
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/views/user/PasswordRecoveryForm.vue
Expand Up @@ -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.')
})
}
})
}
Expand Down
6 changes: 5 additions & 1 deletion frontend/src/views/user/PasswordRecoverySmsTotpForm.vue
Expand Up @@ -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.')
})
}
})
},
Expand Down
19 changes: 14 additions & 5 deletions modoboa/admin/api/v1/viewsets.py
Expand Up @@ -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
Expand All @@ -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, ]
Expand All @@ -60,7 +61,7 @@ class Meta:
fields = ["domain"]


class DomainAliasViewSet(lib_viewsets.RevisionModelMixin,
class DomainAliasViewSet(GetThrottleViewsetMixin, lib_viewsets.RevisionModelMixin,
viewsets.ModelViewSet):
"""ViewSet for DomainAlias."""

Expand All @@ -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 = {
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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."""

Expand Down
1 change: 1 addition & 0 deletions modoboa/admin/api/v2/tests.py
Expand Up @@ -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")
Expand Down
10 changes: 6 additions & 4 deletions modoboa/admin/api/v2/viewsets.py
Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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, )
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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, )
Expand Down
6 changes: 5 additions & 1 deletion modoboa/core/api/v1/viewsets.py
Expand Up @@ -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.
Expand Down
28 changes: 28 additions & 0 deletions modoboa/core/api/v2/views.py
Expand Up @@ -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
Expand All @@ -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:
Expand All @@ -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
)
Expand Down Expand Up @@ -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})
Expand All @@ -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)

Expand All @@ -114,13 +133,16 @@ 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)


class PasswordResetSmsTOTP(APIView):
""" Check SMS Totp code. """

throttle_classes = [PasswordResetTotpThrottle]

def post(self, request, *args, **kwargs):
try:
if request.data["type"] == "confirm":
Expand All @@ -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)
Expand All @@ -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()
Expand Down

0 comments on commit 47d17ac

Please sign in to comment.