diff --git a/backend/core/config/settings/drf.py b/backend/core/config/settings/drf.py index 46cf435..ae622d9 100644 --- a/backend/core/config/settings/drf.py +++ b/backend/core/config/settings/drf.py @@ -16,7 +16,13 @@ 'DEFAULT_THROTTLE_CLASSES': [ 'rest_framework.throttling.ScopedRateThrottle', ], - 'DEFAULT_THROTTLE_RATES': {'webhook_inbound': '3/sec'}, + 'DEFAULT_THROTTLE_RATES': { + 'webhook_inbound': '3/sec', + 'auth_login': '10/hour', + 'auth_register': '5/min', + 'auth_verify_email': '5/min', + 'auth_password_reset': '5/min', + }, 'DEFAULT_RENDERER_CLASSES': [ 'api.renderers.OrjsonRenderer', 'rest_framework.renderers.BrowsableAPIRenderer', diff --git a/backend/tests/user/api/test_user_viewsets.py b/backend/tests/user/api/test_user_viewsets.py index 51d2dc7..0298ea9 100644 --- a/backend/tests/user/api/test_user_viewsets.py +++ b/backend/tests/user/api/test_user_viewsets.py @@ -1,6 +1,9 @@ from unittest.mock import patch import pytest +from django.conf import settings +from django.core.cache import cache +from django.test import override_settings from django.urls import reverse from model_bakery import baker from planning.models import PlanningCX, PlanningEmpire @@ -12,6 +15,65 @@ pytestmark = pytest.mark.django_db +# Test settings use DummyCache, which never throttles; ScopedRateThrottle needs a real cache backend. +LOCMEM_CACHES = {'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', 'LOCATION': 'throttle-test'}} + + +def throttle_limit(scope: str) -> int: + """Number of requests allowed for a scope before ScopedRateThrottle returns 429.""" + rate = settings.REST_FRAMEWORK['DEFAULT_THROTTLE_RATES'][scope] + return int(rate.split('/')[0]) + + +class TestAuthEndpointThrottling: + def setup_method(self): + cache.clear() + + @override_settings(CACHES=LOCMEM_CACHES) + def test_login_is_throttled_after_limit(self, api_client): + url = reverse('user:token_obtain_pair') + + for _ in range(throttle_limit('auth_login')): + response = api_client.post(url, data={'username': 'nobody', 'password': 'wrong'}, format='json') + assert response.status_code == 401 + + response = api_client.post(url, data={'username': 'nobody', 'password': 'wrong'}, format='json') + assert response.status_code == 429 + + @override_settings(CACHES=LOCMEM_CACHES) + def test_register_is_throttled_after_limit(self, api_client): + url = reverse('user:user_signup') + + for _ in range(throttle_limit('auth_register')): + response = api_client.post(url, data={}, format='json') + assert response.status_code == 400 + + response = api_client.post(url, data={}, format='json') + assert response.status_code == 429 + + @override_settings(CACHES=LOCMEM_CACHES) + def test_request_email_verification_is_throttled_after_limit(self, api_client, user_factory): + user = user_factory(id=1, is_email_verified=True) + url = reverse('user:user_request_email_verification') + + for _ in range(throttle_limit('auth_verify_email')): + response = api_client.as_user(user).post(url) + assert response.status_code == 400 + + response = api_client.as_user(user).post(url) + assert response.status_code == 429 + + @override_settings(CACHES=LOCMEM_CACHES) + def test_password_reset_request_is_throttled_after_limit(self, api_client): + url = reverse('user:user_request_password_reset') + + for _ in range(throttle_limit('auth_password_reset')): + response = api_client.post(url, data={'email': 'nobody@example.com'}, format='json') + assert response.status_code == 200 + + response = api_client.post(url, data={'email': 'nobody@example.com'}, format='json') + assert response.status_code == 429 + class TestUserPreferenceViewSet: def test_retrieve_requires_auth(self, api_client): diff --git a/backend/user/api/urls.py b/backend/user/api/urls.py index 0373e27..bb370d0 100644 --- a/backend/user/api/urls.py +++ b/backend/user/api/urls.py @@ -19,7 +19,7 @@ @extend_schema(tags=['user : authentication'], summary='Login and retrieve tokens') class DecoratedTokenObtainPairView(TokenObtainPairView): - pass + throttle_scope = 'auth_login' urlpatterns = [ diff --git a/backend/user/api/viewsets.py b/backend/user/api/viewsets.py index 8a17008..f8317f7 100644 --- a/backend/user/api/viewsets.py +++ b/backend/user/api/viewsets.py @@ -55,6 +55,7 @@ def update(self, request, *args, **kwargs): class UserRegisterViewSet(mixins.CreateModelMixin, viewsets.GenericViewSet): serializer_class = UserRegisterSerializer permission_classes = [AllowAny] + throttle_scope = 'auth_register' def perform_create(self, serializer): serializer.save() @@ -107,6 +108,7 @@ def create(self, request, *args, **kwargs): @extend_schema(tags=['user : authentication']) class UserEmailVerificationViewSet(viewsets.ViewSet): permission_classes = [IsAuthenticated] + throttle_scope = 'auth_verify_email' @extend_schema( request=None, @@ -179,6 +181,7 @@ def post(self, request, *args, **kwargs): @extend_schema(tags=['user : authentication']) class UserPasswordResetViewSet(viewsets.ViewSet): permission_classes = [AllowAny] + throttle_scope = 'auth_password_reset' @extend_schema( auth=[],