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

@@ -0,0 +1,40 @@
"""
شماره‌های موجود را به همان شکلِ 09XXXXXXXXX می‌برد که serializerها حالا می‌سازند
(accounts/phone.py)؛ وگرنه کسی که با ‎+98…‎ ثبت‌نام کرده بود دیگر به حسابِ خودش
نمی‌رسید و حسابِ تازه می‌گرفت.
ردیفی که شکلِ نرمالش از قبل مالِ کاربرِ دیگری است دست نمی‌خورد (unique است و
ادغامِ دو حساب تصمیمِ انسانی است)؛ ردیفی هم که موبایلِ ایران نیست همان‌طور می‌ماند.
هر دو فقط شمرده و چاپ می‌شوند. برگشت‌ناپذیر است چون شکلِ قبلی ذخیره نمی‌شود.
"""
from django.db import migrations
def normalize(apps, schema_editor):
from accounts.phone import normalize_phone
User = apps.get_model('accounts', 'User')
changed = skipped_taken = skipped_invalid = 0
for user in User.objects.exclude(phone__isnull=True).exclude(phone=''):
target = normalize_phone(user.phone)
if target is None:
skipped_invalid += 1
elif target != user.phone:
if User.objects.filter(phone=target).exists():
skipped_taken += 1
else:
User.objects.filter(pk=user.pk).update(phone=target)
changed += 1
if changed or skipped_taken or skipped_invalid:
print(f'\n normalize phones: changed={changed} taken={skipped_taken} invalid={skipped_invalid}')
class Migration(migrations.Migration):
dependencies = [
('accounts', '0004_otpcode_failed_attempts_user_tokens_valid_after'),
]
operations = [
migrations.RunPython(normalize, migrations.RunPython.noop),
]