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:
@@ -1560,3 +1560,110 @@ class UpdateUserProfileTests(TestCase):
|
||||
|
||||
def test_out_of_range_birth_year_is_rejected(self):
|
||||
self.assertEqual(self._patch({'birthYear': 2026}).status_code, 400)
|
||||
|
||||
|
||||
class TestLoginPhoneTests(TestCase):
|
||||
"""
|
||||
🔒 شمارههای تستِ تیم (TEST_LOGIN_PHONES): باید ورودِ بدونِ پیامک را برای
|
||||
همان چند شماره ممکن کنند و برای هیچ شمارهی دیگری نه. این تستها با
|
||||
DEBUG=False اجرا میشوند چون دقیقاً همان حالتی است که روی سرور هست و
|
||||
DEV_MASTER_OTP در آن خاموش است.
|
||||
"""
|
||||
|
||||
PHONE = '09120000001'
|
||||
CODE = '834512'
|
||||
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
cache.clear()
|
||||
|
||||
@override_settings(DEBUG=False, DEV_MASTER_OTP=None,
|
||||
TEST_LOGIN_PHONES={PHONE: CODE})
|
||||
def test_configured_phone_logs_in_without_sms(self):
|
||||
with patch('accounts.views.send_otp_sms') as mock_send:
|
||||
request = self.client.post(
|
||||
'/api/auth/otp/request', {'phone': self.PHONE}, format='json',
|
||||
)
|
||||
self.assertEqual(request.status_code, 200)
|
||||
# مهمترین نکتهی این مسیر: هیچ پیامکی نباید فرستاده شود، وگرنه بدونِ
|
||||
# کاوهنگار همینجا ۵۰۳ میگرفتیم و کل هدف از بین میرفت.
|
||||
mock_send.assert_not_called()
|
||||
self.assertEqual(request.data['devOtp'], '')
|
||||
|
||||
login = self.client.post(
|
||||
'/api/auth/login', {'phone': self.PHONE, 'otp': self.CODE}, format='json',
|
||||
)
|
||||
self.assertEqual(login.status_code, 200)
|
||||
self.assertTrue(login.data['token'])
|
||||
|
||||
@override_settings(DEBUG=False, DEV_MASTER_OTP=None,
|
||||
TEST_LOGIN_PHONES={PHONE: CODE})
|
||||
def test_code_is_never_returned_in_the_response(self):
|
||||
response = self.client.post(
|
||||
'/api/auth/otp/request', {'phone': self.PHONE}, format='json',
|
||||
)
|
||||
self.assertNotIn(self.CODE, str(response.data))
|
||||
|
||||
@override_settings(DEBUG=False, DEV_MASTER_OTP=None,
|
||||
TEST_LOGIN_PHONES={PHONE: CODE})
|
||||
def test_wrong_code_for_a_configured_phone_is_rejected(self):
|
||||
response = self.client.post(
|
||||
'/api/auth/login', {'phone': self.PHONE, 'otp': '000000'}, format='json',
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
@override_settings(DEBUG=False, DEV_MASTER_OTP=None,
|
||||
TEST_LOGIN_PHONES={PHONE: CODE})
|
||||
def test_the_code_does_not_work_for_any_other_phone(self):
|
||||
"""تفاوتِ اصلی با DEV_MASTER_OTP: کد فقط مالِ همان یک شماره است."""
|
||||
response = self.client.post(
|
||||
'/api/auth/login', {'phone': '09129999999', 'otp': self.CODE}, format='json',
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
@override_settings(DEBUG=False, DEV_MASTER_OTP=None, TEST_LOGIN_PHONES={})
|
||||
def test_empty_configuration_changes_nothing(self):
|
||||
"""بدونِ تنظیم، مسیرِ OTP دقیقاً همان رفتارِ قبلی را دارد."""
|
||||
with patch('accounts.views.send_otp_sms') as mock_send:
|
||||
response = self.client.post(
|
||||
'/api/auth/otp/request', {'phone': '09121112233'}, format='json',
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
mock_send.assert_called_once()
|
||||
|
||||
@override_settings(DEBUG=False, DEV_MASTER_OTP=None,
|
||||
TEST_LOGIN_PHONES={PHONE: CODE},
|
||||
OTP_LOGIN_PHONE_MAX_ATTEMPTS=3)
|
||||
def test_brute_force_on_a_configured_phone_is_still_rate_limited(self):
|
||||
for _ in range(3):
|
||||
self.client.post(
|
||||
'/api/auth/login', {'phone': self.PHONE, 'otp': '111111'}, format='json',
|
||||
)
|
||||
blocked = self.client.post(
|
||||
'/api/auth/login', {'phone': self.PHONE, 'otp': '111111'}, format='json',
|
||||
)
|
||||
self.assertEqual(blocked.status_code, 429)
|
||||
|
||||
|
||||
class TestLoginPhoneParsingTests(TestCase):
|
||||
"""ورودیِ خرابِ TEST_LOGIN_PHONES باید سرویس را بالا نیاورد، نه اینکه بیصدا رد شود."""
|
||||
|
||||
def _parse(self, raw):
|
||||
from config.settings import _parse_test_login_phones
|
||||
return _parse_test_login_phones(raw)
|
||||
|
||||
def test_valid_pairs_are_parsed(self):
|
||||
self.assertEqual(
|
||||
self._parse(' 09120000001:834512 , +989120000002:110947 '),
|
||||
{'09120000001': '834512', '+989120000002': '110947'},
|
||||
)
|
||||
|
||||
def test_empty_value_yields_no_test_phones(self):
|
||||
self.assertEqual(self._parse(''), {})
|
||||
|
||||
def test_malformed_entry_raises(self):
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
for raw in ('09120000001', '09120000001:12', 'abc:123456',
|
||||
'09120000001:abcdef', '09120000001:1234567'):
|
||||
with self.subTest(raw=raw), self.assertRaises(ImproperlyConfigured):
|
||||
self._parse(raw)
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user