diff --git a/.env.example b/.env.example index ca8466a..bb78548 100644 --- a/.env.example +++ b/.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 diff --git a/README.md b/README.md index 7a2d84e..0946cae 100644 --- a/README.md +++ b/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 پایین رو ببینید | ### تنظیمات پروداکشن diff --git a/accounts/tests.py b/accounts/tests.py index cb7ace9..252f168 100644 --- a/accounts/tests.py +++ b/accounts/tests.py @@ -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) diff --git a/accounts/views.py b/accounts/views.py index 4e6c763..ebef00a 100644 --- a/accounts/views.py +++ b/accounts/views.py @@ -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() diff --git a/config/health.py b/config/health.py index 893a15c..008446d 100644 --- a/config/health.py +++ b/config/health.py @@ -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ها مشترک نیست.') diff --git a/config/settings.py b/config/settings.py index 2bfa837..45b3a87 100644 --- a/config/settings.py +++ b/config/settings.py @@ -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) # ----------------------------------------------------------------------- diff --git a/docs/frontend/README.md b/docs/frontend/README.md index 270c8db..a26bf35 100644 --- a/docs/frontend/README.md +++ b/docs/frontend/README.md @@ -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()` بخوانید تا هر پایه‌ای که اضافه شد خودبه‌خود بیاید.