throttling, abuse-protection & logging/monitoring pass

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

10
.dockerignore Normal file
View File

@@ -0,0 +1,10 @@
.git/
venv/
.venv/
__pycache__/
*.pyc
*.pyo
db.sqlite3
.env
*.log
.DS_Store

20
.env.example Normal file
View 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
View 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"]

View File

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

View File

@@ -10,6 +10,22 @@ from rest_framework.exceptions import APIException
logger = logging.getLogger(__name__)
def _phone_suffix(phone: str) -> str:
"""
⚠️ اصلاح: قبلاً لاگ‌های مربوط به OTP، شماره‌تلفنِ کامل رو لاگ می‌کردن
(PIIِ غیرضروری در لاگ‌های اپلیکیشن). حالا فقط ۴ رقمِ آخر (برای
correlation/دیباگ، بدونِ افشای کاملِ شماره) لاگ می‌شه — همون الگویی که
قبلاً در accounts/views.py::LoginView هم برای همین منظور استفاده شده.
⚠️ اصلاحِ این دور: لاگ‌های خطای ارائه‌دهنده (provider) دیگه
response.text خام رو لاگ نمی‌کنن — چون بدنه‌ی پاسخِ کاوه‌نگار می‌تونه
داده‌ی حساس/غیرضروری (مثلاً جزئیاتِ گیرنده یا پیامِ متنی) داشته باشه.
به‌جاش فقط context مفید و بی‌خطر (phone_suffix، status، provider_status)
لاگ می‌شه.
"""
return phone[-4:] if phone else phone
class OTPDeliveryError(APIException):
"""
⚠️ اصلاح: قبلاً send_otp_sms یک استاب بود که فقط لاگ می‌کرد و همیشه
@@ -44,11 +60,17 @@ def send_otp_sms(*, phone: str, code: str) -> None:
می‌گیره، نه یک success دروغین).
"""
if settings.DEBUG:
logger.info('OTP sms dispatch (dev, no real SMS sent): phone=%s code_len=%d', phone, len(code))
logger.info(
'OTP sms dispatch (dev, no real SMS sent): phone_suffix=%s code_len=%d',
_phone_suffix(phone), len(code),
)
return
if not settings.KAVENEGAR_API_KEY:
logger.error('OTP SMS misconfigured: KAVENEGAR_API_KEY is not set (phone=%s)', phone)
logger.error(
'OTP SMS misconfigured: KAVENEGAR_API_KEY is not set (phone_suffix=%s)',
_phone_suffix(phone),
)
raise OTPDeliveryError('پیکربندیِ سرویسِ پیامک ناقص است.')
message = settings.OTP_SMS_TEMPLATE.format(code=code)
@@ -63,13 +85,13 @@ def send_otp_sms(*, phone: str, code: str) -> None:
timeout=5,
)
except requests.RequestException:
logger.exception('OTP SMS request to provider failed: phone=%s', phone)
logger.exception('OTP SMS request to provider failed: phone_suffix=%s', _phone_suffix(phone))
raise OTPDeliveryError('ارسال پیامک OTP با خطا مواجه شد.')
if response.status_code != 200:
logger.error(
'OTP SMS provider returned non-200: phone=%s status=%s body=%s',
phone, response.status_code, response.text[:500],
'OTP SMS provider returned non-200: phone_suffix=%s status=%s',
_phone_suffix(phone), response.status_code,
)
raise OTPDeliveryError('ارسال پیامک OTP با خطا مواجه شد.')
@@ -85,15 +107,15 @@ def send_otp_sms(*, phone: str, code: str) -> None:
provider_status = body['return']['status']
except (ValueError, KeyError, TypeError):
logger.error(
'OTP SMS provider returned malformed response body: phone=%s body=%s',
phone, response.text[:500],
'OTP SMS provider returned malformed response body: phone_suffix=%s status=%s',
_phone_suffix(phone), response.status_code,
)
raise OTPDeliveryError('ارسال پیامک OTP با خطا مواجه شد.')
if provider_status != 200:
logger.error(
'OTP SMS provider rejected message at application level: phone=%s '
'provider_status=%s body=%s',
phone, provider_status, response.text[:500],
'OTP SMS provider rejected message at application level: phone_suffix=%s '
'provider_status=%s',
_phone_suffix(phone), provider_status,
)
raise OTPDeliveryError('ارسال پیامک OTP با خطا مواجه شد.')

View File

@@ -2,7 +2,7 @@ from unittest.mock import Mock, patch
from django.conf import settings
from django.core.cache import cache
from django.test import TestCase, override_settings
from django.test import TestCase, TransactionTestCase, override_settings
from django.utils import timezone
from rest_framework.test import APIClient
from rest_framework.throttling import ScopedRateThrottle
@@ -90,6 +90,71 @@ class SendOtpSmsTests(TestCase):
with self.assertRaises(OTPDeliveryError):
send_otp_sms(phone='+989120000000', code='1234')
@override_settings(DEBUG=True)
def test_dev_mode_log_never_contains_full_phone_only_safe_suffix(self):
# 🔒 اثبات فیکسِ این دور: لاگِ OTP نباید هیچ‌وقت شماره‌تلفنِ کامل رو
# نشون بده؛ فقط ۴ رقمِ آخر (شناسه‌ی امن) باید در پیامِ لاگ باشه.
phone = '+989120000000'
with self.assertLogs('accounts.services', level='INFO') as captured:
send_otp_sms(phone=phone, code='1234')
self.assertEqual(len(captured.records), 1)
message = captured.records[0].getMessage()
self.assertNotIn(phone, message)
self.assertIn('phone_suffix=0000', message)
# رفتار/سطح/کدِ قبلی حفظ شده: کدِ OTP هیچ‌وقت لاگ نمی‌شه، فقط طولش.
self.assertNotIn('1234', message)
self.assertIn('code_len=4', message)
@override_settings(DEBUG=False, KAVENEGAR_API_KEY='test-key')
def test_provider_error_logs_never_contain_raw_response_body(self):
# 🔒 اثبات فیکسِ این دور: لاگ‌های خطای provider دیگه response.text
# خام رو شامل نمی‌شن؛ فقط context مفید و بی‌خطر (phone_suffix،
# status، provider_status) باقی می‌مونه.
# حالتِ ۱: HTTP غیرِ ۲۰۰.
mock_response = Mock(status_code=400, text='bad request body secret-ish')
with patch('accounts.services.requests.post', return_value=mock_response):
with self.assertLogs('accounts.services', level='ERROR') as captured:
with self.assertRaises(OTPDeliveryError):
send_otp_sms(phone='+989120000000', code='1234')
message = captured.records[0].getMessage()
self.assertNotIn('bad request body secret-ish', message)
self.assertIn('phone_suffix=0000', message)
self.assertIn('status=400', message)
# حالتِ ۲: بدنه‌ی نامعتبر/غیرقابل‌parse.
mock_response = Mock(status_code=200, text='not json body secret-ish')
mock_response.json.side_effect = ValueError('No JSON object could be decoded')
with patch('accounts.services.requests.post', return_value=mock_response):
with self.assertLogs('accounts.services', level='ERROR') as captured:
with self.assertRaises(OTPDeliveryError):
send_otp_sms(phone='+989120000000', code='1234')
message = captured.records[0].getMessage()
self.assertNotIn('not json body secret-ish', message)
self.assertIn('phone_suffix=0000', message)
self.assertIn('status=200', message)
# حالتِ ۳: ردِ اپلیکیشنی (HTTP 200، return.status != 200).
mock_response = Mock(
status_code=200,
text='{"return": {"status": 401, "message": "secret-ish detail"}, "entries": []}',
)
mock_response.json.return_value = {
'return': {'status': 401, 'message': 'secret-ish detail'},
'entries': [],
}
with patch('accounts.services.requests.post', return_value=mock_response):
with self.assertLogs('accounts.services', level='ERROR') as captured:
with self.assertRaises(OTPDeliveryError):
send_otp_sms(phone='+989120000000', code='1234')
message = captured.records[0].getMessage()
self.assertNotIn('secret-ish detail', message)
self.assertIn('phone_suffix=0000', message)
self.assertIn('provider_status=401', message)
class RequestOTPViewTests(TestCase):
"""
@@ -166,7 +231,12 @@ class RequestOTPStaleInvalidationTests(TestCase):
self.assertEqual(response.data['devOtp'], code)
return code
@override_settings(DEBUG=True)
# ⚠️ این کلاس چندین درخواست پشتِ‌سرِهم برای *همون* شماره می‌فرسته (بدونِ
# فاصله)، چون داره رفتارِ invalidation رو می‌سنجه، نه cooldown رو. پس
# cooldown جدیدِ بین‌درخواستی رو اینجا خاموش می‌کنیم تا این تست‌ها با
# اون تداخل نکنن؛ cooldown خودش تستِ اختصاصیِ جدا داره
# (RequestOTPPhoneCooldownTests).
@override_settings(DEBUG=True, OTP_REQUEST_PHONE_COOLDOWN_SECONDS=0)
def test_second_request_invalidates_first_otp(self):
phone = '+989121110001'
first_code = self._request_otp(phone, '1111')
@@ -178,7 +248,7 @@ class RequestOTPStaleInvalidationTests(TestCase):
first_otp.refresh_from_db()
self.assertTrue(first_otp.is_used)
@override_settings(DEBUG=True)
@override_settings(DEBUG=True, OTP_REQUEST_PHONE_COOLDOWN_SECONDS=0)
def test_newest_otp_still_works_for_login(self):
phone = '+989121110002'
self._request_otp(phone, '1111')
@@ -189,7 +259,7 @@ class RequestOTPStaleInvalidationTests(TestCase):
)
self.assertEqual(response.status_code, 200)
@override_settings(DEBUG=True)
@override_settings(DEBUG=True, OTP_REQUEST_PHONE_COOLDOWN_SECONDS=0)
def test_older_otp_is_rejected_after_new_request(self):
phone = '+989121110003'
first_code = self._request_otp(phone, '1111')
@@ -282,6 +352,464 @@ class LoginPhoneRateLimitTests(TestCase):
self.assertEqual(blocked_response.status_code, 429)
class RequestOTPPhoneRateLimitTests(TestCase):
"""
🔒 اثبات محدودیتِ جدیدِ بر اساسِ شماره‌تلفن روی RequestOTPView: درخواست‌های
تکراریِ صدورِ OTP برای همون شماره باید بعد از حدِ مجاز بلاک بشن، حتی اگه
throttle_scope='otp_request' (IP-محور) رو با تغییرِ IP دور بزنیم. همچنین
درخواستِ بلاک‌شده نباید OTPCode جدید بسازه یا پیامک بفرسته.
"""
def setUp(self):
self.client = APIClient()
cache.clear()
# cooldown بین‌درخواستیِ جدید رو اینجا خاموش می‌کنیم چون این تست‌ها دارن
# سقفِ *ساعتی* رو می‌سنجن (با چند درخواستِ پشتِ‌سرِهم بدونِ فاصله)، نه
# cooldown رو؛ cooldown تستِ اختصاصیِ خودش رو داره
# (RequestOTPPhoneCooldownTests).
@override_settings(DEBUG=True, OTP_REQUEST_PHONE_MAX_ATTEMPTS=3, OTP_REQUEST_PHONE_COOLDOWN_SECONDS=0)
def test_repeated_requests_for_same_phone_are_eventually_blocked(self):
phone = '+989127778888'
# otp_request (IP-محور) پیش‌فرض 5/hour است؛ برای اینکه فقط
# محدودیتِ شماره‌تلفن رو بسنجیم، throttle IP-محور رو موقتاً باز
# می‌کنیم تا تداخلی با شمارشِ این تست نداشته باشه.
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'otp_request': '1000/hour'}):
with patch('accounts.views.send_otp_sms'):
for _ in range(3):
response = self.client.post(
'/api/auth/otp/request', {'phone': phone}, format='json',
)
self.assertEqual(response.status_code, 200)
blocked_response = self.client.post(
'/api/auth/otp/request', {'phone': phone}, format='json',
)
self.assertEqual(blocked_response.status_code, 429)
@override_settings(DEBUG=True, OTP_REQUEST_PHONE_MAX_ATTEMPTS=3, OTP_REQUEST_PHONE_COOLDOWN_SECONDS=0)
def test_changing_client_ip_does_not_bypass_the_phone_limit(self):
phone = '+989127778889'
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'otp_request': '1000/hour'}):
with patch('accounts.views.send_otp_sms'):
for i in range(3):
# هر درخواست از یک IP متفاوت میاد؛ throttle IP-محور
# (که به‌هرحال بالا باز شده) اینجا اهمیتی نداره — نکته
# اینه که محدودیتِ شماره‌تلفن مستقل از IP باید اعمال بشه.
response = self.client.post(
'/api/auth/otp/request', {'phone': phone}, format='json',
REMOTE_ADDR=f'10.0.0.{i + 1}',
)
self.assertEqual(response.status_code, 200)
blocked_response = self.client.post(
'/api/auth/otp/request', {'phone': phone}, format='json',
REMOTE_ADDR='10.0.0.99',
)
self.assertEqual(blocked_response.status_code, 429)
@override_settings(DEBUG=True, OTP_REQUEST_PHONE_MAX_ATTEMPTS=1, OTP_REQUEST_PHONE_COOLDOWN_SECONDS=0)
def test_throttled_request_creates_no_new_otp_and_sends_no_sms(self):
phone = '+989127778890'
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'otp_request': '1000/hour'}):
with patch('accounts.views.send_otp_sms') as mock_send:
first_response = self.client.post(
'/api/auth/otp/request', {'phone': phone}, format='json',
)
self.assertEqual(first_response.status_code, 200)
self.assertEqual(mock_send.call_count, 1)
otp_count_before = OTPCode.objects.filter(phone=phone).count()
blocked_response = self.client.post(
'/api/auth/otp/request', {'phone': phone}, format='json',
)
self.assertEqual(blocked_response.status_code, 429)
# نه OTPCode جدیدی ساخته شده، نه پیامکِ جدیدی ارسال شده.
self.assertEqual(mock_send.call_count, 1)
self.assertEqual(
OTPCode.objects.filter(phone=phone).count(), otp_count_before,
)
class RequestOTPPhoneRateLimitConcurrencyTests(TransactionTestCase):
"""
🔒 اثباتِ ایمنی محدودیتِ شماره‌تلفن زیرِ همزمانی: دو درخواستِ *واقعاً*
هم‌زمان (با threading.Barrier هم‌زمان‌شده، نه صرفاً پشتِ‌سرِهم) برای یک
شماره، با سقفِ مجازِ ۱، نباید هر دو موفق بشن. الگویِ cache.add که یک
عملیاتِ اتمیکِ سطحِ backend cache است (نه یک read-then-write ناامن)
دقیقاً همینو تضمین می‌کنه. از TransactionTestCase استفاده می‌شه چون
TestCase هر تست رو داخلِ یک تراکنشِ بیرونیِ خودش می‌پیچه که برای
درخواست‌هایِ واقعاً هم‌زمان از threadهایِ جدا مناسب نیست.
"""
def setUp(self):
cache.clear()
@override_settings(DEBUG=True, OTP_REQUEST_PHONE_MAX_ATTEMPTS=1)
def test_two_simultaneous_requests_do_not_both_pass_the_phone_limit(self):
import threading
phone = '+989127778891'
barrier = threading.Barrier(2)
results = []
results_lock = threading.Lock()
def _make_request():
client = APIClient()
barrier.wait() # هر دو thread دقیقاً هم‌زمان درخواست بفرستن
response = client.post(
'/api/auth/otp/request', {'phone': phone}, format='json',
)
with results_lock:
results.append(response.status_code)
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'otp_request': '1000/hour'}):
with patch('accounts.views.send_otp_sms'):
threads = [threading.Thread(target=_make_request) for _ in range(2)]
for t in threads:
t.start()
for t in threads:
t.join(timeout=10)
self.assertEqual(len(results), 2)
# دقیقاً یکی باید موفق بشه (200) و اون یکی باید بلاک بشه (429)؛
# هیچ‌وقت هر دو نباید 200 بگیرن.
self.assertEqual(sorted(results), [200, 429])
self.assertEqual(OTPCode.objects.filter(phone=phone).count(), 1)
class RequestOTPPhoneCooldownTests(TestCase):
"""
🔒 اثباتِ محدودیتِ جدیدِ cooldown بین‌درخواستی برای RequestOTPView: هدفِ
این محدودیت جلوگیریِ burst (چند درخواست برای همون شماره در عرضِ چند
ثانیه/بدونِ فاصله) است — چیزی که سقفِ ساعتیِ per-phone موجود
(OTP_REQUEST_PHONE_MAX_ATTEMPTS) به‌تنهایی جلوش رو نمی‌گیره، چون فقط
حجمِ کلی رو محدود می‌کنه نه فاصله‌ی بینِ تلاش‌ها رو.
"""
def setUp(self):
self.client = APIClient()
cache.clear()
@override_settings(DEBUG=True, OTP_REQUEST_PHONE_COOLDOWN_SECONDS=60)
def test_immediate_resend_for_same_phone_is_blocked_by_cooldown(self):
phone = '+989127779001'
with patch('accounts.views.send_otp_sms'):
first_response = self.client.post(
'/api/auth/otp/request', {'phone': phone}, format='json',
)
self.assertEqual(first_response.status_code, 200)
second_response = self.client.post(
'/api/auth/otp/request', {'phone': phone}, format='json',
)
self.assertEqual(second_response.status_code, 429)
@override_settings(DEBUG=True, OTP_REQUEST_PHONE_COOLDOWN_SECONDS=60, OTP_REQUEST_PHONE_MAX_ATTEMPTS=5)
def test_cooldown_blocks_burst_without_consuming_the_hourly_quota(self):
# پنج درخواستِ پشتِ‌سرِهم بدونِ فاصله برای همون شماره: فقط اولی باید
# موفق بشه، بقیه باید با cooldown (429) بلاک بشن — و چون
# درخواست‌هایِ بلاک‌شده توسطِ cooldown هیچ‌وقت به شمارنده‌ی ساعتی
# نمی‌رسن، سهمیه‌ی ساعتی مصرف نمی‌شه.
phone = '+989127779002'
# throttle IP-محورِ otp_request (پیش‌فرض 5/hour) رو موقتاً باز
# می‌کنیم تا اینجا فقط cooldown/سهمیه‌ی ساعتیِ per-phone سنجیده بشه،
# نه throttle IP-محورِ جدا (که تستِ اختصاصیِ خودش رو داره).
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'otp_request': '1000/hour'}):
with patch('accounts.views.send_otp_sms') as mock_send:
statuses = []
for _ in range(5):
response = self.client.post(
'/api/auth/otp/request', {'phone': phone}, format='json',
)
statuses.append(response.status_code)
self.assertEqual(statuses, [200, 429, 429, 429, 429])
mock_send.assert_called_once()
self.assertEqual(OTPCode.objects.filter(phone=phone).count(), 1)
# سهمیه‌ی ساعتی هنوز دست‌نخورده است: بعد از پایانِ cooldown،
# همچنان می‌شه تا سقفِ مجاز درخواست داد.
cache.delete(f'otp_request_phone_cooldown:{phone}')
with patch('accounts.views.send_otp_sms') as mock_send_again:
allowed_again = self.client.post(
'/api/auth/otp/request', {'phone': phone}, format='json',
)
self.assertEqual(allowed_again.status_code, 200)
mock_send_again.assert_called_once()
@override_settings(DEBUG=True, OTP_REQUEST_PHONE_COOLDOWN_SECONDS=60)
def test_cooldown_blocked_request_creates_no_otp_and_sends_no_sms(self):
phone = '+989127779003'
with patch('accounts.views.send_otp_sms') as mock_send:
self.client.post('/api/auth/otp/request', {'phone': phone}, format='json')
otp_count_before = OTPCode.objects.filter(phone=phone).count()
blocked_response = self.client.post(
'/api/auth/otp/request', {'phone': phone}, format='json',
)
self.assertEqual(blocked_response.status_code, 429)
self.assertEqual(mock_send.call_count, 1)
self.assertEqual(OTPCode.objects.filter(phone=phone).count(), otp_count_before)
@override_settings(DEBUG=True, OTP_REQUEST_PHONE_COOLDOWN_SECONDS=1)
def test_cooldown_expires_after_configured_window(self):
import time
phone = '+989127779004'
with patch('accounts.views.send_otp_sms'):
first_response = self.client.post(
'/api/auth/otp/request', {'phone': phone}, format='json',
)
self.assertEqual(first_response.status_code, 200)
time.sleep(1.2)
second_response = self.client.post(
'/api/auth/otp/request', {'phone': phone}, format='json',
)
self.assertEqual(second_response.status_code, 200)
@override_settings(DEBUG=True, OTP_REQUEST_PHONE_COOLDOWN_SECONDS=60)
def test_cooldown_is_per_phone_not_global(self):
with patch('accounts.views.send_otp_sms'):
first_response = self.client.post(
'/api/auth/otp/request', {'phone': '+989127779005'}, format='json',
)
second_response = self.client.post(
'/api/auth/otp/request', {'phone': '+989127779006'}, format='json',
)
self.assertEqual(first_response.status_code, 200)
self.assertEqual(second_response.status_code, 200)
def test_default_cooldown_is_120_seconds(self):
# ⚠️ تصمیمِ محصول/امنیت: کاربر باید هر ۲ دقیقه بتونه OTP دوباره
# بگیره؛ این باید دقیقاً همون مقداریه که enforcement سمتِ سرور
# اعمال می‌کنه (نه صرفاً یک عددِ hardcode‌شده در فرانت).
self.assertEqual(settings.OTP_REQUEST_PHONE_COOLDOWN_SECONDS, 120)
@override_settings(DEBUG=True, OTP_REQUEST_PHONE_COOLDOWN_SECONDS=120)
def test_cooldown_response_reports_retry_after_header_and_body(self):
phone = '+989127779008'
with patch('accounts.views.send_otp_sms'):
first_response = self.client.post(
'/api/auth/otp/request', {'phone': phone}, format='json',
)
self.assertEqual(first_response.status_code, 200)
blocked_response = self.client.post(
'/api/auth/otp/request', {'phone': phone}, format='json',
)
self.assertEqual(blocked_response.status_code, 429)
# هدرِ استانداردِ Retry-After — فرانت باید countdown رو دقیقاً از
# همین مقدار شروع کنه، نه از یک عددِ ثابتِ سمتِ کلاینت.
self.assertIn('Retry-After', blocked_response.headers)
retry_after_header = int(blocked_response.headers['Retry-After'])
self.assertGreaterEqual(retry_after_header, 1)
self.assertLessEqual(retry_after_header, 120)
# همون مقدار در بدنه‌ی پاسخ هم برگردونده می‌شه (retryAfter، هم‌راستا
# با استایلِ camelCase بقیه‌ی فیلدهای response در این پروژه).
self.assertIn('retryAfter', blocked_response.data)
self.assertEqual(blocked_response.data['retryAfter'], retry_after_header)
@override_settings(DEBUG=True, OTP_REQUEST_PHONE_COOLDOWN_SECONDS=120)
def test_retry_after_counts_down_as_time_passes(self):
import time
phone = '+989127779009'
with patch('accounts.views.send_otp_sms'):
self.client.post('/api/auth/otp/request', {'phone': phone}, format='json')
immediate_retry = self.client.post(
'/api/auth/otp/request', {'phone': phone}, format='json',
)
self.assertEqual(immediate_retry.status_code, 429)
immediate_retry_after = immediate_retry.data['retryAfter']
time.sleep(1.1)
later_retry = self.client.post(
'/api/auth/otp/request', {'phone': phone}, format='json',
)
self.assertEqual(later_retry.status_code, 429)
later_retry_after = later_retry.data['retryAfter']
# با گذشتِ زمان، عددِ retryAfter باید کم بشه (نه ثابت بمونه) — این
# همون چیزیه که فرانت برای countdown دقیق بهش نیاز داره.
self.assertLess(later_retry_after, immediate_retry_after)
class RequestOTPPhoneCooldownConcurrencyTests(TransactionTestCase):
"""
🔒 اثباتِ ایمنیِ cooldown زیرِ همزمانیِ واقعی (نه صرفاً پشتِ‌سرِهم):
دقیقاً همون الگویِ RequestOTPPhoneRateLimitConcurrencyTests، این‌بار
برایِ کلیدِ cooldown. از TransactionTestCase استفاده می‌شه چون
TestCase هر تست رو داخلِ یک تراکنشِ بیرونیِ خودش می‌پیچه که برایِ
درخواست‌هایِ واقعاً هم‌زمان از threadهایِ جدا مناسب نیست.
"""
def setUp(self):
cache.clear()
@override_settings(DEBUG=True, OTP_REQUEST_PHONE_COOLDOWN_SECONDS=60)
def test_two_simultaneous_requests_for_same_phone_only_one_passes_cooldown(self):
import threading
phone = '+989127779007'
barrier = threading.Barrier(2)
results = []
results_lock = threading.Lock()
def _make_request():
client = APIClient()
barrier.wait()
response = client.post(
'/api/auth/otp/request', {'phone': phone}, format='json',
)
with results_lock:
results.append(response.status_code)
with patch('accounts.views.send_otp_sms'):
threads = [threading.Thread(target=_make_request) for _ in range(2)]
for t in threads:
t.start()
for t in threads:
t.join(timeout=10)
self.assertEqual(len(results), 2)
self.assertEqual(sorted(results), [200, 429])
self.assertEqual(OTPCode.objects.filter(phone=phone).count(), 1)
@override_settings(DEBUG=True, OTP_REQUEST_PHONE_COOLDOWN_SECONDS=120)
def test_cooldown_is_cleared_when_sms_delivery_fails(self):
# ⚠️ فیکسِ باگ: قبلاً cooldown *قبل از* فراخوانیِ send_otp_sms ثبت
# می‌شد. اگه ارسالِ پیامک با OTPDeliveryError شکست می‌خورد، کاربر
# هیچ کدی دریافت نکرده بود ولی همچنان تا ۱۲۰ ثانیه بلاک می‌موند.
# این تست تضمین می‌کنه که با شکستِ ارسال، cooldown پاک می‌شه و
# کاربر می‌تونه فوراً دوباره تلاش کنه.
phone = '+989127779010'
with patch('accounts.views.send_otp_sms', side_effect=OTPDeliveryError('boom')):
first_response = self.client.post(
'/api/auth/otp/request', {'phone': phone}, format='json',
)
self.assertEqual(first_response.status_code, 503)
# اثباتِ اینکه cooldown دیگه فعال نیست: بلافاصله یک تلاشِ دیگه (این‌بار
# موفق) باید 429 نگیره.
with patch('accounts.views.send_otp_sms') as mock_send:
retry_response = self.client.post(
'/api/auth/otp/request', {'phone': phone}, format='json',
)
self.assertEqual(retry_response.status_code, 200)
mock_send.assert_called_once()
@override_settings(DEBUG=True, OTP_REQUEST_PHONE_COOLDOWN_SECONDS=120)
def test_cooldown_still_active_after_successful_delivery(self):
# اثباتِ عدمِ رگرسیون: وقتی ارسال موفق باشه، cooldown همچنان طبقِ
# رفتارِ قبلی فعال می‌مونه (فقط شکستِ تحویل باید cooldown رو پاک کنه).
phone = '+989127779011'
with patch('accounts.views.send_otp_sms'):
first_response = self.client.post(
'/api/auth/otp/request', {'phone': phone}, format='json',
)
self.assertEqual(first_response.status_code, 200)
blocked_response = self.client.post(
'/api/auth/otp/request', {'phone': phone}, format='json',
)
self.assertEqual(blocked_response.status_code, 429)
self.assertIn('Retry-After', blocked_response.headers)
class RefreshTokenThrottleTests(TestCase):
"""
🔒 اثبات محدودیتِ جدیدِ throttle_scope='token_refresh' روی
RefreshTokenView: درخواست‌های عادی و کافی موفق باشن، درخواست‌های
بیش‌ازحد با ۴۲۹ رد بشن، و یک درخواستِ بلاک‌شده هیچ Session جدیدی
نسازه/revoke نکنه و هیچ توکنِ جدیدی تولید نکنه.
"""
def setUp(self):
cache.clear()
self.client = APIClient()
self.user = User.objects.create(is_guest=True, device_id='device-refresh-throttle-test')
def _create_session(self, token_suffix):
return Session.objects.create(
user=self.user,
refresh_token=f'test-refresh-token-{token_suffix}',
device_id='device-refresh-throttle-test',
expires_at=timezone.now() + timezone.timedelta(days=30),
revoked=False,
)
def test_requests_within_the_limit_still_succeed(self):
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'token_refresh': '3/hour'}):
for i in range(3):
session = self._create_session(i)
response = self.client.post(
'/api/auth/refresh', {'refreshToken': session.refresh_token}, format='json',
)
self.assertEqual(response.status_code, 200)
def test_excessive_refresh_requests_are_eventually_rejected(self):
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'token_refresh': '3/hour'}):
for i in range(3):
session = self._create_session(i)
response = self.client.post(
'/api/auth/refresh', {'refreshToken': session.refresh_token}, format='json',
)
self.assertEqual(response.status_code, 200)
extra_session = self._create_session('extra')
blocked_response = self.client.post(
'/api/auth/refresh', {'refreshToken': extra_session.refresh_token}, format='json',
)
self.assertEqual(blocked_response.status_code, 429)
def test_throttled_request_does_not_revoke_or_create_session_or_token(self):
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'token_refresh': '1/hour'}):
first_session = self._create_session('first')
first_response = self.client.post(
'/api/auth/refresh', {'refreshToken': first_session.refresh_token}, format='json',
)
self.assertEqual(first_response.status_code, 200)
session_count_before = Session.objects.count()
blocked_session = self._create_session('blocked')
with patch('accounts.views.issue_access_token') as mock_issue_token:
blocked_response = self.client.post(
'/api/auth/refresh', {'refreshToken': blocked_session.refresh_token}, format='json',
)
self.assertEqual(blocked_response.status_code, 429)
mock_issue_token.assert_not_called()
# هیچ Session جدیدی ساخته نشده (فقط همون session ای که قبل از
# درخواستِ بلاک‌شده دستی ساختیم) و session ای که با آن تلاش
# کردیم هنوز revoked=False و دست‌نخورده است.
self.assertEqual(Session.objects.count(), session_count_before + 1)
blocked_session.refresh_from_db()
self.assertFalse(blocked_session.revoked)
class GuestSessionThrottleTests(TestCase):
"""
🔒 اثبات محدودیتِ جدیدِ ScopedRateThrottle (scope='guest_session') روی

View File

@@ -1,3 +1,5 @@
import logging
import math
import secrets
import uuid
@@ -14,7 +16,7 @@ from rest_framework.views import APIView
from .authentication import issue_access_token
from .models import OTPCode, Session, User
from .services import send_otp_sms
from .services import OTPDeliveryError, send_otp_sms
from .serializers import (
GuestSessionRequestSerializer,
GuestSessionResponseSerializer,
@@ -28,6 +30,32 @@ from .serializers import (
SetGradeResponseSerializer,
)
logger = logging.getLogger(__name__)
class GuestAwareAccountThrottle(ScopedRateThrottle):
"""
⚠️ اضافه شده: همون الگویِ GuestAwareContentThrottle (content/views.py)،
GuestAwareAIThrottle (ai_tutor/views.py) و بقیه؛ چون GuestSessionView
با هر deviceId جدید یک کاربرِ مهمانِ تازه (pk جدید) می‌سازه، یک
ScopedRateThrottleِ سادهٔ per-user توسطِ چرخشِ حساب مهمان دورزدنی
بود. اینجا برای SetUserGradeView (که مهمان‌ها هم می‌تونن صداش بزنن)
از همین کلید بر اساسِ IP برای مهمان‌ها استفاده می‌شه؛ رفتارِ
throttle برای کاربرهای غیرمهمان (per-user، مثلِ قبل) دست‌نخورده
می‌مونه.
"""
def get_cache_key(self, request, view):
user = request.user
if user and user.is_authenticated and getattr(user, 'is_guest', False):
ident = f'guest-ip:{self.get_ident(request)}'
elif user and user.is_authenticated:
ident = user.pk
else:
ident = self.get_ident(request)
return self.cache_format % {'scope': self.scope, 'ident': ident}
class GuestSessionView(APIView):
"""POST /api/auth/session — ایجاد سشن مهمان"""
@@ -81,6 +109,79 @@ class RequestOTPView(APIView):
serializer.is_valid(raise_exception=True)
phone = serializer.validated_data['phone']
# حداقل فاصله‌ی زمانی بین دو درخواست برای همون شماره (burst
# protection). سقفِ ساعتیِ زیر جلویِ حجمِ کلی رو می‌گیره، اما به‌تنهایی
# جلویِ این رو نمی‌گیره که همون ۵ تلاشِ مجاز در عرضِ چند ثانیه/کمتر
# از یک ثانیه مصرف بشن (که هم به‌معنایِ flood پیامکی روی یک شماره‌ی
# مشخصه، هم می‌تونه با invalidate کردنِ پی‌درپیِ OTPهای قبلی
# (رفتارِ «فقط آخرین کد معتبره» که پایین‌تره) عملاً یک DoS روی
# کاربرِ واقعیِ اون شماره باشه). چک این cooldown *قبل* از شمارنده‌ی
# ساعتی انجام می‌شه تا یک درخواستِ بلاک‌شده توسطِ cooldown سهمیه‌ی
# ساعتی رو مصرف نکنه. از همون الگویِ اتمیکِ cache.add استفاده شده
# (دقیقاً همون مکانیزمِ زیر و مکانیزمِ LoginView).
#
# کلید cooldown به‌جای یک فلگِ ساده، زمانِ epoch انقضاء رو ذخیره
# می‌کنه؛ این‌طوری وقتی add شکست بخوره (کلید از قبل هست)، بدونِ
# هیچ خواندنِ ناامنی می‌شه دقیقاً محاسبه کرد چند ثانیه تا پایانِ
# cooldown مونده و اون رو هم در بدنه‌ی پاسخ (retryAfter) و هم در
# هدرِ استانداردِ Retry-After برگردوند — این‌طوری frontend
# countdown رو دقیقاً از رویِ همین پاسخِ backend شروع می‌کنه، نه
# از رویِ یک عددِ hardcode‌شده‌ی سمتِ کلاینت که مرجعِ امنیتی نیست.
phone_cooldown_key = f'otp_request_phone_cooldown:{phone}'
cooldown_seconds = settings.OTP_REQUEST_PHONE_COOLDOWN_SECONDS
now_epoch = timezone.now().timestamp()
cooldown_expires_at_epoch = now_epoch + cooldown_seconds
if not cache.add(phone_cooldown_key, cooldown_expires_at_epoch, timeout=cooldown_seconds):
stored_expiry_epoch = cache.get(phone_cooldown_key)
if stored_expiry_epoch is None:
# بینِ شکستِ add و این get دقیقاً منقضی شده؛ عملاً دیگه
# cooldownی در کار نیست، اما برای رفتارِ یکدست یک
# retry_after حداقلی برمی‌گردونیم (کلاینت با تلاشِ بعدی
# مسیرِ عادی رو طی می‌کنه).
retry_after = 1
else:
retry_after = max(1, math.ceil(stored_expiry_epoch - now_epoch))
logger.warning(
'OTP request phone cooldown active: phone_suffix=%s retry_after_seconds=%s',
phone[-4:], retry_after,
)
return Response(
{
'detail': f'لطفاً {retry_after} ثانیه صبر کنید و دوباره درخواست کد بدهید.',
'retryAfter': retry_after,
},
status=status.HTTP_429_TOO_MANY_REQUESTS,
headers={'Retry-After': str(retry_after)},
)
# محدودیت صدور OTP بر اساس شماره تلفن، مستقل از throttle IP-محورِ
# otp_request بالا. throttle IP-محور به‌تنهایی کافی نیست چون یک
# مهاجم می‌تونه درخواست‌های تکراری برای یک شماره رو بین چند IP
# مختلف پخش کنه و پیامک OTP رو flood کنه. الگوی cache.add/incr
# همون الگوی atomic زیرِ LoginView است: cache.add فقط وقتی کلید
# وجود نداره مقدار می‌ذاره (اتمیک روی سطح backend cache)، پس دو
# درخواستِ هم‌زمان نمی‌تونن هر دو از یک non-atomic check رد بشن.
phone_requests_key = f'otp_request_phone_attempts:{phone}'
if cache.add(phone_requests_key, 1, timeout=settings.OTP_REQUEST_PHONE_WINDOW_SECONDS):
attempt_count = 1
else:
try:
attempt_count = cache.incr(phone_requests_key)
except ValueError:
# بینِ add و incr دقیقاً منقضی شده؛ پنجره رو از نو شروع کن.
cache.set(phone_requests_key, 1, timeout=settings.OTP_REQUEST_PHONE_WINDOW_SECONDS)
attempt_count = 1
if attempt_count > settings.OTP_REQUEST_PHONE_MAX_ATTEMPTS:
logger.warning(
'OTP request phone limit exceeded: phone_suffix=%s attempt_count=%s max_attempts=%s',
phone[-4:], attempt_count, settings.OTP_REQUEST_PHONE_MAX_ATTEMPTS,
)
return Response(
{'detail': 'تعداد درخواست‌های کد OTP برای این شماره بیش از حد مجاز است.'},
status=status.HTTP_429_TOO_MANY_REQUESTS,
)
# فقط آخرین کد صادرشده معتبر است.
OTPCode.objects.filter(phone=phone, is_used=False).update(is_used=True)
@@ -91,7 +192,18 @@ class RequestOTPView(APIView):
expires_at=timezone.now() + timezone.timedelta(minutes=5),
)
send_otp_sms(phone=phone, code=code)
try:
send_otp_sms(phone=phone, code=code)
except OTPDeliveryError:
# ⚠️ فیکس: کاربر عملاً کد رو دریافت نکرده (ارسالِ پیامک شکست
# خورده)، پس cooldownِ ۱۲۰ثانیه‌ای که بالاتر برای همین درخواست
# ثبت شده نباید جلوی تلاشِ فوریِ بعدیِ کاربر رو بگیره؛ در غیرِ
# این‌صورت کاربر ۲ دقیقه معطل می‌مونه بدونِ اینکه اصلاً کدی
# گرفته باشه. سقفِ ساعتیِ per-phone (بالاتر) و throttle
# IP-محور دست‌نخورده می‌مونن — این‌ها محدودیتِ حجمِ کلی‌ان، نه
# cooldownِ بینِ-درخواستی، و منطقِ متفاوتی دارن.
cache.delete(phone_cooldown_key)
raise
# devOtp فقط در محیط dev برمی‌گردد؛ در پروداکشن کد فقط از طریق پیامک تحویل داده می‌شود.
note = 'در پروداکشن این کد پیامک می‌شه.'
@@ -135,6 +247,14 @@ class LoginView(APIView):
attempt_count = 1
if attempt_count > settings.OTP_LOGIN_PHONE_MAX_ATTEMPTS:
# ⚠️ افزوده شده: لاگ هشدارِ امنیتی برای رصد/آلارمِ brute-force روی
# این endpoint. هیچ سکرتی (OTP، access/refresh token) لاگ نمی‌شه؛
# شماره‌ی تلفن هم کامل نوشته نمی‌شه، فقط ۴ رقم آخرش (برای
# correlation در تحلیل/پشتیبانی، بدون افشای کامل PII).
logger.warning(
'OTP login phone attempt limit exceeded: phone_suffix=%s attempt_count=%s max_attempts=%s',
phone[-4:], attempt_count, settings.OTP_LOGIN_PHONE_MAX_ATTEMPTS,
)
return Response(
{'detail': 'تعداد تلاش‌های ورود برای این شماره بیش از حد مجاز است.'},
status=status.HTTP_429_TOO_MANY_REQUESTS,
@@ -190,6 +310,15 @@ class LoginView(APIView):
class RefreshTokenView(APIView):
"""POST /api/auth/refresh — تمدید توکن"""
# این endpoint احرازهویت نمی‌خواد (خودِ refresh token شناسه است)، پس
# ScopedRateThrottle طبیعتاً روی IP کلید می‌خوره. یک توکنِ رفرش عادتاً
# فقط گاه‌به‌گاه (نه با فرکانسِ بالا) باید استفاده بشه؛ این throttle
# جلوی سوءاستفاده از این endpoint به‌عنوان یک عملیاتِ پرتکرارِ
# دیتابیس/تولیدِ توکن رو می‌گیره، بدون تغییری در rotation یا محافظتِ
# اتمیکِ replay که پایین‌تر پیاده‌سازی شده.
throttle_scope = 'token_refresh'
throttle_classes = [ScopedRateThrottle]
@extend_schema(
request=RefreshRequestSerializer,
responses=RefreshResponseSerializer,
@@ -232,6 +361,12 @@ class SetUserGradeView(APIView):
"""POST /api/user/grade — ثبت پایه‌ی کاربر (نیاز به احراز هویت)"""
permission_classes = [IsAuthenticated]
# ⚠️ اضافه شده: این endpoint قبلاً هیچ throttleای نداشت؛ یک کاربرِ
# (از جمله مهمان) احرازهویت‌شده می‌تونست تعدادِ نامحدودی درخواستِ
# نوشتنِ دیتابیس بفرسته. طبقِ همون الگویِ سایرِ اپ‌ها، یک
# throttle_scope مجزا با GuestAwareAccountThrottle اضافه شد.
throttle_scope = 'user_profile_write'
throttle_classes = [GuestAwareAccountThrottle]
@extend_schema(
request=SetGradeRequestSerializer,

View File

@@ -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)']

View File

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

View File

@@ -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
View 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
View 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')

View File

@@ -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')

View File

@@ -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'])

View File

@@ -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())

View File

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

View File

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

View File

@@ -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
View 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
View 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"
}
}

View 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"
}
]
}
]
}

View 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

View 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

View 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

View File

@@ -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())

View File

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

View File

@@ -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())

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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