throttling, abuse-protection & logging/monitoring pass
This commit is contained in:
10
.dockerignore
Normal file
10
.dockerignore
Normal file
@@ -0,0 +1,10 @@
|
||||
.git/
|
||||
venv/
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
db.sqlite3
|
||||
.env
|
||||
*.log
|
||||
.DS_Store
|
||||
20
.env.example
Normal file
20
.env.example
Normal file
@@ -0,0 +1,20 @@
|
||||
# این فایل نمونهست؛ در صورتِ نیاز کپی کنید به .env و مقداردهی کنید:
|
||||
# cp .env.example .env
|
||||
#
|
||||
# اگه .env نسازید، docker-compose.yml از همین مقادیرِ دیفالتِ پایین
|
||||
# استفاده میکنه (که برای اجرای محلی/دِمو کافیه، نه برای پروداکشن).
|
||||
|
||||
# DEBUG=1 یعنی SECRET_KEY و CACHE_URL دیفالتِ dev دارن و DEV_MASTER_OTP
|
||||
# (کدِ 0000) فعاله؛ برای شبیهسازیِ پروداکشن این رو 0 کنید (اونوقت
|
||||
# DJANGO_SECRET_KEY و CACHE_URL رو هم باید صریحاً بدید).
|
||||
DJANGO_DEBUG=1
|
||||
DJANGO_SECRET_KEY=
|
||||
DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1
|
||||
CACHE_URL=
|
||||
|
||||
# اختیاری — بدونش OTP واقعی ارسال نمیشه (فقط خطای صریح ۵۰۳ برمیگرده،
|
||||
# طبق رفتارِ موجودِ accounts/services.py)
|
||||
KAVENEGAR_API_KEY=
|
||||
|
||||
# رمزِ ادمینِ Grafana (کاربرِ admin) — فقط برای دسترسیِ محلی
|
||||
GRAFANA_ADMIN_PASSWORD=admin
|
||||
31
Dockerfile
Normal file
31
Dockerfile
Normal file
@@ -0,0 +1,31 @@
|
||||
# ---------------------------------------------------------------------
|
||||
# Dockerfile برای اپلیکیشن Django (Tadrisino).
|
||||
#
|
||||
# ⚠️ این ایمیج فقط برای اجرای محلی/dev در کنارِ استکِ لاگِ متمرکز
|
||||
# (docker-compose.yml) ساخته شده، نه یک تنظیمِ پروداکشن. عمداً همون چیزی
|
||||
# که پروژه از قبل با `python manage.py runserver` اجرا میکرد رو نگه
|
||||
# داشته (بدونِ اضافهکردنِ gunicorn/uWSGI یا هر dependency جدید)، چون
|
||||
# هدفِ این تغییر فقط معرفیِ زیرساختِ Docker + لاگِ متمرکزه، نه بازطراحیِ
|
||||
# نحوهی serve شدنِ اپلیکیشن.
|
||||
# ---------------------------------------------------------------------
|
||||
FROM python:3.12-slim
|
||||
|
||||
# جلوگیری از نوشتنِ فایلهای .pyc و بافر نشدنِ stdout/stderr — بدونِ این
|
||||
# دومی، لاگهای JSON ممکنه با تاخیر (یا فقط موقعِ خروج) چاپ بشن.
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# اول فقط requirements.txt کپی میشه تا وقتی صرفاً کدِ اپلیکیشن تغییر
|
||||
# میکنه (نه dependency ها)، لایهی pip install از کشِ Docker استفاده کنه
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
# دستورِ واقعیِ اجرا (migrate + runserver) در docker-compose.yml تعریف
|
||||
# شده، نه اینجا؛ این CMD فقط یک fallback برای اجرای مستقیمِ ایمیجه.
|
||||
CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"]
|
||||
30
README.md
30
README.md
@@ -25,6 +25,36 @@ python manage.py runserver
|
||||
در پروداکشن، `DJANGO_DEBUG` رو ست نکنید (یا `0` بذارید) و حتماً
|
||||
`DJANGO_SECRET_KEY` و `DJANGO_ALLOWED_HOSTS` (کاما-جدا) رو هم از env بدید.
|
||||
|
||||
## اجرا با Docker (اپ + استکِ لاگِ متمرکز)
|
||||
|
||||
برای اجرای محلی، در کنارِ Django یک استکِ لاگِ متمرکز هم فراهم شده:
|
||||
`Loki` (ذخیرهسازیِ لاگ) + `Grafana Alloy` (جمعآوریِ لاگ از فایلِ اپ) +
|
||||
`Grafana` (جستجو/فیلترِ لاگها). این فقط برای dev/local هست، نه یک
|
||||
تنظیمِ پروداکشن (مثلاً هنوز از `runserver` استفاده میشه، نه gunicorn).
|
||||
|
||||
```bash
|
||||
# اختیاری: تنظیماتِ خودتون رو ست کنید (وگرنه دیفالتهای dev استفاده میشن)
|
||||
cp .env.example .env
|
||||
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
- اپ Django: `http://localhost:8000`
|
||||
- Grafana: `http://localhost:3000` (بدونِ نیاز به لاگین، anonymous admin فعاله؛ فقط برای محیطِ محلی)
|
||||
|
||||
داشبوردِ «Tadrisino Django Logs» بهصورتِ خودکار در Grafana لود میشه و
|
||||
امکانِ فیلتر بر اساسِ سطحِ لاگ (`level`) و جستجوی متنی رو میده.
|
||||
|
||||
**جریانِ لاگ:** Django همون خطوطِ JSON موجود رو (بدونِ تغییرِ پیامها یا
|
||||
محافظتهای حساسیتی) هم روی stdout و هم در `/var/log/django/app.log`
|
||||
(یک ولومِ Docker) مینویسه ← Grafana Alloy این فایل رو میخونه ← به Loki
|
||||
push میکنه ← Grafana از Loki میخونه. جزئیاتِ بیشتر در کامنتهای بالای
|
||||
`docker-compose.yml`، `docker/alloy/config.alloy` و `config/settings.py`
|
||||
(بخشِ `LOG_DIR`).
|
||||
|
||||
اجرای بدونِ Docker (روش قبلی، بالای همین فایل) دقیقاً همونطور که بود
|
||||
کار میکنه — `LOG_DIR` فقط داخلِ Docker ست میشه.
|
||||
|
||||
## مستندات API
|
||||
|
||||
| | |
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -3,6 +3,7 @@ from drf_spectacular.utils import extend_schema
|
||||
from rest_framework.exceptions import ValidationError
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.throttling import ScopedRateThrottle
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from .models import AffiliateAccount, Conversion
|
||||
@@ -14,6 +15,32 @@ from .serializers import (
|
||||
)
|
||||
|
||||
|
||||
class GuestAwareAffiliateThrottle(ScopedRateThrottle):
|
||||
"""
|
||||
⚠️ اضافه شده: همون الگویِ GuestAwareDiscountThrottle/GuestAwareFamilyThrottle
|
||||
و بقیه؛ چرخشِ حسابِ مهمان (deviceId جدید → pk جدید) یک ScopedRateThrottleِ
|
||||
سادهٔ per-user رو دور میزد. اینجا حساستره چون referralCode یک
|
||||
شناسهی حدسزدنیست (404 برای کدِ نامعتبر = oracle) و یک حدسِ درست
|
||||
یک Conversion واقعی میسازه که به کمیسیونِ مالیِ affiliate صاحبِ کد
|
||||
منجر میشه؛ یعنی بدونِ این throttle، چرخشِ حساب مهمان به مهاجم
|
||||
guessهای عملاً نامحدودی برای enumerate/fraud کردنِ کدهای رفرال
|
||||
میداد. برای کاربرهای مهمان کلیدِ cache از pk به 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 AffiliateMeView(APIView):
|
||||
"""GET /api/affiliate/me — حساب و کد ارجاع کاربر"""
|
||||
|
||||
@@ -48,6 +75,14 @@ class TrackConversionView(APIView):
|
||||
"""
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
# ⚠️ اضافه شده: قبلاً این endpoint هیچ throttleای نداشت؛ referralCode
|
||||
# نامعتبر ۴۰۴ میگیره (یک oracle قابلprobe) و حدسِ درست یک
|
||||
# Conversion واقعیِ متصل به کمیسیونِ affiliate میسازه. طبقِ همون
|
||||
# الگویِ بقیهی اپها، throttle_scope مجزا با GuestAwareAffiliateThrottle
|
||||
# اضافه شد تا هم probe کردنِ کد رو کند کنه، هم چرخشِ حساب مهمان
|
||||
# دیگه bucket تازه نده.
|
||||
throttle_scope = 'affiliate_track'
|
||||
throttle_classes = [GuestAwareAffiliateThrottle]
|
||||
|
||||
@extend_schema(
|
||||
request=TrackRequestSerializer, responses=TrackResponseSerializer, tags=['Affiliate (affiliate)']
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.core.cache import cache
|
||||
from django.test import TestCase
|
||||
from rest_framework.test import APIClient
|
||||
from rest_framework.throttling import ScopedRateThrottle
|
||||
|
||||
from accounts.authentication import issue_access_token
|
||||
from accounts.models import User
|
||||
@@ -21,6 +23,11 @@ class AskIdempotencyFollowUpsTests(TestCase):
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
# ⚠️ افزوده شده: بعد از اضافهشدنِ ScopedRateThrottle (scope='ai_ask')
|
||||
# به AskAIView، cache باید بینِ تستها پاک بشه؛ وگرنه شمارشگرِ throttle
|
||||
# (که در محیطِ تست روی LocMemCache نگهداری میشه و بهصورتِ خودکار
|
||||
# بینِ تستها پاک نمیشه) میتونه از یک تستِ قبلی باقی بمونه.
|
||||
cache.clear()
|
||||
self.client = APIClient()
|
||||
self.user = User.objects.create(device_id='device-ask-1', is_guest=True, name='')
|
||||
self.token = issue_access_token(self.user.id)
|
||||
@@ -73,6 +80,8 @@ class AskAIGenerationFailureTests(TestCase):
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
# ⚠️ افزوده شده: نگاه کنید به توضیحِ مشابه در AskIdempotencyFollowUpsTests.setUp
|
||||
cache.clear()
|
||||
self.client = APIClient()
|
||||
self.user = User.objects.create(device_id='device-ask-fail-1', is_guest=True, name='')
|
||||
self.token = issue_access_token(self.user.id)
|
||||
@@ -163,6 +172,8 @@ class AskAIConcurrentIdempotencyKeyTests(TestCase):
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
# ⚠️ افزوده شده: نگاه کنید به توضیحِ مشابه در AskIdempotencyFollowUpsTests.setUp
|
||||
cache.clear()
|
||||
self.client = APIClient()
|
||||
self.user = User.objects.create(device_id='device-ask-race-1', is_guest=True, name='')
|
||||
self.token = issue_access_token(self.user.id)
|
||||
@@ -238,6 +249,8 @@ class AskAIConversationApiContractTests(TestCase):
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
# ⚠️ افزوده شده: نگاه کنید به توضیحِ مشابه در AskIdempotencyFollowUpsTests.setUp
|
||||
cache.clear()
|
||||
self.client = APIClient()
|
||||
self.user = User.objects.create(device_id='device-contract-1', is_guest=True, name='')
|
||||
self.token = issue_access_token(self.user.id)
|
||||
@@ -305,6 +318,10 @@ class GenerationBoundaryContextTests(TestCase):
|
||||
تاریخچهی خامِ مکالمه رو بسازه و به این مرز بفرسته.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
# ⚠️ افزوده شده: نگاه کنید به توضیحِ مشابه در AskIdempotencyFollowUpsTests.setUp
|
||||
cache.clear()
|
||||
|
||||
def test_generate_tutor_answer_accepts_grade_lesson_and_application_provided_context(self):
|
||||
# مرز باید grade_id/lesson_id رو (بهعنوانِ metadataِ RAGِ آینده) و
|
||||
# context رو (چه رشته باشه، چه شیء/دیکشنری، چه لیستِ پیامهای
|
||||
@@ -398,3 +415,100 @@ class GenerationBoundaryContextTests(TestCase):
|
||||
# نشدن، None هستن.
|
||||
context_arg = call_args[3] if len(call_args) > 3 else call_kwargs.get('context')
|
||||
self.assertIsNone(context_arg)
|
||||
|
||||
|
||||
class AskAIViewThrottleTests(TestCase):
|
||||
"""
|
||||
🔒 اثبات محدودیتِ جدیدِ ScopedRateThrottle (scope='ai_ask') روی
|
||||
AskAIView: قبلاً این endpoint هیچ throttle مخصوصی نداشت (فقط
|
||||
DAILY_AI_QUOTA که سقفِ روزانه است، نه محدودیتِ burst کوتاهمدت).
|
||||
این تست ثابت میکند که درخواستهای مجاز داخلِ نرخ همچنان کار میکنند
|
||||
و درخواستِ بیش از حد با 429 رد میشود — بدون اینکه به کوتای روزانه یا
|
||||
idempotency دست بزند.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
cache.clear()
|
||||
self.user = User.objects.create(device_id='device-ai-throttle-1', is_guest=True, name='')
|
||||
self.token = issue_access_token(self.user.id)
|
||||
|
||||
def _ask(self, idempotency_key):
|
||||
return self.client.post(
|
||||
'/api/ai/ask',
|
||||
{'userQuestion': 'یک بهعلاوه یک چند میشود؟'},
|
||||
format='json',
|
||||
HTTP_AUTHORIZATION=f'Bearer {self.token}',
|
||||
HTTP_IDEMPOTENCY_KEY=idempotency_key,
|
||||
)
|
||||
|
||||
def test_requests_within_rate_succeed_and_excess_is_throttled(self):
|
||||
# هر درخواست idempotency-key متفاوت دارد تا هر بار واقعاً از کوتای
|
||||
# روزانه مصرف کند (نه یک replay ساده)، و صرفاً throttle سنجیده شود.
|
||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'ai_ask': '3/hour'}):
|
||||
for i in range(3):
|
||||
response = self._ask(f'ai-ask-throttle-key-{i}')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
throttled_response = self._ask('ai-ask-throttle-key-3')
|
||||
self.assertEqual(throttled_response.status_code, 429)
|
||||
|
||||
|
||||
class AskAIViewGuestThrottleBypassTests(TestCase):
|
||||
"""
|
||||
🔒 اثبات فیکسِ امنیتی: قبلاً کلیدِ throttle_scope='ai_ask' برای هر
|
||||
کاربرِ احرازهویتشده (مهمان یا نه) user.pk بود. چون GuestSessionView
|
||||
با هر deviceId جدید یک کاربرِ مهمانِ تازه (pk جدید) میسازه، یک
|
||||
مهاجم میتونست با ساختنِ پیدرپیِ حسابهای مهمان، هر بار یک bucket
|
||||
throttle تازه برای AskAIView بگیره. حالا GuestAwareAIThrottle برای
|
||||
کاربرهای مهمان کلید رو بر اساسِ IP (همون get_ident که guest_session
|
||||
هم استفاده میکنه) میسازه، پس چرخشِ حساب مهمان دیگه bucket تازه
|
||||
نمیده. رفتارِ throttle برای کاربرِ غیرمهمان (per-user، مثلِ قبل)
|
||||
دستنخورده مونده.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
cache.clear()
|
||||
self.client = APIClient()
|
||||
|
||||
def _ask(self, token, idempotency_key):
|
||||
return self.client.post(
|
||||
'/api/ai/ask',
|
||||
{'userQuestion': 'یک بهعلاوه یک چند میشود؟'},
|
||||
format='json',
|
||||
HTTP_AUTHORIZATION=f'Bearer {token}',
|
||||
HTTP_IDEMPOTENCY_KEY=idempotency_key,
|
||||
)
|
||||
|
||||
def test_rotating_guest_accounts_share_the_same_throttle_bucket(self):
|
||||
# سه حساب مهمانِ متفاوت (شبیهسازیِ چرخشِ حساب برای دورزدنِ throttle)،
|
||||
# همه از همون کلاینت/IP — باید همه یک bucket مشترک رو مصرف کنند.
|
||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'ai_ask': '2/hour'}):
|
||||
guest1 = User.objects.create(device_id='device-guest-rot-1', is_guest=True, name='')
|
||||
response1 = self._ask(issue_access_token(guest1.id), 'guest-rot-key-1')
|
||||
self.assertEqual(response1.status_code, 200)
|
||||
|
||||
guest2 = User.objects.create(device_id='device-guest-rot-2', is_guest=True, name='')
|
||||
response2 = self._ask(issue_access_token(guest2.id), 'guest-rot-key-2')
|
||||
self.assertEqual(response2.status_code, 200)
|
||||
|
||||
# سومین حسابِ مهمانِ تازه (pk جدید) باید throttle بشه، چون
|
||||
# bucket بر اساسِ IP بهاشتراک گذاشته شده، نه user.pk.
|
||||
guest3 = User.objects.create(device_id='device-guest-rot-3', is_guest=True, name='')
|
||||
response3 = self._ask(issue_access_token(guest3.id), 'guest-rot-key-3')
|
||||
|
||||
self.assertEqual(response3.status_code, 429)
|
||||
|
||||
def test_registered_users_keep_independent_per_user_buckets(self):
|
||||
# عدمِ رگرسیون: کاربرهای غیرمهمان (ثبتنامشده با شماره) باید مثلِ
|
||||
# قبل هرکدوم bucket جداگانهی خودشون رو داشته باشن — throttle
|
||||
# برای اونا هنوز بر اساسِ user.pk است، نه IP.
|
||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'ai_ask': '1/hour'}):
|
||||
user_a = User.objects.create(is_guest=False, name='', phone='+989120000101')
|
||||
response_a = self._ask(issue_access_token(user_a.id), 'user-a-key-1')
|
||||
self.assertEqual(response_a.status_code, 200)
|
||||
|
||||
user_b = User.objects.create(is_guest=False, name='', phone='+989120000102')
|
||||
response_b = self._ask(issue_access_token(user_b.id), 'user-b-key-1')
|
||||
|
||||
self.assertEqual(response_b.status_code, 200)
|
||||
|
||||
@@ -9,6 +9,7 @@ from drf_spectacular.utils import OpenApiParameter, extend_schema
|
||||
from rest_framework.exceptions import APIException, Throttled
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.throttling import ScopedRateThrottle
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from accounts.models import User as AccountUser
|
||||
@@ -26,6 +27,39 @@ logger = logging.getLogger(__name__)
|
||||
DAILY_AI_QUOTA = 15
|
||||
|
||||
|
||||
class GuestAwareAIThrottle(ScopedRateThrottle):
|
||||
"""
|
||||
⚠️ اضافه شده: ScopedRateThrottle استاندارد برای کاربرِ احرازهویتشده
|
||||
(مهمان یا نه) از request.user.pk بهعنوانِ کلیدِ throttle استفاده
|
||||
میکنه. چون GuestSessionView با هر deviceId جدید یک کاربرِ مهمانِ
|
||||
تازه (pk جدید) میسازه، یک مهاجم میتونست با ساختنِ پیدرپیِ حسابهای
|
||||
مهمان، برای هر حساب یک bucket مستقلِ throttle (و همینطور کوتای
|
||||
روزانهی AskAIView که هم بر اساسِ user_id شمارش میشه) بگیره — یعنی
|
||||
نرخِ واقعیِ استفاده از این endpoint گرونقیمت بهجایِ محدود به نرخِ
|
||||
ai_ask در ساعت، عملاً فقط به نرخِ ساختِ حساب مهمان (guest_session)
|
||||
محدود میشد.
|
||||
|
||||
این کلاس فقط برای کاربرهای مهمان (is_guest=True) کلیدِ cache رو از
|
||||
pk کاربر به IP کلاینت (همون get_ident که guest_session هم استفاده
|
||||
میکنه) تغییر میده؛ یعنی همهی حسابهای مهمانِ ساختهشده از یک IP
|
||||
مشترکِ throttle bucket ai_ask رو دارن و دیگه با عوضکردنِ حساب
|
||||
نمیشه bucket تازه گرفت. رفتارِ throttle برای کاربرهای غیرمهمان
|
||||
(ثبتنامشده با شماره) دقیقاً مثلِ قبل (کلید بر اساسِ pk) باقی
|
||||
میمونه؛ منطقِ DAILY_AI_QUOTA و بقیهی view دستنخوردهست.
|
||||
"""
|
||||
|
||||
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}
|
||||
|
||||
|
||||
def _is_db_ref_id(value) -> bool:
|
||||
"""
|
||||
⚠️ افزوده شده: lessonId/chapterId/questionId در قراردادِ API بهصورتِ
|
||||
@@ -105,6 +139,13 @@ class AskAIView(APIView):
|
||||
"""POST /api/ai/ask — سوال از AI (استریم اختیاری؛ در این فاز استریم پیاده نشده)"""
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
# ⚠️ افزوده شده: قبلاً این endpoint هیچ throttle مخصوصی نداشت؛ DAILY_AI_QUOTA
|
||||
# فقط سقفِ روزانه است و جلوی burst کوتاهمدت (مثلاً چند ده درخواست در چند
|
||||
# ثانیه به generate_tutor_answer) را نمیگیرد. throttle_scope مجزا
|
||||
# (ai_ask) این burst را محدود میکند، بدون اینکه به کوتای روزانه/idempotency
|
||||
# یا منطق AI دست بزند.
|
||||
throttle_scope = 'ai_ask'
|
||||
throttle_classes = [GuestAwareAIThrottle]
|
||||
|
||||
@extend_schema(
|
||||
request=AskRequestSerializer,
|
||||
|
||||
36
config/log_formatters.py
Normal file
36
config/log_formatters.py
Normal file
@@ -0,0 +1,36 @@
|
||||
"""
|
||||
فرمتکنندهی سبکِ JSON برای خروجیِ logging — آمادهسازیِ پروژه برای
|
||||
جمعآوریِ متمرکزِ لاگ (مثلاً یک agentِ بیرونی مثلِ Promtail/Filebeat که
|
||||
stdout رو میخونه و به Loki/ELK میفرسته) در آینده.
|
||||
|
||||
⚠️ توجه (محدودهی این تغییر):
|
||||
- این فایل فقط *فرمتِ* خروجیِ همون لاگهای موجودِ اپلیکیشن رو عوض میکنه
|
||||
(یک شیِ JSON در هر خط، بهجای متنِ سادهی قبلی)؛ هیچ eventِ لاگِ جدیدی
|
||||
اضافه نمیکنه و هیچ loggerِ موجودی رو تغییر نمیده.
|
||||
- فقط از فیلدهای استانداردِ خودِ LogRecord (زمان، سطح، نامِ logger، پیامِ
|
||||
از قبل ساختهشده، و traceback در صورتِ وجود) استفاده میشه؛ یعنی همون
|
||||
محافظتهای حساسیتیِ قبلی (بدون OTP/token/رمزعبور/بدنهی خامِ provider و
|
||||
غیره در پیامها) دستنخورده میمونه — این فرمتکننده چیزی به پیام اضافه
|
||||
نمیکنه، فقط قالببندیِ همون پیامِ از قبل امن رو تغییر میده.
|
||||
- هیچ وابستگیِ جدید (Elasticsearch/Loki/Grafana client) یا appِ جدید یا
|
||||
ذخیرهسازیِ دیتابیسی/APIِ لاگ اضافه نمیکنه؛ فقط stdlibِ logging + json.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
|
||||
class JSONLogFormatter(logging.Formatter):
|
||||
"""هر LogRecord رو به یک خطِ JSON (سازگار با collectorهای سبکِ Loki/ELK) تبدیل میکنه."""
|
||||
|
||||
def format(self, record: logging.LogRecord) -> str:
|
||||
payload = {
|
||||
'timestamp': datetime.fromtimestamp(record.created, tz=timezone.utc).isoformat(),
|
||||
'level': record.levelname,
|
||||
'logger': record.name,
|
||||
'message': record.getMessage(),
|
||||
}
|
||||
if record.exc_info:
|
||||
payload['exception'] = self.formatException(record.exc_info)
|
||||
return json.dumps(payload, ensure_ascii=False)
|
||||
91
config/middleware.py
Normal file
91
config/middleware.py
Normal file
@@ -0,0 +1,91 @@
|
||||
"""
|
||||
میانافزارِ سبکِ لاگِ درخواستهای API — سطحِ پروژه.
|
||||
|
||||
⚠️ افزوده شده: پروژه چند logger پراکندهی اپلیکیشنی داشت (accounts.services،
|
||||
ai_tutor.views، ...) اما هیچ لاگِ یکپارچهای در سطحِ پروژه برای *همهی*
|
||||
درخواستهای API وجود نداشت؛ یعنی درخواستهای عادی، ناموفق، یا الگوهای
|
||||
مشکوک (مثلاً یک IP/کاربر که پشتِ سرِ هم درخواستِ ناموفق میفرسته) بهصورتِ
|
||||
یکدست قابلِ ردیابی نبودن. این میانافزار فقط یک خط لاگِ خلاصه بهازای هر
|
||||
درخواستِ زیرِ /api/ ثبت میکنه: متد، مسیر، کدِ وضعیتِ پاسخ، مدتزمان، و
|
||||
شناسهی کاربرِ احرازهویتشده (اگه وجود داشته باشه).
|
||||
|
||||
نکاتِ امنیتی/حریمِ خصوصی:
|
||||
- هیچوقت بدنهی درخواست/پاسخ، query string، هدرها (از جمله Authorization،
|
||||
Idempotency-Key و غیره)، رمزعبور/OTP، access/refresh token، کلیدِ API، یا
|
||||
سکرتِ webhook لاگ نمیشه — فقط request.path (بدونِ query string) و
|
||||
متادیتای بیضررِ بالا.
|
||||
- در صورتِ هر خطای غیرمنتظره در خودِ فرآیندِ لاگکردن (مثلاً یک مقدارِ
|
||||
غیرمنتظره برای user)، این میانافزار هیچوقت نباید جریانِ درخواست/پاسخ
|
||||
را بشکنه؛ به همین خاطر کل بخشِ لاگ داخلِ try/except قرار گرفته.
|
||||
- ⚠️ اصلاح: قبلاً اگه self.get_response(request) خودش یک exceptionِ
|
||||
دستنخورده (unhandled) بالا میآورد، اون exception قبل از رسیدن به بلوکِ
|
||||
لاگ از __call__ خارج میشد؛ یعنی خطاهای ۵۰۰ اصلاً لاگ نمیشدن. حالا
|
||||
self.get_response در try/except قرار گرفته: در صورتِ exception، یک خطِ
|
||||
لاگِ سطحِ ERROR (با متد/مسیر/مدت/user_id، بدونِ جزئیاتِ حساس) ثبت میشه
|
||||
و بعد همون exceptionِ اصلی بدونِ تغییر دوباره raise میشه (تا رفتارِ
|
||||
مدیریتِ خطای Django/DRF دستنخورده بمونه).
|
||||
- ⚠️ اصلاحِ این دور: پیامِ لاگِ مسیرِ exception از یک placeholderِ واقعیِ
|
||||
status=%s استفاده میکنه (همسو با مسیرِ موفق)، و status=500 حالا از
|
||||
طریقِ آرگومانِ status به _log_safely() صریحاً پاس داده میشه، نه بهصورتِ
|
||||
متنِ ثابت داخلِ رشته. این هم پیام رو با مسیرِ موفق یکدست میکنه و هم از
|
||||
ناهماهنگیِ تعدادِ آرگومانهای % formatting جلوگیری میکنه.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import time
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# فقط مسیرهای API لاگ میشن؛ درخواستهای استاتیک/غیرِ API (که این پروژه
|
||||
# اصلاً مسیرِ URL جداگانهای براشون در config/urls.py تعریف نکرده) عمداً
|
||||
# نادیده گرفته میشن تا لاگ شلوغ/بیفایده تولید نشه.
|
||||
_LOGGED_PATH_PREFIX = '/api/'
|
||||
|
||||
|
||||
def _user_id_for_log(request):
|
||||
"""شناسهی کاربرِ احرازهویتشده برای لاگ، یا None (کاربرِ ناشناس/بدونِ user)."""
|
||||
user = getattr(request, 'user', None)
|
||||
return user.id if getattr(user, 'is_authenticated', False) else None
|
||||
|
||||
|
||||
class APIRequestLoggingMiddleware:
|
||||
"""میانافزارِ Django برای لاگِ خلاصهی هر درخواستِ API (متد/مسیر/وضعیت/مدت/کاربر)."""
|
||||
|
||||
def __init__(self, get_response):
|
||||
self.get_response = get_response
|
||||
|
||||
def __call__(self, request):
|
||||
if not request.path.startswith(_LOGGED_PATH_PREFIX):
|
||||
return self.get_response(request)
|
||||
|
||||
start = time.monotonic()
|
||||
|
||||
try:
|
||||
response = self.get_response(request)
|
||||
except Exception:
|
||||
self._log_safely(
|
||||
logger.error,
|
||||
'api_request method=%s path=%s status=%s duration_ms=%.1f user_id=%s (unhandled exception)',
|
||||
request, start, status=500,
|
||||
)
|
||||
raise
|
||||
|
||||
self._log_safely(
|
||||
logger.info,
|
||||
'api_request method=%s path=%s status=%s duration_ms=%.1f user_id=%s',
|
||||
request, start, response.status_code,
|
||||
)
|
||||
return response
|
||||
|
||||
def _log_safely(self, log_func, message, request, start, status=None):
|
||||
# لاگکردن هیچوقت نباید خودش دلیلِ شکستِ یک درخواست بشه (چه در
|
||||
# مسیرِ موفق، چه در مسیرِ exception)؛ به همین خاطر کل بخشِ لاگ
|
||||
# داخلِ try/except قرار گرفته.
|
||||
try:
|
||||
duration_ms = (time.monotonic() - start) * 1000
|
||||
if status is None:
|
||||
log_func(message, request.method, request.path, duration_ms, _user_id_for_log(request))
|
||||
else:
|
||||
log_func(message, request.method, request.path, status, duration_ms, _user_id_for_log(request))
|
||||
except Exception:
|
||||
logger.exception('api_request logging failed')
|
||||
@@ -90,6 +90,11 @@ MIDDLEWARE = [
|
||||
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||
'django.contrib.messages.middleware.MessageMiddleware',
|
||||
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||
# ⚠️ افزوده شده: لاگِ سبکِ هر درخواستِ API (متد/مسیر/وضعیت/مدت/کاربر)؛
|
||||
# عمداً آخرِ لیست قرار گرفته تا نزدیکترین میانافزار به خودِ view باشه
|
||||
# (مدتزمانِ دقیقتر، و request.user که توسطِ DRF authentication در
|
||||
# حینِ پردازشِ view ست میشه، در دسترس باشه).
|
||||
'config.middleware.APIRequestLoggingMiddleware',
|
||||
]
|
||||
|
||||
ROOT_URLCONF = 'config.urls'
|
||||
@@ -208,10 +213,44 @@ REST_FRAMEWORK = {
|
||||
'DEFAULT_THROTTLE_CLASSES': [
|
||||
'rest_framework.throttling.ScopedRateThrottle',
|
||||
],
|
||||
# ⚠️ اضافه شده (امنیتی): بدونِ NUM_PROXIES، DRF (SimpleRateThrottle.get_ident)
|
||||
# هدرِ X-Forwarded-For رو - اگه در درخواست وجود داشته باشه - عیناً و
|
||||
# بدونچک بهعنوانِ IP کلاینت قبول میکنه؛ یعنی هر کلاینتِ ناشناس میتونست
|
||||
# با فرستادنِ یک X-Forwarded-For دلخواه/تصادفی در هر درخواست، برای
|
||||
# هر GuestAware*Throttle (content/ai_tutor/subscription/payment/discount/
|
||||
# support/family/affiliate) یک IP-bucket تازه بگیره - دقیقاً همون
|
||||
# bypassِ چرخشِ حساب مهمان که این throttle-classها قرار بود ببندن، فقط
|
||||
# از طریقِ هدر بهجای deviceId جدید.
|
||||
# با NUM_PROXIES=1 (پیشفرض؛ برای دیپلویِ پشتِ یک لایهی پراکسی/LB که
|
||||
# خودش X-Forwarded-For رو ست/rewrite میکنه، نه پاسدادنِ مقدارِ
|
||||
# کلاینت)، DRF از انتهای زنجیرهی XFF دقیقاً همون تعداد hop رو
|
||||
# میخونه. اگه دیپلویِ پروداکشن تعداد لایهی پراکسیِ دیگهای داره
|
||||
# (مثلاً Cloudflare + Nginx = ۲ لایه)، این مقدار باید با env
|
||||
# NUM_PROXIES مطابقِ همون تعداد ست بشه، وگرنه یا IP واقعی اشتباه
|
||||
# خونده میشه یا (اگه صفر باشه) دوباره همون مشکلِ بالا برمیگرده.
|
||||
'NUM_PROXIES': int(os.environ.get('NUM_PROXIES', '1')),
|
||||
'DEFAULT_THROTTLE_RATES': {
|
||||
'otp_request': '5/hour',
|
||||
'otp_login': '10/hour',
|
||||
'guest_session': '20/hour',
|
||||
'bazaar_verify': '10/hour',
|
||||
'ai_ask': '20/hour',
|
||||
'purchase': '10/hour',
|
||||
'token_refresh': '30/hour',
|
||||
'question_answer': '60/min',
|
||||
'lesson_complete': '10/hour',
|
||||
'support_create': '30/hour',
|
||||
'offer_redeem': '10/hour',
|
||||
'discount_validate': '20/hour',
|
||||
'discount_redeem': '10/hour',
|
||||
'family_invite_accept': '10/hour',
|
||||
'family_invite_create': '5/hour',
|
||||
# ⚠️ اضافه شده: چهار endpointی که در ممیزیِ throttleِ امروز بدونِ
|
||||
# throttle_scope پیدا شدن (نگاه کنید به توضیحاتِ throttle_scope
|
||||
# مربوطه در accounts/views.py، affiliate/views.py، payment/views.py).
|
||||
'user_profile_write': '60/hour',
|
||||
'affiliate_track': '10/hour',
|
||||
'payment_webhook': '60/hour',
|
||||
},
|
||||
}
|
||||
|
||||
@@ -243,6 +282,23 @@ REFRESH_TOKEN_TTL_DAYS = 30
|
||||
OTP_LOGIN_PHONE_MAX_ATTEMPTS = 10
|
||||
OTP_LOGIN_PHONE_WINDOW_SECONDS = 3600
|
||||
|
||||
# محدودیتِ صدورِ/ارسالِ دوبارهِ OTP بر اساسِ شمارهتلفن، مستقل از throttle
|
||||
# IP-محورِ otp_request؛ جلوی flood کردنِ پیامک به یک شماره از طریق چند IP
|
||||
# مختلف رو میگیره (فقط تعدادِ درخواست رو محدود میکنه، نه شمارهی IP رو).
|
||||
OTP_REQUEST_PHONE_MAX_ATTEMPTS = 5
|
||||
OTP_REQUEST_PHONE_WINDOW_SECONDS = 3600
|
||||
|
||||
# حداقل فاصلهی زمانی بین دو درخواستِ صدورِ OTP برای همون شماره. این مستقل
|
||||
# از سقفِ ساعتیِ بالاست: سقفِ ساعتی جلویِ حجمِ کلی رو میگیره اما جلویِ
|
||||
# burst رو نمیگیره (مثلاً هر ۵ تلاشِ مجاز در عرضِ کمتر از یک ثانیه). این
|
||||
# cooldown دقیقاً همون burst رو هدف میگیره؛ نه جایگزینِ سقفِ ساعتیه، نه
|
||||
# باهاش redundant — هر کدوم یک بُعدِ متفاوتِ سوءاستفاده رو میبندن.
|
||||
# ⚠️ تصمیمِ محصول/امنیت: کاربر واقعاً باید هر ۲ دقیقه بتونه دوباره OTP
|
||||
# بگیره؛ enforcement سمتِ سرور هم باید دقیقاً همین مقدار باشه — countdown
|
||||
# سمتِ فرانت صرفاً UX هست و نباید مرجعِ امنیتی باشه، پس این مقدار همیشه
|
||||
# باید با چیزی که فرانت به کاربر نشون میده یکی باشه.
|
||||
OTP_REQUEST_PHONE_COOLDOWN_SECONDS = 120
|
||||
|
||||
# در محیط dev، این کد OTP همیشه معتبر است تا بدون SMS واقعی تست شود.
|
||||
# فقط وقتی DEBUG=True مقدار دارد؛ در پروداکشن None است.
|
||||
DEV_MASTER_OTP = '0000' if DEBUG else None
|
||||
@@ -307,3 +363,59 @@ BAZAAR_API_BASE = os.environ.get('BAZAAR_API_BASE', 'https://pardakht.cafebazaar
|
||||
# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field
|
||||
|
||||
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Logging (آمادهسازیِ لاگهای موجود برای جمعآوریِ متمرکز — Loki/ELK-style)
|
||||
# -----------------------------------------------------------------------
|
||||
# ⚠️ افزوده شده: پروژه از قبل چندین logger (config.middleware،
|
||||
# accounts.services، ai_tutor.views، ...) با logging.getLogger(__name__)
|
||||
# داشت، اما بدونِ یک LOGGING صریح در settings.py، اینا فقط به rootِ
|
||||
# پیشفرضِ کتابخانهی logging میرسیدن (که برای سطحِ INFO عملاً هیچ خروجیای
|
||||
# تولید نمیکنه). این تنظیم، بدونِ اضافه/حذفِ هیچ loggerِ اپلیکیشنی یا
|
||||
# eventِ لاگی، همون پیامهای موجود رو روی stdout و بهصورتِ یک JSON در هر
|
||||
# خط (فرمتِ سازگار با agentِ جمعآوریِ بیرونی مثلِ Promtail/Filebeat که
|
||||
# بعداً میتونه به Loki/ELK بفرسته) چاپ میکنه. هیچ appِ جدید، ذخیرهسازیِ
|
||||
# دیتابیسی، API/UIِ لاگ، یا وابستگیِ Elasticsearch/Loki/Grafana به خودِ
|
||||
# کدِ Django اضافه نشده.
|
||||
LOGGING = {
|
||||
'version': 1,
|
||||
'disable_existing_loggers': False,
|
||||
'formatters': {
|
||||
'json': {
|
||||
'()': 'config.log_formatters.JSONLogFormatter',
|
||||
},
|
||||
},
|
||||
'handlers': {
|
||||
'console': {
|
||||
'class': 'logging.StreamHandler',
|
||||
'formatter': 'json',
|
||||
},
|
||||
},
|
||||
'root': {
|
||||
'handlers': ['console'],
|
||||
'level': 'INFO',
|
||||
},
|
||||
}
|
||||
|
||||
# ⚠️ افزوده شده (یکپارچهسازیِ Docker + Promtail/Loki/Grafana): وقتی
|
||||
# env var LOG_DIR ست شده باشه (فقط داخلِ docker-compose.yml، روی یک
|
||||
# ولومِ مشترک با کانتینرِ promtail)، همون خطوطِ JSON که از قبل روی
|
||||
# stdout چاپ میشدن، *علاوه* بر stdout در یک فایل هم نوشته میشن؛
|
||||
# Promtail همون فایل رو tail میکنه و به Loki میفرسته.
|
||||
# این فقط یک هندلرِ خروجیِ اضافه با همون formatter (json) هست — نه
|
||||
# پیامهای لاگ، نه محافظتهای حساسیتیِ موجود (بدونِ OTP/token/رمزعبور و
|
||||
# غیره در پیامها)، و نه هیچ loggerِ اپلیکیشنی تغییر نمیکنه.
|
||||
# وقتی LOG_DIR ست نباشه (یعنی اجرای عادیِ خارج از Docker، طبق README)،
|
||||
# رفتار دقیقاً همون چیزیه که از قبل بود: فقط stdout.
|
||||
LOG_DIR = os.environ.get('LOG_DIR', '')
|
||||
|
||||
if LOG_DIR:
|
||||
os.makedirs(LOG_DIR, exist_ok=True)
|
||||
LOGGING['handlers']['file'] = {
|
||||
'class': 'logging.handlers.RotatingFileHandler',
|
||||
'filename': os.path.join(LOG_DIR, 'app.log'),
|
||||
'maxBytes': 10 * 1024 * 1024, # 10MB در هر فایل، قبل از rotate
|
||||
'backupCount': 5,
|
||||
'formatter': 'json',
|
||||
}
|
||||
LOGGING['root']['handlers'].append('file')
|
||||
|
||||
173
config/tests.py
173
config/tests.py
@@ -1,9 +1,16 @@
|
||||
import importlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from django.test import SimpleTestCase
|
||||
from django.test import RequestFactory, SimpleTestCase, TestCase
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from accounts.authentication import issue_access_token
|
||||
from accounts.models import User
|
||||
from config.middleware import APIRequestLoggingMiddleware
|
||||
|
||||
|
||||
class SecretKeyFailClosedTests(SimpleTestCase):
|
||||
@@ -116,3 +123,167 @@ class CacheUrlFailClosedTests(SimpleTestCase):
|
||||
settings_module.CACHES['default']['BACKEND'],
|
||||
'django.core.cache.backends.locmem.LocMemCache',
|
||||
)
|
||||
|
||||
|
||||
class APIRequestLoggingMiddlewareTests(TestCase):
|
||||
"""
|
||||
🔒 اثبات میانافزارِ جدیدِ لاگِ درخواستِ API (config.middleware.
|
||||
APIRequestLoggingMiddleware): باید برای مسیرهای /api/ یک خطِ لاگِ خلاصه
|
||||
(متد/مسیر/وضعیت/مدت/شناسهی کاربر) ثبت کنه، بدونِ اینکه هیچوقت بدنه/
|
||||
هدر/سکرت رو لاگ کنه؛ و برای مسیرهای غیرِ /api/ (مثلِ /admin/) اصلاً
|
||||
لاگ نکنه.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
|
||||
def test_authenticated_api_request_is_logged_with_user_id_and_no_secrets(self):
|
||||
user = User.objects.create(device_id='device-mw-log-1', is_guest=True, name='')
|
||||
token = issue_access_token(user.id)
|
||||
|
||||
with self.assertLogs('config.middleware', level='INFO') as captured:
|
||||
response = self.client.post(
|
||||
'/api/user/grade',
|
||||
{'gradeId': 10},
|
||||
format='json',
|
||||
HTTP_AUTHORIZATION=f'Bearer {token}',
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(len(captured.records), 1)
|
||||
message = captured.records[0].getMessage()
|
||||
|
||||
self.assertIn('method=POST', message)
|
||||
self.assertIn('path=/api/user/grade', message)
|
||||
self.assertIn('status=200', message)
|
||||
self.assertIn(f'user_id={user.id}', message)
|
||||
|
||||
# هیچوقت نباید هیچ سکرتی در پیامِ لاگ باشه.
|
||||
self.assertNotIn(token, message)
|
||||
self.assertNotIn('Bearer', message)
|
||||
self.assertNotIn('gradeId', message)
|
||||
|
||||
def test_anonymous_api_request_is_logged_with_null_user_id(self):
|
||||
with self.assertLogs('config.middleware', level='INFO') as captured:
|
||||
response = self.client.post(
|
||||
'/api/auth/session',
|
||||
{'type': 'guest', 'deviceId': 'device-mw-log-anon-1'},
|
||||
format='json',
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(len(captured.records), 1)
|
||||
message = captured.records[0].getMessage()
|
||||
|
||||
self.assertIn('method=POST', message)
|
||||
self.assertIn('path=/api/auth/session', message)
|
||||
self.assertIn('status=200', message)
|
||||
self.assertIn('user_id=None', message)
|
||||
|
||||
def test_non_api_request_is_not_logged(self):
|
||||
# /admin/ زیرِ /api/ نیست؛ میانافزار عمداً اینجور مسیرها رو لاگ
|
||||
# نمیکنه تا لاگِ اضافه/بیفایده تولید نشه.
|
||||
logger = logging.getLogger('config.middleware')
|
||||
handler = logging.Handler()
|
||||
records = []
|
||||
handler.emit = records.append
|
||||
logger.addHandler(handler)
|
||||
try:
|
||||
self.client.get('/admin/login/')
|
||||
finally:
|
||||
logger.removeHandler(handler)
|
||||
|
||||
self.assertEqual(records, [])
|
||||
|
||||
def test_unhandled_exception_from_view_is_logged_as_error_and_reraised(self):
|
||||
# 🔒 اثبات فیکسِ این دور: اگه self.get_response(request) خودش یک
|
||||
# exceptionِ دستنخورده raise کنه، میانافزار باید همچنان یک خطِ
|
||||
# لاگِ سطحِ ERROR (متد/مسیر/status=500/مدت/user_id، بدونِ جزئیاتِ
|
||||
# حساس) ثبت کنه و بعد دقیقاً همون exceptionِ اصلی رو دوباره raise
|
||||
# کنه — بدونِ اینکه هیچ رفتارِ Django/DRF یا exceptionِ دیگهای رو
|
||||
# تغییر بده. مستقیماً خودِ میانافزار (نه کلِ چرخهی
|
||||
# exception-handlingِ Django) تست میشه، چون همون لایهایست که این
|
||||
# فیکس روش اعمال شده.
|
||||
#
|
||||
# ⚠️ اصلاح (باگِ خودِ تست، نه اپلیکیشن): قبلاً این تست فقط
|
||||
# record.getMessage() رو چک میکرد. getMessage() خودش داخلی
|
||||
# msg %% args رو انجام میده، پس اگه شمارِ آرگومانها با
|
||||
# placeholderهای رشته جور نباشه، همونجا با یک exception (نه یک
|
||||
# پیامِ نادرست) fail میشد و اصلاً به assertion نمیرسید. برای اینکه
|
||||
# این تست واقعاً *صحتِ formatting* رو بسنجه (نه فقط اینکه یکجوری
|
||||
# fail نشه)، حالا با logging.Formatter().format(record) دقیقاً
|
||||
# همون مسیری که یک handlerِ واقعی (کنسول/فایل) طی میکنه رو صدا
|
||||
# میزنیم و روی متنِ نهاییِ فرمتشده assert میکنیم.
|
||||
request = RequestFactory().get('/api/some/failing/path')
|
||||
|
||||
def failing_get_response(inner_request):
|
||||
raise ValueError('boom')
|
||||
|
||||
middleware = APIRequestLoggingMiddleware(failing_get_response)
|
||||
|
||||
with self.assertLogs('config.middleware', level='ERROR') as captured:
|
||||
with self.assertRaises(ValueError):
|
||||
middleware(request)
|
||||
|
||||
self.assertEqual(len(captured.records), 1)
|
||||
record = captured.records[0]
|
||||
self.assertEqual(record.levelname, 'ERROR')
|
||||
|
||||
formatted = logging.Formatter().format(record)
|
||||
|
||||
self.assertIn('method=GET', formatted)
|
||||
self.assertIn('path=/api/some/failing/path', formatted)
|
||||
self.assertIn('status=500', formatted)
|
||||
self.assertIn('user_id=None', formatted)
|
||||
|
||||
|
||||
class JSONLogFormatterTests(SimpleTestCase):
|
||||
"""
|
||||
🔒 اثبات فرمتکنندهی جدیدِ JSON (config.log_formatters.JSONLogFormatter)
|
||||
که پروژه رو برای جمعآوریِ متمرکزِ لاگ (Loki/ELK-style) آماده میکنه:
|
||||
خروجی باید یک JSON معتبر با فیلدهای timestamp/level/logger/message
|
||||
باشه، و در صورتِ وجودِ exception، traceback هم بهعنوانِ فیلدِ
|
||||
"exception" اضافه بشه.
|
||||
"""
|
||||
|
||||
def _make_record(self, level=logging.INFO, message='hello world', exc_info=None):
|
||||
return logging.LogRecord(
|
||||
name='some.logger',
|
||||
level=level,
|
||||
pathname=__file__,
|
||||
lineno=1,
|
||||
msg=message,
|
||||
args=(),
|
||||
exc_info=exc_info,
|
||||
)
|
||||
|
||||
def test_basic_record_is_formatted_as_valid_json_with_expected_fields(self):
|
||||
from config.log_formatters import JSONLogFormatter
|
||||
|
||||
record = self._make_record(level=logging.INFO, message='api_request method=GET path=/api/x status=200')
|
||||
formatted = JSONLogFormatter().format(record)
|
||||
|
||||
payload = json.loads(formatted)
|
||||
self.assertEqual(payload['level'], 'INFO')
|
||||
self.assertEqual(payload['logger'], 'some.logger')
|
||||
self.assertEqual(payload['message'], 'api_request method=GET path=/api/x status=200')
|
||||
self.assertIn('timestamp', payload)
|
||||
self.assertNotIn('exception', payload)
|
||||
|
||||
def test_record_with_exception_includes_traceback_field(self):
|
||||
from config.log_formatters import JSONLogFormatter
|
||||
|
||||
try:
|
||||
raise ValueError('boom')
|
||||
except ValueError:
|
||||
import sys
|
||||
exc_info = sys.exc_info()
|
||||
|
||||
record = self._make_record(level=logging.ERROR, message='something failed', exc_info=exc_info)
|
||||
formatted = JSONLogFormatter().format(record)
|
||||
|
||||
payload = json.loads(formatted)
|
||||
self.assertEqual(payload['level'], 'ERROR')
|
||||
self.assertEqual(payload['message'], 'something failed')
|
||||
self.assertIn('exception', payload)
|
||||
self.assertIn('ValueError: boom', payload['exception'])
|
||||
|
||||
422
content/tests.py
422
content/tests.py
@@ -1,10 +1,14 @@
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.test import TestCase
|
||||
from rest_framework.test import APIClient
|
||||
from rest_framework.throttling import ScopedRateThrottle
|
||||
|
||||
from accounts.authentication import issue_access_token
|
||||
from accounts.models import User
|
||||
from progress.models import UserHearts, UserStats
|
||||
|
||||
from .models import Grade, Lesson, Node, Question, Unit
|
||||
from .models import Choice, Grade, Lesson, Node, NodeProgress, Question, Unit, UserAnswer, UserLessonCompletion
|
||||
|
||||
|
||||
class LessonCompleteImpossibleCountTests(TestCase):
|
||||
@@ -107,3 +111,419 @@ class LessonCompleteRatioIgnoresClientTotalQuestionsTests(TestCase):
|
||||
response = self._complete(correct_count=1, total_questions=1)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.data['stars'], 1)
|
||||
|
||||
|
||||
class QuestionAnswerThrottleTests(TestCase):
|
||||
"""
|
||||
🔒 اثبات محدودیتِ جدیدِ per-user روی QuestionAnswerView
|
||||
(throttle_scope='question_answer'): Idempotency-Key فقط جلوی retry با
|
||||
همون کلید رو میگیره، نه حجمِ بالای درخواستهای *متفاوت* که هرکدوم
|
||||
یک UserAnswer جدید میسازن و منطقِ هارت/NodeProgress رو اجرا میکنن.
|
||||
ScopedRateThrottle با pk کاربرِ احرازهویتشده کلید میخوره، نه IP.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
from django.core.cache import cache
|
||||
cache.clear()
|
||||
self.client = APIClient()
|
||||
self.user = User.objects.create(device_id='device-answer-throttle-1', is_guest=True, name='')
|
||||
self.token = issue_access_token(self.user.id)
|
||||
|
||||
grade = Grade.objects.create(title='پایه اول', icon='icon', color='#fff')
|
||||
unit = Unit.objects.create(grade=grade, title='فصل ۱', order=1)
|
||||
self.node = Node.objects.create(unit=unit, title='گره ۱', order=1)
|
||||
self.lesson = Lesson.objects.create(node=self.node, title='درس ۱', intro_text='...')
|
||||
self.question = Question.objects.create(
|
||||
lesson=self.lesson, type='multiple_choice', prompt='سوال ۱',
|
||||
)
|
||||
self.correct_choice = Choice.objects.create(
|
||||
question=self.question, value='درست', is_correct=True,
|
||||
)
|
||||
|
||||
def _answer(self, idempotency_key=None, remote_addr=None):
|
||||
kwargs = {'HTTP_AUTHORIZATION': f'Bearer {self.token}'}
|
||||
if idempotency_key:
|
||||
kwargs['HTTP_IDEMPOTENCY_KEY'] = idempotency_key
|
||||
if remote_addr:
|
||||
kwargs['REMOTE_ADDR'] = remote_addr
|
||||
return self.client.post(
|
||||
f'/api/questions/{self.question.id}/answer',
|
||||
{'selectedOptionId': str(self.correct_choice.id)},
|
||||
format='json',
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
def test_requests_within_the_limit_still_succeed(self):
|
||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'question_answer': '3/min'}):
|
||||
for _ in range(3):
|
||||
response = self._answer()
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_excessive_requests_from_same_user_return_429(self):
|
||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'question_answer': '3/min'}):
|
||||
for _ in range(3):
|
||||
response = self._answer()
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
blocked_response = self._answer()
|
||||
self.assertEqual(blocked_response.status_code, 429)
|
||||
|
||||
def test_changing_client_ip_does_not_bypass_the_user_limit(self):
|
||||
# ⚠️ توجه: از یک کاربرِ *غیرمهمان* استفاده میشه (نه self.user که
|
||||
# مهمان است)، چون طبقِ فیکسِ GuestAwareContentThrottle حالا کلیدِ
|
||||
# bucket برای کاربرهای مهمان عمداً بر اساسِ IP است (نگاه کنید به
|
||||
# QuestionAnswerGuestThrottleBypassTests پایینتر) — این تست فقط
|
||||
# رفتارِ per-user (نه IP) برای کاربرهایِ ثبتنامشده رو تضمین
|
||||
# میکنه که باید دقیقاً مثلِ قبل دستنخورده بمونه.
|
||||
registered_user = User.objects.create(is_guest=False, name='', phone='+989120000701')
|
||||
registered_token = issue_access_token(registered_user.id)
|
||||
|
||||
def _answer_as(remote_addr):
|
||||
return self.client.post(
|
||||
f'/api/questions/{self.question.id}/answer',
|
||||
{'selectedOptionId': str(self.correct_choice.id)},
|
||||
format='json',
|
||||
HTTP_AUTHORIZATION=f'Bearer {registered_token}',
|
||||
REMOTE_ADDR=remote_addr,
|
||||
)
|
||||
|
||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'question_answer': '3/min'}):
|
||||
for i in range(3):
|
||||
response = _answer_as(f'10.2.0.{i + 1}')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
blocked_response = _answer_as('10.2.0.99')
|
||||
self.assertEqual(blocked_response.status_code, 429)
|
||||
|
||||
def test_throttled_request_creates_no_answer_and_no_hearts_or_progress_change(self):
|
||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'question_answer': '1/min'}):
|
||||
first_response = self._answer()
|
||||
self.assertEqual(first_response.status_code, 200)
|
||||
|
||||
answer_count_before = UserAnswer.objects.filter(user_id=self.user.id).count()
|
||||
hearts_before = UserHearts.objects.filter(user_id=self.user.id).first()
|
||||
hearts_snapshot = hearts_before.current_hearts if hearts_before else None
|
||||
progress_before = NodeProgress.objects.filter(user_id=self.user.id, node=self.node).first()
|
||||
progress_snapshot = (
|
||||
{'status': progress_before.status, 'last_activity_at': progress_before.last_activity_at}
|
||||
if progress_before else None
|
||||
)
|
||||
|
||||
blocked_response = self._answer()
|
||||
self.assertEqual(blocked_response.status_code, 429)
|
||||
|
||||
self.assertEqual(
|
||||
UserAnswer.objects.filter(user_id=self.user.id).count(), answer_count_before,
|
||||
)
|
||||
hearts_after = UserHearts.objects.filter(user_id=self.user.id).first()
|
||||
self.assertEqual(
|
||||
hearts_after.current_hearts if hearts_after else None, hearts_snapshot,
|
||||
)
|
||||
progress_after = NodeProgress.objects.filter(user_id=self.user.id, node=self.node).first()
|
||||
if progress_snapshot is None:
|
||||
self.assertIsNone(progress_after)
|
||||
else:
|
||||
self.assertEqual(progress_after.status, progress_snapshot['status'])
|
||||
self.assertEqual(progress_after.last_activity_at, progress_snapshot['last_activity_at'])
|
||||
|
||||
def test_idempotency_key_retry_still_works_within_the_limit(self):
|
||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'question_answer': '10/min'}):
|
||||
first_response = self._answer(idempotency_key='retry-key-1')
|
||||
self.assertEqual(first_response.status_code, 200)
|
||||
|
||||
second_response = self._answer(idempotency_key='retry-key-1')
|
||||
self.assertEqual(second_response.status_code, 200)
|
||||
self.assertEqual(
|
||||
UserAnswer.objects.filter(
|
||||
user_id=self.user.id, question=self.question, idempotency_key='retry-key-1',
|
||||
).count(),
|
||||
1,
|
||||
)
|
||||
|
||||
|
||||
class LessonCompleteThrottleTests(TestCase):
|
||||
"""
|
||||
🔒 اثبات محدودیتِ جدیدِ per-user روی LessonCompleteView
|
||||
(throttle_scope='lesson_complete'): Idempotency-Key فقط جلوی retry با
|
||||
همون کلید رو میگیره، نه حجمِ بالای درخواستهای *متفاوت* که هرکدوم
|
||||
یک UserLessonCompletion جدید میسازن و سیگنالِ post_save
|
||||
(NodeProgress) + بهروزرسانیِ UserStats رو اجرا میکنن.
|
||||
ScopedRateThrottle با pk کاربرِ احرازهویتشده کلید میخوره، نه IP.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
from django.core.cache import cache
|
||||
cache.clear()
|
||||
self.client = APIClient()
|
||||
self.user = User.objects.create(device_id='device-complete-throttle-1', is_guest=True, name='')
|
||||
self.token = issue_access_token(self.user.id)
|
||||
|
||||
grade = Grade.objects.create(title='پایه اول', icon='icon', color='#fff')
|
||||
unit = Unit.objects.create(grade=grade, title='فصل ۱', order=1)
|
||||
self.node = Node.objects.create(unit=unit, title='گره ۱', order=1)
|
||||
self.lesson = Lesson.objects.create(node=self.node, title='درس ۱', intro_text='...')
|
||||
for i in range(3):
|
||||
Question.objects.create(
|
||||
lesson=self.lesson, type='multiple_choice', prompt=f'سوال {i}',
|
||||
)
|
||||
|
||||
def _complete(self, idempotency_key=None, remote_addr=None):
|
||||
kwargs = {'HTTP_AUTHORIZATION': f'Bearer {self.token}'}
|
||||
if idempotency_key:
|
||||
kwargs['HTTP_IDEMPOTENCY_KEY'] = idempotency_key
|
||||
if remote_addr:
|
||||
kwargs['REMOTE_ADDR'] = remote_addr
|
||||
return self.client.post(
|
||||
f'/api/lessons/{self.lesson.id}/complete',
|
||||
{'correctCount': 1, 'totalQuestions': 3},
|
||||
format='json',
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
def test_requests_within_the_limit_still_succeed(self):
|
||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'lesson_complete': '3/min'}):
|
||||
for _ in range(3):
|
||||
response = self._complete()
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_excessive_requests_from_same_user_return_429(self):
|
||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'lesson_complete': '3/min'}):
|
||||
for _ in range(3):
|
||||
response = self._complete()
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
blocked_response = self._complete()
|
||||
self.assertEqual(blocked_response.status_code, 429)
|
||||
|
||||
def test_changing_client_ip_does_not_bypass_the_user_limit(self):
|
||||
# ⚠️ توجه: از یک کاربرِ *غیرمهمان* استفاده میشه (نه self.user که
|
||||
# مهمان است)، چون طبقِ فیکسِ GuestAwareContentThrottle حالا کلیدِ
|
||||
# bucket برای کاربرهای مهمان عمداً بر اساسِ IP است (نگاه کنید به
|
||||
# LessonCompleteGuestThrottleBypassTests پایینتر) — این تست فقط
|
||||
# رفتارِ per-user (نه IP) برای کاربرهایِ ثبتنامشده رو تضمین
|
||||
# میکنه که باید دقیقاً مثلِ قبل دستنخورده بمونه.
|
||||
registered_user = User.objects.create(is_guest=False, name='', phone='+989120000702')
|
||||
registered_token = issue_access_token(registered_user.id)
|
||||
|
||||
def _complete_as(remote_addr):
|
||||
return self.client.post(
|
||||
f'/api/lessons/{self.lesson.id}/complete',
|
||||
{'correctCount': 1, 'totalQuestions': 3},
|
||||
format='json',
|
||||
HTTP_AUTHORIZATION=f'Bearer {registered_token}',
|
||||
REMOTE_ADDR=remote_addr,
|
||||
)
|
||||
|
||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'lesson_complete': '3/min'}):
|
||||
for i in range(3):
|
||||
response = _complete_as(f'10.3.0.{i + 1}')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
blocked_response = _complete_as('10.3.0.99')
|
||||
self.assertEqual(blocked_response.status_code, 429)
|
||||
|
||||
def test_throttled_request_creates_no_completion_or_other_side_effects(self):
|
||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'lesson_complete': '1/min'}):
|
||||
first_response = self._complete()
|
||||
self.assertEqual(first_response.status_code, 200)
|
||||
|
||||
completion_count_before = UserLessonCompletion.objects.filter(user_id=self.user.id).count()
|
||||
stats_before = UserStats.objects.filter(user_id=self.user.id).first()
|
||||
stats_snapshot = (
|
||||
{'xp': stats_before.xp, 'coins': stats_before.coins, 'progress_percent': stats_before.progress_percent}
|
||||
if stats_before else None
|
||||
)
|
||||
progress_before = NodeProgress.objects.filter(user_id=self.user.id, node=self.node).first()
|
||||
progress_snapshot = (
|
||||
{'status': progress_before.status, 'last_activity_at': progress_before.last_activity_at}
|
||||
if progress_before else None
|
||||
)
|
||||
|
||||
blocked_response = self._complete()
|
||||
self.assertEqual(blocked_response.status_code, 429)
|
||||
|
||||
self.assertEqual(
|
||||
UserLessonCompletion.objects.filter(user_id=self.user.id).count(), completion_count_before,
|
||||
)
|
||||
stats_after = UserStats.objects.filter(user_id=self.user.id).first()
|
||||
if stats_snapshot is None:
|
||||
self.assertIsNone(stats_after)
|
||||
else:
|
||||
self.assertEqual(stats_after.xp, stats_snapshot['xp'])
|
||||
self.assertEqual(stats_after.coins, stats_snapshot['coins'])
|
||||
self.assertEqual(stats_after.progress_percent, stats_snapshot['progress_percent'])
|
||||
progress_after = NodeProgress.objects.filter(user_id=self.user.id, node=self.node).first()
|
||||
if progress_snapshot is None:
|
||||
self.assertIsNone(progress_after)
|
||||
else:
|
||||
self.assertEqual(progress_after.status, progress_snapshot['status'])
|
||||
self.assertEqual(progress_after.last_activity_at, progress_snapshot['last_activity_at'])
|
||||
|
||||
|
||||
class QuestionAnswerGuestThrottleBypassTests(TestCase):
|
||||
"""
|
||||
🔒 اثبات فیکسِ امنیتی: قبلاً کلیدِ throttle_scope='question_answer' برای
|
||||
هر کاربرِ احرازهویتشده (مهمان یا نه) user.pk بود. چون GuestSessionView
|
||||
با هر deviceId جدید یک کاربرِ مهمانِ تازه (pk جدید) میسازه، یک مهاجم
|
||||
میتونست با ساختنِ پیدرپیِ حسابهای مهمان، هر بار یک bucket throttle
|
||||
تازه بگیره و منطقِ هارت/NodeProgress رو بیمحدودیت اجرا کنه. حالا
|
||||
GuestAwareContentThrottle برای کاربرهای مهمان کلید رو بر اساسِ IP
|
||||
(همون get_ident که guest_session هم استفاده میکنه) میسازه، پس چرخشِ
|
||||
حساب مهمان دیگه bucket تازه نمیده. رفتارِ throttle برای کاربرِ
|
||||
غیرمهمان (per-user، مثلِ قبل) دستنخورده مونده.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
from django.core.cache import cache
|
||||
cache.clear()
|
||||
self.client = APIClient()
|
||||
|
||||
grade = Grade.objects.create(title='پایه اول', icon='icon', color='#fff')
|
||||
unit = Unit.objects.create(grade=grade, title='فصل ۱', order=1)
|
||||
node = Node.objects.create(unit=unit, title='گره ۱', order=1)
|
||||
self.lesson = Lesson.objects.create(node=node, title='درس ۱', intro_text='...')
|
||||
self.question = Question.objects.create(
|
||||
lesson=self.lesson, type='multiple_choice', prompt='سوال ۱',
|
||||
)
|
||||
self.correct_choice = Choice.objects.create(
|
||||
question=self.question, value='درست', is_correct=True,
|
||||
)
|
||||
|
||||
def _answer(self, token):
|
||||
return self.client.post(
|
||||
f'/api/questions/{self.question.id}/answer',
|
||||
{'selectedOptionId': str(self.correct_choice.id)},
|
||||
format='json',
|
||||
HTTP_AUTHORIZATION=f'Bearer {token}',
|
||||
)
|
||||
|
||||
def test_rotating_guest_accounts_share_the_same_throttle_bucket(self):
|
||||
# سه حساب مهمانِ متفاوت (شبیهسازیِ چرخشِ حساب برای دورزدنِ throttle)،
|
||||
# همه از همون کلاینت/IP — باید همه یک bucket مشترک رو مصرف کنند.
|
||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'question_answer': '2/min'}):
|
||||
guest1 = User.objects.create(device_id='device-answer-guest-rot-1', is_guest=True, name='')
|
||||
response1 = self._answer(issue_access_token(guest1.id))
|
||||
self.assertEqual(response1.status_code, 200)
|
||||
|
||||
guest2 = User.objects.create(device_id='device-answer-guest-rot-2', is_guest=True, name='')
|
||||
response2 = self._answer(issue_access_token(guest2.id))
|
||||
self.assertEqual(response2.status_code, 200)
|
||||
|
||||
# سومین حسابِ مهمانِ تازه (pk جدید) باید throttle بشه، چون
|
||||
# bucket بر اساسِ IP بهاشتراک گذاشته شده، نه user.pk.
|
||||
guest3 = User.objects.create(device_id='device-answer-guest-rot-3', is_guest=True, name='')
|
||||
response3 = self._answer(issue_access_token(guest3.id))
|
||||
|
||||
self.assertEqual(response3.status_code, 429)
|
||||
|
||||
def test_registered_users_keep_independent_per_user_buckets(self):
|
||||
# عدمِ رگرسیون: کاربرهای غیرمهمان (ثبتنامشده با شماره) باید مثلِ
|
||||
# قبل هرکدوم bucket جداگانهی خودشون رو داشته باشن.
|
||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'question_answer': '1/min'}):
|
||||
user_a = User.objects.create(is_guest=False, name='', phone='+989120000711')
|
||||
response_a = self._answer(issue_access_token(user_a.id))
|
||||
self.assertEqual(response_a.status_code, 200)
|
||||
|
||||
user_b = User.objects.create(is_guest=False, name='', phone='+989120000712')
|
||||
response_b = self._answer(issue_access_token(user_b.id))
|
||||
|
||||
self.assertEqual(response_b.status_code, 200)
|
||||
|
||||
def test_blocked_guest_request_creates_no_answer_or_hearts_change(self):
|
||||
# درخواستِ بلاکشده (از حسابِ مهمانِ چرخشی) نباید UserAnswer بسازه
|
||||
# یا هارت/progress رو تغییر بده.
|
||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'question_answer': '1/min'}):
|
||||
guest1 = User.objects.create(device_id='device-answer-guest-rot-4', is_guest=True, name='')
|
||||
first_response = self._answer(issue_access_token(guest1.id))
|
||||
self.assertEqual(first_response.status_code, 200)
|
||||
|
||||
guest2 = User.objects.create(device_id='device-answer-guest-rot-5', is_guest=True, name='')
|
||||
blocked_response = self._answer(issue_access_token(guest2.id))
|
||||
|
||||
self.assertEqual(blocked_response.status_code, 429)
|
||||
self.assertEqual(UserAnswer.objects.filter(user_id=guest2.id).count(), 0)
|
||||
self.assertFalse(UserHearts.objects.filter(user_id=guest2.id).exists())
|
||||
|
||||
|
||||
class LessonCompleteGuestThrottleBypassTests(TestCase):
|
||||
"""
|
||||
🔒 اثبات فیکسِ امنیتی: قبلاً کلیدِ throttle_scope='lesson_complete' برای
|
||||
هر کاربرِ احرازهویتشده (مهمان یا نه) user.pk بود. چون GuestSessionView
|
||||
با هر deviceId جدید یک کاربرِ مهمانِ تازه (pk جدید) میسازه، یک مهاجم
|
||||
میتونست با ساختنِ پیدرپیِ حسابهای مهمان، هر بار یک bucket throttle
|
||||
تازه بگیره و UserLessonCompletion/xp/coins/progress رو بیمحدودیت
|
||||
تولید کنه. حالا GuestAwareContentThrottle برای کاربرهای مهمان کلید رو
|
||||
بر اساسِ IP میسازه، پس چرخشِ حساب مهمان دیگه bucket تازه نمیده.
|
||||
رفتارِ throttle برای کاربرِ غیرمهمان (per-user، مثلِ قبل) دستنخورده
|
||||
مونده.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
from django.core.cache import cache
|
||||
cache.clear()
|
||||
self.client = APIClient()
|
||||
|
||||
grade = Grade.objects.create(title='پایه اول', icon='icon', color='#fff')
|
||||
unit = Unit.objects.create(grade=grade, title='فصل ۱', order=1)
|
||||
self.node = Node.objects.create(unit=unit, title='گره ۱', order=1)
|
||||
self.lesson = Lesson.objects.create(node=self.node, title='درس ۱', intro_text='...')
|
||||
for i in range(3):
|
||||
Question.objects.create(
|
||||
lesson=self.lesson, type='multiple_choice', prompt=f'سوال {i}',
|
||||
)
|
||||
|
||||
def _complete(self, token):
|
||||
return self.client.post(
|
||||
f'/api/lessons/{self.lesson.id}/complete',
|
||||
{'correctCount': 1, 'totalQuestions': 3},
|
||||
format='json',
|
||||
HTTP_AUTHORIZATION=f'Bearer {token}',
|
||||
)
|
||||
|
||||
def test_rotating_guest_accounts_share_the_same_throttle_bucket(self):
|
||||
# سه حساب مهمانِ متفاوت (شبیهسازیِ چرخشِ حساب برای دورزدنِ throttle)،
|
||||
# همه از همون کلاینت/IP — باید همه یک bucket مشترک رو مصرف کنند.
|
||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'lesson_complete': '2/hour'}):
|
||||
guest1 = User.objects.create(device_id='device-complete-guest-rot-1', is_guest=True, name='')
|
||||
response1 = self._complete(issue_access_token(guest1.id))
|
||||
self.assertEqual(response1.status_code, 200)
|
||||
|
||||
guest2 = User.objects.create(device_id='device-complete-guest-rot-2', is_guest=True, name='')
|
||||
response2 = self._complete(issue_access_token(guest2.id))
|
||||
self.assertEqual(response2.status_code, 200)
|
||||
|
||||
# سومین حسابِ مهمانِ تازه (pk جدید) باید throttle بشه، چون
|
||||
# bucket بر اساسِ IP بهاشتراک گذاشته شده، نه user.pk.
|
||||
guest3 = User.objects.create(device_id='device-complete-guest-rot-3', is_guest=True, name='')
|
||||
response3 = self._complete(issue_access_token(guest3.id))
|
||||
|
||||
self.assertEqual(response3.status_code, 429)
|
||||
|
||||
def test_registered_users_keep_independent_per_user_buckets(self):
|
||||
# عدمِ رگرسیون: کاربرهای غیرمهمان (ثبتنامشده با شماره) باید مثلِ
|
||||
# قبل هرکدوم bucket جداگانهی خودشون رو داشته باشن.
|
||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'lesson_complete': '1/hour'}):
|
||||
user_a = User.objects.create(is_guest=False, name='', phone='+989120000721')
|
||||
response_a = self._complete(issue_access_token(user_a.id))
|
||||
self.assertEqual(response_a.status_code, 200)
|
||||
|
||||
user_b = User.objects.create(is_guest=False, name='', phone='+989120000722')
|
||||
response_b = self._complete(issue_access_token(user_b.id))
|
||||
|
||||
self.assertEqual(response_b.status_code, 200)
|
||||
|
||||
def test_blocked_guest_request_creates_no_completion_or_reward_side_effects(self):
|
||||
# درخواستِ بلاکشده (از حسابِ مهمانِ چرخشی) نباید UserLessonCompletion
|
||||
# بسازه یا UserStats/NodeProgress رو تغییر بده.
|
||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'lesson_complete': '1/hour'}):
|
||||
guest1 = User.objects.create(device_id='device-complete-guest-rot-4', is_guest=True, name='')
|
||||
first_response = self._complete(issue_access_token(guest1.id))
|
||||
self.assertEqual(first_response.status_code, 200)
|
||||
|
||||
guest2 = User.objects.create(device_id='device-complete-guest-rot-5', is_guest=True, name='')
|
||||
blocked_response = self._complete(issue_access_token(guest2.id))
|
||||
|
||||
self.assertEqual(blocked_response.status_code, 429)
|
||||
self.assertEqual(UserLessonCompletion.objects.filter(user_id=guest2.id).count(), 0)
|
||||
self.assertFalse(UserStats.objects.filter(user_id=guest2.id).exists())
|
||||
self.assertFalse(NodeProgress.objects.filter(user_id=guest2.id, node=self.node).exists())
|
||||
|
||||
@@ -5,6 +5,7 @@ from drf_spectacular.utils import OpenApiParameter, extend_schema
|
||||
from rest_framework.exceptions import PermissionDenied, ValidationError
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.throttling import ScopedRateThrottle
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from progress.models import UserHearts, UserStats
|
||||
@@ -23,6 +24,40 @@ from .serializers import (
|
||||
)
|
||||
|
||||
|
||||
class GuestAwareContentThrottle(ScopedRateThrottle):
|
||||
"""
|
||||
⚠️ اضافه شده: همون مشکلِ GuestAwareAIThrottle (ai_tutor/views.py)،
|
||||
GuestAwareFamilyThrottle (family/views.py) و GuestAwareDiscountThrottle/
|
||||
GuestAwarePurchaseThrottle (discount/views.py، subscription/views.py)
|
||||
اینجا هم هست. ScopedRateThrottle استاندارد برای درخواستِ احرازهویتشده
|
||||
از request.user.pk بهعنوانِ کلیدِ throttle استفاده میکنه؛ چون
|
||||
GuestSessionView با هر deviceId جدید یک کاربرِ مهمانِ تازه (pk جدید)
|
||||
میسازه، یک مهاجم میتونست با ساختنِ پیدرپیِ حسابهای مهمان، برای هر
|
||||
حساب یک bucket تازه از throttle_scope 'question_answer'/'lesson_complete'
|
||||
بگیره و محدودیتِ واقعی رو دور بزنه — درحالیکه هر دو endpoint اثرِ
|
||||
جانبیِ ماندگار دارن (UserAnswer + کسرِ هارت، یا UserLessonCompletion +
|
||||
xp/coins/progress).
|
||||
|
||||
دقیقاً طبقِ همون الگو: فقط برای کاربرهای مهمان (is_guest=True) کلیدِ
|
||||
cache از pk کاربر به IP کلاینت (get_ident) تغییر میکنه، پس همهی
|
||||
حسابهای مهمانِ ساختهشده از یک IP یک bucket مشترک دارن و چرخشِ حساب
|
||||
دیگه bucket تازه نمیده. رفتارِ throttle برای کاربرهای غیرمهمان
|
||||
(ثبتنامشده) دقیقاً مثلِ قبل (کلید بر اساسِ pk) باقی میمونه؛ منطقِ
|
||||
answer/heart/progress/completion/idempotency دستنخوردهست.
|
||||
"""
|
||||
|
||||
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 GradesListView(APIView):
|
||||
"""GET /api/grades — لیست پایهها"""
|
||||
|
||||
@@ -130,6 +165,20 @@ class QuestionAnswerView(APIView):
|
||||
|
||||
# userId همیشه از request.user.id گرفته میشود، نه ورودی کلاینت.
|
||||
permission_classes = [IsAuthenticated]
|
||||
# Idempotency-Key فقط جلوی retry با همون کلید رو میگیره، نه حجمِ بالای
|
||||
# درخواستهای *متفاوت* (بدون کلید یا با کلیدهای متفاوت) که هرکدوم یک
|
||||
# UserAnswer جدید میسازن و منطقِ هارت/NodeProgress رو اجرا میکنن —
|
||||
# حتی وقتی هارتِ کاربر صفر شده. ScopedRateThrottle وقتی request.user
|
||||
# احرازهویتشده باشه با pk کاربر کلید میخوره (نه فقط IP)، پس تغییرِ
|
||||
# IP این محدودیت رو دور نمیزنه.
|
||||
# ⚠️ اصلاح شده: چون کاربرهای مهمان (GuestSessionView) با هر deviceId
|
||||
# جدید یک pk تازه میگیرن، ScopedRateThrottleِ ساده (کلید بر اساسِ
|
||||
# user.pk) توسطِ چرخشِ حساب مهمان قابلدورزدن بود. حالا از
|
||||
# GuestAwareContentThrottleِ مشترک استفاده میشه که برای کاربرهای
|
||||
# مهمان کلید رو بر اساسِ IP میسازه و برای کاربرهای غیرمهمان رفتارِ
|
||||
# per-user قبلی رو دستنخورده نگه میداره.
|
||||
throttle_scope = 'question_answer'
|
||||
throttle_classes = [GuestAwareContentThrottle]
|
||||
|
||||
@extend_schema(
|
||||
request=AnswerRequestSerializer,
|
||||
@@ -229,6 +278,18 @@ class LessonCompleteView(APIView):
|
||||
# کلاینت اجازه میداد بهجای هر کاربر دیگری xp/coins ثبت کنه. حالا auth
|
||||
# اجباریه و همیشه از request.user.id استفاده میشه.
|
||||
permission_classes = [IsAuthenticated]
|
||||
# Idempotency-Key فقط جلوی retry با همون کلید رو میگیره، نه حجمِ بالای
|
||||
# درخواستهای *متفاوت* (بدون کلید یا با کلیدهای متفاوت) که هرکدوم یک
|
||||
# UserLessonCompletion جدید میسازن و سیگنالِ post_save (NodeProgress) +
|
||||
# بهروزرسانیِ UserStats رو اجرا میکنن. ScopedRateThrottle با pk
|
||||
# کاربرِ احرازهویتشده کلید میخوره، نه IP.
|
||||
# ⚠️ اصلاح شده: همون مشکلِ چرخشِ حسابِ مهمان (نگاه کنید به
|
||||
# GuestAwareContentThrottle بالا) اینجا هم بود؛ حالا از همون کلاسِ
|
||||
# مشترک استفاده میشه. نرخ هم از ۶۰/min به ۱۰/hour تغییر کرد چون این
|
||||
# endpoint رکوردِ completion ماندگار میسازه و xp/coins/progress رو
|
||||
# واقعاً تغییر میده (برخلافِ question_answer که فرکانسِ طبیعیاش بالاتره).
|
||||
throttle_scope = 'lesson_complete'
|
||||
throttle_classes = [GuestAwareContentThrottle]
|
||||
|
||||
@extend_schema(
|
||||
request=LessonCompleteRequestSerializer,
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
from django.core.cache import cache
|
||||
from django.test import TestCase
|
||||
from django.utils import timezone
|
||||
from rest_framework.test import APIClient
|
||||
from rest_framework.throttling import ScopedRateThrottle
|
||||
from unittest.mock import patch
|
||||
|
||||
from accounts.authentication import issue_access_token
|
||||
from accounts.models import User
|
||||
from .models import DiscountCode
|
||||
from payment.models import Order
|
||||
from .models import DiscountCode, Offer
|
||||
|
||||
|
||||
class ValidateDiscountPermissionTests(TestCase):
|
||||
@@ -52,3 +56,563 @@ class ValidateDiscountPermissionTests(TestCase):
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertFalse(response.data['valid'])
|
||||
|
||||
|
||||
class RedeemOfferCodeThrottleTests(TestCase):
|
||||
"""
|
||||
🔒 اثبات محدودیتِ جدیدِ throttle_scope='offer_redeem' روی
|
||||
RedeemOfferCodeView: قبلاً این endpoint هیچ throttle ای نداشت، پس
|
||||
یک کاربرِ احرازهویتشده میتونست بینهایت کدِ آفر رو probe کنه (چون
|
||||
کدهای نامعتبر فقط یک ValidationError سادهی ۴۰۰ برمیگردوندن، نه
|
||||
۴۲۹). این تستها هم رفتارِ throttle (per-user، نه IP) و هم عدمِ
|
||||
رگرسیون در رفتارِ موفقِ redemption رو تضمین میکنن.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
cache.clear()
|
||||
self.client = APIClient()
|
||||
self.user = User.objects.create(device_id='device-offer-redeem-1', is_guest=True, name='')
|
||||
self.token = issue_access_token(self.user.id)
|
||||
|
||||
def _redeem(self, code):
|
||||
return self.client.post(
|
||||
'/api/discount/offers/redeem',
|
||||
{'code': code},
|
||||
format='json',
|
||||
HTTP_AUTHORIZATION=f'Bearer {self.token}',
|
||||
)
|
||||
|
||||
def test_unauthenticated_request_is_rejected(self):
|
||||
# رفتارِ IsAuthenticated موجود دستنخورده مونده؛ throttle جایگزینِ
|
||||
# authentication نیست.
|
||||
response = self.client.post(
|
||||
'/api/discount/offers/redeem', {'code': 'ANYCODE'}, format='json',
|
||||
)
|
||||
self.assertEqual(response.status_code, 401)
|
||||
|
||||
def test_invalid_code_attempts_are_throttled_after_configured_limit(self):
|
||||
# این دقیقاً همون سناریوی probing هست: کدهای نامعتبرِ پشتِسرِهم.
|
||||
# قبل از این فیکس، همهشون ۴۰۰ میگرفتن و هیچوقت بلاک نمیشدن.
|
||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'offer_redeem': '3/hour'}):
|
||||
for i in range(3):
|
||||
response = self._redeem(f'INVALID-{i}')
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
blocked_response = self._redeem('INVALID-EXTRA')
|
||||
self.assertEqual(blocked_response.status_code, 429)
|
||||
|
||||
def test_throttle_is_scoped_per_user_not_shared_globally(self):
|
||||
# ⚠️ توجه: از یک کاربرِ *غیرمهمان* استفاده میشه (نه یک حسابِ مهمانِ
|
||||
# دومِ متفاوت)، چون طبقِ فیکسِ GuestAwareDiscountThrottle حسابهای
|
||||
# مهمان دیگه bucket جداگانه ندارن (نگاه کنید به
|
||||
# RedeemOfferCodeGuestThrottleBypassTests پایینتر) — این تست فقط
|
||||
# رفتارِ per-user برای کاربرهایِ ثبتنامشده رو تضمین میکنه که باید
|
||||
# دقیقاً مثلِ قبل دستنخورده بمونه.
|
||||
other_user = User.objects.create(is_guest=False, name='', phone='+989120000701')
|
||||
other_token = issue_access_token(other_user.id)
|
||||
|
||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'offer_redeem': '1/hour'}):
|
||||
first_user_response = self._redeem('INVALID-A')
|
||||
self.assertEqual(first_user_response.status_code, 400)
|
||||
|
||||
first_user_blocked = self._redeem('INVALID-B')
|
||||
self.assertEqual(first_user_blocked.status_code, 429)
|
||||
|
||||
# کاربرِ دومِ متفاوت هنوز باید بتونه تلاش کنه — throttle
|
||||
# per-user است، نه یک شمارندهی سراسری روی endpoint.
|
||||
second_user_response = self.client.post(
|
||||
'/api/discount/offers/redeem',
|
||||
{'code': 'INVALID-C'},
|
||||
format='json',
|
||||
HTTP_AUTHORIZATION=f'Bearer {other_token}',
|
||||
)
|
||||
self.assertEqual(second_user_response.status_code, 400)
|
||||
|
||||
def test_successful_redemption_still_works_within_the_limit(self):
|
||||
# عدمِ رگرسیون در رفتارِ اصلیِ redemption: با یک کدِ معتبر و در
|
||||
# محدودهی مجاز، endpoint باید دقیقاً مثلِ قبل کار کنه.
|
||||
offer = Offer.objects.create(
|
||||
name='Test Offer', kind='free_full', code='VALIDCODE1', is_active=True,
|
||||
)
|
||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'offer_redeem': '10/hour'}):
|
||||
response = self._redeem(offer.code)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.data['offerName'], offer.name)
|
||||
self.assertEqual(response.data['kind'], offer.kind)
|
||||
|
||||
def test_blocked_request_does_not_call_redeem_offer_code(self):
|
||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'offer_redeem': '1/hour'}):
|
||||
first_response = self._redeem('INVALID-X')
|
||||
self.assertEqual(first_response.status_code, 400)
|
||||
|
||||
with patch('discount.views.redeem_offer_code') as mock_redeem:
|
||||
blocked_response = self._redeem('INVALID-Y')
|
||||
|
||||
self.assertEqual(blocked_response.status_code, 429)
|
||||
mock_redeem.assert_not_called()
|
||||
|
||||
|
||||
class RedeemOfferCodeGuestThrottleBypassTests(TestCase):
|
||||
"""
|
||||
🔒 اثبات فیکسِ امنیتی: قبلاً کلیدِ throttle_scope='offer_redeem' برای هر
|
||||
کاربرِ احرازهویتشده (مهمان یا نه) user.pk بود. چون GuestSessionView با
|
||||
هر deviceId جدید یک کاربرِ مهمانِ تازه (pk جدید) میسازه، یک مهاجم
|
||||
میتونست با ساختنِ پیدرپیِ حسابهای مهمان، هر بار یک bucket throttle
|
||||
تازه برای probe/brute-force کردنِ کدهای آفر بگیره. چون Offer.code
|
||||
سراسری/یکتاست و redeem_offer_code() فقط جلوی تکرارِ *همون* کاربر رو
|
||||
میگیره، این دورزدن میتونست به یک حسابِ مهمانِ چرخشی اجازه بده همون
|
||||
آفرِ فعال رو دوباره از apply_offer() بگیره و اشتراکِ رایگان/آزمایشیِ
|
||||
مربوطه رو دوباره اعمال کنه. حالا GuestAwareDiscountThrottle برای
|
||||
کاربرهای مهمان کلید رو بر اساسِ IP (همون get_ident که guest_session هم
|
||||
استفاده میکنه) میسازه، پس چرخشِ حساب مهمان دیگه bucket تازه نمیده.
|
||||
رفتارِ throttle برای کاربرِ غیرمهمان (per-user، مثلِ قبل) دستنخورده
|
||||
مونده.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
cache.clear()
|
||||
self.client = APIClient()
|
||||
|
||||
def _redeem(self, code, token):
|
||||
return self.client.post(
|
||||
'/api/discount/offers/redeem',
|
||||
{'code': code},
|
||||
format='json',
|
||||
HTTP_AUTHORIZATION=f'Bearer {token}',
|
||||
)
|
||||
|
||||
def test_rotating_guest_accounts_share_the_same_throttle_bucket(self):
|
||||
# سه حساب مهمانِ متفاوت (شبیهسازیِ چرخشِ حساب برای دورزدنِ throttle)،
|
||||
# همه از همون کلاینت/IP — باید همه یک bucket مشترک رو مصرف کنند.
|
||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'offer_redeem': '2/hour'}):
|
||||
guest1 = User.objects.create(device_id='device-offer-guest-rot-1', is_guest=True, name='')
|
||||
response1 = self._redeem('INVALID-ROT-1', issue_access_token(guest1.id))
|
||||
self.assertEqual(response1.status_code, 400)
|
||||
|
||||
guest2 = User.objects.create(device_id='device-offer-guest-rot-2', is_guest=True, name='')
|
||||
response2 = self._redeem('INVALID-ROT-2', issue_access_token(guest2.id))
|
||||
self.assertEqual(response2.status_code, 400)
|
||||
|
||||
# سومین حسابِ مهمانِ تازه (pk جدید) باید throttle بشه، چون
|
||||
# bucket بر اساسِ IP بهاشتراک گذاشته شده، نه user.pk — یعنی
|
||||
# سقفِ ۲/ساعت هنوز اعمال میشه، صرفنظر از تعدادِ حسابهای
|
||||
# مهمانِ ساختهشده.
|
||||
guest3 = User.objects.create(device_id='device-offer-guest-rot-3', is_guest=True, name='')
|
||||
response3 = self._redeem('INVALID-ROT-3', issue_access_token(guest3.id))
|
||||
|
||||
self.assertEqual(response3.status_code, 429)
|
||||
|
||||
def test_rotated_guest_account_cannot_get_a_fresh_bucket_after_limit(self):
|
||||
# حتی وقتی حسابِ مهمانِ جدید به سقف رسیده، یک حسابِ مهمانِ دیگر
|
||||
# (چهارمین pk) هم نباید bucket تازهای بگیره — سقف هنوز به IP
|
||||
# مشترک متصل است.
|
||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'offer_redeem': '1/hour'}):
|
||||
guest1 = User.objects.create(device_id='device-offer-guest-rot-4', is_guest=True, name='')
|
||||
response1 = self._redeem('INVALID-ROT-4', issue_access_token(guest1.id))
|
||||
self.assertEqual(response1.status_code, 400)
|
||||
|
||||
guest2 = User.objects.create(device_id='device-offer-guest-rot-5', is_guest=True, name='')
|
||||
blocked = self._redeem('INVALID-ROT-5', issue_access_token(guest2.id))
|
||||
self.assertEqual(blocked.status_code, 429)
|
||||
|
||||
guest3 = User.objects.create(device_id='device-offer-guest-rot-6', is_guest=True, name='')
|
||||
still_blocked = self._redeem('INVALID-ROT-6', issue_access_token(guest3.id))
|
||||
|
||||
self.assertEqual(still_blocked.status_code, 429)
|
||||
|
||||
def test_registered_users_keep_independent_per_user_buckets(self):
|
||||
# عدمِ رگرسیون: کاربرهای غیرمهمان (ثبتنامشده با شماره) باید مثلِ
|
||||
# قبل هرکدوم bucket جداگانهی خودشون رو داشته باشن — throttle
|
||||
# برای اونا هنوز بر اساسِ user.pk است، نه IP.
|
||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'offer_redeem': '1/hour'}):
|
||||
user_a = User.objects.create(is_guest=False, name='', phone='+989120000801')
|
||||
response_a = self._redeem('INVALID-REG-A', issue_access_token(user_a.id))
|
||||
self.assertEqual(response_a.status_code, 400)
|
||||
|
||||
user_b = User.objects.create(is_guest=False, name='', phone='+989120000802')
|
||||
response_b = self._redeem('INVALID-REG-B', issue_access_token(user_b.id))
|
||||
|
||||
self.assertEqual(response_b.status_code, 400)
|
||||
|
||||
def test_blocked_guest_request_does_not_call_redeem_offer_code(self):
|
||||
# درخواستِ بلاکشده (از حسابِ مهمانِ چرخشی) نباید redeem_offer_code()
|
||||
# رو صدا بزنه — throttle باید *قبل* از رسیدن به منطقِ redemption/
|
||||
# apply_offer رد کنه، حتی با کدِ درست.
|
||||
offer = Offer.objects.create(
|
||||
name='Guest Rotation Offer', kind='free_full', code='GUESTROTBLOCK', is_active=True,
|
||||
)
|
||||
|
||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'offer_redeem': '1/hour'}):
|
||||
guest1 = User.objects.create(device_id='device-offer-guest-rot-7', is_guest=True, name='')
|
||||
first_response = self._redeem('INVALID-ROT-7', issue_access_token(guest1.id))
|
||||
self.assertEqual(first_response.status_code, 400)
|
||||
|
||||
# حسابِ مهمانِ تازهی دوم، حتی با کدِ درست، باید بلاک بشه
|
||||
# (bucket مشترکِ IP از قبل مصرف شده) و نباید به
|
||||
# redeem_offer_code() برسه.
|
||||
guest2 = User.objects.create(device_id='device-offer-guest-rot-8', is_guest=True, name='')
|
||||
with patch('discount.views.redeem_offer_code') as mock_redeem:
|
||||
blocked_response = self._redeem(offer.code, issue_access_token(guest2.id))
|
||||
self.assertEqual(blocked_response.status_code, 429)
|
||||
mock_redeem.assert_not_called()
|
||||
|
||||
|
||||
class ValidateDiscountThrottleTests(TestCase):
|
||||
"""
|
||||
🔒 اثبات محدودیتِ جدیدِ throttle_scope='discount_validate' روی
|
||||
ValidateDiscountView: قبلاً این endpoint هیچ throttle ای نداشت، در حالی
|
||||
که type/value کدهای معتبر رو هم برمیگردوند — یعنی یک کاربرِ
|
||||
احرازهویتشده میتونست بینهایت کدهای تخفیف رو probe کنه. این تستها
|
||||
هم رفتارِ throttle (per-user، نه IP)، هم عدمِ رگرسیون در پاسخِ
|
||||
validation و رفتارِ authentication رو تضمین میکنن.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
cache.clear()
|
||||
self.client = APIClient()
|
||||
self.user = User.objects.create(device_id='device-validate-throttle-1', is_guest=True, name='')
|
||||
self.token = issue_access_token(self.user.id)
|
||||
self.discount = DiscountCode.objects.create(
|
||||
code='THROTTLE10', type='percent', value=10, max_uses=5, expires_at=None,
|
||||
)
|
||||
|
||||
def _validate(self, code, token=None):
|
||||
headers = {}
|
||||
if token:
|
||||
headers['HTTP_AUTHORIZATION'] = f'Bearer {token}'
|
||||
return self.client.get('/api/discount/validate', {'code': code}, **headers)
|
||||
|
||||
def test_unauthenticated_request_is_still_rejected(self):
|
||||
# رفتارِ IsAuthenticated موجود دستنخورده مونده؛ throttle جایگزینِ
|
||||
# authentication نیست.
|
||||
response = self._validate('ANYCODE')
|
||||
self.assertEqual(response.status_code, 401)
|
||||
|
||||
def test_repeated_code_probing_is_throttled_after_configured_limit(self):
|
||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'discount_validate': '3/hour'}):
|
||||
for i in range(3):
|
||||
response = self._validate(f'PROBE-{i}', token=self.token)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
blocked_response = self._validate('PROBE-EXTRA', token=self.token)
|
||||
self.assertEqual(blocked_response.status_code, 429)
|
||||
|
||||
def test_throttle_is_scoped_per_user_not_shared_globally(self):
|
||||
# ⚠️ توجه: از یک کاربرِ *غیرمهمان* استفاده میشه (نه یک حسابِ مهمانِ
|
||||
# دومِ متفاوت)، چون طبقِ فیکسِ GuestAwareDiscountThrottle حسابهای
|
||||
# مهمان دیگه bucket جداگانه ندارن (نگاه کنید به
|
||||
# ValidateDiscountGuestThrottleBypassTests پایینتر) — این تست فقط
|
||||
# رفتارِ per-user برای کاربرهایِ ثبتنامشده رو تضمین میکنه که باید
|
||||
# دقیقاً مثلِ قبل دستنخورده بمونه.
|
||||
other_user = User.objects.create(is_guest=False, name='', phone='+989120000731')
|
||||
other_token = issue_access_token(other_user.id)
|
||||
|
||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'discount_validate': '1/hour'}):
|
||||
first_user_response = self._validate('PROBE-A', token=self.token)
|
||||
self.assertEqual(first_user_response.status_code, 200)
|
||||
|
||||
first_user_blocked = self._validate('PROBE-B', token=self.token)
|
||||
self.assertEqual(first_user_blocked.status_code, 429)
|
||||
|
||||
# کاربرِ دومِ متفاوت هنوز باید بتونه چک کنه — throttle
|
||||
# per-user است، نه یک شمارندهی سراسری روی endpoint.
|
||||
second_user_response = self._validate('PROBE-C', token=other_token)
|
||||
self.assertEqual(second_user_response.status_code, 200)
|
||||
|
||||
def test_valid_code_response_unchanged_within_the_limit(self):
|
||||
# عدمِ رگرسیون: پاسخِ اعتبارسنجیِ کدِ معتبر دقیقاً مثلِ قبل باید کار کنه.
|
||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'discount_validate': '10/hour'}):
|
||||
response = self._validate(self.discount.code, token=self.token)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTrue(response.data['valid'])
|
||||
self.assertEqual(response.data['type'], 'percent')
|
||||
self.assertEqual(response.data['value'], 10)
|
||||
|
||||
def test_invalid_code_response_unchanged_within_the_limit(self):
|
||||
# عدمِ رگرسیون: پاسخ برای کدِ نامعتبر باید همچنان ۲۰۰ با valid=False
|
||||
# باشه (نه ۴۲۹)، تا وقتی که به سقفِ throttle نرسیده باشیم.
|
||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'discount_validate': '10/hour'}):
|
||||
response = self._validate('NOT-A-REAL-CODE', token=self.token)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertFalse(response.data['valid'])
|
||||
|
||||
|
||||
class RedeemDiscountThrottleTests(TestCase):
|
||||
"""
|
||||
🔒 اثبات محدودیتِ جدیدِ throttle_scope='discount_redeem' روی
|
||||
RedeemDiscountView: این endpoint همون oracle ای که ValidateDiscountView
|
||||
و RedeemOfferCodeView رو throttle کرد داره (۴۰۴ برای کدِ ناموجود،
|
||||
۴۰۰ برای کدِ نامعتبر)، با این تفاوت که حدسِ درست یک کدِ واقعی رو
|
||||
مصرف میکنه، نه فقط اطلاعات لو میده. این تستها هم رفتارِ throttle
|
||||
(per-user، نه IP)، هم عدمِ رگرسیون در ردیمِ موفق رو تضمین میکنن.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
cache.clear()
|
||||
self.client = APIClient()
|
||||
self.user = User.objects.create(device_id='device-redeem-throttle-1', is_guest=True, name='')
|
||||
self.token = issue_access_token(self.user.id)
|
||||
self.order = Order.objects.create(
|
||||
user_id=self.user.id, amount=10000, idempotency_key='order-redeem-throttle-1',
|
||||
)
|
||||
|
||||
def _redeem(self, code, order_id=None, token=None):
|
||||
headers = {}
|
||||
if token:
|
||||
headers['HTTP_AUTHORIZATION'] = f'Bearer {token}'
|
||||
return self.client.post(
|
||||
'/api/discount/redeem',
|
||||
{'code': code, 'orderId': order_id or self.order.id},
|
||||
format='json',
|
||||
**headers,
|
||||
)
|
||||
|
||||
def test_unauthenticated_request_is_still_rejected(self):
|
||||
# رفتارِ IsAuthenticated موجود دستنخورده مونده؛ throttle جایگزینِ
|
||||
# authentication نیست.
|
||||
response = self._redeem('ANYCODE')
|
||||
self.assertEqual(response.status_code, 401)
|
||||
|
||||
def test_repeated_code_probing_is_throttled_after_configured_limit(self):
|
||||
# سناریوی brute-force: کدهای ناموجودِ پشتِسرِهم روی همون سفارش.
|
||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'discount_redeem': '3/hour'}):
|
||||
for i in range(3):
|
||||
response = self._redeem(f'PROBE-{i}', token=self.token)
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
blocked_response = self._redeem('PROBE-EXTRA', token=self.token)
|
||||
self.assertEqual(blocked_response.status_code, 429)
|
||||
|
||||
def test_throttle_is_scoped_per_user_not_shared_globally(self):
|
||||
# ⚠️ توجه: از یک کاربرِ *غیرمهمان* استفاده میشه (نه یک حسابِ مهمانِ
|
||||
# دومِ متفاوت)، چون طبقِ فیکسِ GuestAwareDiscountThrottle حسابهای
|
||||
# مهمان دیگه bucket جداگانه ندارن (نگاه کنید به
|
||||
# RedeemDiscountGuestThrottleBypassTests پایینتر) — این تست فقط
|
||||
# رفتارِ per-user برای کاربرهایِ ثبتنامشده رو تضمین میکنه که باید
|
||||
# دقیقاً مثلِ قبل دستنخورده بمونه.
|
||||
other_user = User.objects.create(is_guest=False, name='', phone='+989120000301')
|
||||
other_token = issue_access_token(other_user.id)
|
||||
other_order = Order.objects.create(
|
||||
user_id=other_user.id, amount=5000, idempotency_key='order-redeem-throttle-2',
|
||||
)
|
||||
|
||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'discount_redeem': '1/hour'}):
|
||||
first_user_response = self._redeem('PROBE-A', token=self.token)
|
||||
self.assertEqual(first_user_response.status_code, 404)
|
||||
|
||||
first_user_blocked = self._redeem('PROBE-B', token=self.token)
|
||||
self.assertEqual(first_user_blocked.status_code, 429)
|
||||
|
||||
# کاربرِ دومِ متفاوت هنوز باید بتونه تلاش کنه — throttle
|
||||
# per-user است، نه یک شمارندهی سراسری روی endpoint.
|
||||
second_user_response = self._redeem('PROBE-C', order_id=other_order.id, token=other_token)
|
||||
self.assertEqual(second_user_response.status_code, 404)
|
||||
|
||||
def test_successful_redemption_still_works_within_the_limit(self):
|
||||
# عدمِ رگرسیون: رفتارِ اصلیِ redemption باید دقیقاً مثلِ قبل کار کنه.
|
||||
discount = DiscountCode.objects.create(
|
||||
code='REDEEMTHROTTLE10', type='percent', value=10, max_uses=5, expires_at=None,
|
||||
)
|
||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'discount_redeem': '10/hour'}):
|
||||
response = self._redeem(discount.code, token=self.token)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTrue(response.data['success'])
|
||||
self.assertEqual(response.data['discountAmount'], 1000)
|
||||
self.order.refresh_from_db()
|
||||
self.assertEqual(self.order.amount, 9000)
|
||||
|
||||
def test_blocked_request_does_not_mutate_order(self):
|
||||
# درخواستِ بلاکشده نباید هیچ Redemption ای بسازه یا مبلغِ سفارش
|
||||
# رو تغییر بده — throttle باید *قبل* از رسیدن به منطقِ ویو رد کنه.
|
||||
discount = DiscountCode.objects.create(
|
||||
code='REDEEMTHROTTLEBLOCK', type='fixed', value=1000, max_uses=5, expires_at=None,
|
||||
)
|
||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'discount_redeem': '1/hour'}):
|
||||
first_response = self._redeem('PROBE-X', token=self.token)
|
||||
self.assertEqual(first_response.status_code, 404)
|
||||
|
||||
blocked_response = self._redeem(discount.code, token=self.token)
|
||||
|
||||
self.assertEqual(blocked_response.status_code, 429)
|
||||
self.order.refresh_from_db()
|
||||
self.assertEqual(self.order.amount, 10000)
|
||||
|
||||
|
||||
class RedeemDiscountGuestThrottleBypassTests(TestCase):
|
||||
"""
|
||||
🔒 اثبات فیکسِ امنیتی: قبلاً کلیدِ throttle_scope='discount_redeem' برای
|
||||
هر کاربرِ احرازهویتشده (مهمان یا نه) user.pk بود. چون GuestSessionView
|
||||
با هر deviceId جدید یک کاربرِ مهمانِ تازه (pk جدید) میسازه، یک مهاجم
|
||||
میتونست با ساختنِ پیدرپیِ حسابهای مهمان، هر بار یک bucket throttle
|
||||
تازه برای probe/brute-force کردنِ کدهای تخفیف بگیره. چون DiscountCode
|
||||
یک رکوردِ سراسری با max_uses مشترک است، این دورزدن صرفاً افشای
|
||||
اطلاعات نبود؛ به مهاجم guessهای واقعیِ بیشتری روی یک کدِ سراسری
|
||||
میداد که هرکدوم میتونست یک Redemption واقعی بسازه و مبلغِ سفارش رو
|
||||
کم کنه. حالا GuestAwareDiscountThrottle برای کاربرهای مهمان کلید رو
|
||||
بر اساسِ IP (همون get_ident که guest_session هم استفاده میکنه)
|
||||
میسازه، پس چرخشِ حساب مهمان دیگه bucket تازه نمیده. رفتارِ throttle
|
||||
برای کاربرِ غیرمهمان (per-user، مثلِ قبل) دستنخورده مونده.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
cache.clear()
|
||||
self.client = APIClient()
|
||||
|
||||
def _make_guest_with_order(self, device_id, amount=10000):
|
||||
guest = User.objects.create(device_id=device_id, is_guest=True, name='')
|
||||
order = Order.objects.create(
|
||||
user_id=guest.id, amount=amount, idempotency_key=f'order-{device_id}',
|
||||
)
|
||||
return guest, order
|
||||
|
||||
def _redeem(self, code, order_id, token):
|
||||
return self.client.post(
|
||||
'/api/discount/redeem',
|
||||
{'code': code, 'orderId': order_id},
|
||||
format='json',
|
||||
HTTP_AUTHORIZATION=f'Bearer {token}',
|
||||
)
|
||||
|
||||
def test_rotating_guest_accounts_share_the_same_throttle_bucket(self):
|
||||
# سه حساب مهمانِ متفاوت (شبیهسازیِ چرخشِ حساب برای دورزدنِ throttle)،
|
||||
# همه از همون کلاینت/IP — باید همه یک bucket مشترک رو مصرف کنند.
|
||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'discount_redeem': '2/hour'}):
|
||||
guest1, order1 = self._make_guest_with_order('device-redeem-guest-rot-1')
|
||||
response1 = self._redeem('PROBE-ROT-1', order1.id, issue_access_token(guest1.id))
|
||||
self.assertEqual(response1.status_code, 404)
|
||||
|
||||
guest2, order2 = self._make_guest_with_order('device-redeem-guest-rot-2')
|
||||
response2 = self._redeem('PROBE-ROT-2', order2.id, issue_access_token(guest2.id))
|
||||
self.assertEqual(response2.status_code, 404)
|
||||
|
||||
# سومین حسابِ مهمانِ تازه (pk جدید) باید throttle بشه، چون
|
||||
# bucket بر اساسِ IP بهاشتراک گذاشته شده، نه user.pk — یعنی
|
||||
# سقفِ ۲/ساعت هنوز اعمال میشه، صرفنظر از تعدادِ حسابهای
|
||||
# مهمانِ ساختهشده.
|
||||
guest3, order3 = self._make_guest_with_order('device-redeem-guest-rot-3')
|
||||
response3 = self._redeem('PROBE-ROT-3', order3.id, issue_access_token(guest3.id))
|
||||
|
||||
self.assertEqual(response3.status_code, 429)
|
||||
|
||||
def test_rotated_guest_account_cannot_get_a_fresh_bucket_after_limit(self):
|
||||
# حتی وقتی حسابِ مهمانِ جدید به سقف رسیده، یک حسابِ مهمانِ دیگر
|
||||
# (چهارمین pk) هم نباید bucket تازهای بگیره — سقف هنوز به IP
|
||||
# مشترک متصل است.
|
||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'discount_redeem': '1/hour'}):
|
||||
guest1, order1 = self._make_guest_with_order('device-redeem-guest-rot-4')
|
||||
response1 = self._redeem('PROBE-ROT-4', order1.id, issue_access_token(guest1.id))
|
||||
self.assertEqual(response1.status_code, 404)
|
||||
|
||||
guest2, order2 = self._make_guest_with_order('device-redeem-guest-rot-5')
|
||||
blocked = self._redeem('PROBE-ROT-5', order2.id, issue_access_token(guest2.id))
|
||||
self.assertEqual(blocked.status_code, 429)
|
||||
|
||||
guest3, order3 = self._make_guest_with_order('device-redeem-guest-rot-6')
|
||||
still_blocked = self._redeem('PROBE-ROT-6', order3.id, issue_access_token(guest3.id))
|
||||
|
||||
self.assertEqual(still_blocked.status_code, 429)
|
||||
|
||||
def test_registered_users_keep_independent_per_user_buckets(self):
|
||||
# عدمِ رگرسیون: کاربرهای غیرمهمان (ثبتنامشده با شماره) باید مثلِ
|
||||
# قبل هرکدوم bucket جداگانهی خودشون رو داشته باشن — throttle
|
||||
# برای اونا هنوز بر اساسِ user.pk است، نه IP.
|
||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'discount_redeem': '1/hour'}):
|
||||
user_a = User.objects.create(is_guest=False, name='', phone='+989120000401')
|
||||
order_a = Order.objects.create(
|
||||
user_id=user_a.id, amount=10000, idempotency_key='order-redeem-reg-a',
|
||||
)
|
||||
response_a = self._redeem('PROBE-REG-A', order_a.id, issue_access_token(user_a.id))
|
||||
self.assertEqual(response_a.status_code, 404)
|
||||
|
||||
user_b = User.objects.create(is_guest=False, name='', phone='+989120000402')
|
||||
order_b = Order.objects.create(
|
||||
user_id=user_b.id, amount=10000, idempotency_key='order-redeem-reg-b',
|
||||
)
|
||||
response_b = self._redeem('PROBE-REG-B', order_b.id, issue_access_token(user_b.id))
|
||||
|
||||
self.assertEqual(response_b.status_code, 404)
|
||||
|
||||
def test_blocked_guest_request_does_not_mutate_order_or_create_redemption(self):
|
||||
# درخواستِ بلاکشده (از حسابِ مهمانِ چرخشی) نباید مبلغِ سفارش رو
|
||||
# تغییر بده یا Redemption بسازه — throttle باید *قبل* از رسیدن به
|
||||
# select_for_update()/منطقِ redemption رد کنه، حتی با کدِ درست.
|
||||
discount = DiscountCode.objects.create(
|
||||
code='REDEEMGUESTROTBLOCK', type='fixed', value=1000, max_uses=5, expires_at=None,
|
||||
)
|
||||
|
||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'discount_redeem': '1/hour'}):
|
||||
guest1, order1 = self._make_guest_with_order('device-redeem-guest-rot-7')
|
||||
first_response = self._redeem('PROBE-ROT-7', order1.id, issue_access_token(guest1.id))
|
||||
self.assertEqual(first_response.status_code, 404)
|
||||
|
||||
# حسابِ مهمانِ تازهی دوم، حتی با کدِ درست روی سفارشِ خودش، باید
|
||||
# بلاک بشه (bucket مشترکِ IP از قبل مصرف شده).
|
||||
guest2, order2 = self._make_guest_with_order('device-redeem-guest-rot-8')
|
||||
blocked_response = self._redeem(discount.code, order2.id, issue_access_token(guest2.id))
|
||||
|
||||
self.assertEqual(blocked_response.status_code, 429)
|
||||
order2.refresh_from_db()
|
||||
self.assertEqual(order2.amount, 10000)
|
||||
self.assertEqual(discount.redemptions.count(), 0)
|
||||
|
||||
|
||||
class ValidateDiscountGuestThrottleBypassTests(TestCase):
|
||||
"""
|
||||
🔒 اثبات فیکسِ امنیتی: قبلاً کلیدِ throttle_scope='discount_validate' برای
|
||||
هر کاربرِ احرازهویتشده (مهمان یا نه) user.pk بود، دقیقاً همون مشکلی که
|
||||
RedeemDiscountGuestThrottleBypassTests برای discount_redeem اثبات کرده.
|
||||
چون GuestSessionView با هر deviceId جدید یک کاربرِ مهمانِ تازه (pk جدید)
|
||||
میسازه، یک مهاجم میتونست با ساختنِ پیدرپیِ حسابهای مهمان، هر بار یک
|
||||
bucket throttle تازه برای probe کردنِ type/value کدهای تخفیف بگیره.
|
||||
حالا GuestAwareDiscountThrottle برای کاربرهای مهمان کلید رو بر اساسِ IP
|
||||
میسازه، پس چرخشِ حساب مهمان دیگه bucket تازه نمیده. رفتارِ throttle
|
||||
برای کاربرِ غیرمهمان (per-user، مثلِ قبل) دستنخورده مونده.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
cache.clear()
|
||||
self.client = APIClient()
|
||||
self.discount = DiscountCode.objects.create(
|
||||
code='GUESTROTVALIDATE10', type='percent', value=10, max_uses=5, expires_at=None,
|
||||
)
|
||||
|
||||
def _validate(self, code, token):
|
||||
return self.client.get(
|
||||
'/api/discount/validate', {'code': code}, HTTP_AUTHORIZATION=f'Bearer {token}',
|
||||
)
|
||||
|
||||
def test_rotating_guest_accounts_share_the_same_throttle_bucket(self):
|
||||
# سه حساب مهمانِ متفاوت (شبیهسازیِ چرخشِ حساب برای دورزدنِ throttle)،
|
||||
# همه از همون کلاینت/IP — باید همه یک bucket مشترک رو مصرف کنند.
|
||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'discount_validate': '2/hour'}):
|
||||
guest1 = User.objects.create(device_id='device-validate-guest-rot-1', is_guest=True, name='')
|
||||
response1 = self._validate(self.discount.code, issue_access_token(guest1.id))
|
||||
self.assertEqual(response1.status_code, 200)
|
||||
|
||||
guest2 = User.objects.create(device_id='device-validate-guest-rot-2', is_guest=True, name='')
|
||||
response2 = self._validate(self.discount.code, issue_access_token(guest2.id))
|
||||
self.assertEqual(response2.status_code, 200)
|
||||
|
||||
# سومین حسابِ مهمانِ تازه (pk جدید) باید throttle بشه، چون
|
||||
# bucket بر اساسِ IP بهاشتراک گذاشته شده، نه user.pk.
|
||||
guest3 = User.objects.create(device_id='device-validate-guest-rot-3', is_guest=True, name='')
|
||||
response3 = self._validate(self.discount.code, issue_access_token(guest3.id))
|
||||
|
||||
self.assertEqual(response3.status_code, 429)
|
||||
|
||||
def test_registered_users_keep_independent_per_user_buckets(self):
|
||||
# عدمِ رگرسیون: کاربرهای غیرمهمان (ثبتنامشده با شماره) باید مثلِ
|
||||
# قبل هرکدوم bucket جداگانهی خودشون رو داشته باشن.
|
||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'discount_validate': '1/hour'}):
|
||||
user_a = User.objects.create(is_guest=False, name='', phone='+989120000741')
|
||||
response_a = self._validate(self.discount.code, issue_access_token(user_a.id))
|
||||
self.assertEqual(response_a.status_code, 200)
|
||||
|
||||
user_b = User.objects.create(is_guest=False, name='', phone='+989120000742')
|
||||
response_b = self._validate(self.discount.code, issue_access_token(user_b.id))
|
||||
|
||||
self.assertEqual(response_b.status_code, 200)
|
||||
|
||||
@@ -6,6 +6,7 @@ from drf_spectacular.utils import OpenApiParameter, extend_schema
|
||||
from rest_framework.exceptions import ValidationError
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.throttling import ScopedRateThrottle
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from .models import DiscountCode, Redemption
|
||||
@@ -19,6 +20,41 @@ from .serializers import (
|
||||
from .services import redeem_offer_code
|
||||
|
||||
|
||||
class GuestAwareDiscountThrottle(ScopedRateThrottle):
|
||||
"""
|
||||
⚠️ اضافه شده: همون مشکلِ GuestAwareAIThrottle (ai_tutor/views.py) و
|
||||
GuestAwareFamilyThrottle (family/views.py) اینجا هم هست.
|
||||
ScopedRateThrottle استاندارد برای درخواستِ احرازهویتشده از
|
||||
request.user.pk بهعنوانِ کلیدِ throttle استفاده میکنه؛ چون
|
||||
GuestSessionView با هر deviceId جدید یک کاربرِ مهمانِ تازه (pk جدید)
|
||||
میسازه، یک مهاجم میتونست با ساختنِ پیدرپیِ حسابهای مهمان (تا سقفِ
|
||||
guest_session=20/hour)، برای هر حساب یک bucket تازه از throttle_scope
|
||||
'discount_redeem' بگیره. ریسکِ این endpoint از بقیه هم بیشتره، چون
|
||||
DiscountCode یک رکوردِ سراسری با max_uses مشترکِ بینِ همهی کاربرهاست؛
|
||||
یک حدسِ درست صرفاً اطلاعات لو نمیده، بلکه یک Redemption واقعی
|
||||
میسازه و مبلغِ سفارش رو کم میکنه — یعنی throttle دورزدهشده به
|
||||
مهاجم guessهای واقعاً بیشتری روی یک کدِ سراسری میده.
|
||||
|
||||
دقیقاً طبقِ همون الگو: فقط برای کاربرهای مهمان (is_guest=True) کلیدِ
|
||||
cache از pk کاربر به IP کلاینت (get_ident) تغییر میکنه، پس همهی
|
||||
حسابهای مهمانِ ساختهشده از یک IP یک bucket مشترک دارن و چرخشِ حساب
|
||||
دیگه bucket تازه نمیده. رفتارِ throttle برای کاربرهای غیرمهمان
|
||||
(ثبتنامشده) دقیقاً مثلِ قبل (کلید بر اساسِ pk) باقی میمونه؛ منطقِ
|
||||
lookup/redemption/order دستنخوردهست.
|
||||
"""
|
||||
|
||||
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}
|
||||
|
||||
|
||||
def _is_usable(discount: DiscountCode) -> bool:
|
||||
return bool(
|
||||
discount.redemptions.count() < discount.max_uses
|
||||
@@ -33,6 +69,24 @@ class ValidateDiscountView(APIView):
|
||||
# صورت کلاینت بدون احراز هویت میتواند کدهای تخفیف را probe کند.
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
# ⚠️ اضافه شده: این endpoint خودِ type/value کد رو هم برمیگردونه (نه
|
||||
# فقط valid/invalid)، پس بدونِ throttle یک کاربرِ احرازهویتشده میتونست
|
||||
# بینهایت کدهای تخفیف رو probe کنه و جزئیاتشون رو یاد بگیره. طبقِ
|
||||
# همون الگویِ ScopedRateThrottle که برای RedeemOfferCodeView
|
||||
# (offer_redeem) اضافه شد: چون کاربر احرازهویتشده، کلید بر اساسِ pk
|
||||
# کاربره (نه IP). نرخ کمی بالاتر از offer_redeem انتخاب شده چون این
|
||||
# یک چکِ read-only پیش از خریده (ممکنه کاربر چند کد رو قبل از تصمیمِ
|
||||
# نهایی امتحان کنه)، نه خودِ مصرف کد.
|
||||
# ⚠️ اصلاح شده: چون کاربرهای مهمان (GuestSessionView) با هر deviceId
|
||||
# جدید یک pk تازه میگیرن، ScopedRateThrottleِ ساده (کلید بر اساسِ
|
||||
# user.pk) توسطِ چرخشِ حساب مهمان قابلدورزدن بود. حالا از همون
|
||||
# GuestAwareDiscountThrottleِ مشترک (که RedeemDiscountView/
|
||||
# RedeemOfferCodeView هم استفاده میکنن) استفاده میشه که برای
|
||||
# کاربرهای مهمان کلید رو بر اساسِ IP میسازه و برای کاربرهای
|
||||
# غیرمهمان رفتارِ per-user قبلی رو دستنخورده نگه میداره.
|
||||
throttle_scope = 'discount_validate'
|
||||
throttle_classes = [GuestAwareDiscountThrottle]
|
||||
|
||||
@extend_schema(
|
||||
parameters=[OpenApiParameter('code', OpenApiTypes.STR, required=True)],
|
||||
responses=ValidateResponseSerializer,
|
||||
@@ -63,6 +117,26 @@ class RedeemDiscountView(APIView):
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
# ⚠️ اضافه شده: این endpoint هم دقیقاً همون oracle ای که باعث شد
|
||||
# ValidateDiscountView و RedeemOfferCodeView throttle بگیرن رو داره —
|
||||
# کدِ ناموجود ۴۰۴ میگیره، کدِ موجود-ولی-نامعتبر ۴۰۰ میگیره، پس بدونِ
|
||||
# throttle یک کاربرِ احرازهویتشده میتونست کدهای تخفیف رو روی سفارشِ
|
||||
# خودش probe/brute-force کنه. اینجا ریسک از اون دوتا هم بیشتره، چون
|
||||
# حدسِ درست صرفاً اطلاعات لو نمیده؛ یک کدِ واقعی رو مصرف میکنه
|
||||
# (سهمِ max_uses مشترک بین همهی کاربرها رو میخوره) و باعثِ افتِ
|
||||
# واقعیِ مبلغِ سفارش میشه؛ ضمناً هر تلاش دو تا select_for_update و یک
|
||||
# تراکنشِ دیتابیسی اجرا میکنه که گرونتر از GET سبکِ validate است.
|
||||
# طبقِ همون الگو، ScopedRateThrottle با کلیدِ per-user (نه IP) اضافه شد.
|
||||
# ⚠️ اصلاح شده: چون کاربرهای مهمان (GuestSessionView) با هر deviceId
|
||||
# جدید یک pk تازه میگیرن، ScopedRateThrottleِ ساده (کلید بر اساسِ
|
||||
# user.pk) توسطِ چرخشِ حساب مهمان قابلدورزدن بود. حالا از
|
||||
# GuestAwareDiscountThrottle استفاده میشه که برای کاربرهای مهمان
|
||||
# کلید رو بر اساسِ IP میسازه (دقیقاً همون الگویِ GuestAwareAIThrottle
|
||||
# و GuestAwareFamilyThrottle) و برای کاربرهای غیرمهمان رفتارِ per-user
|
||||
# قبلی رو دستنخورده نگه میداره.
|
||||
throttle_scope = 'discount_redeem'
|
||||
throttle_classes = [GuestAwareDiscountThrottle]
|
||||
|
||||
@extend_schema(request=RedeemRequestSerializer, responses=RedeemResponseSerializer,
|
||||
tags=['Discount (discount)'])
|
||||
def post(self, request):
|
||||
@@ -126,6 +200,27 @@ class RedeemOfferCodeView(APIView):
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
# ⚠️ اضافه شده: کدهای نامعتبر فقط یک ValidationError ساده (۴۰۰) برمیگردونن
|
||||
# و هیچ throttleای اینجا نبود؛ یعنی یک کاربرِ احرازهویتشده میتونست
|
||||
# بدونِ هیچ محدودیتی کدهای آفر رو حدس بزنه (offer-code probing). طبقِ
|
||||
# همون الگویِ ScopedRateThrottle که در بقیهی اکشنهایِ کمتکرار/حساسِ
|
||||
# پروژه استفاده شده (مثلِ purchase در subscription، support_create در
|
||||
# support)، اینجا هم یک throttle_scope مجزا اضافه شد.
|
||||
# ⚠️ اصلاح شده: چون کاربرهای مهمان (GuestSessionView) با هر deviceId
|
||||
# جدید یک pk تازه میگیرن، ScopedRateThrottleِ ساده (کلید بر اساسِ
|
||||
# user.pk) توسطِ چرخشِ حساب مهمان قابلدورزدن بود — بهخصوص اینجا
|
||||
# حساستره چون Offer.code سراسری/یکتاست و redeem_offer_code() فقط
|
||||
# جلوی تکرارِ *همون* کاربر رو میگیره، نه کاربرهای مهمانِ جدید (یک
|
||||
# حسابِ مهمانِ چرخشی میتونست همون آفرِ سراسری رو دوباره از
|
||||
# apply_offer() بگیره). حالا از همون GuestAwareDiscountThrottle که
|
||||
# برای RedeemDiscountView اضافه شد استفاده میشه (دقیقاً همون الگویِ
|
||||
# GuestAwareAIThrottle، GuestAwareFamilyThrottle و
|
||||
# GuestAwarePurchaseThrottle) که برای کاربرهای مهمان کلید رو بر
|
||||
# اساسِ IP میسازه و برای کاربرهای غیرمهمان رفتارِ per-user قبلی رو
|
||||
# دستنخورده نگه میداره.
|
||||
throttle_scope = 'offer_redeem'
|
||||
throttle_classes = [GuestAwareDiscountThrottle]
|
||||
|
||||
@extend_schema(
|
||||
request=RedeemOfferCodeRequestSerializer, responses=UserOfferResponseSerializer,
|
||||
tags=['Discount (discount)'],
|
||||
|
||||
107
docker-compose.yml
Normal file
107
docker-compose.yml
Normal file
@@ -0,0 +1,107 @@
|
||||
# ---------------------------------------------------------------------
|
||||
# Docker Compose برای اجرای محلیِ Django بههمراه استکِ لاگِ متمرکز
|
||||
# (Grafana Alloy → Loki → Grafana). فقط برای dev/local — تنظیمِ پروداکشن نیست.
|
||||
#
|
||||
# جریانِ لاگ:
|
||||
# Django app --(همون JSON log خطبهخطِ موجود، هم روی stdout هم در
|
||||
# فایلِ /var/log/django/app.log — بدونِ تغییرِ پیامها)-->
|
||||
# Alloy --(این فایل رو از طریقِ ولومِ مشترکِ django_logs میخونه)-->
|
||||
# Loki --(ذخیرهسازیِ لاگ روی فایلسیستمِ محلی)-->
|
||||
# Grafana --(دیتاسورسِ Loki از قبل provision شده؛ داشبوردِ آماده)
|
||||
#
|
||||
# ⚠️ قبلاً این نقش با Promtail انجام میشد؛ چون Promtail مارچ ۲۰۲۶ به
|
||||
# EOL رسید، با Grafana Alloy (جانشینِ رسمیِ خودِ Grafana Labs) جایگزین
|
||||
# شد. جریانِ لاگ (Django → فایلِ مشترک → Loki → Grafana) و لیبلهای
|
||||
# level/logger دقیقاً همون قبلیان.
|
||||
#
|
||||
# اجرا: docker compose up --build
|
||||
# - اپ: http://localhost:8000
|
||||
# - Grafana: http://localhost:3000 (بدونِ لاگین، anonymous admin فعاله)
|
||||
# ---------------------------------------------------------------------
|
||||
|
||||
services:
|
||||
django:
|
||||
build: .
|
||||
container_name: tadrisino_django
|
||||
# migrate هر بار قبل از اجرا صدا زده میشه؛ روی SQLite بیخطر و
|
||||
# idempotent است (مطابقِ همون workflow ای که در README برای اجرای
|
||||
# محلی/غیرِ Docker هم توصیه شده).
|
||||
command: sh -c "python manage.py migrate --noinput && python manage.py runserver 0.0.0.0:8000"
|
||||
ports:
|
||||
- "8000:8000"
|
||||
environment:
|
||||
# این مقادیر فقط برای اجرای محلیِ Docker/دِمو هستن؛ برای پروداکشن
|
||||
# واقعی جایگزین بشن (به .env.example نگاه کنید).
|
||||
- DJANGO_DEBUG=${DJANGO_DEBUG:-1}
|
||||
- DJANGO_SECRET_KEY=${DJANGO_SECRET_KEY:-}
|
||||
- DJANGO_ALLOWED_HOSTS=${DJANGO_ALLOWED_HOSTS:-localhost,127.0.0.1}
|
||||
- CACHE_URL=${CACHE_URL:-}
|
||||
- KAVENEGAR_API_KEY=${KAVENEGAR_API_KEY:-}
|
||||
# مسیرِ اضافهای که Django (علاوه بر stdout) همون خطوطِ JSON رو توش
|
||||
# هم مینویسه؛ فقط وقتی این env ست باشه فعال میشه (config/settings.py)
|
||||
# — یعنی اجرای عادیِ خارج از Docker دقیقاً مثلِ قبل میمونه.
|
||||
- LOG_DIR=/var/log/django
|
||||
volumes:
|
||||
- .:/app # کد برای دیباگ/توسعهی زنده mount میشه
|
||||
- django_logs:/var/log/django # ولومِ مشترک با alloy
|
||||
# ⚠️ عمداً depends_on نداره: Django برای بالا اومدن یا جوابدادن به
|
||||
# درخواستها هیچ نیازی به Loki نداره (فقط توی فایل مینویسه؛ Promtail
|
||||
# و Loki مستقل از این میخونن/push میکنن). وابستگیِ استارتآپیِ اضافه
|
||||
# فقط باعثِ کندشدنِ بیدلیلِ `docker compose up` میشد.
|
||||
|
||||
loki:
|
||||
image: grafana/loki:2.9.6
|
||||
container_name: tadrisino_loki
|
||||
ports:
|
||||
- "3100:3100"
|
||||
command: -config.file=/etc/loki/loki-config.yml
|
||||
volumes:
|
||||
- ./docker/loki/loki-config.yml:/etc/loki/loki-config.yml:ro
|
||||
- loki_data:/loki
|
||||
|
||||
alloy:
|
||||
image: grafana/alloy:v1.18.1
|
||||
container_name: tadrisino_alloy
|
||||
# همون کاری که promtail قبلاً میکرد: config.alloy رو اجرا میکنه که
|
||||
# فایلِ لاگِ Django رو میخونه و به Loki push میکنه. پرچمِ
|
||||
# server.http.listen-addr فقط برای UI دیباگِ خودِ Alloy روی 12345 است.
|
||||
command:
|
||||
- run
|
||||
- --server.http.listen-addr=0.0.0.0:12345
|
||||
- --storage.path=/var/lib/alloy/data
|
||||
- /etc/alloy/config.alloy
|
||||
ports:
|
||||
- "12345:12345" # اختیاری: UI دیباگِ Alloy (وضعیتِ کامپوننتها)
|
||||
volumes:
|
||||
- ./docker/alloy/config.alloy:/etc/alloy/config.alloy:ro
|
||||
- django_logs:/var/log/django:ro # همون لاگهای Django رو میخونه (read-only)
|
||||
- alloy_data:/var/lib/alloy/data # ذخیرهسازیِ داخلیِ Alloy (شاملِ positions)
|
||||
# ⚠️ عمداً فقط به loki وابستهست، نه django: Alloy صرفاً فایلِ
|
||||
# لاگِ رویِ ولومِ مشترک رو میخونه (چه Django بالا باشه چه نه) و به
|
||||
# Loki push میکنه؛ نیازی نداره منتظرِ استارتشدنِ Django بمونه.
|
||||
depends_on:
|
||||
- loki
|
||||
|
||||
grafana:
|
||||
image: grafana/grafana:10.4.3
|
||||
container_name: tadrisino_grafana
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
# فقط برای دسترسیِ محلی/دِمو ساده شده (بدونِ لاگین)؛ برای هر چیزِ
|
||||
# عمومی/پروداکشن حتماً anonymous access رو خاموش و پسورد ست کنید.
|
||||
- GF_AUTH_ANONYMOUS_ENABLED=true
|
||||
- GF_AUTH_ANONYMOUS_ORG_ROLE=Admin
|
||||
- GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_ADMIN_PASSWORD:-admin}
|
||||
volumes:
|
||||
- ./docker/grafana/provisioning:/etc/grafana/provisioning:ro
|
||||
- ./docker/grafana/dashboards:/var/lib/grafana/dashboards:ro
|
||||
- grafana_data:/var/lib/grafana
|
||||
depends_on:
|
||||
- loki
|
||||
|
||||
volumes:
|
||||
django_logs:
|
||||
loki_data:
|
||||
alloy_data:
|
||||
grafana_data:
|
||||
56
docker/alloy/config.alloy
Normal file
56
docker/alloy/config.alloy
Normal file
@@ -0,0 +1,56 @@
|
||||
// پیکربندیِ حداقلیِ Grafana Alloy — جایگزینِ Promtail (که EOL شده،
|
||||
// مارچ ۲۰۲۶). دقیقاً همون کاری که promtail-config.yml قبلی میکرد:
|
||||
// فایلِ لاگِ JSON اپِ Django رو tail میکنه، level/logger رو بهعنوانِ
|
||||
// لیبل استخراج میکنه، و به Loki push میکنه. هیچ لاگِ جدیدی تولید
|
||||
// نمیکنه و پیامِ لاگها دستنخورده میمونه.
|
||||
|
||||
// مرحلهی ۱: پیدا کردنِ فایلهای لاگ روی ولومِ مشترک (django_logs)
|
||||
local.file_match "django_logs" {
|
||||
path_targets = [{
|
||||
__path__ = "/var/log/django/*.log",
|
||||
job = "django",
|
||||
app = "tadrisino",
|
||||
}]
|
||||
}
|
||||
|
||||
// مرحلهی ۲: خوندنِ همون فایلها
|
||||
loki.source.file "django_logs" {
|
||||
targets = local.file_match.django_logs.targets
|
||||
forward_to = [loki.process.django_logs.receiver]
|
||||
}
|
||||
|
||||
// مرحلهی ۳: پردازش — استخراجِ level/logger/timestamp از JSONِ هر خط
|
||||
// (فرمتِ config.log_formatters.JSONLogFormatter) و ستکردنِ اونا بهعنوانِ
|
||||
// لیبل، دقیقاً همارزِ stage.json + stage.labels + stage.timestamp در
|
||||
// promtail-config.yml قبلی.
|
||||
loki.process "django_logs" {
|
||||
stage.json {
|
||||
expressions = {
|
||||
level = "",
|
||||
logger = "",
|
||||
timestamp = "",
|
||||
}
|
||||
}
|
||||
|
||||
stage.labels {
|
||||
values = {
|
||||
level = "",
|
||||
logger = "",
|
||||
}
|
||||
}
|
||||
|
||||
// استفاده از timestampِ خودِ لاگ (نه زمانِ رسیدنِ خط به Alloy)
|
||||
stage.timestamp {
|
||||
source = "timestamp"
|
||||
format = "RFC3339"
|
||||
}
|
||||
|
||||
forward_to = [loki.write.local.receiver]
|
||||
}
|
||||
|
||||
// مرحلهی ۴: ارسال به Loki
|
||||
loki.write "local" {
|
||||
endpoint {
|
||||
url = "http://loki:3100/loki/api/v1/push"
|
||||
}
|
||||
}
|
||||
63
docker/grafana/dashboards/django-logs.json
Normal file
63
docker/grafana/dashboards/django-logs.json
Normal file
@@ -0,0 +1,63 @@
|
||||
{
|
||||
"title": "Tadrisino Django Logs",
|
||||
"uid": "tadrisino-django-logs",
|
||||
"timezone": "browser",
|
||||
"schemaVersion": 39,
|
||||
"version": 1,
|
||||
"time": { "from": "now-6h", "to": "now" },
|
||||
"refresh": "10s",
|
||||
"templating": {
|
||||
"list": [
|
||||
{
|
||||
"name": "level",
|
||||
"type": "query",
|
||||
"datasource": { "type": "loki", "uid": "Loki" },
|
||||
"query": "label_values({job=\"django\"}, level)",
|
||||
"refresh": 2,
|
||||
"includeAll": true,
|
||||
"multi": true,
|
||||
"current": { "text": "All", "value": "$__all" }
|
||||
},
|
||||
{
|
||||
"name": "search",
|
||||
"type": "textbox",
|
||||
"label": "Search text",
|
||||
"current": { "text": "", "value": "" }
|
||||
}
|
||||
]
|
||||
},
|
||||
"panels": [
|
||||
{
|
||||
"id": 1,
|
||||
"title": "Log volume by level",
|
||||
"type": "timeseries",
|
||||
"datasource": { "type": "loki", "uid": "Loki" },
|
||||
"gridPos": { "h": 8, "w": 24, "x": 0, "y": 0 },
|
||||
"targets": [
|
||||
{
|
||||
"expr": "sum by (level) (count_over_time({job=\"django\", level=~\"$level\"} |= `$search` [1m]))",
|
||||
"refId": "A"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"title": "Django application logs",
|
||||
"type": "logs",
|
||||
"datasource": { "type": "loki", "uid": "Loki" },
|
||||
"gridPos": { "h": 16, "w": 24, "x": 0, "y": 8 },
|
||||
"options": {
|
||||
"showTime": true,
|
||||
"showLabels": true,
|
||||
"wrapLogMessage": true,
|
||||
"sortOrder": "Descending"
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"expr": "{job=\"django\", level=~\"$level\"} |= `$search`",
|
||||
"refId": "A"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
13
docker/grafana/provisioning/dashboards/dashboard.yml
Normal file
13
docker/grafana/provisioning/dashboards/dashboard.yml
Normal file
@@ -0,0 +1,13 @@
|
||||
# به Grafana میگه داشبوردهای JSON رو از /var/lib/grafana/dashboards
|
||||
# (که در docker-compose.yml به docker/grafana/dashboards mount شده) بخونه.
|
||||
apiVersion: 1
|
||||
|
||||
providers:
|
||||
- name: Django Logs
|
||||
orgId: 1
|
||||
folder: ""
|
||||
type: file
|
||||
disableDeletion: false
|
||||
updateIntervalSeconds: 30
|
||||
options:
|
||||
path: /var/lib/grafana/dashboards
|
||||
15
docker/grafana/provisioning/datasources/datasource.yml
Normal file
15
docker/grafana/provisioning/datasources/datasource.yml
Normal file
@@ -0,0 +1,15 @@
|
||||
# Loki رو بهصورتِ خودکار بهعنوانِ دیتاسورسِ پیشفرضِ Grafana اضافه میکنه
|
||||
# (نیازی به تنظیمِ دستی در UI نیست).
|
||||
apiVersion: 1
|
||||
|
||||
datasources:
|
||||
- name: Loki
|
||||
type: loki
|
||||
access: proxy
|
||||
url: http://loki:3100
|
||||
# UID رو صریح ست میکنیم چون داشبورد (django-logs.json) با همین UID
|
||||
# (uid: "Loki") به این دیتاسورس ارجاع میده؛ بدونِ این، Grafana خودش
|
||||
# یک UID تصادفی تولید میکرد و ارجاعِ داشبورد resolve نمیشد.
|
||||
uid: Loki
|
||||
isDefault: true
|
||||
editable: true
|
||||
35
docker/loki/loki-config.yml
Normal file
35
docker/loki/loki-config.yml
Normal file
@@ -0,0 +1,35 @@
|
||||
# پیکربندیِ حداقلیِ Loki برای dev/local — تکنود، بدونِ auth، ذخیرهسازیِ
|
||||
# لاگ روی فایلسیستمِ محلیِ خودِ کانتینر (ولومِ loki_data). برای بارِ
|
||||
# پروداکشنِ واقعی این تنظیمات کافی نیست (replication، retention، object
|
||||
# storage و ...)، ولی برای دیدنِ لاگهای محلی در Grafana کاملاً کافیه.
|
||||
|
||||
auth_enabled: false
|
||||
|
||||
server:
|
||||
http_listen_port: 3100
|
||||
|
||||
common:
|
||||
path_prefix: /loki
|
||||
storage:
|
||||
filesystem:
|
||||
chunks_directory: /loki/chunks
|
||||
rules_directory: /loki/rules
|
||||
replication_factor: 1
|
||||
ring:
|
||||
kvstore:
|
||||
store: inmemory
|
||||
|
||||
schema_config:
|
||||
configs:
|
||||
- from: 2024-01-01
|
||||
store: tsdb
|
||||
object_store: filesystem
|
||||
schema: v13
|
||||
index:
|
||||
prefix: index_
|
||||
period: 24h
|
||||
|
||||
limits_config:
|
||||
# جلوگیری از رد شدنِ silent لاگهای خیلی قدیمی/خیلی جدید در محیطِ dev
|
||||
reject_old_samples: true
|
||||
reject_old_samples_max_age: 168h
|
||||
342
family/tests.py
342
family/tests.py
@@ -1 +1,341 @@
|
||||
# Create your tests here.
|
||||
from django.core.cache import cache
|
||||
from django.test import TestCase
|
||||
from rest_framework.test import APIClient
|
||||
from rest_framework.throttling import ScopedRateThrottle
|
||||
from unittest.mock import patch
|
||||
|
||||
from accounts.authentication import issue_access_token
|
||||
from accounts.models import User
|
||||
from .models import FamilyLink
|
||||
|
||||
|
||||
class FamilyLinkAcceptThrottleTests(TestCase):
|
||||
"""
|
||||
🔒 اثبات محدودیتِ جدیدِ throttle_scope='family_invite_accept' روی
|
||||
FamilyLinkAcceptView: inviteCode یک رازِ عددیِ ۶رقمی است که این
|
||||
endpoint با select_for_update() جستوجو میکنه؛ بدونِ throttle یک
|
||||
کاربرِ احرازهویتشده میتونست بینهایت کد رو حدس بزنه. این تستها هم
|
||||
رفتارِ throttle (per-user، نه IP)، هم عدمِ رگرسیون در ردیمِ موفق، هم
|
||||
عدمِ تغییرِ FamilyLink توسطِ درخواستِ بلاکشده رو تضمین میکنن.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
cache.clear()
|
||||
self.client = APIClient()
|
||||
self.parent = User.objects.create(device_id='device-family-parent-1', is_guest=True, name='')
|
||||
self.child = User.objects.create(
|
||||
device_id='device-family-child-1', is_guest=True, name='', phone='+989120000001',
|
||||
)
|
||||
self.child_token = issue_access_token(self.child.id)
|
||||
self.registered_user = User.objects.create(is_guest=False, name='', phone='+989120000098')
|
||||
self.registered_token = issue_access_token(self.registered_user.id)
|
||||
self.link = FamilyLink.objects.create(
|
||||
parent_user_id=self.parent.id, child_phone=self.child.phone, invite_code='123456',
|
||||
)
|
||||
|
||||
def _accept(self, code, token=None):
|
||||
headers = {}
|
||||
if token:
|
||||
headers['HTTP_AUTHORIZATION'] = f'Bearer {token}'
|
||||
return self.client.post(
|
||||
'/api/family/link/accept', {'inviteCode': code}, format='json', **headers,
|
||||
)
|
||||
|
||||
def test_unauthenticated_request_is_still_rejected(self):
|
||||
# رفتارِ IsAuthenticated موجود دستنخورده مونده؛ throttle جایگزینِ
|
||||
# authentication نیست.
|
||||
response = self._accept('123456')
|
||||
self.assertEqual(response.status_code, 401)
|
||||
|
||||
def test_repeated_code_guessing_is_throttled_after_configured_limit(self):
|
||||
# سناریوی brute-force: کدهای نادرستِ پشتِسرِهم.
|
||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'family_invite_accept': '3/hour'}):
|
||||
for i in range(3):
|
||||
response = self._accept(f'00000{i}', token=self.child_token)
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
blocked_response = self._accept('999999', token=self.child_token)
|
||||
self.assertEqual(blocked_response.status_code, 429)
|
||||
|
||||
def test_throttle_is_scoped_per_user_not_shared_globally(self):
|
||||
# این تست مخصوصِ کاربرهای *غیرمهمان* (ثبتنامشده) است — طبقِ فیکسِ
|
||||
# GuestAwareFamilyThrottle، حسابهای مهمان دیگه bucket جداگانه
|
||||
# ندارن (نگاه کنید به FamilyLinkAcceptGuestThrottleBypassTests پایینتر)،
|
||||
# اما رفتارِ per-user برای کاربرهایِ ثبتنامشده باید دقیقاً مثلِ
|
||||
# قبل دستنخورده بمونه.
|
||||
other_user = User.objects.create(
|
||||
is_guest=False, name='', phone='+989120000099',
|
||||
)
|
||||
other_token = issue_access_token(other_user.id)
|
||||
|
||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'family_invite_accept': '1/hour'}):
|
||||
first_user_response = self._accept('000000', token=self.registered_token)
|
||||
self.assertEqual(first_user_response.status_code, 404)
|
||||
|
||||
first_user_blocked = self._accept('111111', token=self.registered_token)
|
||||
self.assertEqual(first_user_blocked.status_code, 429)
|
||||
|
||||
# کاربرِ دومِ غیرمهمانِ متفاوت هنوز باید بتونه تلاش کنه —
|
||||
# throttle برای کاربرهای ثبتنامشده هنوز per-user است، نه IP.
|
||||
second_user_response = self._accept('000000', token=other_token)
|
||||
self.assertEqual(second_user_response.status_code, 404)
|
||||
|
||||
def test_successful_accept_still_works_within_the_limit(self):
|
||||
# عدمِ رگرسیون: رفتارِ اصلیِ accept باید دقیقاً مثلِ قبل کار کنه.
|
||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'family_invite_accept': '10/hour'}):
|
||||
response = self._accept(self.link.invite_code, token=self.child_token)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.data['status'], 'accepted')
|
||||
self.link.refresh_from_db()
|
||||
self.assertEqual(self.link.status, 'accepted')
|
||||
self.assertEqual(self.link.child_user_id, self.child.id)
|
||||
|
||||
def test_blocked_request_does_not_mutate_family_link(self):
|
||||
# درخواستِ بلاکشده نباید FamilyLink رو تغییر بده — throttle باید
|
||||
# *قبل* از رسیدنِ به select_for_update() رد کنه.
|
||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'family_invite_accept': '1/hour'}):
|
||||
first_response = self._accept('000000', token=self.child_token)
|
||||
self.assertEqual(first_response.status_code, 404)
|
||||
|
||||
blocked_response = self._accept(self.link.invite_code, token=self.child_token)
|
||||
|
||||
self.assertEqual(blocked_response.status_code, 429)
|
||||
self.link.refresh_from_db()
|
||||
self.assertEqual(self.link.status, 'pending')
|
||||
self.assertIsNone(self.link.child_user_id)
|
||||
|
||||
|
||||
class FamilyLinkAcceptGuestThrottleBypassTests(TestCase):
|
||||
"""
|
||||
🔒 اثبات فیکسِ امنیتی: قبلاً کلیدِ throttle_scope='family_invite_accept'
|
||||
برای هر کاربرِ احرازهویتشده (مهمان یا نه) user.pk بود. چون
|
||||
GuestSessionView با هر deviceId جدید یک کاربرِ مهمانِ تازه (pk جدید)
|
||||
میسازه، یک مهاجم میتونست با ساختنِ پیدرپیِ حسابهای مهمان، هر بار
|
||||
یک bucket throttle تازه برای brute-force کردنِ inviteCode ۶رقمی
|
||||
بگیره. حالا GuestAwareFamilyThrottle برای کاربرهای مهمان کلید رو بر
|
||||
اساسِ IP (همون get_ident که guest_session هم استفاده میکنه) میسازه،
|
||||
پس چرخشِ حساب مهمان دیگه bucket تازه نمیده. رفتارِ throttle برای
|
||||
کاربرِ غیرمهمان (per-user، مثلِ قبل) دستنخورده مونده.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
cache.clear()
|
||||
self.client = APIClient()
|
||||
|
||||
def _accept(self, code, token):
|
||||
return self.client.post(
|
||||
'/api/family/link/accept',
|
||||
{'inviteCode': code},
|
||||
format='json',
|
||||
HTTP_AUTHORIZATION=f'Bearer {token}',
|
||||
)
|
||||
|
||||
def test_rotating_guest_accounts_share_the_same_throttle_bucket(self):
|
||||
# سه حساب مهمانِ متفاوت (شبیهسازیِ چرخشِ حساب برای دورزدنِ throttle)،
|
||||
# همه از همون کلاینت/IP — باید همه یک bucket مشترک رو مصرف کنند.
|
||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'family_invite_accept': '2/hour'}):
|
||||
guest1 = User.objects.create(device_id='device-family-guest-rot-1', is_guest=True, name='')
|
||||
response1 = self._accept('000001', issue_access_token(guest1.id))
|
||||
self.assertEqual(response1.status_code, 404)
|
||||
|
||||
guest2 = User.objects.create(device_id='device-family-guest-rot-2', is_guest=True, name='')
|
||||
response2 = self._accept('000002', issue_access_token(guest2.id))
|
||||
self.assertEqual(response2.status_code, 404)
|
||||
|
||||
# سومین حسابِ مهمانِ تازه (pk جدید) باید throttle بشه، چون
|
||||
# bucket بر اساسِ IP بهاشتراک گذاشته شده، نه user.pk — یعنی
|
||||
# سقفِ ۲/ساعت هنوز اعمال میشه، صرفنظر از تعدادِ حسابهای
|
||||
# مهمانِ ساختهشده.
|
||||
guest3 = User.objects.create(device_id='device-family-guest-rot-3', is_guest=True, name='')
|
||||
response3 = self._accept('000003', issue_access_token(guest3.id))
|
||||
|
||||
self.assertEqual(response3.status_code, 429)
|
||||
|
||||
def test_registered_users_keep_independent_per_user_buckets(self):
|
||||
# عدمِ رگرسیون: کاربرهای غیرمهمان (ثبتنامشده با شماره) باید مثلِ
|
||||
# قبل هرکدوم bucket جداگانهی خودشون رو داشته باشن — throttle
|
||||
# برای اونا هنوز بر اساسِ user.pk است، نه IP.
|
||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'family_invite_accept': '1/hour'}):
|
||||
user_a = User.objects.create(is_guest=False, name='', phone='+989120000201')
|
||||
response_a = self._accept('000001', issue_access_token(user_a.id))
|
||||
self.assertEqual(response_a.status_code, 404)
|
||||
|
||||
user_b = User.objects.create(is_guest=False, name='', phone='+989120000202')
|
||||
response_b = self._accept('000002', issue_access_token(user_b.id))
|
||||
|
||||
self.assertEqual(response_b.status_code, 404)
|
||||
|
||||
def test_blocked_guest_request_does_not_reach_or_mutate_family_link(self):
|
||||
# درخواستِ بلاکشده (چه از همون حساب، چه از حسابِ مهمانِ چرخشی)
|
||||
# نباید FamilyLink رو تغییر بده و نباید حتی به select_for_update()
|
||||
# برسه — throttle باید *قبل* از منطقِ accept رد کنه.
|
||||
parent = User.objects.create(device_id='device-family-guest-rot-parent', is_guest=True, name='')
|
||||
child = User.objects.create(
|
||||
device_id='device-family-guest-rot-child', is_guest=True, name='', phone='+989120000203',
|
||||
)
|
||||
link = FamilyLink.objects.create(
|
||||
parent_user_id=parent.id, child_phone=child.phone, invite_code='654321',
|
||||
)
|
||||
|
||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'family_invite_accept': '1/hour'}):
|
||||
guest1 = User.objects.create(device_id='device-family-guest-rot-4', is_guest=True, name='')
|
||||
first_response = self._accept('000000', issue_access_token(guest1.id))
|
||||
self.assertEqual(first_response.status_code, 404)
|
||||
|
||||
# حسابِ مهمانِ تازهی دوم، حتی با inviteCode درست، باید بلاک بشه
|
||||
# (bucket مشترکِ IP از قبل مصرف شده) و FamilyLink رو دست نزنه.
|
||||
guest2 = User.objects.create(device_id='device-family-guest-rot-5', is_guest=True, name='')
|
||||
blocked_response = self._accept(link.invite_code, issue_access_token(guest2.id))
|
||||
|
||||
self.assertEqual(blocked_response.status_code, 429)
|
||||
link.refresh_from_db()
|
||||
self.assertEqual(link.status, 'pending')
|
||||
self.assertIsNone(link.child_user_id)
|
||||
|
||||
|
||||
class FamilyLinkInviteThrottleTests(TestCase):
|
||||
"""
|
||||
🔒 اثبات محدودیتِ جدیدِ throttle_scope='family_invite_create' روی
|
||||
FamilyLinkInviteView: این endpoint قبلاً هیچ throttle مخصوصی نداشت،
|
||||
درحالیکه هر درخواستِ پذیرفتهشده یک FamilyLink ماندگار و یک کدِ
|
||||
دعوتِ ۶رقمیِ تازه میسازه. GuestAwareFamilyThrottle با pk کاربرِ
|
||||
احرازهویتشده کلید میخوره (نه IP)، پس تغییرِ IP این محدودیت رو دور
|
||||
نمیزنه.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
cache.clear()
|
||||
self.client = APIClient()
|
||||
self.parent = User.objects.create(device_id='device-family-invite-throttle-1', is_guest=True, name='')
|
||||
self.token = issue_access_token(self.parent.id)
|
||||
|
||||
def _invite(self, child_phone, token=None, remote_addr=None):
|
||||
headers = {}
|
||||
if token:
|
||||
headers['HTTP_AUTHORIZATION'] = f'Bearer {token}'
|
||||
if remote_addr:
|
||||
headers['REMOTE_ADDR'] = remote_addr
|
||||
return self.client.post(
|
||||
'/api/family/link/invite', {'childPhone': child_phone}, format='json', **headers,
|
||||
)
|
||||
|
||||
def test_unauthenticated_request_is_rejected(self):
|
||||
response = self._invite('+989120001001')
|
||||
self.assertEqual(response.status_code, 401)
|
||||
|
||||
def test_requests_within_the_limit_still_succeed(self):
|
||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'family_invite_create': '3/hour'}):
|
||||
for i in range(3):
|
||||
response = self._invite(f'+98912000110{i}', token=self.token)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn('inviteCode', response.data)
|
||||
|
||||
def test_excessive_invite_requests_from_same_user_are_blocked(self):
|
||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'family_invite_create': '3/hour'}):
|
||||
for i in range(3):
|
||||
response = self._invite(f'+98912000120{i}', token=self.token)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
blocked_response = self._invite('+989120001299', token=self.token)
|
||||
self.assertEqual(blocked_response.status_code, 429)
|
||||
|
||||
def test_changing_client_ip_does_not_bypass_the_user_limit(self):
|
||||
# ⚠️ توجه: از یک کاربرِ *غیرمهمان* استفاده میشه (نه self.parent که
|
||||
# مهمان است)، چون طبقِ فیکسِ GuestAwareFamilyThrottle حالا کلیدِ
|
||||
# bucket برای کاربرهای مهمان عمداً بر اساسِ IP است (نگاه کنید به
|
||||
# FamilyLinkInviteGuestThrottleBypassTests پایینتر) — این تست
|
||||
# فقط رفتارِ per-user (نه IP) برای کاربرهایِ ثبتنامشده رو تضمین
|
||||
# میکنه.
|
||||
registered_parent = User.objects.create(is_guest=False, name='', phone='+989120001300')
|
||||
registered_token = issue_access_token(registered_parent.id)
|
||||
|
||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'family_invite_create': '3/hour'}):
|
||||
for i in range(3):
|
||||
response = self._invite(
|
||||
f'+98912000131{i}', token=registered_token, remote_addr=f'10.5.0.{i + 1}',
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
blocked_response = self._invite('+989120001399', token=registered_token, remote_addr='10.5.0.99')
|
||||
self.assertEqual(blocked_response.status_code, 429)
|
||||
|
||||
def test_throttled_request_does_not_create_family_link(self):
|
||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'family_invite_create': '1/hour'}):
|
||||
first_response = self._invite('+989120001401', token=self.token)
|
||||
self.assertEqual(first_response.status_code, 200)
|
||||
|
||||
link_count_before = FamilyLink.objects.filter(parent_user_id=self.parent.id).count()
|
||||
blocked_response = self._invite('+989120001402', token=self.token)
|
||||
|
||||
self.assertEqual(blocked_response.status_code, 429)
|
||||
self.assertEqual(
|
||||
FamilyLink.objects.filter(parent_user_id=self.parent.id).count(), link_count_before,
|
||||
)
|
||||
self.assertFalse(
|
||||
FamilyLink.objects.filter(parent_user_id=self.parent.id, child_phone='+989120001402').exists(),
|
||||
)
|
||||
|
||||
|
||||
class FamilyLinkInviteGuestThrottleBypassTests(TestCase):
|
||||
"""
|
||||
🔒 اثبات فیکسِ امنیتی: FamilyLinkInviteView تازه throttle گرفته و از همون
|
||||
ابتدا با GuestAwareFamilyThrottle محافظت میشه (نه یک ScopedRateThrottleِ
|
||||
ساده که بعداً فیکس بشه). چون GuestSessionView با هر deviceId جدید یک
|
||||
کاربرِ مهمانِ تازه (pk جدید) میسازه، بدونِ این فیکس یک مهاجم میتونست با
|
||||
ساختنِ پیدرپیِ حسابهای مهمان، هر بار یک bucket throttle تازه برای
|
||||
ساختنِ FamilyLink/کدِ دعوتِ نامحدود بگیره. GuestAwareFamilyThrottle برای
|
||||
کاربرهای مهمان کلید رو بر اساسِ IP میسازه، پس چرخشِ حساب مهمان دیگه
|
||||
bucket تازه نمیده. رفتارِ throttle برای کاربرِ غیرمهمان (per-user)
|
||||
دستنخورده مونده.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
cache.clear()
|
||||
self.client = APIClient()
|
||||
|
||||
def _invite(self, child_phone, token):
|
||||
return self.client.post(
|
||||
'/api/family/link/invite', {'childPhone': child_phone}, format='json',
|
||||
HTTP_AUTHORIZATION=f'Bearer {token}',
|
||||
)
|
||||
|
||||
def test_rotating_guest_accounts_share_the_same_throttle_bucket(self):
|
||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'family_invite_create': '2/hour'}):
|
||||
guest1 = User.objects.create(device_id='device-family-invite-guest-rot-1', is_guest=True, name='')
|
||||
response1 = self._invite('+989120001501', issue_access_token(guest1.id))
|
||||
self.assertEqual(response1.status_code, 200)
|
||||
|
||||
guest2 = User.objects.create(device_id='device-family-invite-guest-rot-2', is_guest=True, name='')
|
||||
response2 = self._invite('+989120001502', issue_access_token(guest2.id))
|
||||
self.assertEqual(response2.status_code, 200)
|
||||
|
||||
# سومین حسابِ مهمانِ تازه (pk جدید) باید throttle بشه، چون
|
||||
# bucket بر اساسِ IP بهاشتراک گذاشته شده، نه user.pk.
|
||||
guest3 = User.objects.create(device_id='device-family-invite-guest-rot-3', is_guest=True, name='')
|
||||
response3 = self._invite('+989120001503', issue_access_token(guest3.id))
|
||||
|
||||
self.assertEqual(response3.status_code, 429)
|
||||
|
||||
def test_registered_users_keep_independent_per_user_buckets(self):
|
||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'family_invite_create': '1/hour'}):
|
||||
user_a = User.objects.create(is_guest=False, name='', phone='+989120001601')
|
||||
response_a = self._invite('+989120001701', issue_access_token(user_a.id))
|
||||
self.assertEqual(response_a.status_code, 200)
|
||||
|
||||
user_b = User.objects.create(is_guest=False, name='', phone='+989120001602')
|
||||
response_b = self._invite('+989120001702', issue_access_token(user_b.id))
|
||||
|
||||
self.assertEqual(response_b.status_code, 200)
|
||||
|
||||
def test_blocked_guest_request_does_not_create_family_link(self):
|
||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'family_invite_create': '1/hour'}):
|
||||
guest1 = User.objects.create(device_id='device-family-invite-guest-rot-4', is_guest=True, name='')
|
||||
first_response = self._invite('+989120001801', issue_access_token(guest1.id))
|
||||
self.assertEqual(first_response.status_code, 200)
|
||||
|
||||
guest2 = User.objects.create(device_id='device-family-invite-guest-rot-5', is_guest=True, name='')
|
||||
blocked_response = self._invite('+989120001802', issue_access_token(guest2.id))
|
||||
|
||||
self.assertEqual(blocked_response.status_code, 429)
|
||||
self.assertFalse(FamilyLink.objects.filter(parent_user_id=guest2.id).exists())
|
||||
|
||||
@@ -9,6 +9,7 @@ from drf_spectacular.utils import extend_schema
|
||||
from rest_framework.exceptions import NotFound, PermissionDenied
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.throttling import ScopedRateThrottle
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from .models import FamilyLink, ParentalSettings, WeeklyReportLog
|
||||
@@ -30,11 +31,56 @@ def _week_start(dt):
|
||||
return (dt - timedelta(days=dt.weekday())).date()
|
||||
|
||||
|
||||
class GuestAwareFamilyThrottle(ScopedRateThrottle):
|
||||
"""
|
||||
⚠️ اضافه شده: همون مشکلِ GuestAwareAIThrottle (ai_tutor/views.py) اینجا
|
||||
هم هست. ScopedRateThrottle استاندارد برای درخواستِ احرازهویتشده از
|
||||
request.user.pk بهعنوانِ کلیدِ throttle استفاده میکنه؛ چون
|
||||
GuestSessionView با هر deviceId جدید یک کاربرِ مهمانِ تازه (pk جدید)
|
||||
میسازه، یک مهاجم میتونست با ساختنِ پیدرپیِ حسابهای مهمان (تا سقفِ
|
||||
guest_session=20/hour)، برای هر حساب یک bucket تازه از throttle_scope
|
||||
'family_invite_accept' بگیره و عملاً محدودیتِ ۱۰/ساعتِ brute-force
|
||||
رویِ inviteCode ۶رقمی رو دور بزنه.
|
||||
|
||||
دقیقاً طبقِ همون الگو: فقط برای کاربرهای مهمان (is_guest=True) کلیدِ
|
||||
cache از pk کاربر به IP کلاینت (get_ident) تغییر میکنه، پس همهی
|
||||
حسابهای مهمانِ ساختهشده از یک IP یک bucket مشترک دارن و چرخشِ حساب
|
||||
دیگه bucket تازه نمیده. رفتارِ throttle برای کاربرهای غیرمهمان
|
||||
(ثبتنامشده) دقیقاً مثلِ قبل (کلید بر اساسِ pk) باقی میمونه؛ منطقِ
|
||||
generation/authorization/notify اکسپت دستنخوردهست.
|
||||
"""
|
||||
|
||||
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 FamilyLinkInviteView(APIView):
|
||||
"""POST /api/family/link/invite — دعوت فرزند توسط والد (تولید کد اتصال)"""
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
# ⚠️ اضافه شده: این endpoint قبلاً هیچ throttle مخصوصی نداشت، درحالیکه
|
||||
# یک FamilyLink ماندگار میسازه و یک کدِ دعوتِ ۶رقمیِ تازه تولید میکنه.
|
||||
# بدونِ throttle، یک کاربرِ احرازهویتشده میتونست با فرستادنِ
|
||||
# پیدرپیِ شمارههای مختلف، تعدادِ زیادی FamilyLink/کدِ دعوت بسازه.
|
||||
# طبقِ همون الگویِ GuestAwareFamilyThrottleِ مشترک (که
|
||||
# FamilyLinkAcceptView هم استفاده میکنه): برای کاربرهای مهمان کلید
|
||||
# بر اساسِ IP و برای کاربرهای غیرمهمان بر اساسِ user.pk است، پس
|
||||
# چرخشِ حسابِ مهمان نمیتونه bucket تازه بگیره. یک throttle_scope
|
||||
# مجزا ('family_invite_create'، نه 'family_invite_accept') استفاده
|
||||
# شده چون تولید و مصرفِ کدِ دعوت باید مستقل از هم قابلتنظیم بمونن؛
|
||||
# منطقِ generation/authorization دستنخوردهست.
|
||||
throttle_scope = 'family_invite_create'
|
||||
throttle_classes = [GuestAwareFamilyThrottle]
|
||||
|
||||
@extend_schema(
|
||||
request=FamilyLinkInviteRequestSerializer,
|
||||
responses=FamilyLinkInviteResponseSerializer,
|
||||
@@ -85,6 +131,19 @@ class FamilyLinkAcceptView(APIView):
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
# ⚠️ اضافه شده: inviteCode یک رازِ عددیِ ۶رقمی تولیدشدهی سرور است
|
||||
# (family/views.py::FamilyLinkInviteView)، و این endpoint دقیقاً همون
|
||||
# کد رو با select_for_update() جستوجو میکنه؛ بدونِ throttle، یک
|
||||
# کاربرِ احرازهویتشده میتونست بدونِ هیچ محدودیتی فضای ۶رقمی رو
|
||||
# brute-force کنه. طبقِ همون الگویِ ScopedRateThrottle که برای بقیهی
|
||||
# اندپوینتهایِ حدسپذیر پروژه اضافه شد، اینجا هم یک scope مجزا اضافه
|
||||
# شد (نه scope مشترک با discount/offer) تا نرخِ محدودسازی مستقل از اونها
|
||||
# قابلتنظیم باشه. چون DRF throttle check قبل از dispatch به post() در
|
||||
# APIView.initial() اجرا میشه، حدسهای بلاکشده اصلاً به select_for_update()
|
||||
# نمیرسن.
|
||||
throttle_scope = 'family_invite_accept'
|
||||
throttle_classes = [GuestAwareFamilyThrottle]
|
||||
|
||||
@extend_schema(
|
||||
request=FamilyLinkAcceptRequestSerializer,
|
||||
responses=FamilyLinkAcceptResponseSerializer,
|
||||
|
||||
217
payment/tests.py
217
payment/tests.py
@@ -4,9 +4,15 @@ from unittest.mock import patch
|
||||
|
||||
from django.core.cache import cache
|
||||
from django.test import TestCase, override_settings
|
||||
from rest_framework.test import APIRequestFactory
|
||||
from rest_framework.test import APIClient, APIRequestFactory
|
||||
from rest_framework.throttling import ScopedRateThrottle
|
||||
|
||||
from accounts.authentication import issue_access_token
|
||||
from accounts.models import User
|
||||
|
||||
from . import bazaar
|
||||
from .bazaar import BazaarPurchaseResult
|
||||
from .models import Order, Transaction
|
||||
from .permissions import HasInternalServiceKey, HasWebhookSecret
|
||||
|
||||
|
||||
@@ -156,3 +162,212 @@ class BazaarVerifyPurchaseFailClosedTests(TestCase):
|
||||
result = bazaar.verify_purchase(product_sku='sku1', purchase_token='cancelled-token')
|
||||
|
||||
self.assertFalse(result.is_valid)
|
||||
|
||||
|
||||
class BazaarVerifyPurchaseThrottleTests(TestCase):
|
||||
"""
|
||||
🔒 اثبات محدودیتِ جدیدِ per-user روی BazaarVerifyPurchaseView
|
||||
(throttle_scope='bazaar_verify'): هر درخواستِ پذیرفتهشده یک تماسِ
|
||||
سرور-به-سرورِ واقعی با API بازار میزنه؛ بدونِ throttle مخصوص، یک
|
||||
کاربرِ احرازهویتشده میتونست با ارسالِ مکررِ purchaseToken بارِ زیادی
|
||||
روی سرویسِ بازار/بکاند بندازه. GuestAwareBazaarThrottle با pk کاربرِ
|
||||
احرازهویتشده کلید میخوره (نه IP)، پس تغییرِ IP این محدودیت رو دور
|
||||
نمیزنه.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
cache.clear()
|
||||
self.client = APIClient()
|
||||
self.user = User.objects.create(device_id='device-bazaar-throttle-1', is_guest=True, name='')
|
||||
self.token = issue_access_token(self.user.id)
|
||||
|
||||
def _make_order(self, key):
|
||||
return Order.objects.create(
|
||||
user_id=self.user.id, amount=10000, provider='bazaar',
|
||||
bazaar_product_sku='sku_one_time', idempotency_key=key,
|
||||
)
|
||||
|
||||
def _verify(self, order_id, purchase_token, token=None, remote_addr=None):
|
||||
headers = {}
|
||||
if token:
|
||||
headers['HTTP_AUTHORIZATION'] = f'Bearer {token}'
|
||||
if remote_addr:
|
||||
headers['REMOTE_ADDR'] = remote_addr
|
||||
return self.client.post(
|
||||
'/api/payment/bazaar/verify',
|
||||
{'orderId': order_id, 'purchaseToken': purchase_token},
|
||||
format='json',
|
||||
**headers,
|
||||
)
|
||||
|
||||
def test_unauthenticated_request_is_still_rejected(self):
|
||||
response = self._verify(order_id='1', purchase_token='tok')
|
||||
self.assertEqual(response.status_code, 401)
|
||||
|
||||
def test_requests_within_the_limit_still_succeed(self):
|
||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'bazaar_verify': '3/hour'}):
|
||||
with patch('payment.views.bazaar.verify_purchase') as mock_verify:
|
||||
mock_verify.return_value = BazaarPurchaseResult(is_valid=True, purchase_state=0)
|
||||
for i in range(3):
|
||||
order = self._make_order(f'order-bazaar-within-{i}')
|
||||
response = self._verify(order.id, f'token-within-{i}', token=self.token)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_excessive_verify_attempts_from_same_user_are_blocked(self):
|
||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'bazaar_verify': '3/hour'}):
|
||||
with patch('payment.views.bazaar.verify_purchase') as mock_verify:
|
||||
mock_verify.return_value = BazaarPurchaseResult(is_valid=True, purchase_state=0)
|
||||
for i in range(3):
|
||||
order = self._make_order(f'order-bazaar-block-{i}')
|
||||
response = self._verify(order.id, f'token-block-{i}', token=self.token)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
extra_order = self._make_order('order-bazaar-block-extra')
|
||||
blocked_response = self._verify(extra_order.id, 'token-block-extra', token=self.token)
|
||||
self.assertEqual(blocked_response.status_code, 429)
|
||||
|
||||
def test_changing_client_ip_does_not_bypass_the_user_limit(self):
|
||||
# ⚠️ توجه: از یک کاربرِ *غیرمهمان* استفاده میشه (نه self.user که
|
||||
# مهمان است)، چون طبقِ فیکسِ GuestAwareBazaarThrottle حالا کلیدِ
|
||||
# bucket برای کاربرهای مهمان عمداً بر اساسِ IP است (نگاه کنید به
|
||||
# BazaarVerifyPurchaseGuestThrottleBypassTests پایینتر) — این تست
|
||||
# فقط رفتارِ per-user (نه IP) برای کاربرهایِ ثبتنامشده رو تضمین
|
||||
# میکنه.
|
||||
registered_user = User.objects.create(is_guest=False, name='', phone='+989120000751')
|
||||
registered_token = issue_access_token(registered_user.id)
|
||||
|
||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'bazaar_verify': '3/hour'}):
|
||||
with patch('payment.views.bazaar.verify_purchase') as mock_verify:
|
||||
mock_verify.return_value = BazaarPurchaseResult(is_valid=True, purchase_state=0)
|
||||
for i in range(3):
|
||||
order = Order.objects.create(
|
||||
user_id=registered_user.id, amount=10000, provider='bazaar',
|
||||
bazaar_product_sku='sku_one_time', idempotency_key=f'order-bazaar-ip-{i}',
|
||||
)
|
||||
response = self._verify(
|
||||
order.id, f'token-ip-{i}', token=registered_token, remote_addr=f'10.4.0.{i + 1}',
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
extra_order = Order.objects.create(
|
||||
user_id=registered_user.id, amount=10000, provider='bazaar',
|
||||
bazaar_product_sku='sku_one_time', idempotency_key='order-bazaar-ip-extra',
|
||||
)
|
||||
blocked_response = self._verify(
|
||||
extra_order.id, 'token-ip-extra', token=registered_token, remote_addr='10.4.0.99',
|
||||
)
|
||||
self.assertEqual(blocked_response.status_code, 429)
|
||||
|
||||
def test_throttled_request_does_not_call_bazaar_or_mutate_order(self):
|
||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'bazaar_verify': '1/hour'}):
|
||||
with patch('payment.views.bazaar.verify_purchase') as mock_verify:
|
||||
mock_verify.return_value = BazaarPurchaseResult(is_valid=True, purchase_state=0)
|
||||
first_order = self._make_order('order-bazaar-mutate-1')
|
||||
first_response = self._verify(first_order.id, 'token-mutate-1', token=self.token)
|
||||
self.assertEqual(first_response.status_code, 200)
|
||||
|
||||
mock_verify.reset_mock()
|
||||
second_order = self._make_order('order-bazaar-mutate-2')
|
||||
blocked_response = self._verify(second_order.id, 'token-mutate-2', token=self.token)
|
||||
self.assertEqual(blocked_response.status_code, 429)
|
||||
mock_verify.assert_not_called()
|
||||
|
||||
second_order.refresh_from_db()
|
||||
self.assertEqual(second_order.status, 'pending')
|
||||
self.assertEqual(second_order.bazaar_purchase_token, '')
|
||||
self.assertFalse(Transaction.objects.filter(order=second_order).exists())
|
||||
|
||||
|
||||
class BazaarVerifyPurchaseGuestThrottleBypassTests(TestCase):
|
||||
"""
|
||||
🔒 اثبات فیکسِ امنیتی: قبلاً کلیدِ throttle_scope='bazaar_verify' برای
|
||||
هر کاربرِ احرازهویتشده (مهمان یا نه) user.pk بود. چون GuestSessionView
|
||||
با هر deviceId جدید یک کاربرِ مهمانِ تازه (pk جدید) میسازه، یک مهاجم
|
||||
میتونست با ساختنِ پیدرپیِ حسابهای مهمان، هر بار یک bucket throttle
|
||||
تازه برای تریگر کردنِ تماسِ سرور-به-سرورِ verify_purchase بگیره. حالا
|
||||
GuestAwareBazaarThrottle برای کاربرهای مهمان کلید رو بر اساسِ IP
|
||||
میسازه، پس چرخشِ حساب مهمان دیگه bucket تازه نمیده. رفتارِ throttle
|
||||
برای کاربرِ غیرمهمان (per-user، مثلِ قبل) دستنخورده مونده.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
cache.clear()
|
||||
self.client = APIClient()
|
||||
|
||||
def _make_guest_with_order(self, device_id, key):
|
||||
guest = User.objects.create(device_id=device_id, is_guest=True, name='')
|
||||
order = Order.objects.create(
|
||||
user_id=guest.id, amount=10000, provider='bazaar',
|
||||
bazaar_product_sku='sku_one_time', idempotency_key=key,
|
||||
)
|
||||
return guest, order
|
||||
|
||||
def _verify(self, order_id, purchase_token, token):
|
||||
return self.client.post(
|
||||
'/api/payment/bazaar/verify',
|
||||
{'orderId': order_id, 'purchaseToken': purchase_token},
|
||||
format='json',
|
||||
HTTP_AUTHORIZATION=f'Bearer {token}',
|
||||
)
|
||||
|
||||
def test_rotating_guest_accounts_share_the_same_throttle_bucket(self):
|
||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'bazaar_verify': '2/hour'}):
|
||||
with patch('payment.views.bazaar.verify_purchase') as mock_verify:
|
||||
mock_verify.return_value = BazaarPurchaseResult(is_valid=True, purchase_state=0)
|
||||
|
||||
guest1, order1 = self._make_guest_with_order('device-bazaar-guest-rot-1', 'order-bazaar-rot-1')
|
||||
response1 = self._verify(order1.id, 'token-rot-1', issue_access_token(guest1.id))
|
||||
self.assertEqual(response1.status_code, 200)
|
||||
|
||||
guest2, order2 = self._make_guest_with_order('device-bazaar-guest-rot-2', 'order-bazaar-rot-2')
|
||||
response2 = self._verify(order2.id, 'token-rot-2', issue_access_token(guest2.id))
|
||||
self.assertEqual(response2.status_code, 200)
|
||||
|
||||
# سومین حسابِ مهمانِ تازه (pk جدید) باید throttle بشه، چون
|
||||
# bucket بر اساسِ IP بهاشتراک گذاشته شده، نه user.pk.
|
||||
guest3, order3 = self._make_guest_with_order('device-bazaar-guest-rot-3', 'order-bazaar-rot-3')
|
||||
response3 = self._verify(order3.id, 'token-rot-3', issue_access_token(guest3.id))
|
||||
|
||||
self.assertEqual(response3.status_code, 429)
|
||||
|
||||
def test_registered_users_keep_independent_per_user_buckets(self):
|
||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'bazaar_verify': '1/hour'}):
|
||||
with patch('payment.views.bazaar.verify_purchase') as mock_verify:
|
||||
mock_verify.return_value = BazaarPurchaseResult(is_valid=True, purchase_state=0)
|
||||
|
||||
user_a = User.objects.create(is_guest=False, name='', phone='+989120000761')
|
||||
order_a = Order.objects.create(
|
||||
user_id=user_a.id, amount=10000, provider='bazaar',
|
||||
bazaar_product_sku='sku_one_time', idempotency_key='order-bazaar-reg-a',
|
||||
)
|
||||
response_a = self._verify(order_a.id, 'token-reg-a', issue_access_token(user_a.id))
|
||||
self.assertEqual(response_a.status_code, 200)
|
||||
|
||||
user_b = User.objects.create(is_guest=False, name='', phone='+989120000762')
|
||||
order_b = Order.objects.create(
|
||||
user_id=user_b.id, amount=10000, provider='bazaar',
|
||||
bazaar_product_sku='sku_one_time', idempotency_key='order-bazaar-reg-b',
|
||||
)
|
||||
response_b = self._verify(order_b.id, 'token-reg-b', issue_access_token(user_b.id))
|
||||
|
||||
self.assertEqual(response_b.status_code, 200)
|
||||
|
||||
def test_blocked_guest_request_does_not_call_bazaar_or_mutate_order(self):
|
||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'bazaar_verify': '1/hour'}):
|
||||
with patch('payment.views.bazaar.verify_purchase') as mock_verify:
|
||||
mock_verify.return_value = BazaarPurchaseResult(is_valid=True, purchase_state=0)
|
||||
|
||||
guest1, order1 = self._make_guest_with_order('device-bazaar-guest-rot-4', 'order-bazaar-rot-4')
|
||||
first_response = self._verify(order1.id, 'token-rot-4', issue_access_token(guest1.id))
|
||||
self.assertEqual(first_response.status_code, 200)
|
||||
|
||||
mock_verify.reset_mock()
|
||||
guest2, order2 = self._make_guest_with_order('device-bazaar-guest-rot-5', 'order-bazaar-rot-5')
|
||||
blocked_response = self._verify(order2.id, 'token-rot-5', issue_access_token(guest2.id))
|
||||
self.assertEqual(blocked_response.status_code, 429)
|
||||
mock_verify.assert_not_called()
|
||||
|
||||
order2.refresh_from_db()
|
||||
self.assertEqual(order2.status, 'pending')
|
||||
self.assertEqual(order2.bazaar_purchase_token, '')
|
||||
self.assertFalse(Transaction.objects.filter(order=order2).exists())
|
||||
|
||||
@@ -4,6 +4,7 @@ from drf_spectacular.types import OpenApiTypes
|
||||
from drf_spectacular.utils import OpenApiParameter, extend_schema
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.throttling import ScopedRateThrottle
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from . import bazaar
|
||||
@@ -21,6 +22,39 @@ from .serializers import (
|
||||
from .services import apply_order_payment_result, create_order
|
||||
|
||||
|
||||
class GuestAwareBazaarThrottle(ScopedRateThrottle):
|
||||
"""
|
||||
⚠️ اضافه شده: همون مشکلِ GuestAwareAIThrottle (ai_tutor/views.py)،
|
||||
GuestAwareFamilyThrottle (family/views.py) و GuestAwareDiscountThrottle/
|
||||
GuestAwarePurchaseThrottle (discount/views.py، subscription/views.py)
|
||||
اینجا هم هست. ScopedRateThrottle استاندارد برای درخواستِ احرازهویتشده
|
||||
از request.user.pk بهعنوانِ کلیدِ throttle استفاده میکنه؛ چون
|
||||
GuestSessionView با هر deviceId جدید یک کاربرِ مهمانِ تازه (pk جدید)
|
||||
میسازه، یک مهاجم میتونست با ساختنِ پیدرپیِ حسابهای مهمان، برای هر
|
||||
حساب یک bucket تازه از throttle_scope 'bazaar_verify' بگیره — درحالیکه
|
||||
هر درخواستِ پذیرفتهشده یک فراخوانیِ سرور-به-سرور به API بازار رو
|
||||
تریگر میکنه.
|
||||
|
||||
دقیقاً طبقِ همون الگو: فقط برای کاربرهای مهمان (is_guest=True) کلیدِ
|
||||
cache از pk کاربر به IP کلاینت (get_ident) تغییر میکنه، پس همهی
|
||||
حسابهای مهمانِ ساختهشده از یک IP یک bucket مشترک دارن و چرخشِ حساب
|
||||
دیگه bucket تازه نمیده. رفتارِ throttle برای کاربرهای غیرمهمان
|
||||
(ثبتنامشده) دقیقاً مثلِ قبل (کلید بر اساسِ pk) باقی میمونه؛ منطقِ
|
||||
verify/order ownership/transaction/apply-payment دستنخوردهست.
|
||||
"""
|
||||
|
||||
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 CreateOrderView(APIView):
|
||||
"""
|
||||
POST /api/payment/orders — ساخت سفارش (فراخوانیشده از اپ Subscription)
|
||||
@@ -87,6 +121,16 @@ class PaymentWebhookView(APIView):
|
||||
"""
|
||||
|
||||
permission_classes = [HasWebhookSecret]
|
||||
# ⚠️ اضافه شده: این endpoint قبلاً هیچ throttleای نداشت، درحالیکه فقط
|
||||
# با مقایسهی یک هدرِ ثابت (X-Webhook-Secret) محافظت میشه؛ بدونِ
|
||||
# throttle، تعدادِ نامحدودی تلاش برای حدسِ این سکرت (یا fuzz کردنِ
|
||||
# orderId بعد از افشای احتمالیِ سکرت) ممکن بود، درحالیکه یک حدسِ
|
||||
# درست باعثِ paid شدنِ واقعیِ سفارش و فعالسازیِ اشتراک میشه. چون
|
||||
# این caller یک کاربرِ اپ نیست (خودِ درگاهِ پرداخته)، از یک
|
||||
# ScopedRateThrottleِ سادهٔ IP-محور استفاده شده، نه الگویِ
|
||||
# GuestAware*Throttle (که نیازمندِ request.user است).
|
||||
throttle_scope = 'payment_webhook'
|
||||
throttle_classes = [ScopedRateThrottle]
|
||||
|
||||
# ⚠️ افزوده شده: مستندسازی هدر X-Webhook-Secret که قبلاً در Swagger UI
|
||||
# قابل تنظیم/تست نبود.
|
||||
@@ -138,6 +182,21 @@ class BazaarVerifyPurchaseView(APIView):
|
||||
"""
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
# ⚠️ افزوده شده: این endpoint یک فراخوانیِ سرور-به-سرور به API بازار
|
||||
# (verify_purchase) رو تریگر میکنه؛ بدون throttle مخصوص، یک کاربر
|
||||
# میتونست با ارسال مکرر purchaseToken نامعتبر، بار زیادی روی سرویس
|
||||
# بازار و بکاند بندازه. throttle_scope مجزا (bazaar_verify) جدا از
|
||||
# نرخهای دیگه (مثل otp_*) تنظیم شده.
|
||||
# ⚠️ اصلاح شده: چون کاربرهای مهمان (GuestSessionView) با هر deviceId
|
||||
# جدید یک pk تازه میگیرن، ScopedRateThrottleِ ساده (کلید بر اساسِ
|
||||
# user.pk) توسطِ چرخشِ حساب مهمان قابلدورزدن بود. حالا از
|
||||
# GuestAwareBazaarThrottle استفاده میشه که برای کاربرهای مهمان کلید
|
||||
# رو بر اساسِ IP میسازه و برای کاربرهای غیرمهمان رفتارِ per-user
|
||||
# قبلی رو دستنخورده نگه میداره. نرخ هم از ۲۰/hour به ۱۰/hour
|
||||
# سختگیرانهتر شد چون هر درخواستِ پذیرفتهشده یک تماسِ سرور-به-سرورِ
|
||||
# واقعی با بازار میزنه.
|
||||
throttle_scope = 'bazaar_verify'
|
||||
throttle_classes = [GuestAwareBazaarThrottle]
|
||||
|
||||
@extend_schema(
|
||||
request=BazaarVerifyRequestSerializer,
|
||||
|
||||
@@ -2,4 +2,4 @@ Django==5.2.17
|
||||
djangorestframework==3.18.0
|
||||
drf-spectacular==0.30.0
|
||||
requests==2.33.1
|
||||
redis==8.1.0
|
||||
redis==5.2.1
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.conf import settings
|
||||
from django.db.models import F
|
||||
from django.test import TestCase, TransactionTestCase
|
||||
from django.test import TestCase, TransactionTestCase, override_settings
|
||||
from rest_framework.test import APIClient
|
||||
from rest_framework.throttling import ScopedRateThrottle
|
||||
|
||||
from accounts.authentication import issue_access_token
|
||||
from accounts.models import User
|
||||
@@ -296,3 +298,224 @@ class PurchaseIdenticalIntermediateStateRaceRegressionTests(TestCase):
|
||||
self.assertEqual(subscription.plan_id, self.plan.id)
|
||||
self.assertIsNone(subscription.order_id)
|
||||
self.assertEqual(subscription.version, 2)
|
||||
|
||||
|
||||
class PurchaseThrottleTests(TestCase):
|
||||
"""
|
||||
🔒 اثبات محدودیتِ جدیدِ per-user روی PurchaseView (throttle_scope='purchase'):
|
||||
تعدادِ زیادِ درخواستِ purchase با Idempotency-Key های *متفاوت* از یک
|
||||
اکانتِ احرازهویتشده باید بعد از حدِ مجاز با ۴۲۹ رد بشه — چون محافظتِ
|
||||
idempotency فقط جلوی reuse یک کلیدِ یکسان رو میگیره، نه حجمِ بالای
|
||||
تلاشهای *متفاوت*. ScopedRateThrottle اینجا با pk کاربر (نه IP) کلید
|
||||
میخوره، پس تغییرِ IP از همون اکانت این محدودیت رو دور نمیزنه.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
from django.core.cache import cache
|
||||
cache.clear()
|
||||
self.client = APIClient()
|
||||
self.user = User.objects.create(device_id='device-purchase-throttle-1', is_guest=True, name='')
|
||||
self.token = issue_access_token(self.user.id)
|
||||
self.plan = Plan.objects.create(
|
||||
title='ماهانه', price=100000, currency='IRT', duration_days=30,
|
||||
)
|
||||
|
||||
def _purchase(self, idempotency_key, remote_addr=None):
|
||||
kwargs = {}
|
||||
if remote_addr:
|
||||
kwargs['REMOTE_ADDR'] = remote_addr
|
||||
return self.client.post(
|
||||
'/api/subscription/purchase',
|
||||
{'planId': str(self.plan.id)},
|
||||
format='json',
|
||||
HTTP_AUTHORIZATION=f'Bearer {self.token}',
|
||||
HTTP_IDEMPOTENCY_KEY=idempotency_key,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
@override_settings(
|
||||
REST_FRAMEWORK={
|
||||
**settings.REST_FRAMEWORK,
|
||||
'DEFAULT_THROTTLE_RATES': {
|
||||
**settings.REST_FRAMEWORK['DEFAULT_THROTTLE_RATES'],
|
||||
'purchase': '3/hour',
|
||||
},
|
||||
}
|
||||
)
|
||||
def test_requests_within_the_limit_still_succeed(self):
|
||||
# ⚠️ مثلِ GuestSessionThrottleTests: override_settings بهتنهایی روی
|
||||
# ScopedRateThrottle.THROTTLE_RATES (که یکبار موقعِ import گرفته
|
||||
# شده) اثر نداره؛ برای اینکه نرخِ واقعاً محدودشده اعمال بشه، مستقیماً
|
||||
# دیکشنریِ زندهی THROTTLE_RATES رو patch میکنیم.
|
||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'purchase': '3/hour'}):
|
||||
for i in range(3):
|
||||
response = self._purchase(f'key-within-limit-{i}')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_excessive_purchase_attempts_from_same_user_are_blocked(self):
|
||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'purchase': '3/hour'}):
|
||||
for i in range(3):
|
||||
response = self._purchase(f'key-block-{i}')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
blocked_response = self._purchase('key-block-extra')
|
||||
self.assertEqual(blocked_response.status_code, 429)
|
||||
|
||||
def test_changing_client_ip_does_not_bypass_the_user_limit(self):
|
||||
# ⚠️ توجه: از یک کاربرِ *غیرمهمان* استفاده میشه (نه self.user که
|
||||
# مهمان است)، چون طبقِ فیکسِ GuestAwarePurchaseThrottle حالا کلیدِ
|
||||
# bucket برای کاربرهای مهمان عمداً بر اساسِ IP است (نگاه کنید به
|
||||
# PurchaseGuestThrottleBypassTests پایینتر) — این تست فقط رفتارِ
|
||||
# per-user (نه IP) برای کاربرهایِ ثبتنامشده رو تضمین میکنه که
|
||||
# باید دقیقاً مثلِ قبل دستنخورده بمونه.
|
||||
registered_user = User.objects.create(is_guest=False, name='', phone='+989120000501')
|
||||
registered_token = issue_access_token(registered_user.id)
|
||||
|
||||
def _purchase_as(idempotency_key, remote_addr):
|
||||
return self.client.post(
|
||||
'/api/subscription/purchase',
|
||||
{'planId': str(self.plan.id)},
|
||||
format='json',
|
||||
HTTP_AUTHORIZATION=f'Bearer {registered_token}',
|
||||
HTTP_IDEMPOTENCY_KEY=idempotency_key,
|
||||
REMOTE_ADDR=remote_addr,
|
||||
)
|
||||
|
||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'purchase': '3/hour'}):
|
||||
for i in range(3):
|
||||
response = _purchase_as(f'key-ip-{i}', f'10.1.0.{i + 1}')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
blocked_response = _purchase_as('key-ip-extra', '10.1.0.99')
|
||||
self.assertEqual(blocked_response.status_code, 429)
|
||||
|
||||
def test_throttled_request_creates_no_order_and_does_not_mutate_subscription(self):
|
||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'purchase': '1/hour'}):
|
||||
first_response = self._purchase('key-mutate-1')
|
||||
self.assertEqual(first_response.status_code, 200)
|
||||
|
||||
subscription = Subscription.objects.get(user_id=self.user.id)
|
||||
snapshot = {
|
||||
'status': subscription.status,
|
||||
'plan_id': subscription.plan_id,
|
||||
'order_id': subscription.order_id,
|
||||
'version': subscription.version,
|
||||
}
|
||||
order_count_before = Order.objects.filter(user_id=self.user.id).count()
|
||||
|
||||
with patch('subscription.views.create_order') as mock_create_order:
|
||||
blocked_response = self._purchase('key-mutate-2')
|
||||
self.assertEqual(blocked_response.status_code, 429)
|
||||
mock_create_order.assert_not_called()
|
||||
|
||||
self.assertEqual(Order.objects.filter(user_id=self.user.id).count(), order_count_before)
|
||||
subscription.refresh_from_db()
|
||||
self.assertEqual(subscription.status, snapshot['status'])
|
||||
self.assertEqual(subscription.plan_id, snapshot['plan_id'])
|
||||
self.assertEqual(subscription.order_id, snapshot['order_id'])
|
||||
self.assertEqual(subscription.version, snapshot['version'])
|
||||
|
||||
|
||||
class PurchaseGuestThrottleBypassTests(TestCase):
|
||||
"""
|
||||
🔒 اثبات فیکسِ امنیتی: قبلاً کلیدِ throttle_scope='purchase' برای هر
|
||||
کاربرِ احرازهویتشده (مهمان یا نه) user.pk بود. چون GuestSessionView
|
||||
با هر deviceId جدید یک کاربرِ مهمانِ تازه (pk جدید) میسازه، یک مهاجم
|
||||
میتونست با ساختنِ پیدرپیِ حسابهای مهمان، هر بار یک bucket throttle
|
||||
تازه برای سقفِ ۱۰/ساعتِ purchase بگیره. این endpoint حساسِ مالیه: هر
|
||||
درخواستِ پذیرفتهشده Subscription میسازه/آپدیت میکنه و create_order()
|
||||
رو صدا میزنه. حالا GuestAwarePurchaseThrottle برای کاربرهای مهمان
|
||||
کلید رو بر اساسِ IP (همون get_ident که guest_session هم استفاده
|
||||
میکنه) میسازه، پس چرخشِ حساب مهمان دیگه bucket تازه نمیده. رفتارِ
|
||||
throttle برای کاربرِ غیرمهمان (per-user، مثلِ قبل) دستنخورده مونده.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
from django.core.cache import cache
|
||||
cache.clear()
|
||||
self.client = APIClient()
|
||||
self.plan = Plan.objects.create(
|
||||
title='ماهانه', price=100000, currency='IRT', duration_days=30,
|
||||
)
|
||||
|
||||
def _purchase(self, idempotency_key, token):
|
||||
return self.client.post(
|
||||
'/api/subscription/purchase',
|
||||
{'planId': str(self.plan.id)},
|
||||
format='json',
|
||||
HTTP_AUTHORIZATION=f'Bearer {token}',
|
||||
HTTP_IDEMPOTENCY_KEY=idempotency_key,
|
||||
)
|
||||
|
||||
def test_rotating_guest_accounts_share_the_same_throttle_bucket(self):
|
||||
# سه حساب مهمانِ متفاوت (شبیهسازیِ چرخشِ حساب برای دورزدنِ throttle)،
|
||||
# همه از همون کلاینت/IP — باید همه یک bucket مشترک رو مصرف کنند.
|
||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'purchase': '2/hour'}):
|
||||
guest1 = User.objects.create(device_id='device-purchase-guest-rot-1', is_guest=True, name='')
|
||||
response1 = self._purchase('key-guest-rot-1', issue_access_token(guest1.id))
|
||||
self.assertEqual(response1.status_code, 200)
|
||||
|
||||
guest2 = User.objects.create(device_id='device-purchase-guest-rot-2', is_guest=True, name='')
|
||||
response2 = self._purchase('key-guest-rot-2', issue_access_token(guest2.id))
|
||||
self.assertEqual(response2.status_code, 200)
|
||||
|
||||
# سومین حسابِ مهمانِ تازه (pk جدید) باید throttle بشه، چون
|
||||
# bucket بر اساسِ IP بهاشتراک گذاشته شده، نه user.pk — یعنی
|
||||
# سقفِ ۲/ساعت هنوز اعمال میشه، صرفنظر از تعدادِ حسابهای
|
||||
# مهمانِ ساختهشده.
|
||||
guest3 = User.objects.create(device_id='device-purchase-guest-rot-3', is_guest=True, name='')
|
||||
response3 = self._purchase('key-guest-rot-3', issue_access_token(guest3.id))
|
||||
|
||||
self.assertEqual(response3.status_code, 429)
|
||||
|
||||
def test_rotated_guest_account_cannot_get_a_fresh_bucket_after_limit(self):
|
||||
# حتی وقتی حسابِ مهمانِ جدید به سقف رسیده، یک حسابِ مهمانِ دیگر
|
||||
# (چهارمین pk) هم نباید bucket تازهای بگیره — سقف هنوز به IP
|
||||
# مشترک متصل است.
|
||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'purchase': '1/hour'}):
|
||||
guest1 = User.objects.create(device_id='device-purchase-guest-rot-4', is_guest=True, name='')
|
||||
response1 = self._purchase('key-guest-rot-4', issue_access_token(guest1.id))
|
||||
self.assertEqual(response1.status_code, 200)
|
||||
|
||||
guest2 = User.objects.create(device_id='device-purchase-guest-rot-5', is_guest=True, name='')
|
||||
blocked = self._purchase('key-guest-rot-5', issue_access_token(guest2.id))
|
||||
self.assertEqual(blocked.status_code, 429)
|
||||
|
||||
guest3 = User.objects.create(device_id='device-purchase-guest-rot-6', is_guest=True, name='')
|
||||
still_blocked = self._purchase('key-guest-rot-6', issue_access_token(guest3.id))
|
||||
|
||||
self.assertEqual(still_blocked.status_code, 429)
|
||||
|
||||
def test_registered_users_keep_independent_per_user_buckets(self):
|
||||
# عدمِ رگرسیون: کاربرهای غیرمهمان (ثبتنامشده با شماره) باید مثلِ
|
||||
# قبل هرکدوم bucket جداگانهی خودشون رو داشته باشن — throttle
|
||||
# برای اونا هنوز بر اساسِ user.pk است، نه IP.
|
||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'purchase': '1/hour'}):
|
||||
user_a = User.objects.create(is_guest=False, name='', phone='+989120000601')
|
||||
response_a = self._purchase('key-reg-a', issue_access_token(user_a.id))
|
||||
self.assertEqual(response_a.status_code, 200)
|
||||
|
||||
user_b = User.objects.create(is_guest=False, name='', phone='+989120000602')
|
||||
response_b = self._purchase('key-reg-b', issue_access_token(user_b.id))
|
||||
|
||||
self.assertEqual(response_b.status_code, 200)
|
||||
|
||||
def test_blocked_guest_request_does_not_mutate_subscription_or_call_purchase_logic(self):
|
||||
# درخواستِ بلاکشده (از حسابِ مهمانِ چرخشی) نباید Subscription/Order
|
||||
# بسازه یا create_order() رو صدا بزنه — throttle باید *قبل* از
|
||||
# رسیدن به منطقِ purchase رد کنه، حتی با Idempotency-Key معتبر.
|
||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'purchase': '1/hour'}):
|
||||
guest1 = User.objects.create(device_id='device-purchase-guest-rot-7', is_guest=True, name='')
|
||||
first_response = self._purchase('key-guest-rot-7', issue_access_token(guest1.id))
|
||||
self.assertEqual(first_response.status_code, 200)
|
||||
|
||||
# حسابِ مهمانِ تازهی دوم باید بلاک بشه (bucket مشترکِ IP از
|
||||
# قبل مصرف شده) و نباید Subscription/Order برای آن بسازد.
|
||||
guest2 = User.objects.create(device_id='device-purchase-guest-rot-8', is_guest=True, name='')
|
||||
with patch('subscription.views.create_order') as mock_create_order:
|
||||
blocked_response = self._purchase('key-guest-rot-8', issue_access_token(guest2.id))
|
||||
self.assertEqual(blocked_response.status_code, 429)
|
||||
mock_create_order.assert_not_called()
|
||||
|
||||
self.assertFalse(Subscription.objects.filter(user_id=guest2.id).exists())
|
||||
self.assertEqual(Order.objects.filter(user_id=guest2.id).count(), 0)
|
||||
|
||||
@@ -5,6 +5,7 @@ from drf_spectacular.types import OpenApiTypes
|
||||
from drf_spectacular.utils import OpenApiParameter, extend_schema
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.throttling import ScopedRateThrottle
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from payment.models import Order
|
||||
@@ -19,6 +20,41 @@ from .serializers import (
|
||||
)
|
||||
|
||||
|
||||
class GuestAwarePurchaseThrottle(ScopedRateThrottle):
|
||||
"""
|
||||
⚠️ اضافه شده: همون مشکلِ GuestAwareAIThrottle (ai_tutor/views.py)،
|
||||
GuestAwareFamilyThrottle (family/views.py) و GuestAwareDiscountThrottle
|
||||
(discount/views.py) اینجا هم هست. ScopedRateThrottle استاندارد برای
|
||||
درخواستِ احرازهویتشده از request.user.pk بهعنوانِ کلیدِ throttle
|
||||
استفاده میکنه؛ چون GuestSessionView با هر deviceId جدید یک کاربرِ
|
||||
مهمانِ تازه (pk جدید) میسازه، یک مهاجم میتونست با ساختنِ پیدرپیِ
|
||||
حسابهای مهمان (تا سقفِ guest_session=20/hour)، برای هر حساب یک
|
||||
bucket تازه از throttle_scope 'purchase' بگیره — یعنی سقفِ ۱۰/ساعتِ
|
||||
درخواستِ purchase عملاً با چرخشِ حساب مهمان چند برابر میشد. این
|
||||
endpoint حساسِ مالیه: هر درخواستِ پذیرفتهشده وارد جریانِ purchase
|
||||
میشه، یک Subscription میسازه/آپدیت میکنه و create_order() رو صدا
|
||||
میزنه (که در پروداکشن با درگاهِ واقعی جایگزینِ stub فعلی میشه).
|
||||
|
||||
دقیقاً طبقِ همون الگو: فقط برای کاربرهای مهمان (is_guest=True) کلیدِ
|
||||
cache از pk کاربر به IP کلاینت (get_ident) تغییر میکنه، پس همهی
|
||||
حسابهای مهمانِ ساختهشده از یک IP یک bucket مشترک دارن و چرخشِ حساب
|
||||
دیگه bucket تازه نمیده. رفتارِ throttle برای کاربرهای غیرمهمان
|
||||
(ثبتنامشده) دقیقاً مثلِ قبل (کلید بر اساسِ pk) باقی میمونه؛ منطقِ
|
||||
purchase/subscription/order/Idempotency-Key/plan-validation دستنخوردهست.
|
||||
"""
|
||||
|
||||
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 PlansListView(APIView):
|
||||
"""GET /api/subscription/plans — list available plans."""
|
||||
|
||||
@@ -49,6 +85,22 @@ class PurchaseView(APIView):
|
||||
to create an Order."""
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
# scope کیدهای cache رو با pk کاربرِ احرازهویتشده میسازه (نه فقط IP)؛
|
||||
# چون Idempotency-Key بهتنهایی جلوی حجمِ بالای درخواستهای *متفاوت*
|
||||
# (با کلیدهای idempotency متفاوت) رو نمیگیره، این throttle مستقل و
|
||||
# مکملِ اون محافظته. ScopedRateThrottle خودش وقتی request.user
|
||||
# احرازهویتشده باشه (is_authenticated=True) بهجای IP از
|
||||
# request.user.pk بهعنوانِ ident استفاده میکنه، پس تغییرِ IP از
|
||||
# همون اکانت این محدودیت رو دور نمیزنه.
|
||||
# ⚠️ اصلاح شده: چون کاربرهای مهمان (GuestSessionView) با هر deviceId
|
||||
# جدید یک pk تازه میگیرن، ScopedRateThrottleِ ساده (کلید بر اساسِ
|
||||
# user.pk) توسطِ چرخشِ حساب مهمان قابلدورزدن بود. حالا از
|
||||
# GuestAwarePurchaseThrottle استفاده میشه که برای کاربرهای مهمان
|
||||
# کلید رو بر اساسِ IP میسازه (دقیقاً همون الگویِ GuestAwareAIThrottle،
|
||||
# GuestAwareFamilyThrottle و GuestAwareDiscountThrottle) و برای
|
||||
# کاربرهای غیرمهمان رفتارِ per-user قبلی رو دستنخورده نگه میداره.
|
||||
throttle_scope = 'purchase'
|
||||
throttle_classes = [GuestAwarePurchaseThrottle]
|
||||
|
||||
@extend_schema(
|
||||
request=PurchaseRequestSerializer,
|
||||
|
||||
365
support/tests.py
365
support/tests.py
@@ -1 +1,366 @@
|
||||
# Create your tests here.
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.core.cache import cache
|
||||
from django.test import TestCase
|
||||
from rest_framework.test import APIClient
|
||||
from rest_framework.throttling import ScopedRateThrottle
|
||||
|
||||
from accounts.authentication import issue_access_token
|
||||
from accounts.models import User
|
||||
|
||||
from .models import BugReport, Ticket, TicketMessage
|
||||
|
||||
|
||||
class BugReportCreateThrottleTests(TestCase):
|
||||
"""
|
||||
🔒 اثبات محدودیتِ جدیدِ per-user روی BugReportCreateView
|
||||
(throttle_scope='support_create'): هیچ idempotency ای روی این
|
||||
endpoint نیست، پس بدونِ throttle یک کاربرِ احرازهویتشده میتونست
|
||||
تعدادِ نامحدودی BugReport با درخواستهای پرتکرار بسازه.
|
||||
ScopedRateThrottle با pk کاربر کلید میخوره، نه IP.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
cache.clear()
|
||||
self.client = APIClient()
|
||||
self.user = User.objects.create(device_id='device-bugreport-throttle-1', is_guest=True, name='')
|
||||
self.token = issue_access_token(self.user.id)
|
||||
|
||||
def _create(self, i=0, remote_addr=None):
|
||||
kwargs = {'HTTP_AUTHORIZATION': f'Bearer {self.token}'}
|
||||
if remote_addr:
|
||||
kwargs['REMOTE_ADDR'] = remote_addr
|
||||
return self.client.post(
|
||||
'/api/support/bug-reports',
|
||||
{'title': f'باگ {i}', 'description': f'توضیحات {i}'},
|
||||
format='json',
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
def test_requests_within_the_limit_still_succeed(self):
|
||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'support_create': '3/hour'}):
|
||||
for i in range(3):
|
||||
response = self._create(i)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_excessive_requests_from_same_user_return_429(self):
|
||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'support_create': '3/hour'}):
|
||||
for i in range(3):
|
||||
response = self._create(i)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
blocked_response = self._create('extra')
|
||||
self.assertEqual(blocked_response.status_code, 429)
|
||||
|
||||
def test_changing_client_ip_does_not_bypass_the_user_limit(self):
|
||||
# ⚠️ توجه: از یک کاربرِ *غیرمهمان* استفاده میشه (نه self.user که
|
||||
# مهمان است)، چون طبقِ فیکسِ GuestAwareSupportThrottle حالا کلیدِ
|
||||
# bucket برای کاربرهای مهمان عمداً بر اساسِ IP است (نگاه کنید به
|
||||
# SupportCreateGuestThrottleBypassTests پایینتر) — این تست فقط
|
||||
# رفتارِ per-user (نه IP) برای کاربرهایِ ثبتنامشده رو تضمین میکنه
|
||||
# که باید دقیقاً مثلِ قبل دستنخورده بمونه.
|
||||
registered_user = User.objects.create(is_guest=False, name='', phone='+989120000901')
|
||||
registered_token = issue_access_token(registered_user.id)
|
||||
|
||||
def _create_as(i, remote_addr):
|
||||
return self.client.post(
|
||||
'/api/support/bug-reports',
|
||||
{'title': f'باگ {i}', 'description': f'توضیحات {i}'},
|
||||
format='json',
|
||||
HTTP_AUTHORIZATION=f'Bearer {registered_token}',
|
||||
REMOTE_ADDR=remote_addr,
|
||||
)
|
||||
|
||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'support_create': '3/hour'}):
|
||||
for i in range(3):
|
||||
response = _create_as(i, f'10.4.0.{i + 1}')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
blocked_response = _create_as('extra', '10.4.0.99')
|
||||
self.assertEqual(blocked_response.status_code, 429)
|
||||
|
||||
def test_throttled_request_creates_no_additional_bug_report(self):
|
||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'support_create': '1/hour'}):
|
||||
first_response = self._create()
|
||||
self.assertEqual(first_response.status_code, 200)
|
||||
|
||||
count_before = BugReport.objects.filter(user_id=self.user.id).count()
|
||||
|
||||
blocked_response = self._create('blocked')
|
||||
self.assertEqual(blocked_response.status_code, 429)
|
||||
self.assertEqual(BugReport.objects.filter(user_id=self.user.id).count(), count_before)
|
||||
|
||||
|
||||
class TicketCreateThrottleTests(TestCase):
|
||||
"""
|
||||
🔒 اثبات محدودیتِ جدیدِ per-user روی TicketCreateView
|
||||
(throttle_scope='support_create'، مشترک با BugReport/TicketMessage).
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
cache.clear()
|
||||
self.client = APIClient()
|
||||
self.user = User.objects.create(device_id='device-ticket-throttle-1', is_guest=True, name='')
|
||||
self.token = issue_access_token(self.user.id)
|
||||
|
||||
def _create(self, i=0, remote_addr=None):
|
||||
kwargs = {'HTTP_AUTHORIZATION': f'Bearer {self.token}'}
|
||||
if remote_addr:
|
||||
kwargs['REMOTE_ADDR'] = remote_addr
|
||||
return self.client.post(
|
||||
'/api/support/tickets',
|
||||
{'subject': f'موضوع {i}', 'category': 'billing'},
|
||||
format='json',
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
def test_requests_within_the_limit_still_succeed(self):
|
||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'support_create': '3/hour'}):
|
||||
for i in range(3):
|
||||
response = self._create(i)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_excessive_requests_from_same_user_return_429(self):
|
||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'support_create': '3/hour'}):
|
||||
for i in range(3):
|
||||
response = self._create(i)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
blocked_response = self._create('extra')
|
||||
self.assertEqual(blocked_response.status_code, 429)
|
||||
|
||||
def test_changing_client_ip_does_not_bypass_the_user_limit(self):
|
||||
# ⚠️ توجه: از یک کاربرِ *غیرمهمان* استفاده میشه (نه self.user که
|
||||
# مهمان است) — طبقِ فیکسِ GuestAwareSupportThrottle، کاربرهای مهمان
|
||||
# عمداً بر اساسِ IP کلید میخورن (نگاه کنید به
|
||||
# SupportCreateGuestThrottleBypassTests پایینتر)؛ این تست رفتارِ
|
||||
# per-user (نه IP) برای کاربرهایِ ثبتنامشده رو تضمین میکنه.
|
||||
registered_user = User.objects.create(is_guest=False, name='', phone='+989120000902')
|
||||
registered_token = issue_access_token(registered_user.id)
|
||||
|
||||
def _create_as(i, remote_addr):
|
||||
return self.client.post(
|
||||
'/api/support/tickets',
|
||||
{'subject': f'موضوع {i}', 'category': 'billing'},
|
||||
format='json',
|
||||
HTTP_AUTHORIZATION=f'Bearer {registered_token}',
|
||||
REMOTE_ADDR=remote_addr,
|
||||
)
|
||||
|
||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'support_create': '3/hour'}):
|
||||
for i in range(3):
|
||||
response = _create_as(i, f'10.5.0.{i + 1}')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
blocked_response = _create_as('extra', '10.5.0.99')
|
||||
self.assertEqual(blocked_response.status_code, 429)
|
||||
|
||||
def test_throttled_request_creates_no_additional_ticket(self):
|
||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'support_create': '1/hour'}):
|
||||
first_response = self._create()
|
||||
self.assertEqual(first_response.status_code, 200)
|
||||
|
||||
count_before = Ticket.objects.filter(user_id=self.user.id).count()
|
||||
|
||||
blocked_response = self._create('blocked')
|
||||
self.assertEqual(blocked_response.status_code, 429)
|
||||
self.assertEqual(Ticket.objects.filter(user_id=self.user.id).count(), count_before)
|
||||
|
||||
|
||||
class TicketMessageCreateThrottleTests(TestCase):
|
||||
"""
|
||||
🔒 اثبات محدودیتِ جدیدِ per-user روی TicketMessageCreateView
|
||||
(throttle_scope='support_create'، مشترک با BugReport/Ticket).
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
cache.clear()
|
||||
self.client = APIClient()
|
||||
self.user = User.objects.create(device_id='device-ticketmsg-throttle-1', is_guest=True, name='')
|
||||
self.token = issue_access_token(self.user.id)
|
||||
self.ticket = Ticket.objects.create(user_id=self.user.id, subject='موضوع', category='billing')
|
||||
|
||||
def _create(self, i=0, remote_addr=None):
|
||||
kwargs = {'HTTP_AUTHORIZATION': f'Bearer {self.token}'}
|
||||
if remote_addr:
|
||||
kwargs['REMOTE_ADDR'] = remote_addr
|
||||
return self.client.post(
|
||||
f'/api/support/tickets/{self.ticket.id}/messages',
|
||||
{'text': f'پیام {i}'},
|
||||
format='json',
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
def test_requests_within_the_limit_still_succeed(self):
|
||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'support_create': '3/hour'}):
|
||||
for i in range(3):
|
||||
response = self._create(i)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_excessive_requests_from_same_user_return_429(self):
|
||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'support_create': '3/hour'}):
|
||||
for i in range(3):
|
||||
response = self._create(i)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
blocked_response = self._create('extra')
|
||||
self.assertEqual(blocked_response.status_code, 429)
|
||||
|
||||
def test_changing_client_ip_does_not_bypass_the_user_limit(self):
|
||||
# ⚠️ توجه: از یک کاربرِ *غیرمهمان* استفاده میشه (نه self.user که
|
||||
# مهمان است) — طبقِ فیکسِ GuestAwareSupportThrottle، کاربرهای مهمان
|
||||
# عمداً بر اساسِ IP کلید میخورن (نگاه کنید به
|
||||
# SupportCreateGuestThrottleBypassTests پایینتر)؛ این تست رفتارِ
|
||||
# per-user (نه IP) برای کاربرهایِ ثبتنامشده رو تضمین میکنه.
|
||||
registered_user = User.objects.create(is_guest=False, name='', phone='+989120000903')
|
||||
registered_token = issue_access_token(registered_user.id)
|
||||
registered_ticket = Ticket.objects.create(
|
||||
user_id=registered_user.id, subject='موضوع', category='billing',
|
||||
)
|
||||
|
||||
def _create_as(i, remote_addr):
|
||||
return self.client.post(
|
||||
f'/api/support/tickets/{registered_ticket.id}/messages',
|
||||
{'text': f'پیام {i}'},
|
||||
format='json',
|
||||
HTTP_AUTHORIZATION=f'Bearer {registered_token}',
|
||||
REMOTE_ADDR=remote_addr,
|
||||
)
|
||||
|
||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'support_create': '3/hour'}):
|
||||
for i in range(3):
|
||||
response = _create_as(i, f'10.6.0.{i + 1}')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
blocked_response = _create_as('extra', '10.6.0.99')
|
||||
self.assertEqual(blocked_response.status_code, 429)
|
||||
|
||||
def test_throttled_request_creates_no_additional_message(self):
|
||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'support_create': '1/hour'}):
|
||||
first_response = self._create()
|
||||
self.assertEqual(first_response.status_code, 200)
|
||||
|
||||
count_before = TicketMessage.objects.filter(ticket=self.ticket).count()
|
||||
|
||||
blocked_response = self._create('blocked')
|
||||
self.assertEqual(blocked_response.status_code, 429)
|
||||
self.assertEqual(TicketMessage.objects.filter(ticket=self.ticket).count(), count_before)
|
||||
|
||||
|
||||
class SupportCreateGuestThrottleBypassTests(TestCase):
|
||||
"""
|
||||
🔒 اثبات فیکسِ امنیتی: قبلاً کلیدِ throttle_scope='support_create'
|
||||
(مشترک بینِ BugReportCreateView، TicketCreateView و
|
||||
TicketMessageCreateView) برای هر کاربرِ احرازهویتشده (مهمان یا نه)
|
||||
user.pk بود. چون GuestSessionView با هر deviceId جدید یک کاربرِ
|
||||
مهمانِ تازه (pk جدید) میسازه، یک مهاجم میتونست با ساختنِ پیدرپیِ
|
||||
حسابهای مهمان، هر بار یک bucket throttle تازه برای این scopeِ
|
||||
مشترک بگیره و سقفِ ۳/ساعتِ ساختِ رکوردهای پشتیبانی (BugReport/
|
||||
Ticket/TicketMessage) رو چند برابر کنه. حالا GuestAwareSupportThrottle
|
||||
برای کاربرهای مهمان کلید رو بر اساسِ IP (همون get_ident که
|
||||
guest_session هم استفاده میکنه) میسازه، پس چرخشِ حساب مهمان دیگه
|
||||
bucket تازه نمیده — این هم روی bucket مشترکِ بینِ هر سه Endpoint و
|
||||
هم روی رفتارِ per-user برای کاربرهای غیرمهمان تست میشه.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
cache.clear()
|
||||
self.client = APIClient()
|
||||
|
||||
def _create_bug_report(self, token, i=0, remote_addr=None):
|
||||
kwargs = {'HTTP_AUTHORIZATION': f'Bearer {token}'}
|
||||
if remote_addr:
|
||||
kwargs['REMOTE_ADDR'] = remote_addr
|
||||
return self.client.post(
|
||||
'/api/support/bug-reports',
|
||||
{'title': f'باگ {i}', 'description': f'توضیحات {i}'},
|
||||
format='json',
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
def _create_ticket(self, token, i=0):
|
||||
return self.client.post(
|
||||
'/api/support/tickets',
|
||||
{'subject': f'موضوع {i}', 'category': 'billing'},
|
||||
format='json',
|
||||
HTTP_AUTHORIZATION=f'Bearer {token}',
|
||||
)
|
||||
|
||||
def test_rotating_guest_accounts_share_the_same_throttle_bucket(self):
|
||||
# سه حساب مهمانِ متفاوت (شبیهسازیِ چرخشِ حساب برای دورزدنِ throttle)،
|
||||
# همه از همون کلاینت/IP — باید همه یک bucket مشترک رو مصرف کنند،
|
||||
# حتی وقتی از دو Endpointِ متفاوتِ همون scope استفاده میکنن.
|
||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'support_create': '2/hour'}):
|
||||
guest1 = User.objects.create(device_id='device-support-guest-rot-1', is_guest=True, name='')
|
||||
response1 = self._create_bug_report(issue_access_token(guest1.id), 1)
|
||||
self.assertEqual(response1.status_code, 200)
|
||||
|
||||
guest2 = User.objects.create(device_id='device-support-guest-rot-2', is_guest=True, name='')
|
||||
response2 = self._create_ticket(issue_access_token(guest2.id), 2)
|
||||
self.assertEqual(response2.status_code, 200)
|
||||
|
||||
# سومین حسابِ مهمانِ تازه (pk جدید) باید throttle بشه، چون
|
||||
# bucket بر اساسِ IP بهاشتراک گذاشته شده، نه user.pk — یعنی
|
||||
# سقفِ ۲/ساعت هنوز اعمال میشه، صرفنظر از تعدادِ حسابهای
|
||||
# مهمانِ ساختهشده یا اینکه کدوم Endpoint صدا زده شده.
|
||||
guest3 = User.objects.create(device_id='device-support-guest-rot-3', is_guest=True, name='')
|
||||
response3 = self._create_bug_report(issue_access_token(guest3.id), 3)
|
||||
|
||||
self.assertEqual(response3.status_code, 429)
|
||||
|
||||
def test_rotated_guest_account_cannot_get_a_fresh_bucket_after_limit(self):
|
||||
# حتی وقتی حسابِ مهمانِ جدید به سقف رسیده، یک حسابِ مهمانِ دیگر
|
||||
# (چهارمین pk) هم نباید bucket تازهای بگیره — سقف هنوز به IP
|
||||
# مشترک متصل است.
|
||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'support_create': '1/hour'}):
|
||||
guest1 = User.objects.create(device_id='device-support-guest-rot-4', is_guest=True, name='')
|
||||
response1 = self._create_bug_report(issue_access_token(guest1.id), 4)
|
||||
self.assertEqual(response1.status_code, 200)
|
||||
|
||||
guest2 = User.objects.create(device_id='device-support-guest-rot-5', is_guest=True, name='')
|
||||
blocked = self._create_bug_report(issue_access_token(guest2.id), 5)
|
||||
self.assertEqual(blocked.status_code, 429)
|
||||
|
||||
guest3 = User.objects.create(device_id='device-support-guest-rot-6', is_guest=True, name='')
|
||||
still_blocked = self._create_bug_report(issue_access_token(guest3.id), 6)
|
||||
|
||||
self.assertEqual(still_blocked.status_code, 429)
|
||||
|
||||
def test_registered_users_keep_independent_per_user_buckets(self):
|
||||
# عدمِ رگرسیون: کاربرهای غیرمهمان (ثبتنامشده با شماره) باید مثلِ
|
||||
# قبل هرکدوم bucket جداگانهی خودشون رو داشته باشن — throttle
|
||||
# برای اونا هنوز بر اساسِ user.pk است، نه IP.
|
||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'support_create': '1/hour'}):
|
||||
user_a = User.objects.create(is_guest=False, name='', phone='+989120001001')
|
||||
response_a = self._create_bug_report(issue_access_token(user_a.id), 'a')
|
||||
self.assertEqual(response_a.status_code, 200)
|
||||
|
||||
user_b = User.objects.create(is_guest=False, name='', phone='+989120001002')
|
||||
response_b = self._create_bug_report(issue_access_token(user_b.id), 'b')
|
||||
|
||||
self.assertEqual(response_b.status_code, 200)
|
||||
|
||||
def test_blocked_guest_request_does_not_create_support_record(self):
|
||||
# درخواستِ بلاکشده (از حسابِ مهمانِ چرخشی) نباید هیچ BugReport یا
|
||||
# Ticket جدیدی بسازه — throttle باید *قبل* از رسیدن به منطقِ
|
||||
# ساختِ رکورد رد کنه.
|
||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'support_create': '1/hour'}):
|
||||
guest1 = User.objects.create(device_id='device-support-guest-rot-7', is_guest=True, name='')
|
||||
first_response = self._create_bug_report(issue_access_token(guest1.id), 7)
|
||||
self.assertEqual(first_response.status_code, 200)
|
||||
|
||||
# حسابِ مهمانِ تازهی دوم باید بلاک بشه (bucket مشترکِ IP از
|
||||
# قبل مصرف شده) و نباید BugReport یا Ticket جدیدی بسازد.
|
||||
guest2 = User.objects.create(device_id='device-support-guest-rot-8', is_guest=True, name='')
|
||||
bug_count_before = BugReport.objects.count()
|
||||
ticket_count_before = Ticket.objects.count()
|
||||
|
||||
blocked_bug_response = self._create_bug_report(issue_access_token(guest2.id), 8)
|
||||
self.assertEqual(blocked_bug_response.status_code, 429)
|
||||
|
||||
blocked_ticket_response = self._create_ticket(issue_access_token(guest2.id), 8)
|
||||
self.assertEqual(blocked_ticket_response.status_code, 429)
|
||||
|
||||
self.assertEqual(BugReport.objects.count(), bug_count_before)
|
||||
self.assertEqual(Ticket.objects.count(), ticket_count_before)
|
||||
|
||||
@@ -2,6 +2,7 @@ from django.shortcuts import get_object_or_404
|
||||
from drf_spectacular.utils import extend_schema
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.throttling import ScopedRateThrottle
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from .models import BugReport, Ticket, TicketMessage
|
||||
@@ -16,10 +17,57 @@ from .serializers import (
|
||||
)
|
||||
|
||||
|
||||
class GuestAwareSupportThrottle(ScopedRateThrottle):
|
||||
"""
|
||||
⚠️ اضافه شده: همون مشکلِ GuestAwareAIThrottle (ai_tutor/views.py)،
|
||||
GuestAwareFamilyThrottle (family/views.py) و GuestAwareDiscountThrottle/
|
||||
GuestAwarePurchaseThrottle (discount/views.py، subscription/views.py)
|
||||
اینجا هم هست. ScopedRateThrottle استاندارد برای درخواستِ
|
||||
احرازهویتشده از request.user.pk بهعنوانِ کلیدِ throttle استفاده
|
||||
میکنه؛ چون GuestSessionView با هر deviceId جدید یک کاربرِ مهمانِ
|
||||
تازه (pk جدید) میسازه، یک مهاجم میتونست با ساختنِ پیدرپیِ حسابهای
|
||||
مهمان، برای هر حساب یک bucket تازه از throttle_scope 'support_create'
|
||||
بگیره. چون هر سه Endpointِ این اپ (BugReportCreateView،
|
||||
TicketCreateView، TicketMessageCreateView) این scope رو بهاشتراک
|
||||
میذارن و هرکدوم واقعاً یک رکورد در دیتابیس میسازن (BugReport،
|
||||
Ticket، یا TicketMessage)، چرخشِ حساب مهمان میتونست محدودیتِ
|
||||
۳/ساعتِ در نظر گرفتهشده رو چند برابر کنه و باعثِ spam بشه.
|
||||
|
||||
این کلاسِ throttle یکبار اینجا تعریف شده و بینِ هر سه Endpoint
|
||||
بهاشتراک گذاشته میشه (بهجایِ تکرارِ یک کلاسِ مشابه برای هرکدوم)،
|
||||
دقیقاً چون هر سه از یک throttle_scope مشترک استفاده میکنن. فقط
|
||||
برای کاربرهای مهمان (is_guest=True) کلیدِ cache از pk کاربر به IP
|
||||
کلاینت (get_ident) تغییر میکنه، پس همهی حسابهای مهمانِ ساختهشده
|
||||
از یک IP یک bucket مشترک دارن و چرخشِ حساب دیگه bucket تازه نمیده.
|
||||
رفتارِ throttle برای کاربرهای غیرمهمان (ثبتنامشده) دقیقاً مثلِ قبل
|
||||
(کلید بر اساسِ pk) باقی میمونه؛ منطقِ ساختِ رکورد/ownership/serializer
|
||||
در هر سه ویو دستنخوردهست.
|
||||
"""
|
||||
|
||||
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 BugReportCreateView(APIView):
|
||||
"""POST /api/support/bug-reports — ثبت گزارش باگ"""
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
# هیچ مکانیزمِ idempotency ای اینجا نیست، پس بدونِ throttle یک کاربرِ
|
||||
# احرازهویتشده میتونه تعدادِ نامحدودی BugReport با درخواستهای
|
||||
# پرتکرار بسازه. ScopedRateThrottle با pk کاربر کلید میخوره (نه IP).
|
||||
# ⚠️ اصلاح شده: چون کاربرهای مهمان با هر deviceId جدید یک pk تازه
|
||||
# میگیرن، از GuestAwareSupportThrottle استفاده میشه تا چرخشِ حساب
|
||||
# مهمان دیگه bucket تازه ندهد (نگاه کنید به تعریفِ کلاس بالا).
|
||||
throttle_scope = 'support_create'
|
||||
throttle_classes = [GuestAwareSupportThrottle]
|
||||
|
||||
@extend_schema(
|
||||
request=BugReportCreateRequestSerializer,
|
||||
@@ -46,6 +94,13 @@ class TicketCreateView(APIView):
|
||||
"""POST /api/support/tickets — ایجاد تیکت"""
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
# مثلِ BugReportCreateView: هیچ idempotency ای نیست، پس این endpoint هم
|
||||
# همون scope=support_create رو بهاشتراک میذاره تا محدودیتِ ساختِ
|
||||
# رکوردهای پشتیبانی، مستقل از اینکه کدوم نوع رکورده، رعایت بشه.
|
||||
# ⚠️ اصلاح شده: از همون GuestAwareSupportThrottleِ مشترک استفاده میشه
|
||||
# تا چرخشِ حساب مهمان bucket تازه ندهد.
|
||||
throttle_scope = 'support_create'
|
||||
throttle_classes = [GuestAwareSupportThrottle]
|
||||
|
||||
@extend_schema(
|
||||
request=TicketCreateRequestSerializer,
|
||||
@@ -107,6 +162,12 @@ class TicketMessageCreateView(APIView):
|
||||
"""
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
# مثلِ دو Endpoint بالا: بدونِ throttle، یک کاربر میتونست تعدادِ
|
||||
# نامحدودی پیام در یک تیکت بسازه.
|
||||
# ⚠️ اصلاح شده: از همون GuestAwareSupportThrottleِ مشترک استفاده میشه
|
||||
# تا چرخشِ حساب مهمان bucket تازه ندهد.
|
||||
throttle_scope = 'support_create'
|
||||
throttle_classes = [GuestAwareSupportThrottle]
|
||||
|
||||
@extend_schema(
|
||||
request=TicketMessageCreateRequestSerializer,
|
||||
|
||||
Reference in New Issue
Block a user