throttling, abuse-protection & logging/monitoring pass
This commit is contained in:
@@ -10,6 +10,22 @@ from rest_framework.exceptions import APIException
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _phone_suffix(phone: str) -> str:
|
||||
"""
|
||||
⚠️ اصلاح: قبلاً لاگهای مربوط به OTP، شمارهتلفنِ کامل رو لاگ میکردن
|
||||
(PIIِ غیرضروری در لاگهای اپلیکیشن). حالا فقط ۴ رقمِ آخر (برای
|
||||
correlation/دیباگ، بدونِ افشای کاملِ شماره) لاگ میشه — همون الگویی که
|
||||
قبلاً در accounts/views.py::LoginView هم برای همین منظور استفاده شده.
|
||||
|
||||
⚠️ اصلاحِ این دور: لاگهای خطای ارائهدهنده (provider) دیگه
|
||||
response.text خام رو لاگ نمیکنن — چون بدنهی پاسخِ کاوهنگار میتونه
|
||||
دادهی حساس/غیرضروری (مثلاً جزئیاتِ گیرنده یا پیامِ متنی) داشته باشه.
|
||||
بهجاش فقط context مفید و بیخطر (phone_suffix، status، provider_status)
|
||||
لاگ میشه.
|
||||
"""
|
||||
return phone[-4:] if phone else phone
|
||||
|
||||
|
||||
class OTPDeliveryError(APIException):
|
||||
"""
|
||||
⚠️ اصلاح: قبلاً send_otp_sms یک استاب بود که فقط لاگ میکرد و همیشه
|
||||
@@ -44,11 +60,17 @@ def send_otp_sms(*, phone: str, code: str) -> None:
|
||||
میگیره، نه یک success دروغین).
|
||||
"""
|
||||
if settings.DEBUG:
|
||||
logger.info('OTP sms dispatch (dev, no real SMS sent): phone=%s code_len=%d', phone, len(code))
|
||||
logger.info(
|
||||
'OTP sms dispatch (dev, no real SMS sent): phone_suffix=%s code_len=%d',
|
||||
_phone_suffix(phone), len(code),
|
||||
)
|
||||
return
|
||||
|
||||
if not settings.KAVENEGAR_API_KEY:
|
||||
logger.error('OTP SMS misconfigured: KAVENEGAR_API_KEY is not set (phone=%s)', phone)
|
||||
logger.error(
|
||||
'OTP SMS misconfigured: KAVENEGAR_API_KEY is not set (phone_suffix=%s)',
|
||||
_phone_suffix(phone),
|
||||
)
|
||||
raise OTPDeliveryError('پیکربندیِ سرویسِ پیامک ناقص است.')
|
||||
|
||||
message = settings.OTP_SMS_TEMPLATE.format(code=code)
|
||||
@@ -63,13 +85,13 @@ def send_otp_sms(*, phone: str, code: str) -> None:
|
||||
timeout=5,
|
||||
)
|
||||
except requests.RequestException:
|
||||
logger.exception('OTP SMS request to provider failed: phone=%s', phone)
|
||||
logger.exception('OTP SMS request to provider failed: phone_suffix=%s', _phone_suffix(phone))
|
||||
raise OTPDeliveryError('ارسال پیامک OTP با خطا مواجه شد.')
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.error(
|
||||
'OTP SMS provider returned non-200: phone=%s status=%s body=%s',
|
||||
phone, response.status_code, response.text[:500],
|
||||
'OTP SMS provider returned non-200: phone_suffix=%s status=%s',
|
||||
_phone_suffix(phone), response.status_code,
|
||||
)
|
||||
raise OTPDeliveryError('ارسال پیامک OTP با خطا مواجه شد.')
|
||||
|
||||
@@ -85,15 +107,15 @@ def send_otp_sms(*, phone: str, code: str) -> None:
|
||||
provider_status = body['return']['status']
|
||||
except (ValueError, KeyError, TypeError):
|
||||
logger.error(
|
||||
'OTP SMS provider returned malformed response body: phone=%s body=%s',
|
||||
phone, response.text[:500],
|
||||
'OTP SMS provider returned malformed response body: phone_suffix=%s status=%s',
|
||||
_phone_suffix(phone), response.status_code,
|
||||
)
|
||||
raise OTPDeliveryError('ارسال پیامک OTP با خطا مواجه شد.')
|
||||
|
||||
if provider_status != 200:
|
||||
logger.error(
|
||||
'OTP SMS provider rejected message at application level: phone=%s '
|
||||
'provider_status=%s body=%s',
|
||||
phone, provider_status, response.text[:500],
|
||||
'OTP SMS provider rejected message at application level: phone_suffix=%s '
|
||||
'provider_status=%s',
|
||||
_phone_suffix(phone), provider_status,
|
||||
)
|
||||
raise OTPDeliveryError('ارسال پیامک OTP با خطا مواجه شد.')
|
||||
|
||||
@@ -2,7 +2,7 @@ from unittest.mock import Mock, patch
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
from django.test import TestCase, override_settings
|
||||
from django.test import TestCase, TransactionTestCase, override_settings
|
||||
from django.utils import timezone
|
||||
from rest_framework.test import APIClient
|
||||
from rest_framework.throttling import ScopedRateThrottle
|
||||
@@ -90,6 +90,71 @@ class SendOtpSmsTests(TestCase):
|
||||
with self.assertRaises(OTPDeliveryError):
|
||||
send_otp_sms(phone='+989120000000', code='1234')
|
||||
|
||||
@override_settings(DEBUG=True)
|
||||
def test_dev_mode_log_never_contains_full_phone_only_safe_suffix(self):
|
||||
# 🔒 اثبات فیکسِ این دور: لاگِ OTP نباید هیچوقت شمارهتلفنِ کامل رو
|
||||
# نشون بده؛ فقط ۴ رقمِ آخر (شناسهی امن) باید در پیامِ لاگ باشه.
|
||||
phone = '+989120000000'
|
||||
with self.assertLogs('accounts.services', level='INFO') as captured:
|
||||
send_otp_sms(phone=phone, code='1234')
|
||||
|
||||
self.assertEqual(len(captured.records), 1)
|
||||
message = captured.records[0].getMessage()
|
||||
|
||||
self.assertNotIn(phone, message)
|
||||
self.assertIn('phone_suffix=0000', message)
|
||||
# رفتار/سطح/کدِ قبلی حفظ شده: کدِ OTP هیچوقت لاگ نمیشه، فقط طولش.
|
||||
self.assertNotIn('1234', message)
|
||||
self.assertIn('code_len=4', message)
|
||||
|
||||
@override_settings(DEBUG=False, KAVENEGAR_API_KEY='test-key')
|
||||
def test_provider_error_logs_never_contain_raw_response_body(self):
|
||||
# 🔒 اثبات فیکسِ این دور: لاگهای خطای provider دیگه response.text
|
||||
# خام رو شامل نمیشن؛ فقط context مفید و بیخطر (phone_suffix،
|
||||
# status، provider_status) باقی میمونه.
|
||||
|
||||
# حالتِ ۱: HTTP غیرِ ۲۰۰.
|
||||
mock_response = Mock(status_code=400, text='bad request body secret-ish')
|
||||
with patch('accounts.services.requests.post', return_value=mock_response):
|
||||
with self.assertLogs('accounts.services', level='ERROR') as captured:
|
||||
with self.assertRaises(OTPDeliveryError):
|
||||
send_otp_sms(phone='+989120000000', code='1234')
|
||||
message = captured.records[0].getMessage()
|
||||
self.assertNotIn('bad request body secret-ish', message)
|
||||
self.assertIn('phone_suffix=0000', message)
|
||||
self.assertIn('status=400', message)
|
||||
|
||||
# حالتِ ۲: بدنهی نامعتبر/غیرقابلparse.
|
||||
mock_response = Mock(status_code=200, text='not json body secret-ish')
|
||||
mock_response.json.side_effect = ValueError('No JSON object could be decoded')
|
||||
with patch('accounts.services.requests.post', return_value=mock_response):
|
||||
with self.assertLogs('accounts.services', level='ERROR') as captured:
|
||||
with self.assertRaises(OTPDeliveryError):
|
||||
send_otp_sms(phone='+989120000000', code='1234')
|
||||
message = captured.records[0].getMessage()
|
||||
self.assertNotIn('not json body secret-ish', message)
|
||||
self.assertIn('phone_suffix=0000', message)
|
||||
self.assertIn('status=200', message)
|
||||
|
||||
# حالتِ ۳: ردِ اپلیکیشنی (HTTP 200، return.status != 200).
|
||||
mock_response = Mock(
|
||||
status_code=200,
|
||||
text='{"return": {"status": 401, "message": "secret-ish detail"}, "entries": []}',
|
||||
)
|
||||
mock_response.json.return_value = {
|
||||
'return': {'status': 401, 'message': 'secret-ish detail'},
|
||||
'entries': [],
|
||||
}
|
||||
with patch('accounts.services.requests.post', return_value=mock_response):
|
||||
with self.assertLogs('accounts.services', level='ERROR') as captured:
|
||||
with self.assertRaises(OTPDeliveryError):
|
||||
send_otp_sms(phone='+989120000000', code='1234')
|
||||
message = captured.records[0].getMessage()
|
||||
self.assertNotIn('secret-ish detail', message)
|
||||
self.assertIn('phone_suffix=0000', message)
|
||||
self.assertIn('provider_status=401', message)
|
||||
|
||||
|
||||
|
||||
class RequestOTPViewTests(TestCase):
|
||||
"""
|
||||
@@ -166,7 +231,12 @@ class RequestOTPStaleInvalidationTests(TestCase):
|
||||
self.assertEqual(response.data['devOtp'], code)
|
||||
return code
|
||||
|
||||
@override_settings(DEBUG=True)
|
||||
# ⚠️ این کلاس چندین درخواست پشتِسرِهم برای *همون* شماره میفرسته (بدونِ
|
||||
# فاصله)، چون داره رفتارِ invalidation رو میسنجه، نه cooldown رو. پس
|
||||
# cooldown جدیدِ بیندرخواستی رو اینجا خاموش میکنیم تا این تستها با
|
||||
# اون تداخل نکنن؛ cooldown خودش تستِ اختصاصیِ جدا داره
|
||||
# (RequestOTPPhoneCooldownTests).
|
||||
@override_settings(DEBUG=True, OTP_REQUEST_PHONE_COOLDOWN_SECONDS=0)
|
||||
def test_second_request_invalidates_first_otp(self):
|
||||
phone = '+989121110001'
|
||||
first_code = self._request_otp(phone, '1111')
|
||||
@@ -178,7 +248,7 @@ class RequestOTPStaleInvalidationTests(TestCase):
|
||||
first_otp.refresh_from_db()
|
||||
self.assertTrue(first_otp.is_used)
|
||||
|
||||
@override_settings(DEBUG=True)
|
||||
@override_settings(DEBUG=True, OTP_REQUEST_PHONE_COOLDOWN_SECONDS=0)
|
||||
def test_newest_otp_still_works_for_login(self):
|
||||
phone = '+989121110002'
|
||||
self._request_otp(phone, '1111')
|
||||
@@ -189,7 +259,7 @@ class RequestOTPStaleInvalidationTests(TestCase):
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
@override_settings(DEBUG=True)
|
||||
@override_settings(DEBUG=True, OTP_REQUEST_PHONE_COOLDOWN_SECONDS=0)
|
||||
def test_older_otp_is_rejected_after_new_request(self):
|
||||
phone = '+989121110003'
|
||||
first_code = self._request_otp(phone, '1111')
|
||||
@@ -282,6 +352,464 @@ class LoginPhoneRateLimitTests(TestCase):
|
||||
self.assertEqual(blocked_response.status_code, 429)
|
||||
|
||||
|
||||
class RequestOTPPhoneRateLimitTests(TestCase):
|
||||
"""
|
||||
🔒 اثبات محدودیتِ جدیدِ بر اساسِ شمارهتلفن روی RequestOTPView: درخواستهای
|
||||
تکراریِ صدورِ OTP برای همون شماره باید بعد از حدِ مجاز بلاک بشن، حتی اگه
|
||||
throttle_scope='otp_request' (IP-محور) رو با تغییرِ IP دور بزنیم. همچنین
|
||||
درخواستِ بلاکشده نباید OTPCode جدید بسازه یا پیامک بفرسته.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
cache.clear()
|
||||
|
||||
# cooldown بیندرخواستیِ جدید رو اینجا خاموش میکنیم چون این تستها دارن
|
||||
# سقفِ *ساعتی* رو میسنجن (با چند درخواستِ پشتِسرِهم بدونِ فاصله)، نه
|
||||
# cooldown رو؛ cooldown تستِ اختصاصیِ خودش رو داره
|
||||
# (RequestOTPPhoneCooldownTests).
|
||||
@override_settings(DEBUG=True, OTP_REQUEST_PHONE_MAX_ATTEMPTS=3, OTP_REQUEST_PHONE_COOLDOWN_SECONDS=0)
|
||||
def test_repeated_requests_for_same_phone_are_eventually_blocked(self):
|
||||
phone = '+989127778888'
|
||||
|
||||
# otp_request (IP-محور) پیشفرض 5/hour است؛ برای اینکه فقط
|
||||
# محدودیتِ شمارهتلفن رو بسنجیم، throttle IP-محور رو موقتاً باز
|
||||
# میکنیم تا تداخلی با شمارشِ این تست نداشته باشه.
|
||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'otp_request': '1000/hour'}):
|
||||
with patch('accounts.views.send_otp_sms'):
|
||||
for _ in range(3):
|
||||
response = self.client.post(
|
||||
'/api/auth/otp/request', {'phone': phone}, format='json',
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
blocked_response = self.client.post(
|
||||
'/api/auth/otp/request', {'phone': phone}, format='json',
|
||||
)
|
||||
self.assertEqual(blocked_response.status_code, 429)
|
||||
|
||||
@override_settings(DEBUG=True, OTP_REQUEST_PHONE_MAX_ATTEMPTS=3, OTP_REQUEST_PHONE_COOLDOWN_SECONDS=0)
|
||||
def test_changing_client_ip_does_not_bypass_the_phone_limit(self):
|
||||
phone = '+989127778889'
|
||||
|
||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'otp_request': '1000/hour'}):
|
||||
with patch('accounts.views.send_otp_sms'):
|
||||
for i in range(3):
|
||||
# هر درخواست از یک IP متفاوت میاد؛ throttle IP-محور
|
||||
# (که بههرحال بالا باز شده) اینجا اهمیتی نداره — نکته
|
||||
# اینه که محدودیتِ شمارهتلفن مستقل از IP باید اعمال بشه.
|
||||
response = self.client.post(
|
||||
'/api/auth/otp/request', {'phone': phone}, format='json',
|
||||
REMOTE_ADDR=f'10.0.0.{i + 1}',
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
blocked_response = self.client.post(
|
||||
'/api/auth/otp/request', {'phone': phone}, format='json',
|
||||
REMOTE_ADDR='10.0.0.99',
|
||||
)
|
||||
self.assertEqual(blocked_response.status_code, 429)
|
||||
|
||||
@override_settings(DEBUG=True, OTP_REQUEST_PHONE_MAX_ATTEMPTS=1, OTP_REQUEST_PHONE_COOLDOWN_SECONDS=0)
|
||||
def test_throttled_request_creates_no_new_otp_and_sends_no_sms(self):
|
||||
phone = '+989127778890'
|
||||
|
||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'otp_request': '1000/hour'}):
|
||||
with patch('accounts.views.send_otp_sms') as mock_send:
|
||||
first_response = self.client.post(
|
||||
'/api/auth/otp/request', {'phone': phone}, format='json',
|
||||
)
|
||||
self.assertEqual(first_response.status_code, 200)
|
||||
self.assertEqual(mock_send.call_count, 1)
|
||||
|
||||
otp_count_before = OTPCode.objects.filter(phone=phone).count()
|
||||
|
||||
blocked_response = self.client.post(
|
||||
'/api/auth/otp/request', {'phone': phone}, format='json',
|
||||
)
|
||||
self.assertEqual(blocked_response.status_code, 429)
|
||||
|
||||
# نه OTPCode جدیدی ساخته شده، نه پیامکِ جدیدی ارسال شده.
|
||||
self.assertEqual(mock_send.call_count, 1)
|
||||
self.assertEqual(
|
||||
OTPCode.objects.filter(phone=phone).count(), otp_count_before,
|
||||
)
|
||||
|
||||
|
||||
class RequestOTPPhoneRateLimitConcurrencyTests(TransactionTestCase):
|
||||
"""
|
||||
🔒 اثباتِ ایمنی محدودیتِ شمارهتلفن زیرِ همزمانی: دو درخواستِ *واقعاً*
|
||||
همزمان (با threading.Barrier همزمانشده، نه صرفاً پشتِسرِهم) برای یک
|
||||
شماره، با سقفِ مجازِ ۱، نباید هر دو موفق بشن. الگویِ cache.add که یک
|
||||
عملیاتِ اتمیکِ سطحِ backend cache است (نه یک read-then-write ناامن)
|
||||
دقیقاً همینو تضمین میکنه. از TransactionTestCase استفاده میشه چون
|
||||
TestCase هر تست رو داخلِ یک تراکنشِ بیرونیِ خودش میپیچه که برای
|
||||
درخواستهایِ واقعاً همزمان از threadهایِ جدا مناسب نیست.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
cache.clear()
|
||||
|
||||
@override_settings(DEBUG=True, OTP_REQUEST_PHONE_MAX_ATTEMPTS=1)
|
||||
def test_two_simultaneous_requests_do_not_both_pass_the_phone_limit(self):
|
||||
import threading
|
||||
|
||||
phone = '+989127778891'
|
||||
barrier = threading.Barrier(2)
|
||||
results = []
|
||||
results_lock = threading.Lock()
|
||||
|
||||
def _make_request():
|
||||
client = APIClient()
|
||||
barrier.wait() # هر دو thread دقیقاً همزمان درخواست بفرستن
|
||||
response = client.post(
|
||||
'/api/auth/otp/request', {'phone': phone}, format='json',
|
||||
)
|
||||
with results_lock:
|
||||
results.append(response.status_code)
|
||||
|
||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'otp_request': '1000/hour'}):
|
||||
with patch('accounts.views.send_otp_sms'):
|
||||
threads = [threading.Thread(target=_make_request) for _ in range(2)]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join(timeout=10)
|
||||
|
||||
self.assertEqual(len(results), 2)
|
||||
# دقیقاً یکی باید موفق بشه (200) و اون یکی باید بلاک بشه (429)؛
|
||||
# هیچوقت هر دو نباید 200 بگیرن.
|
||||
self.assertEqual(sorted(results), [200, 429])
|
||||
self.assertEqual(OTPCode.objects.filter(phone=phone).count(), 1)
|
||||
|
||||
|
||||
class RequestOTPPhoneCooldownTests(TestCase):
|
||||
"""
|
||||
🔒 اثباتِ محدودیتِ جدیدِ cooldown بیندرخواستی برای RequestOTPView: هدفِ
|
||||
این محدودیت جلوگیریِ burst (چند درخواست برای همون شماره در عرضِ چند
|
||||
ثانیه/بدونِ فاصله) است — چیزی که سقفِ ساعتیِ per-phone موجود
|
||||
(OTP_REQUEST_PHONE_MAX_ATTEMPTS) بهتنهایی جلوش رو نمیگیره، چون فقط
|
||||
حجمِ کلی رو محدود میکنه نه فاصلهی بینِ تلاشها رو.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
cache.clear()
|
||||
|
||||
@override_settings(DEBUG=True, OTP_REQUEST_PHONE_COOLDOWN_SECONDS=60)
|
||||
def test_immediate_resend_for_same_phone_is_blocked_by_cooldown(self):
|
||||
phone = '+989127779001'
|
||||
|
||||
with patch('accounts.views.send_otp_sms'):
|
||||
first_response = self.client.post(
|
||||
'/api/auth/otp/request', {'phone': phone}, format='json',
|
||||
)
|
||||
self.assertEqual(first_response.status_code, 200)
|
||||
|
||||
second_response = self.client.post(
|
||||
'/api/auth/otp/request', {'phone': phone}, format='json',
|
||||
)
|
||||
self.assertEqual(second_response.status_code, 429)
|
||||
|
||||
@override_settings(DEBUG=True, OTP_REQUEST_PHONE_COOLDOWN_SECONDS=60, OTP_REQUEST_PHONE_MAX_ATTEMPTS=5)
|
||||
def test_cooldown_blocks_burst_without_consuming_the_hourly_quota(self):
|
||||
# پنج درخواستِ پشتِسرِهم بدونِ فاصله برای همون شماره: فقط اولی باید
|
||||
# موفق بشه، بقیه باید با cooldown (429) بلاک بشن — و چون
|
||||
# درخواستهایِ بلاکشده توسطِ cooldown هیچوقت به شمارندهی ساعتی
|
||||
# نمیرسن، سهمیهی ساعتی مصرف نمیشه.
|
||||
phone = '+989127779002'
|
||||
|
||||
# throttle IP-محورِ otp_request (پیشفرض 5/hour) رو موقتاً باز
|
||||
# میکنیم تا اینجا فقط cooldown/سهمیهی ساعتیِ per-phone سنجیده بشه،
|
||||
# نه throttle IP-محورِ جدا (که تستِ اختصاصیِ خودش رو داره).
|
||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'otp_request': '1000/hour'}):
|
||||
with patch('accounts.views.send_otp_sms') as mock_send:
|
||||
statuses = []
|
||||
for _ in range(5):
|
||||
response = self.client.post(
|
||||
'/api/auth/otp/request', {'phone': phone}, format='json',
|
||||
)
|
||||
statuses.append(response.status_code)
|
||||
|
||||
self.assertEqual(statuses, [200, 429, 429, 429, 429])
|
||||
mock_send.assert_called_once()
|
||||
self.assertEqual(OTPCode.objects.filter(phone=phone).count(), 1)
|
||||
|
||||
# سهمیهی ساعتی هنوز دستنخورده است: بعد از پایانِ cooldown،
|
||||
# همچنان میشه تا سقفِ مجاز درخواست داد.
|
||||
cache.delete(f'otp_request_phone_cooldown:{phone}')
|
||||
with patch('accounts.views.send_otp_sms') as mock_send_again:
|
||||
allowed_again = self.client.post(
|
||||
'/api/auth/otp/request', {'phone': phone}, format='json',
|
||||
)
|
||||
self.assertEqual(allowed_again.status_code, 200)
|
||||
mock_send_again.assert_called_once()
|
||||
|
||||
@override_settings(DEBUG=True, OTP_REQUEST_PHONE_COOLDOWN_SECONDS=60)
|
||||
def test_cooldown_blocked_request_creates_no_otp_and_sends_no_sms(self):
|
||||
phone = '+989127779003'
|
||||
|
||||
with patch('accounts.views.send_otp_sms') as mock_send:
|
||||
self.client.post('/api/auth/otp/request', {'phone': phone}, format='json')
|
||||
otp_count_before = OTPCode.objects.filter(phone=phone).count()
|
||||
|
||||
blocked_response = self.client.post(
|
||||
'/api/auth/otp/request', {'phone': phone}, format='json',
|
||||
)
|
||||
|
||||
self.assertEqual(blocked_response.status_code, 429)
|
||||
self.assertEqual(mock_send.call_count, 1)
|
||||
self.assertEqual(OTPCode.objects.filter(phone=phone).count(), otp_count_before)
|
||||
|
||||
@override_settings(DEBUG=True, OTP_REQUEST_PHONE_COOLDOWN_SECONDS=1)
|
||||
def test_cooldown_expires_after_configured_window(self):
|
||||
import time
|
||||
|
||||
phone = '+989127779004'
|
||||
|
||||
with patch('accounts.views.send_otp_sms'):
|
||||
first_response = self.client.post(
|
||||
'/api/auth/otp/request', {'phone': phone}, format='json',
|
||||
)
|
||||
self.assertEqual(first_response.status_code, 200)
|
||||
|
||||
time.sleep(1.2)
|
||||
|
||||
second_response = self.client.post(
|
||||
'/api/auth/otp/request', {'phone': phone}, format='json',
|
||||
)
|
||||
self.assertEqual(second_response.status_code, 200)
|
||||
|
||||
@override_settings(DEBUG=True, OTP_REQUEST_PHONE_COOLDOWN_SECONDS=60)
|
||||
def test_cooldown_is_per_phone_not_global(self):
|
||||
with patch('accounts.views.send_otp_sms'):
|
||||
first_response = self.client.post(
|
||||
'/api/auth/otp/request', {'phone': '+989127779005'}, format='json',
|
||||
)
|
||||
second_response = self.client.post(
|
||||
'/api/auth/otp/request', {'phone': '+989127779006'}, format='json',
|
||||
)
|
||||
self.assertEqual(first_response.status_code, 200)
|
||||
self.assertEqual(second_response.status_code, 200)
|
||||
|
||||
def test_default_cooldown_is_120_seconds(self):
|
||||
# ⚠️ تصمیمِ محصول/امنیت: کاربر باید هر ۲ دقیقه بتونه OTP دوباره
|
||||
# بگیره؛ این باید دقیقاً همون مقداریه که enforcement سمتِ سرور
|
||||
# اعمال میکنه (نه صرفاً یک عددِ hardcodeشده در فرانت).
|
||||
self.assertEqual(settings.OTP_REQUEST_PHONE_COOLDOWN_SECONDS, 120)
|
||||
|
||||
@override_settings(DEBUG=True, OTP_REQUEST_PHONE_COOLDOWN_SECONDS=120)
|
||||
def test_cooldown_response_reports_retry_after_header_and_body(self):
|
||||
phone = '+989127779008'
|
||||
|
||||
with patch('accounts.views.send_otp_sms'):
|
||||
first_response = self.client.post(
|
||||
'/api/auth/otp/request', {'phone': phone}, format='json',
|
||||
)
|
||||
self.assertEqual(first_response.status_code, 200)
|
||||
|
||||
blocked_response = self.client.post(
|
||||
'/api/auth/otp/request', {'phone': phone}, format='json',
|
||||
)
|
||||
|
||||
self.assertEqual(blocked_response.status_code, 429)
|
||||
# هدرِ استانداردِ Retry-After — فرانت باید countdown رو دقیقاً از
|
||||
# همین مقدار شروع کنه، نه از یک عددِ ثابتِ سمتِ کلاینت.
|
||||
self.assertIn('Retry-After', blocked_response.headers)
|
||||
retry_after_header = int(blocked_response.headers['Retry-After'])
|
||||
self.assertGreaterEqual(retry_after_header, 1)
|
||||
self.assertLessEqual(retry_after_header, 120)
|
||||
|
||||
# همون مقدار در بدنهی پاسخ هم برگردونده میشه (retryAfter، همراستا
|
||||
# با استایلِ camelCase بقیهی فیلدهای response در این پروژه).
|
||||
self.assertIn('retryAfter', blocked_response.data)
|
||||
self.assertEqual(blocked_response.data['retryAfter'], retry_after_header)
|
||||
|
||||
@override_settings(DEBUG=True, OTP_REQUEST_PHONE_COOLDOWN_SECONDS=120)
|
||||
def test_retry_after_counts_down_as_time_passes(self):
|
||||
import time
|
||||
|
||||
phone = '+989127779009'
|
||||
|
||||
with patch('accounts.views.send_otp_sms'):
|
||||
self.client.post('/api/auth/otp/request', {'phone': phone}, format='json')
|
||||
|
||||
immediate_retry = self.client.post(
|
||||
'/api/auth/otp/request', {'phone': phone}, format='json',
|
||||
)
|
||||
self.assertEqual(immediate_retry.status_code, 429)
|
||||
immediate_retry_after = immediate_retry.data['retryAfter']
|
||||
|
||||
time.sleep(1.1)
|
||||
|
||||
later_retry = self.client.post(
|
||||
'/api/auth/otp/request', {'phone': phone}, format='json',
|
||||
)
|
||||
self.assertEqual(later_retry.status_code, 429)
|
||||
later_retry_after = later_retry.data['retryAfter']
|
||||
|
||||
# با گذشتِ زمان، عددِ retryAfter باید کم بشه (نه ثابت بمونه) — این
|
||||
# همون چیزیه که فرانت برای countdown دقیق بهش نیاز داره.
|
||||
self.assertLess(later_retry_after, immediate_retry_after)
|
||||
|
||||
|
||||
class RequestOTPPhoneCooldownConcurrencyTests(TransactionTestCase):
|
||||
"""
|
||||
🔒 اثباتِ ایمنیِ cooldown زیرِ همزمانیِ واقعی (نه صرفاً پشتِسرِهم):
|
||||
دقیقاً همون الگویِ RequestOTPPhoneRateLimitConcurrencyTests، اینبار
|
||||
برایِ کلیدِ cooldown. از TransactionTestCase استفاده میشه چون
|
||||
TestCase هر تست رو داخلِ یک تراکنشِ بیرونیِ خودش میپیچه که برایِ
|
||||
درخواستهایِ واقعاً همزمان از threadهایِ جدا مناسب نیست.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
cache.clear()
|
||||
|
||||
@override_settings(DEBUG=True, OTP_REQUEST_PHONE_COOLDOWN_SECONDS=60)
|
||||
def test_two_simultaneous_requests_for_same_phone_only_one_passes_cooldown(self):
|
||||
import threading
|
||||
|
||||
phone = '+989127779007'
|
||||
barrier = threading.Barrier(2)
|
||||
results = []
|
||||
results_lock = threading.Lock()
|
||||
|
||||
def _make_request():
|
||||
client = APIClient()
|
||||
barrier.wait()
|
||||
response = client.post(
|
||||
'/api/auth/otp/request', {'phone': phone}, format='json',
|
||||
)
|
||||
with results_lock:
|
||||
results.append(response.status_code)
|
||||
|
||||
with patch('accounts.views.send_otp_sms'):
|
||||
threads = [threading.Thread(target=_make_request) for _ in range(2)]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join(timeout=10)
|
||||
|
||||
self.assertEqual(len(results), 2)
|
||||
self.assertEqual(sorted(results), [200, 429])
|
||||
self.assertEqual(OTPCode.objects.filter(phone=phone).count(), 1)
|
||||
|
||||
|
||||
@override_settings(DEBUG=True, OTP_REQUEST_PHONE_COOLDOWN_SECONDS=120)
|
||||
def test_cooldown_is_cleared_when_sms_delivery_fails(self):
|
||||
# ⚠️ فیکسِ باگ: قبلاً cooldown *قبل از* فراخوانیِ send_otp_sms ثبت
|
||||
# میشد. اگه ارسالِ پیامک با OTPDeliveryError شکست میخورد، کاربر
|
||||
# هیچ کدی دریافت نکرده بود ولی همچنان تا ۱۲۰ ثانیه بلاک میموند.
|
||||
# این تست تضمین میکنه که با شکستِ ارسال، cooldown پاک میشه و
|
||||
# کاربر میتونه فوراً دوباره تلاش کنه.
|
||||
phone = '+989127779010'
|
||||
|
||||
with patch('accounts.views.send_otp_sms', side_effect=OTPDeliveryError('boom')):
|
||||
first_response = self.client.post(
|
||||
'/api/auth/otp/request', {'phone': phone}, format='json',
|
||||
)
|
||||
self.assertEqual(first_response.status_code, 503)
|
||||
|
||||
# اثباتِ اینکه cooldown دیگه فعال نیست: بلافاصله یک تلاشِ دیگه (اینبار
|
||||
# موفق) باید 429 نگیره.
|
||||
with patch('accounts.views.send_otp_sms') as mock_send:
|
||||
retry_response = self.client.post(
|
||||
'/api/auth/otp/request', {'phone': phone}, format='json',
|
||||
)
|
||||
self.assertEqual(retry_response.status_code, 200)
|
||||
mock_send.assert_called_once()
|
||||
|
||||
@override_settings(DEBUG=True, OTP_REQUEST_PHONE_COOLDOWN_SECONDS=120)
|
||||
def test_cooldown_still_active_after_successful_delivery(self):
|
||||
# اثباتِ عدمِ رگرسیون: وقتی ارسال موفق باشه، cooldown همچنان طبقِ
|
||||
# رفتارِ قبلی فعال میمونه (فقط شکستِ تحویل باید cooldown رو پاک کنه).
|
||||
phone = '+989127779011'
|
||||
|
||||
with patch('accounts.views.send_otp_sms'):
|
||||
first_response = self.client.post(
|
||||
'/api/auth/otp/request', {'phone': phone}, format='json',
|
||||
)
|
||||
self.assertEqual(first_response.status_code, 200)
|
||||
|
||||
blocked_response = self.client.post(
|
||||
'/api/auth/otp/request', {'phone': phone}, format='json',
|
||||
)
|
||||
self.assertEqual(blocked_response.status_code, 429)
|
||||
self.assertIn('Retry-After', blocked_response.headers)
|
||||
|
||||
|
||||
class RefreshTokenThrottleTests(TestCase):
|
||||
"""
|
||||
🔒 اثبات محدودیتِ جدیدِ throttle_scope='token_refresh' روی
|
||||
RefreshTokenView: درخواستهای عادی و کافی موفق باشن، درخواستهای
|
||||
بیشازحد با ۴۲۹ رد بشن، و یک درخواستِ بلاکشده هیچ Session جدیدی
|
||||
نسازه/revoke نکنه و هیچ توکنِ جدیدی تولید نکنه.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
cache.clear()
|
||||
self.client = APIClient()
|
||||
self.user = User.objects.create(is_guest=True, device_id='device-refresh-throttle-test')
|
||||
|
||||
def _create_session(self, token_suffix):
|
||||
return Session.objects.create(
|
||||
user=self.user,
|
||||
refresh_token=f'test-refresh-token-{token_suffix}',
|
||||
device_id='device-refresh-throttle-test',
|
||||
expires_at=timezone.now() + timezone.timedelta(days=30),
|
||||
revoked=False,
|
||||
)
|
||||
|
||||
def test_requests_within_the_limit_still_succeed(self):
|
||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'token_refresh': '3/hour'}):
|
||||
for i in range(3):
|
||||
session = self._create_session(i)
|
||||
response = self.client.post(
|
||||
'/api/auth/refresh', {'refreshToken': session.refresh_token}, format='json',
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_excessive_refresh_requests_are_eventually_rejected(self):
|
||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'token_refresh': '3/hour'}):
|
||||
for i in range(3):
|
||||
session = self._create_session(i)
|
||||
response = self.client.post(
|
||||
'/api/auth/refresh', {'refreshToken': session.refresh_token}, format='json',
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
extra_session = self._create_session('extra')
|
||||
blocked_response = self.client.post(
|
||||
'/api/auth/refresh', {'refreshToken': extra_session.refresh_token}, format='json',
|
||||
)
|
||||
self.assertEqual(blocked_response.status_code, 429)
|
||||
|
||||
def test_throttled_request_does_not_revoke_or_create_session_or_token(self):
|
||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'token_refresh': '1/hour'}):
|
||||
first_session = self._create_session('first')
|
||||
first_response = self.client.post(
|
||||
'/api/auth/refresh', {'refreshToken': first_session.refresh_token}, format='json',
|
||||
)
|
||||
self.assertEqual(first_response.status_code, 200)
|
||||
|
||||
session_count_before = Session.objects.count()
|
||||
blocked_session = self._create_session('blocked')
|
||||
|
||||
with patch('accounts.views.issue_access_token') as mock_issue_token:
|
||||
blocked_response = self.client.post(
|
||||
'/api/auth/refresh', {'refreshToken': blocked_session.refresh_token}, format='json',
|
||||
)
|
||||
self.assertEqual(blocked_response.status_code, 429)
|
||||
mock_issue_token.assert_not_called()
|
||||
|
||||
# هیچ Session جدیدی ساخته نشده (فقط همون session ای که قبل از
|
||||
# درخواستِ بلاکشده دستی ساختیم) و session ای که با آن تلاش
|
||||
# کردیم هنوز revoked=False و دستنخورده است.
|
||||
self.assertEqual(Session.objects.count(), session_count_before + 1)
|
||||
blocked_session.refresh_from_db()
|
||||
self.assertFalse(blocked_session.revoked)
|
||||
|
||||
|
||||
class GuestSessionThrottleTests(TestCase):
|
||||
"""
|
||||
🔒 اثبات محدودیتِ جدیدِ ScopedRateThrottle (scope='guest_session') روی
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import logging
|
||||
import math
|
||||
import secrets
|
||||
import uuid
|
||||
|
||||
@@ -14,7 +16,7 @@ from rest_framework.views import APIView
|
||||
|
||||
from .authentication import issue_access_token
|
||||
from .models import OTPCode, Session, User
|
||||
from .services import send_otp_sms
|
||||
from .services import OTPDeliveryError, send_otp_sms
|
||||
from .serializers import (
|
||||
GuestSessionRequestSerializer,
|
||||
GuestSessionResponseSerializer,
|
||||
@@ -28,6 +30,32 @@ from .serializers import (
|
||||
SetGradeResponseSerializer,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class GuestAwareAccountThrottle(ScopedRateThrottle):
|
||||
"""
|
||||
⚠️ اضافه شده: همون الگویِ GuestAwareContentThrottle (content/views.py)،
|
||||
GuestAwareAIThrottle (ai_tutor/views.py) و بقیه؛ چون GuestSessionView
|
||||
با هر deviceId جدید یک کاربرِ مهمانِ تازه (pk جدید) میسازه، یک
|
||||
ScopedRateThrottleِ سادهٔ per-user توسطِ چرخشِ حساب مهمان دورزدنی
|
||||
بود. اینجا برای SetUserGradeView (که مهمانها هم میتونن صداش بزنن)
|
||||
از همین کلید بر اساسِ IP برای مهمانها استفاده میشه؛ رفتارِ
|
||||
throttle برای کاربرهای غیرمهمان (per-user، مثلِ قبل) دستنخورده
|
||||
میمونه.
|
||||
"""
|
||||
|
||||
def get_cache_key(self, request, view):
|
||||
user = request.user
|
||||
if user and user.is_authenticated and getattr(user, 'is_guest', False):
|
||||
ident = f'guest-ip:{self.get_ident(request)}'
|
||||
elif user and user.is_authenticated:
|
||||
ident = user.pk
|
||||
else:
|
||||
ident = self.get_ident(request)
|
||||
|
||||
return self.cache_format % {'scope': self.scope, 'ident': ident}
|
||||
|
||||
|
||||
class GuestSessionView(APIView):
|
||||
"""POST /api/auth/session — ایجاد سشن مهمان"""
|
||||
@@ -81,6 +109,79 @@ class RequestOTPView(APIView):
|
||||
serializer.is_valid(raise_exception=True)
|
||||
phone = serializer.validated_data['phone']
|
||||
|
||||
# حداقل فاصلهی زمانی بین دو درخواست برای همون شماره (burst
|
||||
# protection). سقفِ ساعتیِ زیر جلویِ حجمِ کلی رو میگیره، اما بهتنهایی
|
||||
# جلویِ این رو نمیگیره که همون ۵ تلاشِ مجاز در عرضِ چند ثانیه/کمتر
|
||||
# از یک ثانیه مصرف بشن (که هم بهمعنایِ flood پیامکی روی یک شمارهی
|
||||
# مشخصه، هم میتونه با invalidate کردنِ پیدرپیِ OTPهای قبلی
|
||||
# (رفتارِ «فقط آخرین کد معتبره» که پایینتره) عملاً یک DoS روی
|
||||
# کاربرِ واقعیِ اون شماره باشه). چک این cooldown *قبل* از شمارندهی
|
||||
# ساعتی انجام میشه تا یک درخواستِ بلاکشده توسطِ cooldown سهمیهی
|
||||
# ساعتی رو مصرف نکنه. از همون الگویِ اتمیکِ cache.add استفاده شده
|
||||
# (دقیقاً همون مکانیزمِ زیر و مکانیزمِ LoginView).
|
||||
#
|
||||
# کلید cooldown بهجای یک فلگِ ساده، زمانِ epoch انقضاء رو ذخیره
|
||||
# میکنه؛ اینطوری وقتی add شکست بخوره (کلید از قبل هست)، بدونِ
|
||||
# هیچ خواندنِ ناامنی میشه دقیقاً محاسبه کرد چند ثانیه تا پایانِ
|
||||
# cooldown مونده و اون رو هم در بدنهی پاسخ (retryAfter) و هم در
|
||||
# هدرِ استانداردِ Retry-After برگردوند — اینطوری frontend
|
||||
# countdown رو دقیقاً از رویِ همین پاسخِ backend شروع میکنه، نه
|
||||
# از رویِ یک عددِ hardcodeشدهی سمتِ کلاینت که مرجعِ امنیتی نیست.
|
||||
phone_cooldown_key = f'otp_request_phone_cooldown:{phone}'
|
||||
cooldown_seconds = settings.OTP_REQUEST_PHONE_COOLDOWN_SECONDS
|
||||
now_epoch = timezone.now().timestamp()
|
||||
cooldown_expires_at_epoch = now_epoch + cooldown_seconds
|
||||
if not cache.add(phone_cooldown_key, cooldown_expires_at_epoch, timeout=cooldown_seconds):
|
||||
stored_expiry_epoch = cache.get(phone_cooldown_key)
|
||||
if stored_expiry_epoch is None:
|
||||
# بینِ شکستِ add و این get دقیقاً منقضی شده؛ عملاً دیگه
|
||||
# cooldownی در کار نیست، اما برای رفتارِ یکدست یک
|
||||
# retry_after حداقلی برمیگردونیم (کلاینت با تلاشِ بعدی
|
||||
# مسیرِ عادی رو طی میکنه).
|
||||
retry_after = 1
|
||||
else:
|
||||
retry_after = max(1, math.ceil(stored_expiry_epoch - now_epoch))
|
||||
logger.warning(
|
||||
'OTP request phone cooldown active: phone_suffix=%s retry_after_seconds=%s',
|
||||
phone[-4:], retry_after,
|
||||
)
|
||||
return Response(
|
||||
{
|
||||
'detail': f'لطفاً {retry_after} ثانیه صبر کنید و دوباره درخواست کد بدهید.',
|
||||
'retryAfter': retry_after,
|
||||
},
|
||||
status=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
headers={'Retry-After': str(retry_after)},
|
||||
)
|
||||
|
||||
# محدودیت صدور OTP بر اساس شماره تلفن، مستقل از throttle IP-محورِ
|
||||
# otp_request بالا. throttle IP-محور بهتنهایی کافی نیست چون یک
|
||||
# مهاجم میتونه درخواستهای تکراری برای یک شماره رو بین چند IP
|
||||
# مختلف پخش کنه و پیامک OTP رو flood کنه. الگوی cache.add/incr
|
||||
# همون الگوی atomic زیرِ LoginView است: cache.add فقط وقتی کلید
|
||||
# وجود نداره مقدار میذاره (اتمیک روی سطح backend cache)، پس دو
|
||||
# درخواستِ همزمان نمیتونن هر دو از یک non-atomic check رد بشن.
|
||||
phone_requests_key = f'otp_request_phone_attempts:{phone}'
|
||||
if cache.add(phone_requests_key, 1, timeout=settings.OTP_REQUEST_PHONE_WINDOW_SECONDS):
|
||||
attempt_count = 1
|
||||
else:
|
||||
try:
|
||||
attempt_count = cache.incr(phone_requests_key)
|
||||
except ValueError:
|
||||
# بینِ add و incr دقیقاً منقضی شده؛ پنجره رو از نو شروع کن.
|
||||
cache.set(phone_requests_key, 1, timeout=settings.OTP_REQUEST_PHONE_WINDOW_SECONDS)
|
||||
attempt_count = 1
|
||||
|
||||
if attempt_count > settings.OTP_REQUEST_PHONE_MAX_ATTEMPTS:
|
||||
logger.warning(
|
||||
'OTP request phone limit exceeded: phone_suffix=%s attempt_count=%s max_attempts=%s',
|
||||
phone[-4:], attempt_count, settings.OTP_REQUEST_PHONE_MAX_ATTEMPTS,
|
||||
)
|
||||
return Response(
|
||||
{'detail': 'تعداد درخواستهای کد OTP برای این شماره بیش از حد مجاز است.'},
|
||||
status=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
)
|
||||
|
||||
# فقط آخرین کد صادرشده معتبر است.
|
||||
OTPCode.objects.filter(phone=phone, is_used=False).update(is_used=True)
|
||||
|
||||
@@ -91,7 +192,18 @@ class RequestOTPView(APIView):
|
||||
expires_at=timezone.now() + timezone.timedelta(minutes=5),
|
||||
)
|
||||
|
||||
send_otp_sms(phone=phone, code=code)
|
||||
try:
|
||||
send_otp_sms(phone=phone, code=code)
|
||||
except OTPDeliveryError:
|
||||
# ⚠️ فیکس: کاربر عملاً کد رو دریافت نکرده (ارسالِ پیامک شکست
|
||||
# خورده)، پس cooldownِ ۱۲۰ثانیهای که بالاتر برای همین درخواست
|
||||
# ثبت شده نباید جلوی تلاشِ فوریِ بعدیِ کاربر رو بگیره؛ در غیرِ
|
||||
# اینصورت کاربر ۲ دقیقه معطل میمونه بدونِ اینکه اصلاً کدی
|
||||
# گرفته باشه. سقفِ ساعتیِ per-phone (بالاتر) و throttle
|
||||
# IP-محور دستنخورده میمونن — اینها محدودیتِ حجمِ کلیان، نه
|
||||
# cooldownِ بینِ-درخواستی، و منطقِ متفاوتی دارن.
|
||||
cache.delete(phone_cooldown_key)
|
||||
raise
|
||||
|
||||
# devOtp فقط در محیط dev برمیگردد؛ در پروداکشن کد فقط از طریق پیامک تحویل داده میشود.
|
||||
note = 'در پروداکشن این کد پیامک میشه.'
|
||||
@@ -135,6 +247,14 @@ class LoginView(APIView):
|
||||
attempt_count = 1
|
||||
|
||||
if attempt_count > settings.OTP_LOGIN_PHONE_MAX_ATTEMPTS:
|
||||
# ⚠️ افزوده شده: لاگ هشدارِ امنیتی برای رصد/آلارمِ brute-force روی
|
||||
# این endpoint. هیچ سکرتی (OTP، access/refresh token) لاگ نمیشه؛
|
||||
# شمارهی تلفن هم کامل نوشته نمیشه، فقط ۴ رقم آخرش (برای
|
||||
# correlation در تحلیل/پشتیبانی، بدون افشای کامل PII).
|
||||
logger.warning(
|
||||
'OTP login phone attempt limit exceeded: phone_suffix=%s attempt_count=%s max_attempts=%s',
|
||||
phone[-4:], attempt_count, settings.OTP_LOGIN_PHONE_MAX_ATTEMPTS,
|
||||
)
|
||||
return Response(
|
||||
{'detail': 'تعداد تلاشهای ورود برای این شماره بیش از حد مجاز است.'},
|
||||
status=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
@@ -190,6 +310,15 @@ class LoginView(APIView):
|
||||
class RefreshTokenView(APIView):
|
||||
"""POST /api/auth/refresh — تمدید توکن"""
|
||||
|
||||
# این endpoint احرازهویت نمیخواد (خودِ refresh token شناسه است)، پس
|
||||
# ScopedRateThrottle طبیعتاً روی IP کلید میخوره. یک توکنِ رفرش عادتاً
|
||||
# فقط گاهبهگاه (نه با فرکانسِ بالا) باید استفاده بشه؛ این throttle
|
||||
# جلوی سوءاستفاده از این endpoint بهعنوان یک عملیاتِ پرتکرارِ
|
||||
# دیتابیس/تولیدِ توکن رو میگیره، بدون تغییری در rotation یا محافظتِ
|
||||
# اتمیکِ replay که پایینتر پیادهسازی شده.
|
||||
throttle_scope = 'token_refresh'
|
||||
throttle_classes = [ScopedRateThrottle]
|
||||
|
||||
@extend_schema(
|
||||
request=RefreshRequestSerializer,
|
||||
responses=RefreshResponseSerializer,
|
||||
@@ -232,6 +361,12 @@ class SetUserGradeView(APIView):
|
||||
"""POST /api/user/grade — ثبت پایهی کاربر (نیاز به احراز هویت)"""
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
# ⚠️ اضافه شده: این endpoint قبلاً هیچ throttleای نداشت؛ یک کاربرِ
|
||||
# (از جمله مهمان) احرازهویتشده میتونست تعدادِ نامحدودی درخواستِ
|
||||
# نوشتنِ دیتابیس بفرسته. طبقِ همون الگویِ سایرِ اپها، یک
|
||||
# throttle_scope مجزا با GuestAwareAccountThrottle اضافه شد.
|
||||
throttle_scope = 'user_profile_write'
|
||||
throttle_classes = [GuestAwareAccountThrottle]
|
||||
|
||||
@extend_schema(
|
||||
request=SetGradeRequestSerializer,
|
||||
|
||||
Reference in New Issue
Block a user