Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion backend/core/config/settings/drf.py
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
62 changes: 62 additions & 0 deletions backend/tests/user/api/test_user_viewsets.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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):
Expand Down
2 changes: 1 addition & 1 deletion backend/user/api/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@

@extend_schema(tags=['user : authentication'], summary='Login and retrieve tokens')
class DecoratedTokenObtainPairView(TokenObtainPairView):
pass
throttle_scope = 'auth_login'


urlpatterns = [
Expand Down
3 changes: 3 additions & 0 deletions backend/user/api/viewsets.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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=[],
Expand Down
Loading