feat(throttling): add auth throttling and structured cooldown errors
This commit is contained in:
@@ -1,5 +1,8 @@
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
from django.test import override_settings
|
||||
from rest_framework.test import APIRequestFactory
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APITestCase
|
||||
@@ -197,3 +200,189 @@ class UserApiViewTests(APITestCase):
|
||||
success = self.client.get(f"/api/users/search/?mobile={self.other_user.mobile}")
|
||||
self.assertEqual(success.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(success.data["mobile"], self.other_user.mobile)
|
||||
|
||||
|
||||
class UserThrottleTests(APITestCase):
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
cls.user = User.objects.create_user(
|
||||
mobile="09124440001",
|
||||
password="secret123",
|
||||
)
|
||||
|
||||
def setUp(self):
|
||||
cache.clear()
|
||||
|
||||
def tearDown(self):
|
||||
cache.clear()
|
||||
|
||||
@override_settings(
|
||||
REST_FRAMEWORK={
|
||||
**settings.REST_FRAMEWORK,
|
||||
"DEFAULT_THROTTLE_RATES": {
|
||||
**settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"],
|
||||
"login_password": "2/min",
|
||||
},
|
||||
}
|
||||
)
|
||||
@patch("apps.users.api.views.login_with_password")
|
||||
def test_password_login_returns_structured_429_with_retry_after(self, login_with_password):
|
||||
login_with_password.return_value = {"access": "a", "refresh": "r"}
|
||||
|
||||
first = self.client.post(
|
||||
"/api/users/login/",
|
||||
{"mobile": "09124440001", "password": "secret123"},
|
||||
format="json",
|
||||
REMOTE_ADDR="10.0.0.1",
|
||||
)
|
||||
second = self.client.post(
|
||||
"/api/users/login/",
|
||||
{"mobile": "09124440001", "password": "secret123"},
|
||||
format="json",
|
||||
REMOTE_ADDR="10.0.0.1",
|
||||
)
|
||||
throttled = self.client.post(
|
||||
"/api/users/login/",
|
||||
{"mobile": "09124440001", "password": "secret123"},
|
||||
format="json",
|
||||
REMOTE_ADDR="10.0.0.1",
|
||||
)
|
||||
|
||||
self.assertEqual(first.status_code, 200)
|
||||
self.assertEqual(second.status_code, 200)
|
||||
self.assertEqual(throttled.status_code, 429)
|
||||
self.assertEqual(throttled.data["code"], "throttled")
|
||||
self.assertEqual(throttled.data["scope"], "login_password")
|
||||
self.assertIsInstance(throttled.data["retry_after_seconds"], int)
|
||||
self.assertTrue(throttled.data["throttled_until"])
|
||||
self.assertIn("Retry-After", throttled.headers)
|
||||
|
||||
@override_settings(
|
||||
REST_FRAMEWORK={
|
||||
**settings.REST_FRAMEWORK,
|
||||
"DEFAULT_THROTTLE_RATES": {
|
||||
**settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"],
|
||||
"otp_send_burst": "1/min",
|
||||
"otp_send_sustained": "10/day",
|
||||
},
|
||||
}
|
||||
)
|
||||
@patch("apps.users.api.views.generate_and_send_otp")
|
||||
def test_otp_send_throttle_is_keyed_by_mobile_and_ip(self, generate_and_send_otp):
|
||||
first_mobile_first = self.client.post(
|
||||
"/api/users/otp/send/",
|
||||
{"mobile": "09124440011", "mode": "login"},
|
||||
format="json",
|
||||
REMOTE_ADDR="10.0.0.2",
|
||||
)
|
||||
second_mobile_first = self.client.post(
|
||||
"/api/users/otp/send/",
|
||||
{"mobile": "09124440012", "mode": "login"},
|
||||
format="json",
|
||||
REMOTE_ADDR="10.0.0.2",
|
||||
)
|
||||
first_mobile_second = self.client.post(
|
||||
"/api/users/otp/send/",
|
||||
{"mobile": "09124440011", "mode": "login"},
|
||||
format="json",
|
||||
REMOTE_ADDR="10.0.0.2",
|
||||
)
|
||||
|
||||
self.assertEqual(first_mobile_first.status_code, 200)
|
||||
self.assertEqual(second_mobile_first.status_code, 200)
|
||||
self.assertEqual(first_mobile_second.status_code, 429)
|
||||
self.assertEqual(first_mobile_second.data["scope"], "otp_send_burst")
|
||||
|
||||
@override_settings(
|
||||
REST_FRAMEWORK={
|
||||
**settings.REST_FRAMEWORK,
|
||||
"DEFAULT_THROTTLE_RATES": {
|
||||
**settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"],
|
||||
"login_otp": "1/min",
|
||||
},
|
||||
}
|
||||
)
|
||||
@patch("apps.users.api.views.login_with_otp")
|
||||
def test_otp_login_throttle_blocks_after_limit(self, login_with_otp):
|
||||
login_with_otp.return_value = {"access": "a", "refresh": "r"}
|
||||
|
||||
allowed = self.client.post(
|
||||
"/api/users/otp/login/",
|
||||
{"mobile": "09124440021", "code": "123456"},
|
||||
format="json",
|
||||
REMOTE_ADDR="10.0.0.3",
|
||||
)
|
||||
throttled = self.client.post(
|
||||
"/api/users/otp/login/",
|
||||
{"mobile": "09124440021", "code": "123456"},
|
||||
format="json",
|
||||
REMOTE_ADDR="10.0.0.3",
|
||||
)
|
||||
|
||||
self.assertEqual(allowed.status_code, 200)
|
||||
self.assertEqual(throttled.status_code, 429)
|
||||
self.assertEqual(throttled.data["scope"], "login_otp")
|
||||
|
||||
@patch.dict("rest_framework.throttling.AnonRateThrottle.THROTTLE_RATES", {"anon": "1/min"}, clear=False)
|
||||
@patch("apps.users.api.views.register_user_with_otp")
|
||||
def test_global_anon_throttle_applies_to_unrestricted_anonymous_endpoint(
|
||||
self,
|
||||
register_user_with_otp,
|
||||
):
|
||||
register_user_with_otp.return_value = {"access": "a", "refresh": "r"}
|
||||
|
||||
first = self.client.post(
|
||||
"/api/users/register/",
|
||||
{
|
||||
"mobile": "09124440031",
|
||||
"code": "12345",
|
||||
"password": "secret123",
|
||||
"re_password": "secret123",
|
||||
},
|
||||
format="json",
|
||||
REMOTE_ADDR="10.0.0.4",
|
||||
)
|
||||
throttled = self.client.post(
|
||||
"/api/users/register/",
|
||||
{
|
||||
"mobile": "09124440032",
|
||||
"code": "12345",
|
||||
"password": "secret123",
|
||||
"re_password": "secret123",
|
||||
},
|
||||
format="json",
|
||||
REMOTE_ADDR="10.0.0.4",
|
||||
)
|
||||
|
||||
self.assertEqual(first.status_code, 201)
|
||||
self.assertEqual(throttled.status_code, 429)
|
||||
self.assertEqual(throttled.data["code"], "throttled")
|
||||
|
||||
@override_settings(
|
||||
REST_FRAMEWORK={
|
||||
**settings.REST_FRAMEWORK,
|
||||
"DEFAULT_THROTTLE_RATES": {
|
||||
**settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"],
|
||||
"login_password": "1/min",
|
||||
},
|
||||
}
|
||||
)
|
||||
@patch("apps.users.api.views.login_with_password")
|
||||
def test_throttle_falls_back_to_ip_when_mobile_is_missing(self, login_with_password):
|
||||
login_with_password.return_value = {"access": "a", "refresh": "r"}
|
||||
|
||||
first = self.client.post(
|
||||
"/api/users/login/",
|
||||
{"password": "secret123"},
|
||||
format="json",
|
||||
REMOTE_ADDR="10.0.0.5",
|
||||
)
|
||||
second = self.client.post(
|
||||
"/api/users/login/",
|
||||
{"password": "secret123"},
|
||||
format="json",
|
||||
REMOTE_ADDR="10.0.0.5",
|
||||
)
|
||||
|
||||
self.assertEqual(first.status_code, 400)
|
||||
self.assertEqual(second.status_code, 429)
|
||||
|
||||
Reference in New Issue
Block a user