diff --git a/apiserver/plane/authentication/adapter/error.py b/apiserver/plane/authentication/adapter/error.py index bf45f1d6a7..85fb69cfcf 100644 --- a/apiserver/plane/authentication/adapter/error.py +++ b/apiserver/plane/authentication/adapter/error.py @@ -61,6 +61,8 @@ AUTHENTICATION_ERROR_CODES = { "ADMIN_USER_ALREADY_EXIST": 5180, "ADMIN_USER_DOES_NOT_EXIST": 5185, "ADMIN_USER_DEACTIVATED": 5190, + # Rate limit + "RATE_LIMIT_EXCEEDED": 5900, } diff --git a/apiserver/plane/authentication/adapter/exception.py b/apiserver/plane/authentication/adapter/exception.py index 12845ea025..a6f7637a98 100644 --- a/apiserver/plane/authentication/adapter/exception.py +++ b/apiserver/plane/authentication/adapter/exception.py @@ -1,5 +1,10 @@ +# Third party imports from rest_framework.views import exception_handler from rest_framework.exceptions import NotAuthenticated +from rest_framework.exceptions import Throttled + +# Module imports +from plane.authentication.adapter.error import AuthenticationException, AUTHENTICATION_ERROR_CODES def auth_exception_handler(exc, context): @@ -9,4 +14,14 @@ def auth_exception_handler(exc, context): if isinstance(exc, NotAuthenticated): response.status_code = 401 + # Check if an Throttled exception is raised. + if isinstance(exc, Throttled): + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["RATE_LIMIT_EXCEEDED"], + error_message="RATE_LIMIT_EXCEEDED", + ) + response.data = exc.get_error_dict() + response.status_code = 429 + + # Return the response that is generated by the default exception handler. return response diff --git a/apiserver/plane/authentication/rate_limit.py b/apiserver/plane/authentication/rate_limit.py new file mode 100644 index 0000000000..744bd38fec --- /dev/null +++ b/apiserver/plane/authentication/rate_limit.py @@ -0,0 +1,26 @@ +# Third party imports +from rest_framework.throttling import AnonRateThrottle +from rest_framework import status +from rest_framework.response import Response + +# Module imports +from plane.authentication.adapter.error import ( + AuthenticationException, + AUTHENTICATION_ERROR_CODES, +) + + +class AuthenticationThrottle(AnonRateThrottle): + rate = "30/minute" + scope = "authentication" + + def throttle_failure_view(self, request, *args, **kwargs): + try: + raise AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["RATE_LIMIT_EXCEEDED"], + error_message="RATE_LIMIT_EXCEEDED", + ) + except AuthenticationException as e: + return Response( + e.get_error_dict(), status=status.HTTP_429_TOO_MANY_REQUESTS + ) diff --git a/apiserver/plane/authentication/views/app/check.py b/apiserver/plane/authentication/views/app/check.py index 4f164e3088..2448aee559 100644 --- a/apiserver/plane/authentication/views/app/check.py +++ b/apiserver/plane/authentication/views/app/check.py @@ -15,7 +15,7 @@ from plane.authentication.adapter.error import ( AuthenticationException, AUTHENTICATION_ERROR_CODES, ) - +from plane.authentication.rate_limit import AuthenticationThrottle class EmailCheckSignUpEndpoint(APIView): @@ -23,6 +23,10 @@ class EmailCheckSignUpEndpoint(APIView): AllowAny, ] + throttle_classes = [ + AuthenticationThrottle, + ] + def post(self, request): try: # Check instance configuration @@ -86,6 +90,10 @@ class EmailCheckSignInEndpoint(APIView): AllowAny, ] + throttle_classes = [ + AuthenticationThrottle, + ] + def post(self, request): try: # Check instance configuration diff --git a/apiserver/plane/authentication/views/app/password_management.py b/apiserver/plane/authentication/views/app/password_management.py index dd14ceb914..43054867ef 100644 --- a/apiserver/plane/authentication/views/app/password_management.py +++ b/apiserver/plane/authentication/views/app/password_management.py @@ -32,7 +32,7 @@ from plane.authentication.adapter.error import ( AuthenticationException, AUTHENTICATION_ERROR_CODES, ) - +from plane.authentication.rate_limit import AuthenticationThrottle def generate_password_token(user): uidb64 = urlsafe_base64_encode(smart_bytes(user.id)) @@ -46,6 +46,10 @@ class ForgotPasswordEndpoint(APIView): AllowAny, ] + throttle_classes = [ + AuthenticationThrottle, + ] + def post(self, request): email = request.data.get("email") diff --git a/apiserver/plane/authentication/views/common.py b/apiserver/plane/authentication/views/common.py index 640f744ceb..3e95d6ed88 100644 --- a/apiserver/plane/authentication/views/common.py +++ b/apiserver/plane/authentication/views/common.py @@ -1,3 +1,6 @@ +# Django imports +from django.shortcuts import render + # Third party imports from rest_framework import status from rest_framework.permissions import AllowAny @@ -17,7 +20,7 @@ from plane.authentication.adapter.error import ( ) from django.middleware.csrf import get_token from plane.utils.cache import invalidate_cache - +from plane.authentication.utils.host import base_host class CSRFTokenEndpoint(APIView): @@ -34,6 +37,11 @@ class CSRFTokenEndpoint(APIView): ) +def csrf_failure(request, reason=""): + """Custom CSRF failure view""" + return render(request, "csrf_failure.html", {"reason": reason, "root_url": base_host(request=request)}) + + class ChangePasswordEndpoint(APIView): def post(self, request): user = User.objects.get(pk=request.user.id) diff --git a/apiserver/plane/authentication/views/space/check.py b/apiserver/plane/authentication/views/space/check.py index 83f52e28f5..1b20d19a26 100644 --- a/apiserver/plane/authentication/views/space/check.py +++ b/apiserver/plane/authentication/views/space/check.py @@ -15,7 +15,7 @@ from plane.authentication.adapter.error import ( AUTHENTICATION_ERROR_CODES, AuthenticationException, ) - +from plane.authentication.rate_limit import AuthenticationThrottle class EmailCheckEndpoint(APIView): @@ -23,6 +23,10 @@ class EmailCheckEndpoint(APIView): AllowAny, ] + throttle_classes = [ + AuthenticationThrottle, + ] + def post(self, request): # Check instance configuration instance = Instance.objects.first() diff --git a/apiserver/plane/authentication/views/space/password_management.py b/apiserver/plane/authentication/views/space/password_management.py index fa20fa618b..3e0379b964 100644 --- a/apiserver/plane/authentication/views/space/password_management.py +++ b/apiserver/plane/authentication/views/space/password_management.py @@ -32,6 +32,7 @@ from plane.authentication.adapter.error import ( AuthenticationException, AUTHENTICATION_ERROR_CODES, ) +from plane.authentication.rate_limit import AuthenticationThrottle def generate_password_token(user): @@ -46,6 +47,10 @@ class ForgotPasswordSpaceEndpoint(APIView): AllowAny, ] + throttle_classes = [ + AuthenticationThrottle, + ] + def post(self, request): email = request.data.get("email") diff --git a/apiserver/plane/settings/common.py b/apiserver/plane/settings/common.py index d2a6b023a4..be040830de 100644 --- a/apiserver/plane/settings/common.py +++ b/apiserver/plane/settings/common.py @@ -348,6 +348,7 @@ CSRF_COOKIE_SECURE = secure_origins CSRF_COOKIE_HTTPONLY = True CSRF_TRUSTED_ORIGINS = cors_allowed_origins CSRF_COOKIE_DOMAIN = os.environ.get("COOKIE_DOMAIN", None) +CSRF_FAILURE_VIEW = "plane.authentication.views.common.csrf_failure" # Base URLs ADMIN_BASE_URL = os.environ.get("ADMIN_BASE_URL", None) diff --git a/apiserver/templates/csrf_failure.html b/apiserver/templates/csrf_failure.html new file mode 100644 index 0000000000..b5a58cb021 --- /dev/null +++ b/apiserver/templates/csrf_failure.html @@ -0,0 +1,66 @@ + + + +
+ + ++ It looks like your form submission has expired or there was a problem + with your request. +
+Please try the following:
+