Harden auth, AI tutor, payments, and subscriptions

This commit is contained in:
2026-08-22 04:42:36 +03:30
parent f945f960c9
commit cb18c93a3e
41 changed files with 2202 additions and 595 deletions

View File

@@ -13,6 +13,8 @@ https://docs.djangoproject.com/en/5.2/ref/settings/
import os
from pathlib import Path
from django.core.exceptions import ImproperlyConfigured
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent
@@ -20,13 +22,23 @@ BASE_DIR = Path(__file__).resolve().parent.parent
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = 'django-insecure-jn!s*25lz^+bjm101tzmlz8jlj70i0qka++*h-(!0s^^vk-0ss'
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True
# Defaults to False; must be explicitly enabled via env for local dev.
DEBUG = os.environ.get('DJANGO_DEBUG', '') == '1'
ALLOWED_HOSTS = []
# SECURITY WARNING: keep the secret key used in production secret!
# No hardcoded fallback when DEBUG=False — fails closed at startup instead.
SECRET_KEY = os.environ.get('DJANGO_SECRET_KEY')
if not SECRET_KEY:
if DEBUG:
SECRET_KEY = 'django-insecure-jn!s*25lz^+bjm101tzmlz8jlj70i0qka++*h-(!0s^^vk-0ss'
else:
raise ImproperlyConfigured(
'DJANGO_SECRET_KEY environment variable is not set. '
'A predictable fallback secret key cannot be used when DEBUG=False.'
)
ALLOWED_HOSTS = [h for h in os.environ.get('DJANGO_ALLOWED_HOSTS', '').split(',') if h]
# Application definition
@@ -110,6 +122,31 @@ DATABASES = {
}
}
# In production a shared cache is required for rate limiting to work across
# workers/instances; falls back to LocMemCache only when DEBUG=True.
CACHE_URL = os.environ.get('CACHE_URL', '')
if CACHE_URL:
CACHES = {
'default': {
'BACKEND': 'django.core.cache.backends.redis.RedisCache',
'LOCATION': CACHE_URL,
}
}
elif DEBUG:
CACHES = {
'default': {
'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
}
}
else:
raise ImproperlyConfigured(
'CACHE_URL environment variable is not set. '
'A shared cache is required when DEBUG=False, since rate limits '
'(OTP throttles and the phone-based login limit) rely on a cache '
'shared across all workers/instances; LocMemCache cannot be used in production.'
)
# Password validation
# https://docs.djangoproject.com/en/5.2/ref/settings/#auth-password-validators
@@ -168,6 +205,14 @@ REST_FRAMEWORK = {
'rest_framework.renderers.JSONRenderer',
'rest_framework.renderers.BrowsableAPIRenderer',
],
'DEFAULT_THROTTLE_CLASSES': [
'rest_framework.throttling.ScopedRateThrottle',
],
'DEFAULT_THROTTLE_RATES': {
'otp_request': '5/hour',
'otp_login': '10/hour',
'guest_session': '20/hour',
},
}
SPECTACULAR_SETTINGS = {
@@ -194,9 +239,22 @@ SPECTACULAR_SETTINGS = {
ACCESS_TOKEN_TTL_SECONDS = 3600
REFRESH_TOKEN_TTL_DAYS = 30
# در محیط توسعه/تست، این کد OTP همیشه معتبره تا بدون نیاز به SMS واقعی
# بشه لاگین رو با Swagger تست کرد. در پروداکشن حتماً حذف بشه.
DEV_MASTER_OTP = '0000'
# محدودیت تلاش ورود بر اساس شماره تلفن، مستقل از throttle IP-محورِ otp_login.
OTP_LOGIN_PHONE_MAX_ATTEMPTS = 10
OTP_LOGIN_PHONE_WINDOW_SECONDS = 3600
# در محیط dev، این کد OTP همیشه معتبر است تا بدون SMS واقعی تست شود.
# فقط وقتی DEBUG=True مقدار دارد؛ در پروداکشن None است.
DEV_MASTER_OTP = '0000' if DEBUG else None
# -----------------------------------------------------------------------
# OTP SMS delivery (accounts/services.py: send_otp_sms)
# -----------------------------------------------------------------------
# در پروداکشن send_otp_sms از طریق کاوه‌نگار پیامک واقعی می‌فرستد.
# بدون تنظیم این env، ارسال باید صراحتاً خطا بدهد، نه silently موفق باشد.
KAVENEGAR_API_KEY = os.environ.get('KAVENEGAR_API_KEY', '')
KAVENEGAR_SENDER = os.environ.get('KAVENEGAR_SENDER', '')
OTP_SMS_TEMPLATE = os.environ.get('OTP_SMS_TEMPLATE', 'کد ورود شما به تدریسینو: {code}')
# -----------------------------------------------------------------------
# Content progress (NodeProgress status)
@@ -212,23 +270,21 @@ NODE_PROGRESS_ABANDON_DAYS = 14
# -----------------------------------------------------------------------
# Payment security
# -----------------------------------------------------------------------
# POST /api/payment/orders طبق سند یک فراخوانی سرور-به-سرور از اپ
# Subscription‌ه (نه از کلاینت). در عمل subscription/views.py مستقیماً
# تابع payment.services.create_order رو import و صدا می‌زنه، پس این
# endpoint اصلاً لازم نیست از بیرون صدا زده بشه؛ اما چون در urls.py
# رجیستر و AllowAny‌ه، هر کلاینتی می‌تونه با یک userId دلخواه سفارش بسازه.
# برای بستن این حفره، درخواست باید هدر X-Internal-Key رو برابر همین مقدار
# بفرسته؛ در پروداکشن حتماً از env خونده بشه و یک مقدار تصادفی/طولانی باشه.
INTERNAL_SERVICE_KEY = os.environ.get('INTERNAL_SERVICE_KEY', 'dev-internal-service-key-change-me')
# POST /api/payment/orders باید فقط سرور-به-سرور (از subscription) صدا زده شود؛
# کلاینت باید هدر X-Internal-Key برابر همین مقدار بفرستد. بدون env در
# پروداکشن مقدار خالی می‌ماند و HasInternalServiceKey صراحتاً رد می‌کند.
INTERNAL_SERVICE_KEY = os.environ.get(
'INTERNAL_SERVICE_KEY',
'dev-internal-service-key-change-me' if DEBUG else '',
)
# POST /api/payment/webhook قراره از درگاه پرداخت بیاد، نه از یک کلاینت
# دلخواه؛ بدون امضا هر کسی می‌تونه با یک orderId معتبر status=paid بفرسته
# و بدون پرداخت واقعی، اشتراک رو فعال کنه. درگاه‌های واقعی (زرین‌پال و غیره)
# امضای HMAC واقعی خودشون رو دارن؛ اینجا چون درگاه واقعی وصل نیست، به‌جای
# HMAC یک shared-secret ساده به اسم X-Webhook-Secret استفاده شده — همون
# سطح حفاظتی که در سند API برای این endpoint خواسته شده. برای اتصال واقعی
# این باید با بررسی امضای HMAC درگاه جایگزین بشه.
PAYMENT_WEBHOOK_SECRET = os.environ.get('PAYMENT_WEBHOOK_SECRET', 'dev-webhook-secret-change-me')
# POST /api/payment/webhook باید از درگاه پرداخت بیاید، نه یک کلاینت دلخواه.
# فعلاً یک shared-secret ساده (X-Webhook-Secret) استفاده می‌شود؛ در اتصال
# واقعی باید با بررسی امضای HMAC درگاه جایگزین شود.
PAYMENT_WEBHOOK_SECRET = os.environ.get(
'PAYMENT_WEBHOOK_SECRET',
'dev-webhook-secret-change-me' if DEBUG else '',
)
# -----------------------------------------------------------------------
# Bazaar In-App Billing (پرداخت درون‌برنامه‌ای کافه‌بازار — REST API v2)

118
config/tests.py Normal file
View File

@@ -0,0 +1,118 @@
import importlib
import os
from unittest.mock import patch
from django.core.exceptions import ImproperlyConfigured
from django.test import SimpleTestCase
class SecretKeyFailClosedTests(SimpleTestCase):
"""
تست‌های متمرکز روی این فیکس: SECRET_KEY دیگه در پروداکشن (DEBUG=False)
دیفالتِ هاردکد/قابل‌حدس‌زدن نداره. اگه DJANGO_SECRET_KEY ست نشده باشه،
settings.py باید fail-closed بشه (خطای صریح موقعِ import)، نه اینکه با
یک fallback شناخته‌شده بالا بیاد. برای DEBUG=True، fallbackِ محلیِ قبلی
باید دست‌نخورده بمونه.
مثل SecretDefaultsFailClosedTests در payment/tests.py، چون SECRET_KEY
فقط یک‌بار موقعِ import محاسبه می‌شه (نه چیزی که override_settings
بتونه toggle کنه)، ماژولِ config.settings رو با متغیرهای محیطیِ
کنترل‌شده reload می‌کنیم.
"""
allow_database_queries = True
def _reload_settings_with_env(self, env_overrides, env_removed=()):
from config import settings as settings_module
clean_env = {k: v for k, v in os.environ.items() if k not in env_removed}
clean_env.update(env_overrides)
with patch.dict(os.environ, clean_env, clear=True):
importlib.reload(settings_module)
return settings_module
def tearDown(self):
# همیشه ماژول رو به حالتِ واقعیِ محیطِ تست برمی‌گردونیم تا روی
# بقیه‌ی تست‌ها اثر نذاره.
from config import settings as settings_module
importlib.reload(settings_module)
def test_production_missing_secret_key_fails_closed(self):
with self.assertRaises(ImproperlyConfigured):
self._reload_settings_with_env(
env_overrides={'DJANGO_DEBUG': ''},
env_removed={'DJANGO_SECRET_KEY'},
)
def test_production_configured_secret_key_used(self):
# ⚠️ اصلاح (باگِ خودِ تست، نه اپلیکیشن): این تست فقط رفتارِ
# SECRET_KEY رو می‌سنجه، اما چون CACHE_URL رو ست نمی‌کرد، وقتی
# فیکسِ جداگانه‌ی CACHE_URL fail-closed (پایین‌ترِ همین فایل، نگاه
# کنید به CacheUrlFailClosedTests) اضافه شد، همین import/reload با
# DEBUG=False و بدونِ CACHE_URL به‌جای رسیدن به assertion، با
# ImproperlyConfigured مربوط به CACHE_URL (نه SECRET_KEY) fail
# می‌شد — یک false negative روی چیزی که این تست اصلاً قرار نیست
# بسنجه. یک CACHE_URL معتبر هم ست می‌کنیم تا این تست فقط دقیقاً
# همون چیزی که اسمش می‌گه (SECRET_KEY) رو بسنجه.
settings_module = self._reload_settings_with_env(
env_overrides={
'DJANGO_DEBUG': '',
'DJANGO_SECRET_KEY': 'prod-secret-xyz',
'CACHE_URL': 'redis://localhost:6379/0',
},
)
self.assertFalse(settings_module.DEBUG)
self.assertEqual(settings_module.SECRET_KEY, 'prod-secret-xyz')
def test_debug_mode_missing_secret_key_uses_local_fallback(self):
settings_module = self._reload_settings_with_env(
env_overrides={'DJANGO_DEBUG': '1'},
env_removed={'DJANGO_SECRET_KEY'},
)
self.assertTrue(settings_module.DEBUG)
self.assertEqual(
settings_module.SECRET_KEY,
'django-insecure-jn!s*25lz^+bjm101tzmlz8jlj70i0qka++*h-(!0s^^vk-0ss',
)
class CacheUrlFailClosedTests(SimpleTestCase):
"""
🔒 تستِ متمرکز روی فیکسِ CACHE_URL: در پروداکشن (DEBUG=False)، اگه
CACHE_URL ست نشده باشه، settings.py باید fail-closed بشه (خطای صریح
موقعِ import)، نه fallback خاموش به LocMemCache (که بینِ worker/instance
ها مشترک نیست و throttle/rate-limit ها رو بی‌اثر می‌کنه).
"""
allow_database_queries = True
def _reload_settings_with_env(self, env_overrides, env_removed=()):
from config import settings as settings_module
clean_env = {k: v for k, v in os.environ.items() if k not in env_removed}
clean_env.update(env_overrides)
with patch.dict(os.environ, clean_env, clear=True):
importlib.reload(settings_module)
return settings_module
def tearDown(self):
from config import settings as settings_module
importlib.reload(settings_module)
def test_production_missing_cache_url_fails_closed(self):
with self.assertRaises(ImproperlyConfigured):
self._reload_settings_with_env(
env_overrides={'DJANGO_DEBUG': '', 'DJANGO_SECRET_KEY': 'prod-secret-xyz'},
env_removed={'CACHE_URL'},
)
def test_debug_mode_missing_cache_url_uses_locmem_fallback(self):
settings_module = self._reload_settings_with_env(
env_overrides={'DJANGO_DEBUG': '1'},
env_removed={'CACHE_URL'},
)
self.assertTrue(settings_module.DEBUG)
self.assertEqual(
settings_module.CACHES['default']['BACKEND'],
'django.core.cache.backends.locmem.LocMemCache',
)