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

@@ -186,6 +186,8 @@ class RequestOTPView(APIView):
# محدودیتِ صدورِ OTP بر اساسِ شماره، مستقل از throttleِ IP-محورِ بالا
# (وگرنه یه مهاجم می‌تونه بینِ چند IP پخش کنه). cache.add فقط وقتی
# کلید نیست مقدار می‌ذاره، پس اتمیکه.
# شماره پیش از اینجا به 09XXXXXXXXX نرمال شده (accounts/phone.py)، پس
# املای دیگرِ همان شماره سهمِ جدا نمی‌گیرد.
phone_requests_key = f'otp_request_phone_attempts:{phone}'
if cache.add(phone_requests_key, 1, timeout=settings.OTP_REQUEST_PHONE_WINDOW_SECONDS):
attempt_count = 1
@@ -207,25 +209,62 @@ class RequestOTPView(APIView):
status=status.HTTP_429_TOO_MANY_REQUESTS,
)
# فقط آخرین کد صادرشده معتبر است.
OTPCode.objects.filter(phone=phone, is_used=False).update(is_used=True)
# ۶ رقم: با ۴ رقم و محدودیتِ تلاشِ per-worker، گرفتنِ حسابِ یک نفرِ مشخص
# روزی چند درصد شانس داشت. فرانت تا ۶ رقم را می‌پذیرد.
code = f'{secrets.randbelow(1_000_000):06d}'
OTPCode.objects.create(
phone=phone,
code=code,
expires_at=timezone.now() + timezone.timedelta(minutes=5),
)
# سقف‌های دیتابیسی: cooldown و سقفِ ساعتیِ بالا در کشِ LocMem و مالِ هر
# worker‌اند (روی سرور ×۳). این‌ها از ردیف‌های OTPCode شمرده می‌شوند و بینِ
# workerها مشترک‌اند. زمانِ صدور = expires_at − OTP_TTL_SECONDS (مدل
# created_at ندارد). بررسی و ساختِ ردیف در یک تراکنش‌اند؛ با
# transaction_mode=IMMEDIATE دو درخواستِ هم‌زمان پشتِ هم اجرا می‌شوند
# (هم‌زمانیِ بینِ دو workerِ واقعی با تست پوشش داده نشده).
# فراخوانیِ کاوه‌نگار عمداً بیرونِ تراکنش است تا قفلِ نوشتن حینِ شبکه نماند.
now = timezone.now()
ttl = timezone.timedelta(seconds=settings.OTP_TTL_SECONDS)
day_window_expiry = now - timezone.timedelta(days=1) + ttl
with transaction.atomic():
phone_codes = OTPCode.objects.filter(phone=phone)
last_expiry = phone_codes.order_by('-expires_at').values_list('expires_at', flat=True).first()
if cooldown_seconds and last_expiry is not None:
elapsed = (now - (last_expiry - ttl)).total_seconds()
if elapsed < cooldown_seconds:
retry_after = max(1, math.ceil(cooldown_seconds - elapsed))
return Response(
{
'detail': f'لطفاً {retry_after} ثانیه صبر کنید و دوباره درخواست کد بدهید.',
'retryAfter': retry_after,
},
status=status.HTTP_429_TOO_MANY_REQUESTS,
headers={'Retry-After': str(retry_after)},
)
if phone_codes.filter(expires_at__gt=day_window_expiry).count() >= settings.OTP_REQUEST_PHONE_DAILY_MAX:
logger.warning('OTP request phone daily limit exceeded: phone_suffix=%s', phone[-4:])
return Response(
{'detail': 'سقفِ روزانه‌ی درخواستِ کد برای این شماره پر شده است؛ فردا دوباره تلاش کنید.'},
status=status.HTTP_429_TOO_MANY_REQUESTS,
)
if OTPCode.objects.filter(expires_at__gt=day_window_expiry).count() >= settings.OTP_SMS_GLOBAL_DAILY_MAX:
# یا حمله‌ی SMS pumping است یا رشدِ واقعی؛ هر دو یعنی کسی باید نگاه کند.
logger.error(
'OTP global daily SMS cap reached: cap=%s phone_suffix=%s',
settings.OTP_SMS_GLOBAL_DAILY_MAX, phone[-4:],
)
return Response(
{'detail': 'ارسالِ پیامک موقتاً ممکن نیست؛ لطفاً کمی بعد دوباره تلاش کنید.'},
status=status.HTTP_503_SERVICE_UNAVAILABLE,
)
# فقط آخرین کد صادرشده معتبر است.
OTPCode.objects.filter(phone=phone, is_used=False).update(is_used=True)
# ۶ رقم: با ۴ رقم و محدودیتِ تلاشِ per-worker، گرفتنِ حسابِ یک نفرِ مشخص
# روزی چند درصد شانس داشت. فرانت تا ۶ رقم را می‌پذیرد.
code = f'{secrets.randbelow(1_000_000):06d}'
otp_row = OTPCode.objects.create(phone=phone, code=code, expires_at=now + ttl)
try:
send_otp_sms(phone=phone, code=code)
except OTPDeliveryError:
# ارسال شکست خورده یعنی کاربر کدی نگرفته؛ cooldown رو پاک می‌کنیم
# تا مجبور نشه ۲ دقیقه بدونِ داشتنِ کد منتظر بمونه. سقفِ ساعتی/
# throttleِ IP دست‌نخورده می‌مونن (محدودیتِ حجم، نه این cooldown).
# تا مجبور نشه ۲ دقیقه بدونِ داشتنِ کد منتظر بمونه، و ردیفِ کد رو هم
# حذف می‌کنیم تا در cooldown و سقفِ روزانه‌ی دیتابیسی شمرده نشه.
# سقفِ ساعتیِ کش و throttleِ IP دست‌نخورده می‌مونن (محدودیتِ حجم).
cache.delete(phone_cooldown_key)
otp_row.delete()
raise
# devOtp فقط در محیط dev برمی‌گردد؛ در پروداکشن کد فقط از طریق پیامک تحویل داده می‌شود.
@@ -380,9 +419,13 @@ class LogoutView(APIView):
"""
POST /api/auth/logout — خروج از حساب.
refreshTokenِ این دستگاه (اگر فرستاده شود) باطل می‌شود و همه‌ی access
tokenهای صادرشده تا این لحظه رد می‌شوند. دستگاه‌های دیگر با refreshِ
refreshTokenِ این دستگاه (اگر فرستاده شود) باطل می‌شود و access tokenهای
صادرشده *پیش از ثانیه‌ی خروج* رد می‌شوند. دستگاه‌های دیگر با refreshِ
خودشان بی‌صدا توکنِ تازه می‌گیرند؛ فقط همین دستگاه بیرون می‌ماند.
⚠️ مقایسه ثانیه‌ای است: توکنی که در همان ثانیه‌ی خروج صادر شده معتبر
می‌ماند (بازتولید شد). با AUTH_USER_CACHE_TTL>0 و کشِ per-worker، کشِ
کاربر فقط در همین worker پاک می‌شود؛ پیش‌فرض ۰ است و آن حالت تست نشده.
"""
permission_classes = [IsAuthenticated]