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:
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user