From 6ed2c175ef90ea84df04f12d207b57b64dc01b9e Mon Sep 17 00:00:00 2001 From: Andrey Date: Tue, 23 Jun 2026 18:47:17 +0300 Subject: [PATCH] :lock: 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 --- apps/backend/hub_backend/settings.py | 6 ++++ .../hub_platform/identity/auth_views.py | 9 ++++++ apps/backend/hub_platform/identity/tests.py | 29 ++++++++++++++++++- 3 files changed, 43 insertions(+), 1 deletion(-) diff --git a/apps/backend/hub_backend/settings.py b/apps/backend/hub_backend/settings.py index 5b92e26..4bbe28e 100644 --- a/apps/backend/hub_backend/settings.py +++ b/apps/backend/hub_backend/settings.py @@ -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( diff --git a/apps/backend/hub_platform/identity/auth_views.py b/apps/backend/hub_platform/identity/auth_views.py index 9a8ae92..df1e131 100644 --- a/apps/backend/hub_platform/identity/auth_views.py +++ b/apps/backend/hub_platform/identity/auth_views.py @@ -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) diff --git a/apps/backend/hub_platform/identity/tests.py b/apps/backend/hub_platform/identity/tests.py index 87844f8..4a8fa73 100644 --- a/apps/backend/hub_platform/identity/tests.py +++ b/apps/backend/hub_platform/identity/tests.py @@ -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)