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

@@ -57,6 +57,22 @@ CACHE_URL=
# طبق رفتارِ موجودِ accounts/services.py) # طبق رفتارِ موجودِ accounts/services.py)
KAVENEGAR_API_KEY= KAVENEGAR_API_KEY=
# شماره‌های تستِ تیم — «شماره:کد» با کامای جداکننده.
#
# بدونِ این، روی سروری که DEBUG=0 است و کاوه‌نگار ندارد، هیچ توسعه‌دهنده‌ای
# نمی‌تواند وارد API شود مگر اینکه کسی دستی از پنلِ ادمین یک OTPCode بسازد.
# با این، هر عضوِ تیم شماره و کدِ خودش را دارد و به کسی وابسته نیست.
#
# برای این شماره‌ها پیامکی ارسال نمی‌شود و کدشان هیچ‌وقت در پاسخِ API
# برنمی‌گردد. هر شماره‌ی دیگری مسیرِ کاملاً عادیِ OTP را می‌رود.
#
# کد را تصادفی بگذارید (نه 1234)، شماره را دقیقاً همان‌طور که کلاینت
# می‌فرستد بنویسید، و پیش از باز کردنِ سرویس روی کاربرانِ واقعی خالی‌اش کنید —
# /api/health تا وقتی پر باشد هشدار می‌دهد.
#
# TEST_LOGIN_PHONES=09120000001:834512,09120000002:110947
TEST_LOGIN_PHONES=
# رمزِ ادمینِ Grafana (کاربرِ admin) — فقط برای دسترسیِ محلی # رمزِ ادمینِ Grafana (کاربرِ admin) — فقط برای دسترسیِ محلی
GRAFANA_ADMIN_PASSWORD=admin GRAFANA_ADMIN_PASSWORD=admin

View File

@@ -199,6 +199,41 @@ python manage.py generate_questions_from_chunks --subject ریاضی --grade ن
می‌شوند. محتوای واقعی از مسیرِ جدولِ بالا می‌رود، نه از این دستور. می‌شوند. محتوای واقعی از مسیرِ جدولِ بالا می‌رود، نه از این دستور.
### ورودِ توسعه‌دهنده‌ها به سرور (بدونِ کاوه‌نگار)
تا وقتی `KAVENEGAR_API_KEY` ست نشده، `POST /api/auth/otp/request` خطای صریحِ
۵۰۳ می‌دهد و با `DEBUG=0` هیچ‌کس نمی‌تواند وارد API شود. برای اینکه ورودِ تیمِ
فرانت به یک نفرِ خاص وابسته نباشد، `TEST_LOGIN_PHONES` را در `.env` سرور پر
کنید:
```bash
TEST_LOGIN_PHONES=09120000001:834512,09120000002:110947
```
هر جفت یک حسابِ تست است: برای آن شماره پیامکی ارسال نمی‌شود و همان کدِ ثابت
همیشه معتبر است. هر شماره‌ی دیگری مسیرِ عادیِ OTP را می‌رود، بی‌هیچ تغییری.
```bash
curl -X POST https://api.math.tadrisino2.ir/api/auth/otp/request \
-H 'Content-Type: application/json' -d '{"phone":"09120000001"}'
curl -X POST https://api.math.tadrisino2.ir/api/auth/login \
-H 'Content-Type: application/json' \
-d '{"phone":"09120000001","otp":"834512"}'
```
چند نکته که عمدی‌اند:
- **این با `DEV_MASTER_OTP` یکی نیست.** آن کدِ `0000` هر شماره‌ای را قبول
می‌کند و فقط با `DEBUG=1` روشن می‌شود؛ این فقط همین چند شماره را، هرکدام با
کدِ خودش.
- **کد در پاسخِ API برنمی‌گردد.** کسی که شماره را بداند هم نمی‌تواند کد را از
سرور بیرون بکشد، و سقفِ تلاشِ ورودِ هر شماره روی این‌ها هم اعمال می‌شود.
- **`/api/health` تا وقتی این متغیر پر باشد هشدار می‌دهد** (تعداد، نه شماره و
نه کد) تا روی سروری که کاربرِ واقعی دارد فراموش نشود.
- **مسیرِ مهمان دست‌نخورده است.** فرانت که امروز با
`POST /api/auth/session` و `deviceId` کار می‌کند، هیچ تغییری نمی‌بیند.
- خالی گذاشتنِ این متغیر یعنی رفتارِ قبلی، مو‌به‌مو.
### متغیرهای محیطی موردنیاز ### متغیرهای محیطی موردنیاز
| متغیر | لازم در پروداکشن | توضیح | | متغیر | لازم در پروداکشن | توضیح |
|---|---|---| |---|---|---|
@@ -207,6 +242,7 @@ python manage.py generate_questions_from_chunks --subject ریاضی --grade ن
| `DJANGO_ALLOWED_HOSTS` | **بله** | لیستِ host ها، کاما-جدا | | `DJANGO_ALLOWED_HOSTS` | **بله** | لیستِ host ها، کاما-جدا |
| `CACHE_URL` | **بله** | آدرسِ Redis، مثلاً `redis://host:6379/0` — برای rate limitِ مشترک وقتی `DEBUG=False` لازمه | | `CACHE_URL` | **بله** | آدرسِ Redis، مثلاً `redis://host:6379/0` — برای rate limitِ مشترک وقتی `DEBUG=False` لازمه |
| `KAVENEGAR_API_KEY` | **بله** | ارسال واقعیِ OTP از طریق کاوه‌نگار | | `KAVENEGAR_API_KEY` | **بله** | ارسال واقعیِ OTP از طریق کاوه‌نگار |
| `TEST_LOGIN_PHONES` | نه (فقط تا وقتی تیم دارد توسعه می‌دهد) | شماره‌های تستِ ورود، بخشِ بالا |
| `GRAFANA_ADMIN_PASSWORD` | فقط اگه از استکِ Grafana استفاده می‌کنید | بخشِ Docker پایین رو ببینید | | `GRAFANA_ADMIN_PASSWORD` | فقط اگه از استکِ Grafana استفاده می‌کنید | بخشِ Docker پایین رو ببینید |
### تنظیمات پروداکشن ### تنظیمات پروداکشن

View File

@@ -1560,3 +1560,110 @@ class UpdateUserProfileTests(TestCase):
def test_out_of_range_birth_year_is_rejected(self): def test_out_of_range_birth_year_is_rejected(self):
self.assertEqual(self._patch({'birthYear': 2026}).status_code, 400) 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)

View File

@@ -117,6 +117,22 @@ class RequestOTPView(APIView):
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
phone = serializer.validated_data['phone'] 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)؛ # حداقل فاصله‌ی زمانی بین دو درخواستِ همون شماره (burst protection)؛
# قبل از شمارنده‌ی ساعتی چک می‌شه تا یک درخواستِ بلاک‌شده سهمیه‌ی # قبل از شمارنده‌ی ساعتی چک می‌شه تا یک درخواستِ بلاک‌شده سهمیه‌ی
# ساعتی رو مصرف نکنه. کلید مقدارِ epoch انقضا رو ذخیره می‌کنه تا # ساعتی رو مصرف نکنه. کلید مقدارِ epoch انقضا رو ذخیره می‌کنه تا
@@ -249,8 +265,15 @@ class LoginView(APIView):
# قفل ردیف OTP جلوی مصرف دوباره‌ی یک کد یک‌بارمصرف را در درخواست‌های هم‌زمان می‌گیرد. # قفل ردیف OTP جلوی مصرف دوباره‌ی یک کد یک‌بارمصرف را در درخواست‌های هم‌زمان می‌گیرد.
with transaction.atomic(): with transaction.atomic():
valid = False valid = False
test_code = settings.TEST_LOGIN_PHONES.get(phone, '')
if settings.DEV_MASTER_OTP is not None and otp == settings.DEV_MASTER_OTP: if settings.DEV_MASTER_OTP is not None and otp == settings.DEV_MASTER_OTP:
valid = True 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: else:
otp_obj = OTPCode.objects.select_for_update().filter( otp_obj = OTPCode.objects.select_for_update().filter(
phone=phone, code=otp, is_used=False, expires_at__gte=timezone.now() phone=phone, code=otp, is_used=False, expires_at__gte=timezone.now()

View File

@@ -132,6 +132,14 @@ def _warnings() -> list[str]:
) )
if getattr(settings, 'DEV_MASTER_OTP', None) is not None: if getattr(settings, 'DEV_MASTER_OTP', None) is not None:
out.append('کدِ یک‌سانِ ورود (DEV_MASTER_OTP) فعال است — هر کسی می‌تواند به‌جای هر کاربری وارد شود.') out.append('کدِ یک‌سانِ ورود (DEV_MASTER_OTP) فعال است — هر کسی می‌تواند به‌جای هر کاربری وارد شود.')
test_phones = getattr(settings, 'TEST_LOGIN_PHONES', {})
if test_phones:
# نه خودِ شماره و نه کد برنمی‌گردد؛ فقط تعداد، تا کسی که تنظیماتِ سرور
# را ندیده هم بداند چند حسابِ تست با کدِ ثابت باز است و بتواند ببنددشان.
out.append(
f'{len(test_phones)} شماره‌ی تستِ ورود با کدِ ثابت تعریف شده (TEST_LOGIN_PHONES). '
'برای توسعه لازم است؛ پیش از تحویلِ نهایی به کاربرانِ واقعی حذفشان کنید.'
)
backend = settings.CACHES['default']['BACKEND'] backend = settings.CACHES['default']['BACKEND']
if not settings.DEBUG and 'LocMem' in backend: if not settings.DEBUG and 'LocMem' in backend:
out.append('کش محلیِ هر پروسه است؛ محدودیتِ نرخ بینِ workerها مشترک نیست.') out.append('کش محلیِ هر پروسه است؛ محدودیتِ نرخ بینِ workerها مشترک نیست.')

View File

@@ -11,6 +11,7 @@ https://docs.djangoproject.com/en/5.2/ref/settings/
""" """
import os import os
import re
from pathlib import Path from pathlib import Path
from django.core.exceptions import ImproperlyConfigured from django.core.exceptions import ImproperlyConfigured
@@ -482,6 +483,58 @@ OTP_REQUEST_PHONE_COOLDOWN_SECONDS = 120
# فقط وقتی DEBUG=True مقدار دارد؛ در پروداکشن None است. # فقط وقتی DEBUG=True مقدار دارد؛ در پروداکشن None است.
DEV_MASTER_OTP = '0000' if DEBUG else None DEV_MASTER_OTP = '0000' if DEBUG else None
# -----------------------------------------------------------------------
# شماره‌های تستِ ورود (TEST_LOGIN_PHONES)
# -----------------------------------------------------------------------
# مشکلی که این حل می‌کند: با DEBUG=0 و بدونِ کاوه‌نگار، هیچ توسعه‌دهنده‌ای
# نمی‌تواند وارد API شود مگر اینکه کسی با دسترسیِ ادمین دستی یک ردیفِ OTPCode
# بسازد. یعنی ورودِ هر عضوِ تیم به یک آدمِ خاص وابسته بود.
#
# این همان الگویی است که اپ‌های واقعی برای همین کار دارند (Firebase Auth
# «fictional phone numbers»، Apple «demo account» برای بررسیِ App Store):
# چند شماره‌ی مشخص با کدِ ثابت، که پیامک برایشان ارسال نمی‌شود. برخلافِ
# DEV_MASTER_OTP این «هر شماره‌ای» نیست — فقط همین چند شماره، و هر کدام
# کدِ خودش را دارد.
#
# TEST_LOGIN_PHONES=09120000001:834512,09120000002:110947
#
# قواعدی که عمداً رعایت می‌شوند:
# • کد هیچ‌وقت در پاسخِ API برنمی‌گردد — هر کس شماره را بداند هم نمی‌تواند
# کد را از سرور بیرون بکشد.
# • سقفِ تلاشِ ورودِ هر شماره (OTP_LOGIN_PHONE_MAX_ATTEMPTS) روی این‌ها هم
# اعمال می‌شود، پس حدس‌زدنِ کد با brute-force بسته است.
# • ورودیِ خراب باعثِ بالا نیامدنِ سرویس می‌شود، نه نادیده گرفته شدنِ بی‌صدا.
# • /api/health وجودشان را هشدار می‌دهد تا روی سرور فراموش نشوند.
#
# ⚠️ شماره باید *دقیقاً* همان رشته‌ای باشد که کلاینت می‌فرستد. این کدبیس شماره
# را normalize نمی‌کند (accounts/serializers.py فقط CharField است)، پس
# «09121111111» و «+989121111111» دو کاربرِ جدا هستند و تطبیق رشته‌ای است.
#
# ⚠️ این حساب‌ها حسابِ تست‌اند: کدِ واقعی و تصادفی بگذارید (نه 1234)، و هیچ
# داده‌ی واقعیِ کاربری رویشان نگذارید.
def _parse_test_login_phones(raw: str) -> dict[str, str]:
pairs = {}
for item in raw.split(','):
item = item.strip()
if not item:
continue
phone, sep, code = item.partition(':')
phone, code = phone.strip(), code.strip()
# سقفِ ۶ رقمِ کد دلبخواه نیست: LoginRequestSerializer فیلدِ otp را
# max_length=6 گرفته، پس کدِ بلندتر پیش از رسیدن به اینجا ۴۰۰ می‌شد و
# به‌نظر می‌آمد کدِ تست کار نمی‌کند.
if not sep or not re.fullmatch(r'\+?\d{10,15}', phone) or not re.fullmatch(r'\d{4,6}', code):
raise ImproperlyConfigured(
f'TEST_LOGIN_PHONES مقدارِ نامعتبر دارد: «{item}». '
'قالبِ درست «شماره:کد» است؛ شماره ۱۰ تا ۱۵ رقم (با + اختیاری) '
'و کد ۴ تا ۶ رقم. مثال: TEST_LOGIN_PHONES=09120000001:834512'
)
pairs[phone] = code
return pairs
TEST_LOGIN_PHONES = _parse_test_login_phones(os.environ.get('TEST_LOGIN_PHONES', ''))
# ----------------------------------------------------------------------- # -----------------------------------------------------------------------
# OTP SMS delivery (accounts/services.py: send_otp_sms) # OTP SMS delivery (accounts/services.py: send_otp_sms)
# ----------------------------------------------------------------------- # -----------------------------------------------------------------------

View File

@@ -43,6 +43,16 @@ export const api = createClient({ baseUrl: import.meta.env.VITE_API_URL ?? '/api
در حالتِ توسعه (`DJANGO_DEBUG=1`) پاسخِ `requestOtp` فیلدِ `devOtp` دارد، پس در حالتِ توسعه (`DJANGO_DEBUG=1`) پاسخِ `requestOtp` فیلدِ `devOtp` دارد، پس
برای تست لازم نیست پیامک واقعی برسد. برای تست لازم نیست پیامک واقعی برسد.
**برای تستِ مسیرِ کامل روی سرورِ واقعی، شماره‌ی تست بگیرید.** روی
`api.math.tadrisino2.ir` سرویسِ پیامک هنوز تنظیم نشده، پس `requestOtp` برای یک
شماره‌ی معمولی خطای ۵۰۳ می‌دهد. بک‌اند چند شماره‌ی تست با کدِ ثابت دارد
(`TEST_LOGIN_PHONES` در `.env` سرور): برای آن شماره‌ها پیامکی ارسال نمی‌شود و
همان کد همیشه جواب می‌دهد، دقیقاً مثل مسیرِ واقعی. یک جفت «شماره + کد» برای
خودتان بخواهید — ساختنش کارِ یک خط ویرایشِ `.env` و یک ری‌استارت است.
> کدِ شماره‌ی تست در پاسخِ API برنمی‌گردد. اگر `devOtp` خالی آمد، یعنی همین
> مسیر فعال است، نه اینکه چیزی خراب باشد.
**۲. فهرستِ پایه‌ها تا ششم است.** `GRADES` در `App.tsx` هاردکدِ ۱ تا ۶ است، ولی **۲. فهرستِ پایه‌ها تا ششم است.** `GRADES` در `App.tsx` هاردکدِ ۱ تا ۶ است، ولی
جهتِ محصول از ششم به بالا رفته (ترانزیشنی: ۶، ۷، ۹، ۱۰). از `api.grades()` جهتِ محصول از ششم به بالا رفته (ترانزیشنی: ۶، ۷، ۹، ۱۰). از `api.grades()`
بخوانید تا هر پایه‌ای که اضافه شد خودبه‌خود بیاید. بخوانید تا هر پایه‌ای که اضافه شد خودبه‌خود بیاید.