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:
16
.env.example
16
.env.example
@@ -57,6 +57,22 @@ CACHE_URL=
|
||||
# طبق رفتارِ موجودِ accounts/services.py)
|
||||
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_PASSWORD=admin
|
||||
|
||||
|
||||
36
README.md
36
README.md
@@ -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 ها، کاما-جدا |
|
||||
| `CACHE_URL` | **بله** | آدرسِ Redis، مثلاً `redis://host:6379/0` — برای rate limitِ مشترک وقتی `DEBUG=False` لازمه |
|
||||
| `KAVENEGAR_API_KEY` | **بله** | ارسال واقعیِ OTP از طریق کاوهنگار |
|
||||
| `TEST_LOGIN_PHONES` | نه (فقط تا وقتی تیم دارد توسعه میدهد) | شمارههای تستِ ورود، بخشِ بالا |
|
||||
| `GRAFANA_ADMIN_PASSWORD` | فقط اگه از استکِ Grafana استفاده میکنید | بخشِ Docker پایین رو ببینید |
|
||||
|
||||
### تنظیمات پروداکشن
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -132,6 +132,14 @@ def _warnings() -> list[str]:
|
||||
)
|
||||
if getattr(settings, 'DEV_MASTER_OTP', None) is not None:
|
||||
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']
|
||||
if not settings.DEBUG and 'LocMem' in backend:
|
||||
out.append('کش محلیِ هر پروسه است؛ محدودیتِ نرخ بینِ workerها مشترک نیست.')
|
||||
|
||||
@@ -11,6 +11,7 @@ https://docs.djangoproject.com/en/5.2/ref/settings/
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
@@ -482,6 +483,58 @@ OTP_REQUEST_PHONE_COOLDOWN_SECONDS = 120
|
||||
# فقط وقتی DEBUG=True مقدار دارد؛ در پروداکشن 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)
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
@@ -43,6 +43,16 @@ export const api = createClient({ baseUrl: import.meta.env.VITE_API_URL ?? '/api
|
||||
در حالتِ توسعه (`DJANGO_DEBUG=1`) پاسخِ `requestOtp` فیلدِ `devOtp` دارد، پس
|
||||
برای تست لازم نیست پیامک واقعی برسد.
|
||||
|
||||
**برای تستِ مسیرِ کامل روی سرورِ واقعی، شمارهی تست بگیرید.** روی
|
||||
`api.math.tadrisino2.ir` سرویسِ پیامک هنوز تنظیم نشده، پس `requestOtp` برای یک
|
||||
شمارهی معمولی خطای ۵۰۳ میدهد. بکاند چند شمارهی تست با کدِ ثابت دارد
|
||||
(`TEST_LOGIN_PHONES` در `.env` سرور): برای آن شمارهها پیامکی ارسال نمیشود و
|
||||
همان کد همیشه جواب میدهد، دقیقاً مثل مسیرِ واقعی. یک جفت «شماره + کد» برای
|
||||
خودتان بخواهید — ساختنش کارِ یک خط ویرایشِ `.env` و یک ریاستارت است.
|
||||
|
||||
> کدِ شمارهی تست در پاسخِ API برنمیگردد. اگر `devOtp` خالی آمد، یعنی همین
|
||||
> مسیر فعال است، نه اینکه چیزی خراب باشد.
|
||||
|
||||
**۲. فهرستِ پایهها تا ششم است.** `GRADES` در `App.tsx` هاردکدِ ۱ تا ۶ است، ولی
|
||||
جهتِ محصول از ششم به بالا رفته (ترانزیشنی: ۶، ۷، ۹، ۱۰). از `api.grades()`
|
||||
بخوانید تا هر پایهای که اضافه شد خودبهخود بیاید.
|
||||
|
||||
Reference in New Issue
Block a user