From f09014176dcb9697aa838f5dc9514d681ab32992 Mon Sep 17 00:00:00 2001 From: Jan Date: Sat, 5 Sep 2026 19:59:03 +0200 Subject: [PATCH 1/2] improve(user): throttle auth endpoints --- backend/core/config/settings/drf.py | 8 ++- backend/tests/user/api/test_user_viewsets.py | 55 ++++++++++++++++++++ backend/user/api/urls.py | 2 +- backend/user/api/viewsets.py | 3 ++ 4 files changed, 66 insertions(+), 2 deletions(-) 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..0f3f781 100644 --- a/backend/tests/user/api/test_user_viewsets.py +++ b/backend/tests/user/api/test_user_viewsets.py @@ -1,6 +1,8 @@ from unittest.mock import patch import pytest +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 +14,59 @@ 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'}} + + +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(5): + 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(5): + 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(5): + 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(5): + 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=[], From c0d1a097ec8c82816e7674b8828e76f93d2a9352 Mon Sep 17 00:00:00 2001 From: Jan Date: Sat, 5 Sep 2026 20:03:40 +0200 Subject: [PATCH 2/2] fix(test): throttle rates from settings --- backend/tests/user/api/test_user_viewsets.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/backend/tests/user/api/test_user_viewsets.py b/backend/tests/user/api/test_user_viewsets.py index 0f3f781..0298ea9 100644 --- a/backend/tests/user/api/test_user_viewsets.py +++ b/backend/tests/user/api/test_user_viewsets.py @@ -1,6 +1,7 @@ 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 @@ -18,6 +19,12 @@ 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() @@ -26,7 +33,7 @@ def setup_method(self): def test_login_is_throttled_after_limit(self, api_client): url = reverse('user:token_obtain_pair') - for _ in range(5): + for _ in range(throttle_limit('auth_login')): response = api_client.post(url, data={'username': 'nobody', 'password': 'wrong'}, format='json') assert response.status_code == 401 @@ -37,7 +44,7 @@ def test_login_is_throttled_after_limit(self, api_client): def test_register_is_throttled_after_limit(self, api_client): url = reverse('user:user_signup') - for _ in range(5): + for _ in range(throttle_limit('auth_register')): response = api_client.post(url, data={}, format='json') assert response.status_code == 400 @@ -49,7 +56,7 @@ def test_request_email_verification_is_throttled_after_limit(self, api_client, u user = user_factory(id=1, is_email_verified=True) url = reverse('user:user_request_email_verification') - for _ in range(5): + for _ in range(throttle_limit('auth_verify_email')): response = api_client.as_user(user).post(url) assert response.status_code == 400 @@ -60,7 +67,7 @@ def test_request_email_verification_is_throttled_after_limit(self, api_client, u def test_password_reset_request_is_throttled_after_limit(self, api_client): url = reverse('user:user_request_password_reset') - for _ in range(5): + 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