throttling, abuse-protection & logging/monitoring pass

This commit is contained in:
2026-08-23 23:44:00 +03:30
parent cb18c93a3e
commit 80298b47b1
33 changed files with 4202 additions and 23 deletions

View File

@@ -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 با خطا مواجه شد.')

View File

@@ -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') روی

View File

@@ -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,