🔒 fix(security): rate-limit sensitive auth endpoints (#4)

- DRF ScopedRateThrottle on login (10/min), password-reset request/confirm
  (5/min) and TOTP verify (10/min); rates configurable, disabled under tests
- throttling backed by the Redis cache in production
- test asserts login is rejected with 429 after the limit

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
AndreyandClaude Opus 4.8 committed 2026-06-23 18:47:17 +03:00
1 parent f5bec6bf77
commit 6ed2c175ef
3 files changed
+43 -1

No files matched your search

+6
View File
@@ -162,12 +162,18 @@ STORAGES = {
}
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
# Лимиты на чувствительные эндпоинты (брутфорс/злоупотребление). В тестах отключены.
_THROTTLE_RATES = {"login": "10/min", "password_reset": "5/min", "totp": "10/min"}
if TESTING:
_THROTTLE_RATES = {scope: None for scope in _THROTTLE_RATES}
REST_FRAMEWORK = {
"DEFAULT_RENDERER_CLASSES": ["rest_framework.renderers.JSONRenderer"],
"DEFAULT_PARSER_CLASSES": ["rest_framework.parsers.JSONParser"],
"DEFAULT_AUTHENTICATION_CLASSES": ["rest_framework.authentication.SessionAuthentication"],
"DEFAULT_PERMISSION_CLASSES": ["rest_framework.permissions.IsAuthenticated"],
"EXCEPTION_HANDLER": "hub_platform.api.exceptions.api_exception_handler",
"DEFAULT_THROTTLE_RATES": _THROTTLE_RATES,
}
CORS_ALLOWED_ORIGINS = env_list(
@@ -20,6 +20,7 @@ from django.views.decorators.csrf import csrf_protect, ensure_csrf_cookie
from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.throttling import ScopedRateThrottle
from rest_framework.views import APIView
from hub_platform.identity.audit import record_audit_event
@@ -148,6 +149,8 @@ class SessionView(APIView):
class LoginView(APIView):
authentication_classes: list = []
permission_classes = [AllowAny]
throttle_classes = [ScopedRateThrottle]
throttle_scope = "login"
def post(self, request: Request) -> Response:
body = request.data
@@ -205,6 +208,8 @@ class LogoutView(APIView):
class PasswordResetRequestView(APIView):
authentication_classes: list = []
permission_classes = [AllowAny]
throttle_classes = [ScopedRateThrottle]
throttle_scope = "password_reset"
def post(self, request: Request) -> Response:
email = HumanUser.objects.normalize_email(str(request.data.get("email", "")).strip())
@@ -244,6 +249,8 @@ class PasswordResetValidateView(APIView):
class PasswordResetConfirmView(APIView):
authentication_classes: list = []
permission_classes = [AllowAny]
throttle_classes = [ScopedRateThrottle]
throttle_scope = "password_reset"
def post(self, request: Request) -> Response:
body = request.data
@@ -479,6 +486,8 @@ class TotpConfirmView(APIView):
class TotpVerifyView(APIView):
authentication_classes: list = []
permission_classes = [AllowAny]
throttle_classes = [ScopedRateThrottle]
throttle_scope = "totp"
def post(self, request: Request) -> Response:
pending_user_id = request.session.get(TOTP_SESSION_KEY)
+28 -1
View File
@@ -1,12 +1,16 @@
import json
from unittest import mock
from django.contrib.auth.tokens import default_token_generator
from django.core import mail
from django.test import Client, TestCase
from django.test import Client, TestCase, override_settings
from django.contrib.sessions.backends.db import SessionStore
from django.utils.encoding import force_bytes
from django.utils.http import urlsafe_base64_encode
from rest_framework.test import APIClient
from rest_framework.throttling import ScopedRateThrottle
_LOCMEM_CACHE = {"default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"}}
from hub_platform.identity.bootstrap import bootstrap_edevs_owner
from hub_platform.identity.auth_views import _totp_code
@@ -557,3 +561,26 @@ class TotpSecretEncryptionTests(TestCase):
profile.refresh_from_db()
self.assertEqual(profile.totp_secret, "JBSWY3DPEHPK3PXP")
@override_settings(CACHES=_LOCMEM_CACHE)
class ThrottlingTests(TestCase):
def setUp(self) -> None:
bootstrap_edevs_owner(email="owner@edevs.tech", password="temporary-password")
self.client = Client()
def _login(self):
return self.client.post(
"/api/v1/auth/login/",
data=json.dumps({"email": "owner@edevs.tech", "password": "wrong"}),
content_type="application/json",
)
def test_login_endpoint_is_rate_limited(self) -> None:
# DRF биндит THROTTLE_RATES на импорте, поэтому ставим лимит напрямую.
with mock.patch.dict(ScopedRateThrottle.THROTTLE_RATES, {"login": "1/min"}):
first = self._login()
second = self._login()
self.assertEqual(first.status_code, 401)
self.assertEqual(second.status_code, 429)