throttling, abuse-protection & logging/monitoring pass

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

View File

@@ -90,6 +90,11 @@ MIDDLEWARE = [
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
# ⚠️ افزوده شده: لاگِ سبکِ هر درخواستِ API (متد/مسیر/وضعیت/مدت/کاربر)؛
# عمداً آخرِ لیست قرار گرفته تا نزدیک‌ترین میان‌افزار به خودِ view باشه
# (مدت‌زمانِ دقیق‌تر، و request.user که توسطِ DRF authentication در
# حینِ پردازشِ view ست می‌شه، در دسترس باشه).
'config.middleware.APIRequestLoggingMiddleware',
]
ROOT_URLCONF = 'config.urls'
@@ -208,10 +213,44 @@ REST_FRAMEWORK = {
'DEFAULT_THROTTLE_CLASSES': [
'rest_framework.throttling.ScopedRateThrottle',
],
# ⚠️ اضافه شده (امنیتی): بدونِ NUM_PROXIES، DRF (SimpleRateThrottle.get_ident)
# هدرِ X-Forwarded-For رو - اگه در درخواست وجود داشته باشه - عیناً و
# بدون‌چک به‌عنوانِ IP کلاینت قبول می‌کنه؛ یعنی هر کلاینتِ ناشناس می‌تونست
# با فرستادنِ یک X-Forwarded-For دلخواه/تصادفی در هر درخواست، برای
# هر GuestAware*Throttle (content/ai_tutor/subscription/payment/discount/
# support/family/affiliate) یک IP-bucket تازه بگیره - دقیقاً همون
# bypassِ چرخشِ حساب مهمان که این throttle-classها قرار بود ببندن، فقط
# از طریقِ هدر به‌جای deviceId جدید.
# با NUM_PROXIES=1 (پیش‌فرض؛ برای دیپلویِ پشتِ یک لایه‌ی پراکسی/LB که
# خودش X-Forwarded-For رو ست/rewrite می‌کنه، نه پاس‌دادنِ مقدارِ
# کلاینت)، DRF از انتهای زنجیره‌ی XFF دقیقاً همون تعداد hop رو
# می‌خونه. اگه دیپلویِ پروداکشن تعداد لایه‌ی پراکسیِ دیگه‌ای داره
# (مثلاً Cloudflare + Nginx = ۲ لایه)، این مقدار باید با env
# NUM_PROXIES مطابقِ همون تعداد ست بشه، وگرنه یا IP واقعی اشتباه
# خونده می‌شه یا (اگه صفر باشه) دوباره همون مشکلِ بالا برمی‌گرده.
'NUM_PROXIES': int(os.environ.get('NUM_PROXIES', '1')),
'DEFAULT_THROTTLE_RATES': {
'otp_request': '5/hour',
'otp_login': '10/hour',
'guest_session': '20/hour',
'bazaar_verify': '10/hour',
'ai_ask': '20/hour',
'purchase': '10/hour',
'token_refresh': '30/hour',
'question_answer': '60/min',
'lesson_complete': '10/hour',
'support_create': '30/hour',
'offer_redeem': '10/hour',
'discount_validate': '20/hour',
'discount_redeem': '10/hour',
'family_invite_accept': '10/hour',
'family_invite_create': '5/hour',
# ⚠️ اضافه شده: چهار endpointی که در ممیزیِ throttleِ امروز بدونِ
# throttle_scope پیدا شدن (نگاه کنید به توضیحاتِ throttle_scope
# مربوطه در accounts/views.py، affiliate/views.py، payment/views.py).
'user_profile_write': '60/hour',
'affiliate_track': '10/hour',
'payment_webhook': '60/hour',
},
}
@@ -243,6 +282,23 @@ REFRESH_TOKEN_TTL_DAYS = 30
OTP_LOGIN_PHONE_MAX_ATTEMPTS = 10
OTP_LOGIN_PHONE_WINDOW_SECONDS = 3600
# محدودیتِ صدورِ/ارسالِ دوبارهِ OTP بر اساسِ شماره‌تلفن، مستقل از throttle
# IP-محورِ otp_request؛ جلوی flood کردنِ پیامک به یک شماره از طریق چند IP
# مختلف رو می‌گیره (فقط تعدادِ درخواست رو محدود می‌کنه، نه شماره‌ی IP رو).
OTP_REQUEST_PHONE_MAX_ATTEMPTS = 5
OTP_REQUEST_PHONE_WINDOW_SECONDS = 3600
# حداقل فاصله‌ی زمانی بین دو درخواستِ صدورِ OTP برای همون شماره. این مستقل
# از سقفِ ساعتیِ بالاست: سقفِ ساعتی جلویِ حجمِ کلی رو می‌گیره اما جلویِ
# burst رو نمی‌گیره (مثلاً هر ۵ تلاشِ مجاز در عرضِ کمتر از یک ثانیه). این
# cooldown دقیقاً همون burst رو هدف می‌گیره؛ نه جایگزینِ سقفِ ساعتیه، نه
# باهاش redundant — هر کدوم یک بُعدِ متفاوتِ سوءاستفاده رو می‌بندن.
# ⚠️ تصمیمِ محصول/امنیت: کاربر واقعاً باید هر ۲ دقیقه بتونه دوباره OTP
# بگیره؛ enforcement سمتِ سرور هم باید دقیقاً همین مقدار باشه — countdown
# سمتِ فرانت صرفاً UX هست و نباید مرجعِ امنیتی باشه، پس این مقدار همیشه
# باید با چیزی که فرانت به کاربر نشون می‌ده یکی باشه.
OTP_REQUEST_PHONE_COOLDOWN_SECONDS = 120
# در محیط dev، این کد OTP همیشه معتبر است تا بدون SMS واقعی تست شود.
# فقط وقتی DEBUG=True مقدار دارد؛ در پروداکشن None است.
DEV_MASTER_OTP = '0000' if DEBUG else None
@@ -307,3 +363,59 @@ BAZAAR_API_BASE = os.environ.get('BAZAAR_API_BASE', 'https://pardakht.cafebazaar
# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
# -----------------------------------------------------------------------
# Logging (آماده‌سازیِ لاگ‌های موجود برای جمع‌آوریِ متمرکز — Loki/ELK-style)
# -----------------------------------------------------------------------
# ⚠️ افزوده شده: پروژه از قبل چندین logger (config.middleware،
# accounts.services، ai_tutor.views، ...) با logging.getLogger(__name__)
# داشت، اما بدونِ یک LOGGING صریح در settings.py، اینا فقط به rootِ
# پیش‌فرضِ کتابخانه‌ی logging می‌رسیدن (که برای سطحِ INFO عملاً هیچ خروجی‌ای
# تولید نمی‌کنه). این تنظیم، بدونِ اضافه/حذفِ هیچ loggerِ اپلیکیشنی یا
# eventِ لاگی، همون پیام‌های موجود رو روی stdout و به‌صورتِ یک JSON در هر
# خط (فرمتِ سازگار با agentِ جمع‌آوریِ بیرونی مثلِ Promtail/Filebeat که
# بعداً می‌تونه به Loki/ELK بفرسته) چاپ می‌کنه. هیچ appِ جدید، ذخیره‌سازیِ
# دیتابیسی، API/UIِ لاگ، یا وابستگیِ Elasticsearch/Loki/Grafana به خودِ
# کدِ Django اضافه نشده.
LOGGING = {
'version': 1,
'disable_existing_loggers': False,
'formatters': {
'json': {
'()': 'config.log_formatters.JSONLogFormatter',
},
},
'handlers': {
'console': {
'class': 'logging.StreamHandler',
'formatter': 'json',
},
},
'root': {
'handlers': ['console'],
'level': 'INFO',
},
}
# ⚠️ افزوده شده (یکپارچه‌سازیِ Docker + Promtail/Loki/Grafana): وقتی
# env var LOG_DIR ست شده باشه (فقط داخلِ docker-compose.yml، روی یک
# ولومِ مشترک با کانتینرِ promtail)، همون خطوطِ JSON که از قبل روی
# stdout چاپ می‌شدن، *علاوه* بر stdout در یک فایل هم نوشته می‌شن؛
# Promtail همون فایل رو tail می‌کنه و به Loki می‌فرسته.
# این فقط یک هندلرِ خروجیِ اضافه با همون formatter (json) هست — نه
# پیام‌های لاگ، نه محافظت‌های حساسیتیِ موجود (بدونِ OTP/token/رمزعبور و
# غیره در پیام‌ها)، و نه هیچ loggerِ اپلیکیشنی تغییر نمی‌کنه.
# وقتی LOG_DIR ست نباشه (یعنی اجرای عادیِ خارج از Docker، طبق README)،
# رفتار دقیقاً همون چیزیه که از قبل بود: فقط stdout.
LOG_DIR = os.environ.get('LOG_DIR', '')
if LOG_DIR:
os.makedirs(LOG_DIR, exist_ok=True)
LOGGING['handlers']['file'] = {
'class': 'logging.handlers.RotatingFileHandler',
'filename': os.path.join(LOG_DIR, 'app.log'),
'maxBytes': 10 * 1024 * 1024, # 10MB در هر فایل، قبل از rotate
'backupCount': 5,
'formatter': 'json',
}
LOGGING['root']['handlers'].append('file')