give every developer a way into the API that does not depend on one person

With DEBUG=0 and Kavenegar unconfigured, POST /api/auth/otp/request returns a
hard 503, so the only way to sign in was for someone with admin access to hand-
create an OTPCode row. That made every frontend developer's login depend on one
person being available.

TEST_LOGIN_PHONES is the pattern real apps use for exactly this (Firebase Auth's
fictional numbers, Apple's App Store demo account): a short allowlist of
phone:code pairs that skip SMS delivery entirely. Unlike DEV_MASTER_OTP it is
not "any phone with one code" and it is not tied to DEBUG -- each number carries
its own code, and everything else takes the normal OTP path untouched.

The code never appears in an API response, so knowing the number is not enough
to log in, and the existing per-phone login attempt limit still applies, so the
code cannot be brute-forced. Malformed configuration raises ImproperlyConfigured
at startup rather than being skipped silently, and /api/health reports how many
test numbers are open (count only) so they cannot be forgotten on a server that
has real users.

The code is capped at six digits because LoginRequestSerializer caps otp at
max_length=6; a longer code would have been rejected before reaching the check
and looked like the feature was broken.

The guest path the frontend uses today is untouched, and leaving the variable
empty reproduces the previous behaviour exactly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-23 00:52:57 +03:30
parent 8fce3b9548
commit 5aa10eb7b7
7 changed files with 253 additions and 0 deletions

View File

@@ -117,6 +117,22 @@ class RequestOTPView(APIView):
serializer.is_valid(raise_exception=True)
phone = serializer.validated_data['phone']
# شماره‌ی تستِ تیم (TEST_LOGIN_PHONES): کدش ثابت و از قبل معلومه، پس نه
# ردیفِ OTP تازه‌ای لازمه نه پیامکی. cooldown و سقفِ ساعتی هم رد می‌شن —
# اون‌ها برای محافظت از گیرنده‌ی واقعیِ پیامک‌اند و اینجا پیامکی در کار
# نیست؛ نگه‌داشتنشون فقط توسعه‌دهنده رو ۲ دقیقه معطل می‌کرد.
# ⚠️ کد عمداً در پاسخ برنمی‌گرده (برخلافِ devOtp در حالتِ DEBUG): این
# مسیر روی سرورِ عمومی هم فعاله، و برگردوندنش یعنی هر کسی که شماره رو
# بداند می‌تواند وارد شود. سقفِ تلاشِ ورود در LoginView سرِ جاشه.
if phone in settings.TEST_LOGIN_PHONES:
logger.info('OTP request for configured test phone: phone_suffix=%s', phone[-4:])
data = {
'success': True,
'devOtp': '',
'note': 'این شماره‌ی تستِ تیم است؛ کدِ ثابتش در تنظیماتِ سرور تعریف شده و پیامکی ارسال نمی‌شود.',
}
return Response(RequestOTPResponseSerializer(data).data)
# حداقل فاصله‌ی زمانی بین دو درخواستِ همون شماره (burst protection)؛
# قبل از شمارنده‌ی ساعتی چک می‌شه تا یک درخواستِ بلاک‌شده سهمیه‌ی
# ساعتی رو مصرف نکنه. کلید مقدارِ epoch انقضا رو ذخیره می‌کنه تا
@@ -249,8 +265,15 @@ class LoginView(APIView):
# قفل ردیف OTP جلوی مصرف دوباره‌ی یک کد یک‌بارمصرف را در درخواست‌های هم‌زمان می‌گیرد.
with transaction.atomic():
valid = False
test_code = settings.TEST_LOGIN_PHONES.get(phone, '')
if settings.DEV_MASTER_OTP is not None and otp == settings.DEV_MASTER_OTP:
valid = True
elif test_code and secrets.compare_digest(test_code, otp):
# شماره‌ی تستِ تیم. compare_digest تا نتیجه‌ی مقایسه از روی زمانِ
# پاسخ قابلِ استنتاج نباشه. سقفِ تلاشِ همین شماره بالاتر چک شده،
# پس حدسِ کد با brute-force بسته‌ست.
logger.info('Login via configured test phone: phone_suffix=%s', phone[-4:])
valid = True
else:
otp_obj = OTPCode.objects.select_for_update().filter(
phone=phone, code=otp, is_used=False, expires_at__gte=timezone.now()