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

@@ -2,6 +2,8 @@ import re
from rest_framework import serializers
from .phone import normalize_phone
# لینک، آیدیِ شبکه‌ی اجتماعی، و هر رشته‌ی ۵ رقمی یا بیشتر (شماره‌ی تلفن با
# فاصله/خط‌تیره هم) — ارقامِ فارسی و عربی هم.
_CONTACT = re.compile(
@@ -15,6 +17,20 @@ def clean_display_name(value: str) -> str:
return ' '.join(_CONTACT.sub(' ', value or '').split())[:30]
class IranMobileField(serializers.CharField):
"""CharField که خروجی‌اش همیشه ‎09XXXXXXXXX‎ است."""
def __init__(self, **kwargs):
kwargs.setdefault('max_length', 20)
super().__init__(**kwargs)
def to_internal_value(self, data):
phone = normalize_phone(super().to_internal_value(data))
if phone is None:
raise serializers.ValidationError('شماره‌ی موبایل معتبر نیست؛ مثلاً 09121234567.')
return phone
# ---- Request serializers ----
class GuestSessionRequestSerializer(serializers.Serializer):
@@ -23,7 +39,7 @@ class GuestSessionRequestSerializer(serializers.Serializer):
class LoginRequestSerializer(serializers.Serializer):
phone = serializers.CharField(max_length=15)
phone = IranMobileField()
otp = serializers.CharField(max_length=6)
@@ -91,7 +107,7 @@ class UpdateProfileRequestSerializer(serializers.Serializer):
class RequestOTPRequestSerializer(serializers.Serializer):
"""اپ کمکی برای گرفتن OTP (در سند اصلی نبود، برای تست‌پذیری Swagger اضافه شد)."""
phone = serializers.CharField(max_length=15)
phone = IranMobileField()
# ---- Response serializers ----