harden OTP for real SMS and close the second audit round

Kavenegar is being switched on, so every OTP request now costs credit:
- phone numbers are normalised to 09XXXXXXXXX everywhere (login, OTP,
  family invite, TEST_LOGIN_PHONES keys) and existing rows are migrated;
  non-Iranian and landline numbers are rejected before any SMS
- cooldown and a daily per-phone cap are counted in the database, so the
  three gunicorn workers no longer triple them
- a global daily OTP SMS cap (default 300) stops SMS pumping
- per-IP rates widened for CGNAT / school Wi-Fi (env-overridable)
- provider message id is logged; SMS text warns not to share the code

Second audit round: an offer no longer shortens a longer paid plan,
Zarinpal error bodies no longer 500, island XP reaches the parent report,
a non-numeric questionId no longer 500s after the answer is generated.

596 tests green.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-25 12:28:06 +03:30
parent 74aa9aa8ca
commit 0580aec7b0
24 changed files with 685 additions and 119 deletions

View File

@@ -36,6 +36,8 @@
| ۱۴ | SQLite روی دیسکِ مشترکِ پُر، بدونِ بکاپِ خودکارِ بیرون از ماشین | کم (داده) | زیرساخت |
| ۱۵ | دسترسیِ سرور: حسابِ مشترک، رمز، sudoِ کامل | بالا | ادمینِ سرور |
| ۱۶ | فرانت توکن را در `localStorage` نگه می‌دارد | کم | فرانت |
| ۱۷ | ~~شماره‌ی موبایل نرمال نمی‌شود؛ سقف‌های per-phone با املای دیگر دور زده می‌شوند~~ **بسته شد ۲۰۲۶-۰۹-۲۵** | متوسط | کد |
| ۱۸ | `/api/payment/orders/{id}/pay` بدونِ ورود و بدونِ سنجشِ مالکیت | کم | کد |
---
@@ -206,6 +208,43 @@
- **خطر:** هر XSS در سایت هر دو را می‌خواند.
- **رفع:** CSPِ سخت‌گیرانه روی `math.tadrisino2.ir`؛ در بلندمدت refresh در کوکیِ `HttpOnly`.
## ۱۷. شماره‌ی موبایل نرمال نمی‌شود (افزوده در بازبینیِ دومِ ۲۰۲۶-۰۹-۲۵)
- **چیست:** `phone` در ورود، درخواستِ OTP و دعوتِ خانواده رشته‌ی خام است
(`accounts/serializers.py`، `max_length=15` بدونِ اعتبارسنجی). `09121234567`،
`+989121234567`، `989121234567` و همان شماره با ارقامِ فارسی چهار «شماره»ی جدا‌اند.
- **خطر:** cooldownِ ۲ دقیقه‌ای و سقفِ ۵ در ساعتِ هر شماره به‌ازای هر املا جداست، پس با
چند IP می‌شود یک گوشی را با پیامک بمباران کرد (وقتی کاوه‌نگار وصل شود، به هزینه‌ی ما)؛
یک آدم با دو املا دو حسابِ جدا می‌سازد؛ دعوتِ والد با املای دیگر ۴۰۳ می‌گیرد.
(بازتولید شد با تستِ موقت؛ پیامکِ واقعی فرستاده نشد.)
- **رفع:** یک تابعِ `normalize_phone` (ارقامِ فارسی/عربی → لاتین، `+98`/`0098`/`98` → `0`،
فقط `09\d{9}`) در هر سه serializer، و migrationِ داده برای ردیف‌های موجود.
- **چطور بسنجید:** دو درخواستِ OTP پشتِ‌سرِهم با `0912…` و `+98912…` باید دومی ۴۲۹ بگیرد.
- **✅ بسته شد ۲۰۲۶-۰۹-۲۵** (همان روزی که کلیدِ کاوه‌نگار روی سرور رفت؛ کامیت: _پس از کامیت
اینجا بنویسید_). `accounts/phone.py::normalize_phone` در ورود، درخواستِ OTP، دعوتِ
خانواده و کلیدهای `TEST_LOGIN_PHONES`؛ migrationِ `accounts.0005` شماره‌های موجود را
نرمال می‌کند (ردیفی که شکلِ نرمالش مالِ کاربرِ دیگری است دست نمی‌خورد و فقط شمرده می‌شود).
`parentPhone` در پروفایل هنوز نرمال **نمی‌شود**. همراهش، چون از این تاریخ پیامکِ واقعی
خرجِ اعتبار است:
- cooldownِ ۲ دقیقه‌ای و سقفِ روزانه‌ی هر شماره (`OTP_REQUEST_PHONE_DAILY_MAX=10`)
حالا در **دیتابیس** هم شمرده می‌شوند، پس روی ۳ workerِ سرور سه‌برابر نمی‌شوند (بندِ ۹
برای این دو دیگر صدق نمی‌کند؛ برای throttleهای IP هنوز می‌کند).
- سقفِ روزانه‌ی کلِ پیامکِ OTP (`OTP_SMS_GLOBAL_DAILY_MAX=300`) جلوی SMS pumping؛
رسیدن به آن ۵۰۳ و لاگِ ERROR می‌دهد. با رشدِ کاربرِ واقعی از `.env` بالا ببرید.
- سقف‌های per-IP گشاده‌تر شدند (کد ۲۰، ورود ۳۰، مهمان ۶۰ در ساعت، همه از `.env` قابلِ
تغییر) چون پشتِ CGNAT و وای‌فایِ مدرسه ده‌ها نفر یک IP دارند.
- تست‌ها: `accounts/tests_otp_limits.py`. هم‌زمانیِ واقعیِ دو پروسه‌ی gunicorn تست نشده.
## ۱۸. صفحه‌ی پرداخت مالکیت را نمی‌سنجد (افزوده در بازبینیِ دومِ ۲۰۲۶-۰۹-۲۵)
- **چیست:** `PaymentRedirectView` (`AllowAny`) برای هر شناسه‌ی سفارشِ ترتیبی از زرین‌پال
authority می‌گیرد.
- **خطر:** غریبه با پیمایشِ `/orders/1/pay`، `/orders/2/pay`… روی سفارش‌های باز authority
می‌نشاند؛ از آن به بعد صاحبِ سفارش «کدِ تخفیف را پیش از درگاه وارد کنید» می‌گیرد
(بازتولید شد، با زرین‌پالِ mock). وضعیتِ paid/failedِ سفارشِ دیگران هم از ریدایرکت پیداست.
- **رفع:** در `paymentUrl` یک توکنِ امضاشده‌ی کوتاه‌عمر (`signing`) با `order_id` بگذارید و
همان‌جا بسنجید؛ یا شناسه‌ی غیرترتیبی.
---
## چیزهایی که بسته شده‌اند (برای اینکه دوباره باز نشوند)