Skip to content

Commit 95bd2e7

Browse files
authored
Merge pull request #651 from PROCOLLAB-github/codex/api-scoped-throttling
Add scoped throttling for public auth endpoints
2 parents e71527b + 3388e54 commit 95bd2e7

12 files changed

Lines changed: 347 additions & 3 deletions

File tree

.env.example

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,12 @@ EMAIL_PASSWORD=
88
EMAIL_HOST=
99
EMAIL_PORT=
1010

11+
DRF_THROTTLE_AUTH_REGISTER=5/min
12+
DRF_THROTTLE_AUTH_RESEND_EMAIL=3/min
13+
DRF_THROTTLE_AUTH_RESET_PASSWORD=3/min
14+
DRF_THROTTLE_TOKEN_OBTAIN=10/min
15+
DRF_THROTTLE_PROGRAM_REGISTER_NEW=10/min
16+
1117
SENTRY_DSN=
1218

1319
DATABASE_NAME=

core/throttling.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
from rest_framework.throttling import ScopedRateThrottle
2+
from rest_framework.settings import api_settings
3+
4+
5+
class PostOnlyScopedRateThrottle(ScopedRateThrottle):
6+
def get_rate(self):
7+
if not getattr(self, "scope", None):
8+
return None
9+
return api_settings.DEFAULT_THROTTLE_RATES.get(self.scope)
10+
11+
def allow_request(self, request, view):
12+
if request.method != "POST":
13+
return True
14+
return super().allow_request(request, view)

docs/api-safety-fix-plan.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# API Safety Fix Plan: Scoped Throttling
2+
3+
Дата: 2026-07-19.
4+
5+
Implemented in this PR:
6+
7+
- Added `PostOnlyScopedRateThrottle`, a small DRF throttle helper that applies scoped throttling only to `POST` requests.
8+
- Added scoped throttling to public/user-facing high-risk endpoints:
9+
- `POST /auth/users/`
10+
- `POST /auth/resend_email/`
11+
- `POST /auth/reset_password/`
12+
- `POST /api/token/`
13+
- `POST /programs/<id>/register_new/`
14+
- Added env-configurable rates in `REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]` without enabling global `DEFAULT_THROTTLE_CLASSES`.
15+
- Scoped rate keys are `auth_register`, `auth_resend_email`, `auth_reset_password`, `token_obtain`, and `program_register_new`.
16+
- Added `.env.example` entries for the new rates.
17+
- Added targeted tests that override each selected scope to `1/min` and assert the second request is throttled.
18+
19+
Not changed in this PR:
20+
21+
- No serializers, payload schemas, success response bodies, or existing business validation branches were changed.
22+
- No production/dev GitHub Actions, deploy files, release process, nginx, or proxy configuration were changed.
23+
- No global throttling was enabled for the rest of the API.
24+
25+
Proxy/IP note:
26+
27+
DRF throttles anonymous requests by client ident. The current backend has `SECURE_PROXY_SSL_HEADER`, but broader trusted proxy/IP handling must be coordinated with infrastructure before relying on IP throttles as an abuse boundary. Reverse proxy config should ensure client IP headers are overwritten by trusted infrastructure, not accepted from arbitrary clients.
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
from unittest.mock import patch
2+
3+
from django.conf import settings
4+
from django.core.cache import cache
5+
from django.test import TestCase, override_settings
6+
from rest_framework.test import APIClient
7+
8+
from partner_programs.tests.helpers import create_partner_program
9+
10+
11+
def throttle_settings(**rates):
12+
rest_framework = dict(settings.REST_FRAMEWORK)
13+
rest_framework["DEFAULT_THROTTLE_RATES"] = {
14+
**rest_framework.get("DEFAULT_THROTTLE_RATES", {}),
15+
**rates,
16+
}
17+
return rest_framework
18+
19+
20+
class PartnerProgramThrottleTests(TestCase):
21+
def setUp(self):
22+
cache.clear()
23+
self.client = APIClient()
24+
self.program = create_partner_program()
25+
26+
@override_settings(
27+
REST_FRAMEWORK=throttle_settings(program_register_new="1/min")
28+
)
29+
@patch("partner_programs.services.registration.send_email.delay")
30+
def test_external_program_registration_post_is_scoped_throttled(
31+
self,
32+
send_email_delay,
33+
):
34+
first_response = self.client.post(
35+
f"/programs/{self.program.id}/register_new/",
36+
{
37+
"email": "external-throttle-one@example.com",
38+
"password": "pass",
39+
"first_name": "External",
40+
"last_name": "User",
41+
"birthday": "01-01-1990",
42+
},
43+
format="json",
44+
REMOTE_ADDR="203.0.113.20",
45+
)
46+
second_response = self.client.post(
47+
f"/programs/{self.program.id}/register_new/",
48+
{
49+
"email": "external-throttle-two@example.com",
50+
"password": "pass",
51+
"first_name": "External",
52+
"last_name": "User",
53+
"birthday": "01-01-1990",
54+
},
55+
format="json",
56+
REMOTE_ADDR="203.0.113.20",
57+
)
58+
59+
self.assertEqual(first_response.status_code, 201)
60+
self.assertEqual(second_response.status_code, 429)
61+
send_email_delay.assert_called_once()

partner_programs/views.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515

1616
from core.serializers import EmptySerializer, SetLikedSerializer, SetViewedSerializer
1717
from core.services import add_view, set_like
18+
from core.throttling import PostOnlyScopedRateThrottle
1819
from core.utils import build_xlsx_download_response
1920
from partner_programs.models import (
2021
PartnerProgram,
@@ -182,6 +183,8 @@ class PartnerProgramCreateUserAndRegister(generics.GenericAPIView):
182183
permission_classes = [AllowAny]
183184
serializer_class = PartnerProgramNewUserSerializer
184185
queryset = PartnerProgram.objects.all()
186+
throttle_classes = [PostOnlyScopedRateThrottle]
187+
throttle_scope = "program_register_new"
185188

186189
def post(self, request, *args, **kwargs):
187190
data = request.data

procollab/settings.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,23 @@
163163
"rest_framework.renderers.BrowsableAPIRenderer",
164164
"rest_framework.renderers.AdminRenderer",
165165
],
166+
"DEFAULT_THROTTLE_RATES": {
167+
"auth_register": config(
168+
"DRF_THROTTLE_AUTH_REGISTER", default="5/min", cast=str
169+
),
170+
"auth_resend_email": config(
171+
"DRF_THROTTLE_AUTH_RESEND_EMAIL", default="3/min", cast=str
172+
),
173+
"auth_reset_password": config(
174+
"DRF_THROTTLE_AUTH_RESET_PASSWORD", default="3/min", cast=str
175+
),
176+
"token_obtain": config(
177+
"DRF_THROTTLE_TOKEN_OBTAIN", default="10/min", cast=str
178+
),
179+
"program_register_new": config(
180+
"DRF_THROTTLE_PROGRAM_REGISTER_NEW", default="10/min", cast=str
181+
),
182+
},
166183
}
167184

168185
ASGI_APPLICATION = "procollab.asgi.application"

procollab/urls.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,11 @@
66
from drf_yasg.views import get_schema_view
77
from rest_framework import authentication, permissions
88
from rest_framework_simplejwt.views import (
9-
TokenObtainPairView,
109
TokenRefreshView,
1110
TokenVerifyView,
1211
)
1312
from users.authentication import ActivityTrackingJWTAuthentication
13+
from users.token_views import ThrottledTokenObtainPairView
1414

1515
schema_view = get_schema_view(
1616
openapi.Info(
@@ -58,7 +58,11 @@
5858
path("courses/", include("courses.urls", namespace="courses")),
5959
path("rate-project/", include(("project_rates.urls", "rate_projects"))),
6060
path("feed/", include("feed.urls", namespace="feed")),
61-
path("api/token/", TokenObtainPairView.as_view(), name="token_obtain_pair"),
61+
path(
62+
"api/token/",
63+
ThrottledTokenObtainPairView.as_view(),
64+
name="token_obtain_pair",
65+
),
6266
path("api/token/refresh/", TokenRefreshView.as_view(), name="token_refresh"),
6367
path("api/token/verify/", TokenVerifyView.as_view(), name="token_verify"),
6468
path("", include("metrics.urls", namespace="metrics")),

users/password_reset_urls.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
from django.urls import path
2+
from django_rest_passwordreset.views import (
3+
ResetPasswordConfirm,
4+
ResetPasswordRequestToken,
5+
ResetPasswordValidateToken,
6+
)
7+
8+
from core.throttling import PostOnlyScopedRateThrottle
9+
10+
app_name = "password_reset"
11+
12+
13+
class ThrottledResetPasswordRequestToken(ResetPasswordRequestToken):
14+
throttle_classes = [PostOnlyScopedRateThrottle]
15+
throttle_scope = "auth_reset_password"
16+
17+
18+
urlpatterns = [
19+
path("", ThrottledResetPasswordRequestToken.as_view(), name="reset-password-request"),
20+
path("confirm/", ResetPasswordConfirm.as_view(), name="reset-password-confirm"),
21+
path(
22+
"validate_token/",
23+
ResetPasswordValidateToken.as_view(),
24+
name="reset-password-validate",
25+
),
26+
]
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
from unittest.mock import patch
2+
3+
from django.conf import settings
4+
from django.core.cache import cache
5+
from django.test import TestCase, override_settings
6+
from rest_framework.permissions import AllowAny
7+
from rest_framework.response import Response
8+
from rest_framework.test import APIClient, APIRequestFactory
9+
from rest_framework.views import APIView
10+
11+
from core.throttling import PostOnlyScopedRateThrottle
12+
from tests.constants import USER_CREATE_DATA
13+
from users.tests.helpers import build_user
14+
15+
16+
def throttle_settings(**rates):
17+
rest_framework = dict(settings.REST_FRAMEWORK)
18+
rest_framework["DEFAULT_THROTTLE_RATES"] = {
19+
**rest_framework.get("DEFAULT_THROTTLE_RATES", {}),
20+
**rates,
21+
}
22+
return rest_framework
23+
24+
25+
class DummyPostOnlyThrottleView(APIView):
26+
permission_classes = [AllowAny]
27+
throttle_classes = [PostOnlyScopedRateThrottle]
28+
throttle_scope = "auth_register"
29+
30+
def get(self, _request):
31+
return Response({"ok": True})
32+
33+
def post(self, _request):
34+
return Response({"ok": True})
35+
36+
37+
class AuthThrottleTests(TestCase):
38+
def setUp(self):
39+
cache.clear()
40+
self.client = APIClient()
41+
self.factory = APIRequestFactory()
42+
43+
@override_settings(
44+
REST_FRAMEWORK=throttle_settings(auth_register="1/min")
45+
)
46+
@patch("users.views.verify_email")
47+
def test_user_registration_post_is_scoped_throttled(self, verify_email_mock):
48+
first_response = self.client.post(
49+
"/auth/users/",
50+
USER_CREATE_DATA,
51+
format="json",
52+
REMOTE_ADDR="203.0.113.10",
53+
)
54+
second_payload = {
55+
**USER_CREATE_DATA,
56+
"email": "second-user@example.com",
57+
}
58+
second_response = self.client.post(
59+
"/auth/users/",
60+
second_payload,
61+
format="json",
62+
REMOTE_ADDR="203.0.113.10",
63+
)
64+
65+
self.assertEqual(first_response.status_code, 201)
66+
self.assertEqual(second_response.status_code, 429)
67+
verify_email_mock.assert_called_once()
68+
69+
@override_settings(
70+
REST_FRAMEWORK=throttle_settings(auth_resend_email="1/min")
71+
)
72+
@patch("users.views.verify_email")
73+
def test_resend_verify_email_post_is_scoped_throttled(self, verify_email_mock):
74+
user = build_user(email="inactive-throttle@example.com", is_active=False)
75+
76+
first_response = self.client.post(
77+
"/auth/resend_email/",
78+
{"email": user.email},
79+
format="json",
80+
REMOTE_ADDR="203.0.113.11",
81+
)
82+
second_response = self.client.post(
83+
"/auth/resend_email/",
84+
{"email": user.email},
85+
format="json",
86+
REMOTE_ADDR="203.0.113.11",
87+
)
88+
89+
self.assertEqual(first_response.status_code, 200)
90+
self.assertEqual(second_response.status_code, 429)
91+
verify_email_mock.assert_called_once()
92+
93+
@override_settings(
94+
REST_FRAMEWORK=throttle_settings(token_obtain="1/min")
95+
)
96+
def test_token_obtain_post_is_scoped_throttled(self):
97+
user = build_user(email="token-throttle@example.com")
98+
payload = {"email": user.email, "password": "very_strong_password"}
99+
100+
first_response = self.client.post(
101+
"/api/token/",
102+
payload,
103+
format="json",
104+
REMOTE_ADDR="203.0.113.12",
105+
)
106+
second_response = self.client.post(
107+
"/api/token/",
108+
payload,
109+
format="json",
110+
REMOTE_ADDR="203.0.113.12",
111+
)
112+
113+
self.assertEqual(first_response.status_code, 200)
114+
self.assertEqual(second_response.status_code, 429)
115+
116+
@override_settings(
117+
REST_FRAMEWORK=throttle_settings(auth_reset_password="1/min")
118+
)
119+
@patch("users.signals.EmailMultiAlternatives")
120+
def test_password_reset_request_post_is_scoped_throttled(self, _email_message):
121+
user = build_user(email="reset-throttle@example.com")
122+
123+
first_response = self.client.post(
124+
"/auth/reset_password/",
125+
{"email": user.email},
126+
format="json",
127+
REMOTE_ADDR="203.0.113.13",
128+
)
129+
second_response = self.client.post(
130+
"/auth/reset_password/",
131+
{"email": user.email},
132+
format="json",
133+
REMOTE_ADDR="203.0.113.13",
134+
)
135+
136+
self.assertNotEqual(first_response.status_code, 429)
137+
self.assertEqual(second_response.status_code, 429)
138+
139+
@override_settings(
140+
REST_FRAMEWORK=throttle_settings(auth_register="1/min")
141+
)
142+
def test_post_only_throttle_allows_get_and_options(self):
143+
view = DummyPostOnlyThrottleView.as_view()
144+
145+
for method in ("get", "options"):
146+
for _ in range(2):
147+
response = view(
148+
getattr(self.factory, method)(
149+
"/unused/",
150+
REMOTE_ADDR="203.0.113.14",
151+
)
152+
)
153+
self.assertNotEqual(response.status_code, 429)
154+
155+
first_response = view(
156+
self.factory.post(
157+
"/unused/",
158+
{},
159+
format="json",
160+
REMOTE_ADDR="203.0.113.14",
161+
)
162+
)
163+
second_response = view(
164+
self.factory.post(
165+
"/unused/",
166+
{},
167+
format="json",
168+
REMOTE_ADDR="203.0.113.14",
169+
)
170+
)
171+
172+
self.assertEqual(first_response.status_code, 200)
173+
self.assertEqual(second_response.status_code, 429)

users/token_views.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
from rest_framework_simplejwt.views import TokenObtainPairView
2+
3+
from core.throttling import PostOnlyScopedRateThrottle
4+
5+
6+
class ThrottledTokenObtainPairView(TokenObtainPairView):
7+
throttle_classes = [PostOnlyScopedRateThrottle]
8+
throttle_scope = "token_obtain"

0 commit comments

Comments
 (0)