From 80298b47b17fab399cc5a48d99aaa07fd9348e01 Mon Sep 17 00:00:00 2001 From: Zareian Date: Sun, 23 Aug 2026 23:44:00 +0330 Subject: [PATCH] throttling, abuse-protection & logging/monitoring pass --- .dockerignore | 10 + .env.example | 20 + Dockerfile | 31 + README.md | 30 + accounts/services.py | 42 +- accounts/tests.py | 536 ++++++++++++++++- accounts/views.py | 139 ++++- affiliate/views.py | 35 ++ ai_tutor/tests.py | 114 ++++ ai_tutor/views.py | 41 ++ config/log_formatters.py | 36 ++ config/middleware.py | 91 +++ config/settings.py | 112 ++++ config/tests.py | 173 +++++- content/tests.py | 422 ++++++++++++- content/views.py | 61 ++ discount/tests.py | 566 +++++++++++++++++- discount/views.py | 95 +++ docker-compose.yml | 107 ++++ docker/alloy/config.alloy | 56 ++ docker/grafana/dashboards/django-logs.json | 63 ++ .../provisioning/dashboards/dashboard.yml | 13 + .../provisioning/datasources/datasource.yml | 15 + docker/loki/loki-config.yml | 35 ++ family/tests.py | 342 ++++++++++- family/views.py | 59 ++ payment/tests.py | 217 ++++++- payment/views.py | 59 ++ requirements.txt | 2 +- subscription/tests.py | 225 ++++++- subscription/views.py | 52 ++ support/tests.py | 365 +++++++++++ support/views.py | 61 ++ 33 files changed, 4202 insertions(+), 23 deletions(-) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 Dockerfile create mode 100644 config/log_formatters.py create mode 100644 config/middleware.py create mode 100644 docker-compose.yml create mode 100644 docker/alloy/config.alloy create mode 100644 docker/grafana/dashboards/django-logs.json create mode 100644 docker/grafana/provisioning/dashboards/dashboard.yml create mode 100644 docker/grafana/provisioning/datasources/datasource.yml create mode 100644 docker/loki/loki-config.yml diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..5b06a3c --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +.git/ +venv/ +.venv/ +__pycache__/ +*.pyc +*.pyo +db.sqlite3 +.env +*.log +.DS_Store diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..2058047 --- /dev/null +++ b/.env.example @@ -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 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..2b6bc3f --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md index 1ebdb55..547a09d 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,36 @@ python manage.py runserver در پروداکشن، `DJANGO_DEBUG` رو ست نکنید (یا `0` بذارید) و حتماً `DJANGO_SECRET_KEY` و `DJANGO_ALLOWED_HOSTS` (کاما-جدا) رو هم از env بدید. +## اجرا با Docker (اپ + استکِ لاگِ متمرکز) + +برای اجرای محلی، در کنارِ Django یک استکِ لاگِ متمرکز هم فراهم شده: +`Loki` (ذخیره‌سازیِ لاگ) + `Grafana Alloy` (جمع‌آوریِ لاگ از فایلِ اپ) + +`Grafana` (جستجو/فیلترِ لاگ‌ها). این فقط برای dev/local هست، نه یک +تنظیمِ پروداکشن (مثلاً هنوز از `runserver` استفاده می‌شه، نه gunicorn). + +```bash +# اختیاری: تنظیماتِ خودتون رو ست کنید (وگرنه دیفالت‌های dev استفاده می‌شن) +cp .env.example .env + +docker compose up --build +``` + +- اپ Django: `http://localhost:8000` +- Grafana: `http://localhost:3000` (بدونِ نیاز به لاگین، anonymous admin فعاله؛ فقط برای محیطِ محلی) + +داشبوردِ «Tadrisino Django Logs» به‌صورتِ خودکار در Grafana لود می‌شه و +امکانِ فیلتر بر اساسِ سطحِ لاگ (`level`) و جستجوی متنی رو می‌ده. + +**جریانِ لاگ:** Django همون خطوطِ JSON موجود رو (بدونِ تغییرِ پیام‌ها یا +محافظت‌های حساسیتی) هم روی stdout و هم در `/var/log/django/app.log` +(یک ولومِ Docker) می‌نویسه ← Grafana Alloy این فایل رو می‌خونه ← به Loki +push می‌کنه ← Grafana از Loki می‌خونه. جزئیاتِ بیشتر در کامنت‌های بالای +`docker-compose.yml`، `docker/alloy/config.alloy` و `config/settings.py` +(بخشِ `LOG_DIR`). + +اجرای بدونِ Docker (روش قبلی، بالای همین فایل) دقیقاً همون‌طور که بود +کار می‌کنه — `LOG_DIR` فقط داخلِ Docker ست می‌شه. + ## مستندات API | | | diff --git a/accounts/services.py b/accounts/services.py index bb6b22b..c0eb6f1 100644 --- a/accounts/services.py +++ b/accounts/services.py @@ -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 با خطا مواجه شد.') diff --git a/accounts/tests.py b/accounts/tests.py index f5f5b9d..5c43b20 100644 --- a/accounts/tests.py +++ b/accounts/tests.py @@ -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') روی diff --git a/accounts/views.py b/accounts/views.py index 5b942b8..1797ded 100644 --- a/accounts/views.py +++ b/accounts/views.py @@ -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, diff --git a/affiliate/views.py b/affiliate/views.py index a1c5ef5..9ce0415 100644 --- a/affiliate/views.py +++ b/affiliate/views.py @@ -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)'] diff --git a/ai_tutor/tests.py b/ai_tutor/tests.py index 0f87b7c..2fbfc12 100644 --- a/ai_tutor/tests.py +++ b/ai_tutor/tests.py @@ -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) diff --git a/ai_tutor/views.py b/ai_tutor/views.py index 750d610..4d54909 100644 --- a/ai_tutor/views.py +++ b/ai_tutor/views.py @@ -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, diff --git a/config/log_formatters.py b/config/log_formatters.py new file mode 100644 index 0000000..2b10056 --- /dev/null +++ b/config/log_formatters.py @@ -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) diff --git a/config/middleware.py b/config/middleware.py new file mode 100644 index 0000000..5dcf5ad --- /dev/null +++ b/config/middleware.py @@ -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') diff --git a/config/settings.py b/config/settings.py index 608515e..7803bc4 100644 --- a/config/settings.py +++ b/config/settings.py @@ -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') diff --git a/config/tests.py b/config/tests.py index 5f650b1..45269d5 100644 --- a/config/tests.py +++ b/config/tests.py @@ -1,9 +1,16 @@ import importlib +import json +import logging import os from unittest.mock import patch from django.core.exceptions import ImproperlyConfigured -from django.test import SimpleTestCase +from django.test import RequestFactory, SimpleTestCase, TestCase +from rest_framework.test import APIClient + +from accounts.authentication import issue_access_token +from accounts.models import User +from config.middleware import APIRequestLoggingMiddleware class SecretKeyFailClosedTests(SimpleTestCase): @@ -116,3 +123,167 @@ class CacheUrlFailClosedTests(SimpleTestCase): settings_module.CACHES['default']['BACKEND'], 'django.core.cache.backends.locmem.LocMemCache', ) + + +class APIRequestLoggingMiddlewareTests(TestCase): + """ + 🔒 اثبات میان‌افزارِ جدیدِ لاگِ درخواستِ API (config.middleware. + APIRequestLoggingMiddleware): باید برای مسیرهای /api/ یک خطِ لاگِ خلاصه + (متد/مسیر/وضعیت/مدت/شناسه‌ی کاربر) ثبت کنه، بدونِ اینکه هیچ‌وقت بدنه/ + هدر/سکرت رو لاگ کنه؛ و برای مسیرهای غیرِ /api/ (مثلِ /admin/) اصلاً + لاگ نکنه. + """ + + def setUp(self): + self.client = APIClient() + + def test_authenticated_api_request_is_logged_with_user_id_and_no_secrets(self): + user = User.objects.create(device_id='device-mw-log-1', is_guest=True, name='') + token = issue_access_token(user.id) + + with self.assertLogs('config.middleware', level='INFO') as captured: + response = self.client.post( + '/api/user/grade', + {'gradeId': 10}, + format='json', + HTTP_AUTHORIZATION=f'Bearer {token}', + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual(len(captured.records), 1) + message = captured.records[0].getMessage() + + self.assertIn('method=POST', message) + self.assertIn('path=/api/user/grade', message) + self.assertIn('status=200', message) + self.assertIn(f'user_id={user.id}', message) + + # هیچ‌وقت نباید هیچ سکرتی در پیامِ لاگ باشه. + self.assertNotIn(token, message) + self.assertNotIn('Bearer', message) + self.assertNotIn('gradeId', message) + + def test_anonymous_api_request_is_logged_with_null_user_id(self): + with self.assertLogs('config.middleware', level='INFO') as captured: + response = self.client.post( + '/api/auth/session', + {'type': 'guest', 'deviceId': 'device-mw-log-anon-1'}, + format='json', + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual(len(captured.records), 1) + message = captured.records[0].getMessage() + + self.assertIn('method=POST', message) + self.assertIn('path=/api/auth/session', message) + self.assertIn('status=200', message) + self.assertIn('user_id=None', message) + + def test_non_api_request_is_not_logged(self): + # /admin/ زیرِ /api/ نیست؛ میان‌افزار عمداً این‌جور مسیرها رو لاگ + # نمی‌کنه تا لاگِ اضافه/بی‌فایده تولید نشه. + logger = logging.getLogger('config.middleware') + handler = logging.Handler() + records = [] + handler.emit = records.append + logger.addHandler(handler) + try: + self.client.get('/admin/login/') + finally: + logger.removeHandler(handler) + + self.assertEqual(records, []) + + def test_unhandled_exception_from_view_is_logged_as_error_and_reraised(self): + # 🔒 اثبات فیکسِ این دور: اگه self.get_response(request) خودش یک + # exceptionِ دست‌نخورده raise کنه، میان‌افزار باید همچنان یک خطِ + # لاگِ سطحِ ERROR (متد/مسیر/status=500/مدت/user_id، بدونِ جزئیاتِ + # حساس) ثبت کنه و بعد دقیقاً همون exceptionِ اصلی رو دوباره raise + # کنه — بدونِ اینکه هیچ رفتارِ Django/DRF یا exceptionِ دیگه‌ای رو + # تغییر بده. مستقیماً خودِ میان‌افزار (نه کلِ چرخه‌ی + # exception-handlingِ Django) تست می‌شه، چون همون لایه‌ای‌ست که این + # فیکس روش اعمال شده. + # + # ⚠️ اصلاح (باگِ خودِ تست، نه اپلیکیشن): قبلاً این تست فقط + # record.getMessage() رو چک می‌کرد. getMessage() خودش داخلی + # msg %% args رو انجام می‌ده، پس اگه شمارِ آرگومان‌ها با + # placeholderهای رشته جور نباشه، همون‌جا با یک exception (نه یک + # پیامِ نادرست) fail می‌شد و اصلاً به assertion نمی‌رسید. برای اینکه + # این تست واقعاً *صحتِ formatting* رو بسنجه (نه فقط اینکه یک‌جوری + # fail نشه)، حالا با logging.Formatter().format(record) دقیقاً + # همون مسیری که یک handlerِ واقعی (کنسول/فایل) طی می‌کنه رو صدا + # می‌زنیم و روی متنِ نهاییِ فرمت‌شده assert می‌کنیم. + request = RequestFactory().get('/api/some/failing/path') + + def failing_get_response(inner_request): + raise ValueError('boom') + + middleware = APIRequestLoggingMiddleware(failing_get_response) + + with self.assertLogs('config.middleware', level='ERROR') as captured: + with self.assertRaises(ValueError): + middleware(request) + + self.assertEqual(len(captured.records), 1) + record = captured.records[0] + self.assertEqual(record.levelname, 'ERROR') + + formatted = logging.Formatter().format(record) + + self.assertIn('method=GET', formatted) + self.assertIn('path=/api/some/failing/path', formatted) + self.assertIn('status=500', formatted) + self.assertIn('user_id=None', formatted) + + +class JSONLogFormatterTests(SimpleTestCase): + """ + 🔒 اثبات فرمت‌کننده‌ی جدیدِ JSON (config.log_formatters.JSONLogFormatter) + که پروژه رو برای جمع‌آوریِ متمرکزِ لاگ (Loki/ELK-style) آماده می‌کنه: + خروجی باید یک JSON معتبر با فیلدهای timestamp/level/logger/message + باشه، و در صورتِ وجودِ exception، traceback هم به‌عنوانِ فیلدِ + "exception" اضافه بشه. + """ + + def _make_record(self, level=logging.INFO, message='hello world', exc_info=None): + return logging.LogRecord( + name='some.logger', + level=level, + pathname=__file__, + lineno=1, + msg=message, + args=(), + exc_info=exc_info, + ) + + def test_basic_record_is_formatted_as_valid_json_with_expected_fields(self): + from config.log_formatters import JSONLogFormatter + + record = self._make_record(level=logging.INFO, message='api_request method=GET path=/api/x status=200') + formatted = JSONLogFormatter().format(record) + + payload = json.loads(formatted) + self.assertEqual(payload['level'], 'INFO') + self.assertEqual(payload['logger'], 'some.logger') + self.assertEqual(payload['message'], 'api_request method=GET path=/api/x status=200') + self.assertIn('timestamp', payload) + self.assertNotIn('exception', payload) + + def test_record_with_exception_includes_traceback_field(self): + from config.log_formatters import JSONLogFormatter + + try: + raise ValueError('boom') + except ValueError: + import sys + exc_info = sys.exc_info() + + record = self._make_record(level=logging.ERROR, message='something failed', exc_info=exc_info) + formatted = JSONLogFormatter().format(record) + + payload = json.loads(formatted) + self.assertEqual(payload['level'], 'ERROR') + self.assertEqual(payload['message'], 'something failed') + self.assertIn('exception', payload) + self.assertIn('ValueError: boom', payload['exception']) diff --git a/content/tests.py b/content/tests.py index ea22557..b908221 100644 --- a/content/tests.py +++ b/content/tests.py @@ -1,10 +1,14 @@ +from unittest.mock import patch + from django.test import TestCase from rest_framework.test import APIClient +from rest_framework.throttling import ScopedRateThrottle from accounts.authentication import issue_access_token from accounts.models import User +from progress.models import UserHearts, UserStats -from .models import Grade, Lesson, Node, Question, Unit +from .models import Choice, Grade, Lesson, Node, NodeProgress, Question, Unit, UserAnswer, UserLessonCompletion class LessonCompleteImpossibleCountTests(TestCase): @@ -107,3 +111,419 @@ class LessonCompleteRatioIgnoresClientTotalQuestionsTests(TestCase): response = self._complete(correct_count=1, total_questions=1) self.assertEqual(response.status_code, 200) self.assertEqual(response.data['stars'], 1) + + +class QuestionAnswerThrottleTests(TestCase): + """ + 🔒 اثبات محدودیتِ جدیدِ per-user روی QuestionAnswerView + (throttle_scope='question_answer'): Idempotency-Key فقط جلوی retry با + همون کلید رو می‌گیره، نه حجمِ بالای درخواست‌های *متفاوت* که هرکدوم + یک UserAnswer جدید می‌سازن و منطقِ هارت/NodeProgress رو اجرا می‌کنن. + ScopedRateThrottle با pk کاربرِ احرازهویت‌شده کلید می‌خوره، نه IP. + """ + + def setUp(self): + from django.core.cache import cache + cache.clear() + self.client = APIClient() + self.user = User.objects.create(device_id='device-answer-throttle-1', is_guest=True, name='') + self.token = issue_access_token(self.user.id) + + grade = Grade.objects.create(title='پایه اول', icon='icon', color='#fff') + unit = Unit.objects.create(grade=grade, title='فصل ۱', order=1) + self.node = Node.objects.create(unit=unit, title='گره ۱', order=1) + self.lesson = Lesson.objects.create(node=self.node, title='درس ۱', intro_text='...') + self.question = Question.objects.create( + lesson=self.lesson, type='multiple_choice', prompt='سوال ۱', + ) + self.correct_choice = Choice.objects.create( + question=self.question, value='درست', is_correct=True, + ) + + def _answer(self, idempotency_key=None, remote_addr=None): + kwargs = {'HTTP_AUTHORIZATION': f'Bearer {self.token}'} + if idempotency_key: + kwargs['HTTP_IDEMPOTENCY_KEY'] = idempotency_key + if remote_addr: + kwargs['REMOTE_ADDR'] = remote_addr + return self.client.post( + f'/api/questions/{self.question.id}/answer', + {'selectedOptionId': str(self.correct_choice.id)}, + format='json', + **kwargs, + ) + + def test_requests_within_the_limit_still_succeed(self): + with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'question_answer': '3/min'}): + for _ in range(3): + response = self._answer() + self.assertEqual(response.status_code, 200) + + def test_excessive_requests_from_same_user_return_429(self): + with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'question_answer': '3/min'}): + for _ in range(3): + response = self._answer() + self.assertEqual(response.status_code, 200) + + blocked_response = self._answer() + self.assertEqual(blocked_response.status_code, 429) + + def test_changing_client_ip_does_not_bypass_the_user_limit(self): + # ⚠️ توجه: از یک کاربرِ *غیرمهمان* استفاده می‌شه (نه self.user که + # مهمان است)، چون طبقِ فیکسِ GuestAwareContentThrottle حالا کلیدِ + # bucket برای کاربرهای مهمان عمداً بر اساسِ IP است (نگاه کنید به + # QuestionAnswerGuestThrottleBypassTests پایین‌تر) — این تست فقط + # رفتارِ per-user (نه IP) برای کاربرهایِ ثبت‌نام‌شده رو تضمین + # می‌کنه که باید دقیقاً مثلِ قبل دست‌نخورده بمونه. + registered_user = User.objects.create(is_guest=False, name='', phone='+989120000701') + registered_token = issue_access_token(registered_user.id) + + def _answer_as(remote_addr): + return self.client.post( + f'/api/questions/{self.question.id}/answer', + {'selectedOptionId': str(self.correct_choice.id)}, + format='json', + HTTP_AUTHORIZATION=f'Bearer {registered_token}', + REMOTE_ADDR=remote_addr, + ) + + with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'question_answer': '3/min'}): + for i in range(3): + response = _answer_as(f'10.2.0.{i + 1}') + self.assertEqual(response.status_code, 200) + + blocked_response = _answer_as('10.2.0.99') + self.assertEqual(blocked_response.status_code, 429) + + def test_throttled_request_creates_no_answer_and_no_hearts_or_progress_change(self): + with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'question_answer': '1/min'}): + first_response = self._answer() + self.assertEqual(first_response.status_code, 200) + + answer_count_before = UserAnswer.objects.filter(user_id=self.user.id).count() + hearts_before = UserHearts.objects.filter(user_id=self.user.id).first() + hearts_snapshot = hearts_before.current_hearts if hearts_before else None + progress_before = NodeProgress.objects.filter(user_id=self.user.id, node=self.node).first() + progress_snapshot = ( + {'status': progress_before.status, 'last_activity_at': progress_before.last_activity_at} + if progress_before else None + ) + + blocked_response = self._answer() + self.assertEqual(blocked_response.status_code, 429) + + self.assertEqual( + UserAnswer.objects.filter(user_id=self.user.id).count(), answer_count_before, + ) + hearts_after = UserHearts.objects.filter(user_id=self.user.id).first() + self.assertEqual( + hearts_after.current_hearts if hearts_after else None, hearts_snapshot, + ) + progress_after = NodeProgress.objects.filter(user_id=self.user.id, node=self.node).first() + if progress_snapshot is None: + self.assertIsNone(progress_after) + else: + self.assertEqual(progress_after.status, progress_snapshot['status']) + self.assertEqual(progress_after.last_activity_at, progress_snapshot['last_activity_at']) + + def test_idempotency_key_retry_still_works_within_the_limit(self): + with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'question_answer': '10/min'}): + first_response = self._answer(idempotency_key='retry-key-1') + self.assertEqual(first_response.status_code, 200) + + second_response = self._answer(idempotency_key='retry-key-1') + self.assertEqual(second_response.status_code, 200) + self.assertEqual( + UserAnswer.objects.filter( + user_id=self.user.id, question=self.question, idempotency_key='retry-key-1', + ).count(), + 1, + ) + + +class LessonCompleteThrottleTests(TestCase): + """ + 🔒 اثبات محدودیتِ جدیدِ per-user روی LessonCompleteView + (throttle_scope='lesson_complete'): Idempotency-Key فقط جلوی retry با + همون کلید رو می‌گیره، نه حجمِ بالای درخواست‌های *متفاوت* که هرکدوم + یک UserLessonCompletion جدید می‌سازن و سیگنالِ post_save + (NodeProgress) + به‌روزرسانیِ UserStats رو اجرا می‌کنن. + ScopedRateThrottle با pk کاربرِ احرازهویت‌شده کلید می‌خوره، نه IP. + """ + + def setUp(self): + from django.core.cache import cache + cache.clear() + self.client = APIClient() + self.user = User.objects.create(device_id='device-complete-throttle-1', is_guest=True, name='') + self.token = issue_access_token(self.user.id) + + grade = Grade.objects.create(title='پایه اول', icon='icon', color='#fff') + unit = Unit.objects.create(grade=grade, title='فصل ۱', order=1) + self.node = Node.objects.create(unit=unit, title='گره ۱', order=1) + self.lesson = Lesson.objects.create(node=self.node, title='درس ۱', intro_text='...') + for i in range(3): + Question.objects.create( + lesson=self.lesson, type='multiple_choice', prompt=f'سوال {i}', + ) + + def _complete(self, idempotency_key=None, remote_addr=None): + kwargs = {'HTTP_AUTHORIZATION': f'Bearer {self.token}'} + if idempotency_key: + kwargs['HTTP_IDEMPOTENCY_KEY'] = idempotency_key + if remote_addr: + kwargs['REMOTE_ADDR'] = remote_addr + return self.client.post( + f'/api/lessons/{self.lesson.id}/complete', + {'correctCount': 1, 'totalQuestions': 3}, + format='json', + **kwargs, + ) + + def test_requests_within_the_limit_still_succeed(self): + with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'lesson_complete': '3/min'}): + for _ in range(3): + response = self._complete() + self.assertEqual(response.status_code, 200) + + def test_excessive_requests_from_same_user_return_429(self): + with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'lesson_complete': '3/min'}): + for _ in range(3): + response = self._complete() + self.assertEqual(response.status_code, 200) + + blocked_response = self._complete() + self.assertEqual(blocked_response.status_code, 429) + + def test_changing_client_ip_does_not_bypass_the_user_limit(self): + # ⚠️ توجه: از یک کاربرِ *غیرمهمان* استفاده می‌شه (نه self.user که + # مهمان است)، چون طبقِ فیکسِ GuestAwareContentThrottle حالا کلیدِ + # bucket برای کاربرهای مهمان عمداً بر اساسِ IP است (نگاه کنید به + # LessonCompleteGuestThrottleBypassTests پایین‌تر) — این تست فقط + # رفتارِ per-user (نه IP) برای کاربرهایِ ثبت‌نام‌شده رو تضمین + # می‌کنه که باید دقیقاً مثلِ قبل دست‌نخورده بمونه. + registered_user = User.objects.create(is_guest=False, name='', phone='+989120000702') + registered_token = issue_access_token(registered_user.id) + + def _complete_as(remote_addr): + return self.client.post( + f'/api/lessons/{self.lesson.id}/complete', + {'correctCount': 1, 'totalQuestions': 3}, + format='json', + HTTP_AUTHORIZATION=f'Bearer {registered_token}', + REMOTE_ADDR=remote_addr, + ) + + with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'lesson_complete': '3/min'}): + for i in range(3): + response = _complete_as(f'10.3.0.{i + 1}') + self.assertEqual(response.status_code, 200) + + blocked_response = _complete_as('10.3.0.99') + self.assertEqual(blocked_response.status_code, 429) + + def test_throttled_request_creates_no_completion_or_other_side_effects(self): + with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'lesson_complete': '1/min'}): + first_response = self._complete() + self.assertEqual(first_response.status_code, 200) + + completion_count_before = UserLessonCompletion.objects.filter(user_id=self.user.id).count() + stats_before = UserStats.objects.filter(user_id=self.user.id).first() + stats_snapshot = ( + {'xp': stats_before.xp, 'coins': stats_before.coins, 'progress_percent': stats_before.progress_percent} + if stats_before else None + ) + progress_before = NodeProgress.objects.filter(user_id=self.user.id, node=self.node).first() + progress_snapshot = ( + {'status': progress_before.status, 'last_activity_at': progress_before.last_activity_at} + if progress_before else None + ) + + blocked_response = self._complete() + self.assertEqual(blocked_response.status_code, 429) + + self.assertEqual( + UserLessonCompletion.objects.filter(user_id=self.user.id).count(), completion_count_before, + ) + stats_after = UserStats.objects.filter(user_id=self.user.id).first() + if stats_snapshot is None: + self.assertIsNone(stats_after) + else: + self.assertEqual(stats_after.xp, stats_snapshot['xp']) + self.assertEqual(stats_after.coins, stats_snapshot['coins']) + self.assertEqual(stats_after.progress_percent, stats_snapshot['progress_percent']) + progress_after = NodeProgress.objects.filter(user_id=self.user.id, node=self.node).first() + if progress_snapshot is None: + self.assertIsNone(progress_after) + else: + self.assertEqual(progress_after.status, progress_snapshot['status']) + self.assertEqual(progress_after.last_activity_at, progress_snapshot['last_activity_at']) + + +class QuestionAnswerGuestThrottleBypassTests(TestCase): + """ + 🔒 اثبات فیکسِ امنیتی: قبلاً کلیدِ throttle_scope='question_answer' برای + هر کاربرِ احرازهویت‌شده (مهمان یا نه) user.pk بود. چون GuestSessionView + با هر deviceId جدید یک کاربرِ مهمانِ تازه (pk جدید) می‌سازه، یک مهاجم + می‌تونست با ساختنِ پی‌درپیِ حساب‌های مهمان، هر بار یک bucket throttle + تازه بگیره و منطقِ هارت/NodeProgress رو بی‌محدودیت اجرا کنه. حالا + GuestAwareContentThrottle برای کاربرهای مهمان کلید رو بر اساسِ IP + (همون get_ident که guest_session هم استفاده می‌کنه) می‌سازه، پس چرخشِ + حساب مهمان دیگه bucket تازه نمی‌ده. رفتارِ throttle برای کاربرِ + غیرمهمان (per-user، مثلِ قبل) دست‌نخورده مونده. + """ + + def setUp(self): + from django.core.cache import cache + cache.clear() + self.client = APIClient() + + grade = Grade.objects.create(title='پایه اول', icon='icon', color='#fff') + unit = Unit.objects.create(grade=grade, title='فصل ۱', order=1) + node = Node.objects.create(unit=unit, title='گره ۱', order=1) + self.lesson = Lesson.objects.create(node=node, title='درس ۱', intro_text='...') + self.question = Question.objects.create( + lesson=self.lesson, type='multiple_choice', prompt='سوال ۱', + ) + self.correct_choice = Choice.objects.create( + question=self.question, value='درست', is_correct=True, + ) + + def _answer(self, token): + return self.client.post( + f'/api/questions/{self.question.id}/answer', + {'selectedOptionId': str(self.correct_choice.id)}, + format='json', + HTTP_AUTHORIZATION=f'Bearer {token}', + ) + + def test_rotating_guest_accounts_share_the_same_throttle_bucket(self): + # سه حساب مهمانِ متفاوت (شبیه‌سازیِ چرخشِ حساب برای دورزدنِ throttle)، + # همه از همون کلاینت/IP — باید همه یک bucket مشترک رو مصرف کنند. + with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'question_answer': '2/min'}): + guest1 = User.objects.create(device_id='device-answer-guest-rot-1', is_guest=True, name='') + response1 = self._answer(issue_access_token(guest1.id)) + self.assertEqual(response1.status_code, 200) + + guest2 = User.objects.create(device_id='device-answer-guest-rot-2', is_guest=True, name='') + response2 = self._answer(issue_access_token(guest2.id)) + self.assertEqual(response2.status_code, 200) + + # سومین حسابِ مهمانِ تازه (pk جدید) باید throttle بشه، چون + # bucket بر اساسِ IP به‌اشتراک گذاشته شده، نه user.pk. + guest3 = User.objects.create(device_id='device-answer-guest-rot-3', is_guest=True, name='') + response3 = self._answer(issue_access_token(guest3.id)) + + self.assertEqual(response3.status_code, 429) + + def test_registered_users_keep_independent_per_user_buckets(self): + # عدمِ رگرسیون: کاربرهای غیرمهمان (ثبت‌نام‌شده با شماره) باید مثلِ + # قبل هرکدوم bucket جداگانه‌ی خودشون رو داشته باشن. + with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'question_answer': '1/min'}): + user_a = User.objects.create(is_guest=False, name='', phone='+989120000711') + response_a = self._answer(issue_access_token(user_a.id)) + self.assertEqual(response_a.status_code, 200) + + user_b = User.objects.create(is_guest=False, name='', phone='+989120000712') + response_b = self._answer(issue_access_token(user_b.id)) + + self.assertEqual(response_b.status_code, 200) + + def test_blocked_guest_request_creates_no_answer_or_hearts_change(self): + # درخواستِ بلاک‌شده (از حسابِ مهمانِ چرخشی) نباید UserAnswer بسازه + # یا هارت/progress رو تغییر بده. + with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'question_answer': '1/min'}): + guest1 = User.objects.create(device_id='device-answer-guest-rot-4', is_guest=True, name='') + first_response = self._answer(issue_access_token(guest1.id)) + self.assertEqual(first_response.status_code, 200) + + guest2 = User.objects.create(device_id='device-answer-guest-rot-5', is_guest=True, name='') + blocked_response = self._answer(issue_access_token(guest2.id)) + + self.assertEqual(blocked_response.status_code, 429) + self.assertEqual(UserAnswer.objects.filter(user_id=guest2.id).count(), 0) + self.assertFalse(UserHearts.objects.filter(user_id=guest2.id).exists()) + + +class LessonCompleteGuestThrottleBypassTests(TestCase): + """ + 🔒 اثبات فیکسِ امنیتی: قبلاً کلیدِ throttle_scope='lesson_complete' برای + هر کاربرِ احرازهویت‌شده (مهمان یا نه) user.pk بود. چون GuestSessionView + با هر deviceId جدید یک کاربرِ مهمانِ تازه (pk جدید) می‌سازه، یک مهاجم + می‌تونست با ساختنِ پی‌درپیِ حساب‌های مهمان، هر بار یک bucket throttle + تازه بگیره و UserLessonCompletion/xp/coins/progress رو بی‌محدودیت + تولید کنه. حالا GuestAwareContentThrottle برای کاربرهای مهمان کلید رو + بر اساسِ IP می‌سازه، پس چرخشِ حساب مهمان دیگه bucket تازه نمی‌ده. + رفتارِ throttle برای کاربرِ غیرمهمان (per-user، مثلِ قبل) دست‌نخورده + مونده. + """ + + def setUp(self): + from django.core.cache import cache + cache.clear() + self.client = APIClient() + + grade = Grade.objects.create(title='پایه اول', icon='icon', color='#fff') + unit = Unit.objects.create(grade=grade, title='فصل ۱', order=1) + self.node = Node.objects.create(unit=unit, title='گره ۱', order=1) + self.lesson = Lesson.objects.create(node=self.node, title='درس ۱', intro_text='...') + for i in range(3): + Question.objects.create( + lesson=self.lesson, type='multiple_choice', prompt=f'سوال {i}', + ) + + def _complete(self, token): + return self.client.post( + f'/api/lessons/{self.lesson.id}/complete', + {'correctCount': 1, 'totalQuestions': 3}, + format='json', + HTTP_AUTHORIZATION=f'Bearer {token}', + ) + + def test_rotating_guest_accounts_share_the_same_throttle_bucket(self): + # سه حساب مهمانِ متفاوت (شبیه‌سازیِ چرخشِ حساب برای دورزدنِ throttle)، + # همه از همون کلاینت/IP — باید همه یک bucket مشترک رو مصرف کنند. + with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'lesson_complete': '2/hour'}): + guest1 = User.objects.create(device_id='device-complete-guest-rot-1', is_guest=True, name='') + response1 = self._complete(issue_access_token(guest1.id)) + self.assertEqual(response1.status_code, 200) + + guest2 = User.objects.create(device_id='device-complete-guest-rot-2', is_guest=True, name='') + response2 = self._complete(issue_access_token(guest2.id)) + self.assertEqual(response2.status_code, 200) + + # سومین حسابِ مهمانِ تازه (pk جدید) باید throttle بشه، چون + # bucket بر اساسِ IP به‌اشتراک گذاشته شده، نه user.pk. + guest3 = User.objects.create(device_id='device-complete-guest-rot-3', is_guest=True, name='') + response3 = self._complete(issue_access_token(guest3.id)) + + self.assertEqual(response3.status_code, 429) + + def test_registered_users_keep_independent_per_user_buckets(self): + # عدمِ رگرسیون: کاربرهای غیرمهمان (ثبت‌نام‌شده با شماره) باید مثلِ + # قبل هرکدوم bucket جداگانه‌ی خودشون رو داشته باشن. + with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'lesson_complete': '1/hour'}): + user_a = User.objects.create(is_guest=False, name='', phone='+989120000721') + response_a = self._complete(issue_access_token(user_a.id)) + self.assertEqual(response_a.status_code, 200) + + user_b = User.objects.create(is_guest=False, name='', phone='+989120000722') + response_b = self._complete(issue_access_token(user_b.id)) + + self.assertEqual(response_b.status_code, 200) + + def test_blocked_guest_request_creates_no_completion_or_reward_side_effects(self): + # درخواستِ بلاک‌شده (از حسابِ مهمانِ چرخشی) نباید UserLessonCompletion + # بسازه یا UserStats/NodeProgress رو تغییر بده. + with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'lesson_complete': '1/hour'}): + guest1 = User.objects.create(device_id='device-complete-guest-rot-4', is_guest=True, name='') + first_response = self._complete(issue_access_token(guest1.id)) + self.assertEqual(first_response.status_code, 200) + + guest2 = User.objects.create(device_id='device-complete-guest-rot-5', is_guest=True, name='') + blocked_response = self._complete(issue_access_token(guest2.id)) + + self.assertEqual(blocked_response.status_code, 429) + self.assertEqual(UserLessonCompletion.objects.filter(user_id=guest2.id).count(), 0) + self.assertFalse(UserStats.objects.filter(user_id=guest2.id).exists()) + self.assertFalse(NodeProgress.objects.filter(user_id=guest2.id, node=self.node).exists()) diff --git a/content/views.py b/content/views.py index 4e8dbdb..dbe6738 100644 --- a/content/views.py +++ b/content/views.py @@ -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, diff --git a/discount/tests.py b/discount/tests.py index 767dcc9..0c4acdb 100644 --- a/discount/tests.py +++ b/discount/tests.py @@ -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) diff --git a/discount/views.py b/discount/views.py index 0fac9b2..259e4a2 100644 --- a/discount/views.py +++ b/discount/views.py @@ -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)'], diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..afd212a --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/docker/alloy/config.alloy b/docker/alloy/config.alloy new file mode 100644 index 0000000..e37e3b1 --- /dev/null +++ b/docker/alloy/config.alloy @@ -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" + } +} diff --git a/docker/grafana/dashboards/django-logs.json b/docker/grafana/dashboards/django-logs.json new file mode 100644 index 0000000..b09fac5 --- /dev/null +++ b/docker/grafana/dashboards/django-logs.json @@ -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" + } + ] + } + ] +} diff --git a/docker/grafana/provisioning/dashboards/dashboard.yml b/docker/grafana/provisioning/dashboards/dashboard.yml new file mode 100644 index 0000000..2fe5412 --- /dev/null +++ b/docker/grafana/provisioning/dashboards/dashboard.yml @@ -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 diff --git a/docker/grafana/provisioning/datasources/datasource.yml b/docker/grafana/provisioning/datasources/datasource.yml new file mode 100644 index 0000000..1652f12 --- /dev/null +++ b/docker/grafana/provisioning/datasources/datasource.yml @@ -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 diff --git a/docker/loki/loki-config.yml b/docker/loki/loki-config.yml new file mode 100644 index 0000000..36dceee --- /dev/null +++ b/docker/loki/loki-config.yml @@ -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 diff --git a/family/tests.py b/family/tests.py index a39b155..dcf5302 100644 --- a/family/tests.py +++ b/family/tests.py @@ -1 +1,341 @@ -# Create your tests here. +from django.core.cache import cache +from django.test import TestCase +from rest_framework.test import APIClient +from rest_framework.throttling import ScopedRateThrottle +from unittest.mock import patch + +from accounts.authentication import issue_access_token +from accounts.models import User +from .models import FamilyLink + + +class FamilyLinkAcceptThrottleTests(TestCase): + """ + 🔒 اثبات محدودیتِ جدیدِ throttle_scope='family_invite_accept' روی + FamilyLinkAcceptView: inviteCode یک رازِ عددیِ ۶رقمی است که این + endpoint با select_for_update() جست‌وجو می‌کنه؛ بدونِ throttle یک + کاربرِ احرازهویت‌شده می‌تونست بی‌نهایت کد رو حدس بزنه. این تست‌ها هم + رفتارِ throttle (per-user، نه IP)، هم عدمِ رگرسیون در ردیمِ موفق، هم + عدمِ تغییرِ FamilyLink توسطِ درخواستِ بلاک‌شده رو تضمین می‌کنن. + """ + + def setUp(self): + cache.clear() + self.client = APIClient() + self.parent = User.objects.create(device_id='device-family-parent-1', is_guest=True, name='') + self.child = User.objects.create( + device_id='device-family-child-1', is_guest=True, name='', phone='+989120000001', + ) + self.child_token = issue_access_token(self.child.id) + self.registered_user = User.objects.create(is_guest=False, name='', phone='+989120000098') + self.registered_token = issue_access_token(self.registered_user.id) + self.link = FamilyLink.objects.create( + parent_user_id=self.parent.id, child_phone=self.child.phone, invite_code='123456', + ) + + def _accept(self, code, token=None): + headers = {} + if token: + headers['HTTP_AUTHORIZATION'] = f'Bearer {token}' + return self.client.post( + '/api/family/link/accept', {'inviteCode': code}, format='json', **headers, + ) + + def test_unauthenticated_request_is_still_rejected(self): + # رفتارِ IsAuthenticated موجود دست‌نخورده مونده؛ throttle جایگزینِ + # authentication نیست. + response = self._accept('123456') + self.assertEqual(response.status_code, 401) + + def test_repeated_code_guessing_is_throttled_after_configured_limit(self): + # سناریوی brute-force: کدهای نادرستِ پشتِ‌سرِهم. + with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'family_invite_accept': '3/hour'}): + for i in range(3): + response = self._accept(f'00000{i}', token=self.child_token) + self.assertEqual(response.status_code, 404) + + blocked_response = self._accept('999999', token=self.child_token) + self.assertEqual(blocked_response.status_code, 429) + + def test_throttle_is_scoped_per_user_not_shared_globally(self): + # این تست مخصوصِ کاربرهای *غیرمهمان* (ثبت‌نام‌شده) است — طبقِ فیکسِ + # GuestAwareFamilyThrottle، حساب‌های مهمان دیگه bucket جداگانه + # ندارن (نگاه کنید به FamilyLinkAcceptGuestThrottleBypassTests پایین‌تر)، + # اما رفتارِ per-user برای کاربرهایِ ثبت‌نام‌شده باید دقیقاً مثلِ + # قبل دست‌نخورده بمونه. + other_user = User.objects.create( + is_guest=False, name='', phone='+989120000099', + ) + other_token = issue_access_token(other_user.id) + + with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'family_invite_accept': '1/hour'}): + first_user_response = self._accept('000000', token=self.registered_token) + self.assertEqual(first_user_response.status_code, 404) + + first_user_blocked = self._accept('111111', token=self.registered_token) + self.assertEqual(first_user_blocked.status_code, 429) + + # کاربرِ دومِ غیرمهمانِ متفاوت هنوز باید بتونه تلاش کنه — + # throttle برای کاربرهای ثبت‌نام‌شده هنوز per-user است، نه IP. + second_user_response = self._accept('000000', token=other_token) + self.assertEqual(second_user_response.status_code, 404) + + def test_successful_accept_still_works_within_the_limit(self): + # عدمِ رگرسیون: رفتارِ اصلیِ accept باید دقیقاً مثلِ قبل کار کنه. + with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'family_invite_accept': '10/hour'}): + response = self._accept(self.link.invite_code, token=self.child_token) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data['status'], 'accepted') + self.link.refresh_from_db() + self.assertEqual(self.link.status, 'accepted') + self.assertEqual(self.link.child_user_id, self.child.id) + + def test_blocked_request_does_not_mutate_family_link(self): + # درخواستِ بلاک‌شده نباید FamilyLink رو تغییر بده — throttle باید + # *قبل* از رسیدنِ به select_for_update() رد کنه. + with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'family_invite_accept': '1/hour'}): + first_response = self._accept('000000', token=self.child_token) + self.assertEqual(first_response.status_code, 404) + + blocked_response = self._accept(self.link.invite_code, token=self.child_token) + + self.assertEqual(blocked_response.status_code, 429) + self.link.refresh_from_db() + self.assertEqual(self.link.status, 'pending') + self.assertIsNone(self.link.child_user_id) + + +class FamilyLinkAcceptGuestThrottleBypassTests(TestCase): + """ + 🔒 اثبات فیکسِ امنیتی: قبلاً کلیدِ throttle_scope='family_invite_accept' + برای هر کاربرِ احرازهویت‌شده (مهمان یا نه) user.pk بود. چون + GuestSessionView با هر deviceId جدید یک کاربرِ مهمانِ تازه (pk جدید) + می‌سازه، یک مهاجم می‌تونست با ساختنِ پی‌درپیِ حساب‌های مهمان، هر بار + یک bucket throttle تازه برای brute-force کردنِ inviteCode ۶رقمی + بگیره. حالا GuestAwareFamilyThrottle برای کاربرهای مهمان کلید رو بر + اساسِ IP (همون get_ident که guest_session هم استفاده می‌کنه) می‌سازه، + پس چرخشِ حساب مهمان دیگه bucket تازه نمی‌ده. رفتارِ throttle برای + کاربرِ غیرمهمان (per-user، مثلِ قبل) دست‌نخورده مونده. + """ + + def setUp(self): + cache.clear() + self.client = APIClient() + + def _accept(self, code, token): + return self.client.post( + '/api/family/link/accept', + {'inviteCode': code}, + format='json', + HTTP_AUTHORIZATION=f'Bearer {token}', + ) + + def test_rotating_guest_accounts_share_the_same_throttle_bucket(self): + # سه حساب مهمانِ متفاوت (شبیه‌سازیِ چرخشِ حساب برای دورزدنِ throttle)، + # همه از همون کلاینت/IP — باید همه یک bucket مشترک رو مصرف کنند. + with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'family_invite_accept': '2/hour'}): + guest1 = User.objects.create(device_id='device-family-guest-rot-1', is_guest=True, name='') + response1 = self._accept('000001', issue_access_token(guest1.id)) + self.assertEqual(response1.status_code, 404) + + guest2 = User.objects.create(device_id='device-family-guest-rot-2', is_guest=True, name='') + response2 = self._accept('000002', issue_access_token(guest2.id)) + self.assertEqual(response2.status_code, 404) + + # سومین حسابِ مهمانِ تازه (pk جدید) باید throttle بشه، چون + # bucket بر اساسِ IP به‌اشتراک گذاشته شده، نه user.pk — یعنی + # سقفِ ۲/ساعت هنوز اعمال می‌شه، صرف‌نظر از تعدادِ حساب‌های + # مهمانِ ساخته‌شده. + guest3 = User.objects.create(device_id='device-family-guest-rot-3', is_guest=True, name='') + response3 = self._accept('000003', issue_access_token(guest3.id)) + + self.assertEqual(response3.status_code, 429) + + def test_registered_users_keep_independent_per_user_buckets(self): + # عدمِ رگرسیون: کاربرهای غیرمهمان (ثبت‌نام‌شده با شماره) باید مثلِ + # قبل هرکدوم bucket جداگانه‌ی خودشون رو داشته باشن — throttle + # برای اونا هنوز بر اساسِ user.pk است، نه IP. + with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'family_invite_accept': '1/hour'}): + user_a = User.objects.create(is_guest=False, name='', phone='+989120000201') + response_a = self._accept('000001', issue_access_token(user_a.id)) + self.assertEqual(response_a.status_code, 404) + + user_b = User.objects.create(is_guest=False, name='', phone='+989120000202') + response_b = self._accept('000002', issue_access_token(user_b.id)) + + self.assertEqual(response_b.status_code, 404) + + def test_blocked_guest_request_does_not_reach_or_mutate_family_link(self): + # درخواستِ بلاک‌شده (چه از همون حساب، چه از حسابِ مهمانِ چرخشی) + # نباید FamilyLink رو تغییر بده و نباید حتی به select_for_update() + # برسه — throttle باید *قبل* از منطقِ accept رد کنه. + parent = User.objects.create(device_id='device-family-guest-rot-parent', is_guest=True, name='') + child = User.objects.create( + device_id='device-family-guest-rot-child', is_guest=True, name='', phone='+989120000203', + ) + link = FamilyLink.objects.create( + parent_user_id=parent.id, child_phone=child.phone, invite_code='654321', + ) + + with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'family_invite_accept': '1/hour'}): + guest1 = User.objects.create(device_id='device-family-guest-rot-4', is_guest=True, name='') + first_response = self._accept('000000', issue_access_token(guest1.id)) + self.assertEqual(first_response.status_code, 404) + + # حسابِ مهمانِ تازه‌ی دوم، حتی با inviteCode درست، باید بلاک بشه + # (bucket مشترکِ IP از قبل مصرف شده) و FamilyLink رو دست نزنه. + guest2 = User.objects.create(device_id='device-family-guest-rot-5', is_guest=True, name='') + blocked_response = self._accept(link.invite_code, issue_access_token(guest2.id)) + + self.assertEqual(blocked_response.status_code, 429) + link.refresh_from_db() + self.assertEqual(link.status, 'pending') + self.assertIsNone(link.child_user_id) + + +class FamilyLinkInviteThrottleTests(TestCase): + """ + 🔒 اثبات محدودیتِ جدیدِ throttle_scope='family_invite_create' روی + FamilyLinkInviteView: این endpoint قبلاً هیچ throttle مخصوصی نداشت، + درحالی‌که هر درخواستِ پذیرفته‌شده یک FamilyLink ماندگار و یک کدِ + دعوتِ ۶رقمیِ تازه می‌سازه. GuestAwareFamilyThrottle با pk کاربرِ + احرازهویت‌شده کلید می‌خوره (نه IP)، پس تغییرِ IP این محدودیت رو دور + نمی‌زنه. + """ + + def setUp(self): + cache.clear() + self.client = APIClient() + self.parent = User.objects.create(device_id='device-family-invite-throttle-1', is_guest=True, name='') + self.token = issue_access_token(self.parent.id) + + def _invite(self, child_phone, token=None, remote_addr=None): + headers = {} + if token: + headers['HTTP_AUTHORIZATION'] = f'Bearer {token}' + if remote_addr: + headers['REMOTE_ADDR'] = remote_addr + return self.client.post( + '/api/family/link/invite', {'childPhone': child_phone}, format='json', **headers, + ) + + def test_unauthenticated_request_is_rejected(self): + response = self._invite('+989120001001') + self.assertEqual(response.status_code, 401) + + def test_requests_within_the_limit_still_succeed(self): + with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'family_invite_create': '3/hour'}): + for i in range(3): + response = self._invite(f'+98912000110{i}', token=self.token) + self.assertEqual(response.status_code, 200) + self.assertIn('inviteCode', response.data) + + def test_excessive_invite_requests_from_same_user_are_blocked(self): + with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'family_invite_create': '3/hour'}): + for i in range(3): + response = self._invite(f'+98912000120{i}', token=self.token) + self.assertEqual(response.status_code, 200) + + blocked_response = self._invite('+989120001299', token=self.token) + self.assertEqual(blocked_response.status_code, 429) + + def test_changing_client_ip_does_not_bypass_the_user_limit(self): + # ⚠️ توجه: از یک کاربرِ *غیرمهمان* استفاده می‌شه (نه self.parent که + # مهمان است)، چون طبقِ فیکسِ GuestAwareFamilyThrottle حالا کلیدِ + # bucket برای کاربرهای مهمان عمداً بر اساسِ IP است (نگاه کنید به + # FamilyLinkInviteGuestThrottleBypassTests پایین‌تر) — این تست + # فقط رفتارِ per-user (نه IP) برای کاربرهایِ ثبت‌نام‌شده رو تضمین + # می‌کنه. + registered_parent = User.objects.create(is_guest=False, name='', phone='+989120001300') + registered_token = issue_access_token(registered_parent.id) + + with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'family_invite_create': '3/hour'}): + for i in range(3): + response = self._invite( + f'+98912000131{i}', token=registered_token, remote_addr=f'10.5.0.{i + 1}', + ) + self.assertEqual(response.status_code, 200) + + blocked_response = self._invite('+989120001399', token=registered_token, remote_addr='10.5.0.99') + self.assertEqual(blocked_response.status_code, 429) + + def test_throttled_request_does_not_create_family_link(self): + with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'family_invite_create': '1/hour'}): + first_response = self._invite('+989120001401', token=self.token) + self.assertEqual(first_response.status_code, 200) + + link_count_before = FamilyLink.objects.filter(parent_user_id=self.parent.id).count() + blocked_response = self._invite('+989120001402', token=self.token) + + self.assertEqual(blocked_response.status_code, 429) + self.assertEqual( + FamilyLink.objects.filter(parent_user_id=self.parent.id).count(), link_count_before, + ) + self.assertFalse( + FamilyLink.objects.filter(parent_user_id=self.parent.id, child_phone='+989120001402').exists(), + ) + + +class FamilyLinkInviteGuestThrottleBypassTests(TestCase): + """ + 🔒 اثبات فیکسِ امنیتی: FamilyLinkInviteView تازه throttle گرفته و از همون + ابتدا با GuestAwareFamilyThrottle محافظت می‌شه (نه یک ScopedRateThrottleِ + ساده که بعداً فیکس بشه). چون GuestSessionView با هر deviceId جدید یک + کاربرِ مهمانِ تازه (pk جدید) می‌سازه، بدونِ این فیکس یک مهاجم می‌تونست با + ساختنِ پی‌درپیِ حساب‌های مهمان، هر بار یک bucket throttle تازه برای + ساختنِ FamilyLink/کدِ دعوتِ نامحدود بگیره. GuestAwareFamilyThrottle برای + کاربرهای مهمان کلید رو بر اساسِ IP می‌سازه، پس چرخشِ حساب مهمان دیگه + bucket تازه نمی‌ده. رفتارِ throttle برای کاربرِ غیرمهمان (per-user) + دست‌نخورده مونده. + """ + + def setUp(self): + cache.clear() + self.client = APIClient() + + def _invite(self, child_phone, token): + return self.client.post( + '/api/family/link/invite', {'childPhone': child_phone}, format='json', + HTTP_AUTHORIZATION=f'Bearer {token}', + ) + + def test_rotating_guest_accounts_share_the_same_throttle_bucket(self): + with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'family_invite_create': '2/hour'}): + guest1 = User.objects.create(device_id='device-family-invite-guest-rot-1', is_guest=True, name='') + response1 = self._invite('+989120001501', issue_access_token(guest1.id)) + self.assertEqual(response1.status_code, 200) + + guest2 = User.objects.create(device_id='device-family-invite-guest-rot-2', is_guest=True, name='') + response2 = self._invite('+989120001502', issue_access_token(guest2.id)) + self.assertEqual(response2.status_code, 200) + + # سومین حسابِ مهمانِ تازه (pk جدید) باید throttle بشه، چون + # bucket بر اساسِ IP به‌اشتراک گذاشته شده، نه user.pk. + guest3 = User.objects.create(device_id='device-family-invite-guest-rot-3', is_guest=True, name='') + response3 = self._invite('+989120001503', issue_access_token(guest3.id)) + + self.assertEqual(response3.status_code, 429) + + def test_registered_users_keep_independent_per_user_buckets(self): + with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'family_invite_create': '1/hour'}): + user_a = User.objects.create(is_guest=False, name='', phone='+989120001601') + response_a = self._invite('+989120001701', issue_access_token(user_a.id)) + self.assertEqual(response_a.status_code, 200) + + user_b = User.objects.create(is_guest=False, name='', phone='+989120001602') + response_b = self._invite('+989120001702', issue_access_token(user_b.id)) + + self.assertEqual(response_b.status_code, 200) + + def test_blocked_guest_request_does_not_create_family_link(self): + with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'family_invite_create': '1/hour'}): + guest1 = User.objects.create(device_id='device-family-invite-guest-rot-4', is_guest=True, name='') + first_response = self._invite('+989120001801', issue_access_token(guest1.id)) + self.assertEqual(first_response.status_code, 200) + + guest2 = User.objects.create(device_id='device-family-invite-guest-rot-5', is_guest=True, name='') + blocked_response = self._invite('+989120001802', issue_access_token(guest2.id)) + + self.assertEqual(blocked_response.status_code, 429) + self.assertFalse(FamilyLink.objects.filter(parent_user_id=guest2.id).exists()) diff --git a/family/views.py b/family/views.py index 7dad81b..570ac24 100644 --- a/family/views.py +++ b/family/views.py @@ -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, diff --git a/payment/tests.py b/payment/tests.py index 580d660..5c339cb 100644 --- a/payment/tests.py +++ b/payment/tests.py @@ -4,9 +4,15 @@ from unittest.mock import patch from django.core.cache import cache from django.test import TestCase, override_settings -from rest_framework.test import APIRequestFactory +from rest_framework.test import APIClient, APIRequestFactory +from rest_framework.throttling import ScopedRateThrottle + +from accounts.authentication import issue_access_token +from accounts.models import User from . import bazaar +from .bazaar import BazaarPurchaseResult +from .models import Order, Transaction from .permissions import HasInternalServiceKey, HasWebhookSecret @@ -156,3 +162,212 @@ class BazaarVerifyPurchaseFailClosedTests(TestCase): result = bazaar.verify_purchase(product_sku='sku1', purchase_token='cancelled-token') self.assertFalse(result.is_valid) + + +class BazaarVerifyPurchaseThrottleTests(TestCase): + """ + 🔒 اثبات محدودیتِ جدیدِ per-user روی BazaarVerifyPurchaseView + (throttle_scope='bazaar_verify'): هر درخواستِ پذیرفته‌شده یک تماسِ + سرور-به-سرورِ واقعی با API بازار می‌زنه؛ بدونِ throttle مخصوص، یک + کاربرِ احرازهویت‌شده می‌تونست با ارسالِ مکررِ purchaseToken بارِ زیادی + روی سرویسِ بازار/بک‌اند بندازه. GuestAwareBazaarThrottle با pk کاربرِ + احرازهویت‌شده کلید می‌خوره (نه IP)، پس تغییرِ IP این محدودیت رو دور + نمی‌زنه. + """ + + def setUp(self): + cache.clear() + self.client = APIClient() + self.user = User.objects.create(device_id='device-bazaar-throttle-1', is_guest=True, name='') + self.token = issue_access_token(self.user.id) + + def _make_order(self, key): + return Order.objects.create( + user_id=self.user.id, amount=10000, provider='bazaar', + bazaar_product_sku='sku_one_time', idempotency_key=key, + ) + + def _verify(self, order_id, purchase_token, token=None, remote_addr=None): + headers = {} + if token: + headers['HTTP_AUTHORIZATION'] = f'Bearer {token}' + if remote_addr: + headers['REMOTE_ADDR'] = remote_addr + return self.client.post( + '/api/payment/bazaar/verify', + {'orderId': order_id, 'purchaseToken': purchase_token}, + format='json', + **headers, + ) + + def test_unauthenticated_request_is_still_rejected(self): + response = self._verify(order_id='1', purchase_token='tok') + self.assertEqual(response.status_code, 401) + + def test_requests_within_the_limit_still_succeed(self): + with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'bazaar_verify': '3/hour'}): + with patch('payment.views.bazaar.verify_purchase') as mock_verify: + mock_verify.return_value = BazaarPurchaseResult(is_valid=True, purchase_state=0) + for i in range(3): + order = self._make_order(f'order-bazaar-within-{i}') + response = self._verify(order.id, f'token-within-{i}', token=self.token) + self.assertEqual(response.status_code, 200) + + def test_excessive_verify_attempts_from_same_user_are_blocked(self): + with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'bazaar_verify': '3/hour'}): + with patch('payment.views.bazaar.verify_purchase') as mock_verify: + mock_verify.return_value = BazaarPurchaseResult(is_valid=True, purchase_state=0) + for i in range(3): + order = self._make_order(f'order-bazaar-block-{i}') + response = self._verify(order.id, f'token-block-{i}', token=self.token) + self.assertEqual(response.status_code, 200) + + extra_order = self._make_order('order-bazaar-block-extra') + blocked_response = self._verify(extra_order.id, 'token-block-extra', token=self.token) + self.assertEqual(blocked_response.status_code, 429) + + def test_changing_client_ip_does_not_bypass_the_user_limit(self): + # ⚠️ توجه: از یک کاربرِ *غیرمهمان* استفاده می‌شه (نه self.user که + # مهمان است)، چون طبقِ فیکسِ GuestAwareBazaarThrottle حالا کلیدِ + # bucket برای کاربرهای مهمان عمداً بر اساسِ IP است (نگاه کنید به + # BazaarVerifyPurchaseGuestThrottleBypassTests پایین‌تر) — این تست + # فقط رفتارِ per-user (نه IP) برای کاربرهایِ ثبت‌نام‌شده رو تضمین + # می‌کنه. + registered_user = User.objects.create(is_guest=False, name='', phone='+989120000751') + registered_token = issue_access_token(registered_user.id) + + with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'bazaar_verify': '3/hour'}): + with patch('payment.views.bazaar.verify_purchase') as mock_verify: + mock_verify.return_value = BazaarPurchaseResult(is_valid=True, purchase_state=0) + for i in range(3): + order = Order.objects.create( + user_id=registered_user.id, amount=10000, provider='bazaar', + bazaar_product_sku='sku_one_time', idempotency_key=f'order-bazaar-ip-{i}', + ) + response = self._verify( + order.id, f'token-ip-{i}', token=registered_token, remote_addr=f'10.4.0.{i + 1}', + ) + self.assertEqual(response.status_code, 200) + + extra_order = Order.objects.create( + user_id=registered_user.id, amount=10000, provider='bazaar', + bazaar_product_sku='sku_one_time', idempotency_key='order-bazaar-ip-extra', + ) + blocked_response = self._verify( + extra_order.id, 'token-ip-extra', token=registered_token, remote_addr='10.4.0.99', + ) + self.assertEqual(blocked_response.status_code, 429) + + def test_throttled_request_does_not_call_bazaar_or_mutate_order(self): + with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'bazaar_verify': '1/hour'}): + with patch('payment.views.bazaar.verify_purchase') as mock_verify: + mock_verify.return_value = BazaarPurchaseResult(is_valid=True, purchase_state=0) + first_order = self._make_order('order-bazaar-mutate-1') + first_response = self._verify(first_order.id, 'token-mutate-1', token=self.token) + self.assertEqual(first_response.status_code, 200) + + mock_verify.reset_mock() + second_order = self._make_order('order-bazaar-mutate-2') + blocked_response = self._verify(second_order.id, 'token-mutate-2', token=self.token) + self.assertEqual(blocked_response.status_code, 429) + mock_verify.assert_not_called() + + second_order.refresh_from_db() + self.assertEqual(second_order.status, 'pending') + self.assertEqual(second_order.bazaar_purchase_token, '') + self.assertFalse(Transaction.objects.filter(order=second_order).exists()) + + +class BazaarVerifyPurchaseGuestThrottleBypassTests(TestCase): + """ + 🔒 اثبات فیکسِ امنیتی: قبلاً کلیدِ throttle_scope='bazaar_verify' برای + هر کاربرِ احرازهویت‌شده (مهمان یا نه) user.pk بود. چون GuestSessionView + با هر deviceId جدید یک کاربرِ مهمانِ تازه (pk جدید) می‌سازه، یک مهاجم + می‌تونست با ساختنِ پی‌درپیِ حساب‌های مهمان، هر بار یک bucket throttle + تازه برای تریگر کردنِ تماسِ سرور-به-سرورِ verify_purchase بگیره. حالا + GuestAwareBazaarThrottle برای کاربرهای مهمان کلید رو بر اساسِ IP + می‌سازه، پس چرخشِ حساب مهمان دیگه bucket تازه نمی‌ده. رفتارِ throttle + برای کاربرِ غیرمهمان (per-user، مثلِ قبل) دست‌نخورده مونده. + """ + + def setUp(self): + cache.clear() + self.client = APIClient() + + def _make_guest_with_order(self, device_id, key): + guest = User.objects.create(device_id=device_id, is_guest=True, name='') + order = Order.objects.create( + user_id=guest.id, amount=10000, provider='bazaar', + bazaar_product_sku='sku_one_time', idempotency_key=key, + ) + return guest, order + + def _verify(self, order_id, purchase_token, token): + return self.client.post( + '/api/payment/bazaar/verify', + {'orderId': order_id, 'purchaseToken': purchase_token}, + format='json', + HTTP_AUTHORIZATION=f'Bearer {token}', + ) + + def test_rotating_guest_accounts_share_the_same_throttle_bucket(self): + with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'bazaar_verify': '2/hour'}): + with patch('payment.views.bazaar.verify_purchase') as mock_verify: + mock_verify.return_value = BazaarPurchaseResult(is_valid=True, purchase_state=0) + + guest1, order1 = self._make_guest_with_order('device-bazaar-guest-rot-1', 'order-bazaar-rot-1') + response1 = self._verify(order1.id, 'token-rot-1', issue_access_token(guest1.id)) + self.assertEqual(response1.status_code, 200) + + guest2, order2 = self._make_guest_with_order('device-bazaar-guest-rot-2', 'order-bazaar-rot-2') + response2 = self._verify(order2.id, 'token-rot-2', issue_access_token(guest2.id)) + self.assertEqual(response2.status_code, 200) + + # سومین حسابِ مهمانِ تازه (pk جدید) باید throttle بشه، چون + # bucket بر اساسِ IP به‌اشتراک گذاشته شده، نه user.pk. + guest3, order3 = self._make_guest_with_order('device-bazaar-guest-rot-3', 'order-bazaar-rot-3') + response3 = self._verify(order3.id, 'token-rot-3', issue_access_token(guest3.id)) + + self.assertEqual(response3.status_code, 429) + + def test_registered_users_keep_independent_per_user_buckets(self): + with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'bazaar_verify': '1/hour'}): + with patch('payment.views.bazaar.verify_purchase') as mock_verify: + mock_verify.return_value = BazaarPurchaseResult(is_valid=True, purchase_state=0) + + user_a = User.objects.create(is_guest=False, name='', phone='+989120000761') + order_a = Order.objects.create( + user_id=user_a.id, amount=10000, provider='bazaar', + bazaar_product_sku='sku_one_time', idempotency_key='order-bazaar-reg-a', + ) + response_a = self._verify(order_a.id, 'token-reg-a', issue_access_token(user_a.id)) + self.assertEqual(response_a.status_code, 200) + + user_b = User.objects.create(is_guest=False, name='', phone='+989120000762') + order_b = Order.objects.create( + user_id=user_b.id, amount=10000, provider='bazaar', + bazaar_product_sku='sku_one_time', idempotency_key='order-bazaar-reg-b', + ) + response_b = self._verify(order_b.id, 'token-reg-b', issue_access_token(user_b.id)) + + self.assertEqual(response_b.status_code, 200) + + def test_blocked_guest_request_does_not_call_bazaar_or_mutate_order(self): + with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'bazaar_verify': '1/hour'}): + with patch('payment.views.bazaar.verify_purchase') as mock_verify: + mock_verify.return_value = BazaarPurchaseResult(is_valid=True, purchase_state=0) + + guest1, order1 = self._make_guest_with_order('device-bazaar-guest-rot-4', 'order-bazaar-rot-4') + first_response = self._verify(order1.id, 'token-rot-4', issue_access_token(guest1.id)) + self.assertEqual(first_response.status_code, 200) + + mock_verify.reset_mock() + guest2, order2 = self._make_guest_with_order('device-bazaar-guest-rot-5', 'order-bazaar-rot-5') + blocked_response = self._verify(order2.id, 'token-rot-5', issue_access_token(guest2.id)) + self.assertEqual(blocked_response.status_code, 429) + mock_verify.assert_not_called() + + order2.refresh_from_db() + self.assertEqual(order2.status, 'pending') + self.assertEqual(order2.bazaar_purchase_token, '') + self.assertFalse(Transaction.objects.filter(order=order2).exists()) diff --git a/payment/views.py b/payment/views.py index 05335f8..894d6ce 100644 --- a/payment/views.py +++ b/payment/views.py @@ -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, diff --git a/requirements.txt b/requirements.txt index af6635d..e7c788e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 diff --git a/subscription/tests.py b/subscription/tests.py index 440d4f5..2e70d13 100644 --- a/subscription/tests.py +++ b/subscription/tests.py @@ -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) diff --git a/subscription/views.py b/subscription/views.py index 8dacc5e..0e53b31 100644 --- a/subscription/views.py +++ b/subscription/views.py @@ -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, diff --git a/support/tests.py b/support/tests.py index a39b155..144f936 100644 --- a/support/tests.py +++ b/support/tests.py @@ -1 +1,366 @@ # Create your tests here. +from unittest.mock import patch + +from django.core.cache import cache +from django.test import TestCase +from rest_framework.test import APIClient +from rest_framework.throttling import ScopedRateThrottle + +from accounts.authentication import issue_access_token +from accounts.models import User + +from .models import BugReport, Ticket, TicketMessage + + +class BugReportCreateThrottleTests(TestCase): + """ + 🔒 اثبات محدودیتِ جدیدِ per-user روی BugReportCreateView + (throttle_scope='support_create'): هیچ idempotency ای روی این + endpoint نیست، پس بدونِ throttle یک کاربرِ احرازهویت‌شده می‌تونست + تعدادِ نامحدودی BugReport با درخواست‌های پرتکرار بسازه. + ScopedRateThrottle با pk کاربر کلید می‌خوره، نه IP. + """ + + def setUp(self): + cache.clear() + self.client = APIClient() + self.user = User.objects.create(device_id='device-bugreport-throttle-1', is_guest=True, name='') + self.token = issue_access_token(self.user.id) + + def _create(self, i=0, remote_addr=None): + kwargs = {'HTTP_AUTHORIZATION': f'Bearer {self.token}'} + if remote_addr: + kwargs['REMOTE_ADDR'] = remote_addr + return self.client.post( + '/api/support/bug-reports', + {'title': f'باگ {i}', 'description': f'توضیحات {i}'}, + format='json', + **kwargs, + ) + + def test_requests_within_the_limit_still_succeed(self): + with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'support_create': '3/hour'}): + for i in range(3): + response = self._create(i) + self.assertEqual(response.status_code, 200) + + def test_excessive_requests_from_same_user_return_429(self): + with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'support_create': '3/hour'}): + for i in range(3): + response = self._create(i) + self.assertEqual(response.status_code, 200) + + blocked_response = self._create('extra') + self.assertEqual(blocked_response.status_code, 429) + + def test_changing_client_ip_does_not_bypass_the_user_limit(self): + # ⚠️ توجه: از یک کاربرِ *غیرمهمان* استفاده می‌شه (نه self.user که + # مهمان است)، چون طبقِ فیکسِ GuestAwareSupportThrottle حالا کلیدِ + # bucket برای کاربرهای مهمان عمداً بر اساسِ IP است (نگاه کنید به + # SupportCreateGuestThrottleBypassTests پایین‌تر) — این تست فقط + # رفتارِ per-user (نه IP) برای کاربرهایِ ثبت‌نام‌شده رو تضمین می‌کنه + # که باید دقیقاً مثلِ قبل دست‌نخورده بمونه. + registered_user = User.objects.create(is_guest=False, name='', phone='+989120000901') + registered_token = issue_access_token(registered_user.id) + + def _create_as(i, remote_addr): + return self.client.post( + '/api/support/bug-reports', + {'title': f'باگ {i}', 'description': f'توضیحات {i}'}, + format='json', + HTTP_AUTHORIZATION=f'Bearer {registered_token}', + REMOTE_ADDR=remote_addr, + ) + + with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'support_create': '3/hour'}): + for i in range(3): + response = _create_as(i, f'10.4.0.{i + 1}') + self.assertEqual(response.status_code, 200) + + blocked_response = _create_as('extra', '10.4.0.99') + self.assertEqual(blocked_response.status_code, 429) + + def test_throttled_request_creates_no_additional_bug_report(self): + with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'support_create': '1/hour'}): + first_response = self._create() + self.assertEqual(first_response.status_code, 200) + + count_before = BugReport.objects.filter(user_id=self.user.id).count() + + blocked_response = self._create('blocked') + self.assertEqual(blocked_response.status_code, 429) + self.assertEqual(BugReport.objects.filter(user_id=self.user.id).count(), count_before) + + +class TicketCreateThrottleTests(TestCase): + """ + 🔒 اثبات محدودیتِ جدیدِ per-user روی TicketCreateView + (throttle_scope='support_create'، مشترک با BugReport/TicketMessage). + """ + + def setUp(self): + cache.clear() + self.client = APIClient() + self.user = User.objects.create(device_id='device-ticket-throttle-1', is_guest=True, name='') + self.token = issue_access_token(self.user.id) + + def _create(self, i=0, remote_addr=None): + kwargs = {'HTTP_AUTHORIZATION': f'Bearer {self.token}'} + if remote_addr: + kwargs['REMOTE_ADDR'] = remote_addr + return self.client.post( + '/api/support/tickets', + {'subject': f'موضوع {i}', 'category': 'billing'}, + format='json', + **kwargs, + ) + + def test_requests_within_the_limit_still_succeed(self): + with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'support_create': '3/hour'}): + for i in range(3): + response = self._create(i) + self.assertEqual(response.status_code, 200) + + def test_excessive_requests_from_same_user_return_429(self): + with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'support_create': '3/hour'}): + for i in range(3): + response = self._create(i) + self.assertEqual(response.status_code, 200) + + blocked_response = self._create('extra') + self.assertEqual(blocked_response.status_code, 429) + + def test_changing_client_ip_does_not_bypass_the_user_limit(self): + # ⚠️ توجه: از یک کاربرِ *غیرمهمان* استفاده می‌شه (نه self.user که + # مهمان است) — طبقِ فیکسِ GuestAwareSupportThrottle، کاربرهای مهمان + # عمداً بر اساسِ IP کلید می‌خورن (نگاه کنید به + # SupportCreateGuestThrottleBypassTests پایین‌تر)؛ این تست رفتارِ + # per-user (نه IP) برای کاربرهایِ ثبت‌نام‌شده رو تضمین می‌کنه. + registered_user = User.objects.create(is_guest=False, name='', phone='+989120000902') + registered_token = issue_access_token(registered_user.id) + + def _create_as(i, remote_addr): + return self.client.post( + '/api/support/tickets', + {'subject': f'موضوع {i}', 'category': 'billing'}, + format='json', + HTTP_AUTHORIZATION=f'Bearer {registered_token}', + REMOTE_ADDR=remote_addr, + ) + + with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'support_create': '3/hour'}): + for i in range(3): + response = _create_as(i, f'10.5.0.{i + 1}') + self.assertEqual(response.status_code, 200) + + blocked_response = _create_as('extra', '10.5.0.99') + self.assertEqual(blocked_response.status_code, 429) + + def test_throttled_request_creates_no_additional_ticket(self): + with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'support_create': '1/hour'}): + first_response = self._create() + self.assertEqual(first_response.status_code, 200) + + count_before = Ticket.objects.filter(user_id=self.user.id).count() + + blocked_response = self._create('blocked') + self.assertEqual(blocked_response.status_code, 429) + self.assertEqual(Ticket.objects.filter(user_id=self.user.id).count(), count_before) + + +class TicketMessageCreateThrottleTests(TestCase): + """ + 🔒 اثبات محدودیتِ جدیدِ per-user روی TicketMessageCreateView + (throttle_scope='support_create'، مشترک با BugReport/Ticket). + """ + + def setUp(self): + cache.clear() + self.client = APIClient() + self.user = User.objects.create(device_id='device-ticketmsg-throttle-1', is_guest=True, name='') + self.token = issue_access_token(self.user.id) + self.ticket = Ticket.objects.create(user_id=self.user.id, subject='موضوع', category='billing') + + def _create(self, i=0, remote_addr=None): + kwargs = {'HTTP_AUTHORIZATION': f'Bearer {self.token}'} + if remote_addr: + kwargs['REMOTE_ADDR'] = remote_addr + return self.client.post( + f'/api/support/tickets/{self.ticket.id}/messages', + {'text': f'پیام {i}'}, + format='json', + **kwargs, + ) + + def test_requests_within_the_limit_still_succeed(self): + with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'support_create': '3/hour'}): + for i in range(3): + response = self._create(i) + self.assertEqual(response.status_code, 200) + + def test_excessive_requests_from_same_user_return_429(self): + with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'support_create': '3/hour'}): + for i in range(3): + response = self._create(i) + self.assertEqual(response.status_code, 200) + + blocked_response = self._create('extra') + self.assertEqual(blocked_response.status_code, 429) + + def test_changing_client_ip_does_not_bypass_the_user_limit(self): + # ⚠️ توجه: از یک کاربرِ *غیرمهمان* استفاده می‌شه (نه self.user که + # مهمان است) — طبقِ فیکسِ GuestAwareSupportThrottle، کاربرهای مهمان + # عمداً بر اساسِ IP کلید می‌خورن (نگاه کنید به + # SupportCreateGuestThrottleBypassTests پایین‌تر)؛ این تست رفتارِ + # per-user (نه IP) برای کاربرهایِ ثبت‌نام‌شده رو تضمین می‌کنه. + registered_user = User.objects.create(is_guest=False, name='', phone='+989120000903') + registered_token = issue_access_token(registered_user.id) + registered_ticket = Ticket.objects.create( + user_id=registered_user.id, subject='موضوع', category='billing', + ) + + def _create_as(i, remote_addr): + return self.client.post( + f'/api/support/tickets/{registered_ticket.id}/messages', + {'text': f'پیام {i}'}, + format='json', + HTTP_AUTHORIZATION=f'Bearer {registered_token}', + REMOTE_ADDR=remote_addr, + ) + + with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'support_create': '3/hour'}): + for i in range(3): + response = _create_as(i, f'10.6.0.{i + 1}') + self.assertEqual(response.status_code, 200) + + blocked_response = _create_as('extra', '10.6.0.99') + self.assertEqual(blocked_response.status_code, 429) + + def test_throttled_request_creates_no_additional_message(self): + with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'support_create': '1/hour'}): + first_response = self._create() + self.assertEqual(first_response.status_code, 200) + + count_before = TicketMessage.objects.filter(ticket=self.ticket).count() + + blocked_response = self._create('blocked') + self.assertEqual(blocked_response.status_code, 429) + self.assertEqual(TicketMessage.objects.filter(ticket=self.ticket).count(), count_before) + + +class SupportCreateGuestThrottleBypassTests(TestCase): + """ + 🔒 اثبات فیکسِ امنیتی: قبلاً کلیدِ throttle_scope='support_create' + (مشترک بینِ BugReportCreateView، TicketCreateView و + TicketMessageCreateView) برای هر کاربرِ احرازهویت‌شده (مهمان یا نه) + user.pk بود. چون GuestSessionView با هر deviceId جدید یک کاربرِ + مهمانِ تازه (pk جدید) می‌سازه، یک مهاجم می‌تونست با ساختنِ پی‌درپیِ + حساب‌های مهمان، هر بار یک bucket throttle تازه برای این scopeِ + مشترک بگیره و سقفِ ۳/ساعتِ ساختِ رکوردهای پشتیبانی (BugReport/ + Ticket/TicketMessage) رو چند برابر کنه. حالا GuestAwareSupportThrottle + برای کاربرهای مهمان کلید رو بر اساسِ IP (همون get_ident که + guest_session هم استفاده می‌کنه) می‌سازه، پس چرخشِ حساب مهمان دیگه + bucket تازه نمی‌ده — این هم روی bucket مشترکِ بینِ هر سه Endpoint و + هم روی رفتارِ per-user برای کاربرهای غیرمهمان تست می‌شه. + """ + + def setUp(self): + cache.clear() + self.client = APIClient() + + def _create_bug_report(self, token, i=0, remote_addr=None): + kwargs = {'HTTP_AUTHORIZATION': f'Bearer {token}'} + if remote_addr: + kwargs['REMOTE_ADDR'] = remote_addr + return self.client.post( + '/api/support/bug-reports', + {'title': f'باگ {i}', 'description': f'توضیحات {i}'}, + format='json', + **kwargs, + ) + + def _create_ticket(self, token, i=0): + return self.client.post( + '/api/support/tickets', + {'subject': f'موضوع {i}', 'category': 'billing'}, + format='json', + HTTP_AUTHORIZATION=f'Bearer {token}', + ) + + def test_rotating_guest_accounts_share_the_same_throttle_bucket(self): + # سه حساب مهمانِ متفاوت (شبیه‌سازیِ چرخشِ حساب برای دورزدنِ throttle)، + # همه از همون کلاینت/IP — باید همه یک bucket مشترک رو مصرف کنند، + # حتی وقتی از دو Endpointِ متفاوتِ همون scope استفاده می‌کنن. + with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'support_create': '2/hour'}): + guest1 = User.objects.create(device_id='device-support-guest-rot-1', is_guest=True, name='') + response1 = self._create_bug_report(issue_access_token(guest1.id), 1) + self.assertEqual(response1.status_code, 200) + + guest2 = User.objects.create(device_id='device-support-guest-rot-2', is_guest=True, name='') + response2 = self._create_ticket(issue_access_token(guest2.id), 2) + self.assertEqual(response2.status_code, 200) + + # سومین حسابِ مهمانِ تازه (pk جدید) باید throttle بشه، چون + # bucket بر اساسِ IP به‌اشتراک گذاشته شده، نه user.pk — یعنی + # سقفِ ۲/ساعت هنوز اعمال می‌شه، صرف‌نظر از تعدادِ حساب‌های + # مهمانِ ساخته‌شده یا این‌که کدوم Endpoint صدا زده شده. + guest3 = User.objects.create(device_id='device-support-guest-rot-3', is_guest=True, name='') + response3 = self._create_bug_report(issue_access_token(guest3.id), 3) + + self.assertEqual(response3.status_code, 429) + + def test_rotated_guest_account_cannot_get_a_fresh_bucket_after_limit(self): + # حتی وقتی حسابِ مهمانِ جدید به سقف رسیده، یک حسابِ مهمانِ دیگر + # (چهارمین pk) هم نباید bucket تازه‌ای بگیره — سقف هنوز به IP + # مشترک متصل است. + with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'support_create': '1/hour'}): + guest1 = User.objects.create(device_id='device-support-guest-rot-4', is_guest=True, name='') + response1 = self._create_bug_report(issue_access_token(guest1.id), 4) + self.assertEqual(response1.status_code, 200) + + guest2 = User.objects.create(device_id='device-support-guest-rot-5', is_guest=True, name='') + blocked = self._create_bug_report(issue_access_token(guest2.id), 5) + self.assertEqual(blocked.status_code, 429) + + guest3 = User.objects.create(device_id='device-support-guest-rot-6', is_guest=True, name='') + still_blocked = self._create_bug_report(issue_access_token(guest3.id), 6) + + self.assertEqual(still_blocked.status_code, 429) + + def test_registered_users_keep_independent_per_user_buckets(self): + # عدمِ رگرسیون: کاربرهای غیرمهمان (ثبت‌نام‌شده با شماره) باید مثلِ + # قبل هرکدوم bucket جداگانه‌ی خودشون رو داشته باشن — throttle + # برای اونا هنوز بر اساسِ user.pk است، نه IP. + with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'support_create': '1/hour'}): + user_a = User.objects.create(is_guest=False, name='', phone='+989120001001') + response_a = self._create_bug_report(issue_access_token(user_a.id), 'a') + self.assertEqual(response_a.status_code, 200) + + user_b = User.objects.create(is_guest=False, name='', phone='+989120001002') + response_b = self._create_bug_report(issue_access_token(user_b.id), 'b') + + self.assertEqual(response_b.status_code, 200) + + def test_blocked_guest_request_does_not_create_support_record(self): + # درخواستِ بلاک‌شده (از حسابِ مهمانِ چرخشی) نباید هیچ BugReport یا + # Ticket جدیدی بسازه — throttle باید *قبل* از رسیدن به منطقِ + # ساختِ رکورد رد کنه. + with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'support_create': '1/hour'}): + guest1 = User.objects.create(device_id='device-support-guest-rot-7', is_guest=True, name='') + first_response = self._create_bug_report(issue_access_token(guest1.id), 7) + self.assertEqual(first_response.status_code, 200) + + # حسابِ مهمانِ تازه‌ی دوم باید بلاک بشه (bucket مشترکِ IP از + # قبل مصرف شده) و نباید BugReport یا Ticket جدیدی بسازد. + guest2 = User.objects.create(device_id='device-support-guest-rot-8', is_guest=True, name='') + bug_count_before = BugReport.objects.count() + ticket_count_before = Ticket.objects.count() + + blocked_bug_response = self._create_bug_report(issue_access_token(guest2.id), 8) + self.assertEqual(blocked_bug_response.status_code, 429) + + blocked_ticket_response = self._create_ticket(issue_access_token(guest2.id), 8) + self.assertEqual(blocked_ticket_response.status_code, 429) + + self.assertEqual(BugReport.objects.count(), bug_count_before) + self.assertEqual(Ticket.objects.count(), ticket_count_before) diff --git a/support/views.py b/support/views.py index 661b64e..316dd58 100644 --- a/support/views.py +++ b/support/views.py @@ -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,