Harden auth, AI tutor, payments, and subscriptions
This commit is contained in:
@@ -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
118
config/tests.py
Normal 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',
|
||||
)
|
||||
Reference in New Issue
Block a user