From 8835e0159504ae4f0ccf23e524b97263daaf6c71 Mon Sep 17 00:00:00 2001 From: Zareian Date: Mon, 5 Oct 2026 17:38:18 +0330 Subject: [PATCH] Remove guest login: every user registers with a phone number MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /api/auth/session is gone (404). Existing is_guest rows keep refresh and upgrade-by-phone until the demo/guest rows are purged; the code that serves them is listed in docs/security-before-production.md §10. Tests that used the endpoint as a fixture now use accounts/testing.guest_session. Co-Authored-By: Claude Sonnet 5.5 --- README.md | 7 ++- accounts/admin.py | 2 +- .../management/commands/seed_test_data.py | 2 +- accounts/schema.py | 2 +- accounts/serializers.py | 13 ----- accounts/testing.py | 30 ++++++++++ accounts/tests.py | 44 ++++---------- accounts/tests_audit_fixes.py | 10 ++-- accounts/tests_guest_refresh.py | 9 ++- accounts/tests_security_review.py | 3 +- accounts/urls.py | 2 - accounts/views.py | 58 +------------------ config/settings.py | 20 ++----- config/tests.py | 10 ++-- content/tests_flow.py | 28 ++++----- docs/operations.md | 24 ++------ docs/security-before-production.md | 34 +++++------ docs/technical-handbook.md | 1 - 18 files changed, 101 insertions(+), 198 deletions(-) create mode 100644 accounts/testing.py diff --git a/README.md b/README.md index 4a89f38..9e80a6c 100644 --- a/README.md +++ b/README.md @@ -40,9 +40,10 @@ python manage.py runserver | پنلِ ادمین | `/admin/` | | سلامتِ سرویس | `/api/health` | -**اولین درخواست در Swagger:** `POST /api/auth/session` با -`{"type":"guest","deviceId":"d_test1"}` بزنید، `token` را بردارید، روی -**Authorize** کلیک کنید و همان رشته را (بدونِ کلمه‌ی `Bearer`) وارد کنید. +**اولین درخواست در Swagger:** در حالتِ `DJANGO_DEBUG=1` با `POST /api/auth/otp/request` +شماره‌ای بفرستید و با `POST /api/auth/login` و کدِ `DEV_MASTER_OTP` وارد شوید؛ `token` را +بردارید، روی **Authorize** کلیک کنید و همان رشته را (بدونِ کلمه‌ی `Bearer`) وارد کنید. +ورودِ مهمان نداریم. دستورهای پرکاربردِ دیگر: diff --git a/accounts/admin.py b/accounts/admin.py index 791b5e2..d6ba425 100644 --- a/accounts/admin.py +++ b/accounts/admin.py @@ -51,7 +51,7 @@ class SendSMSForm(forms.Form): class DeviceIdForSuperuserOnly: """ - شناسه‌ی دستگاه برای مهمان تنها رمزِ ورود است (`POST /api/auth/session`)؛ پس فقط ابرکاربر + شناسه‌ی دستگاهِ حساب‌های مهمانِ قدیمی (که دیگر ساخته نمی‌شوند) تنها رمزِ ورودشان بود؛ پس فقط ابرکاربر آن را در فهرست، جستجو و فرم می‌بیند، نه کارمندی با اجازه‌ی دیدن (گروهِ Supervisor). """ diff --git a/accounts/management/commands/seed_test_data.py b/accounts/management/commands/seed_test_data.py index 288a3ef..07eac27 100644 --- a/accounts/management/commands/seed_test_data.py +++ b/accounts/management/commands/seed_test_data.py @@ -89,7 +89,7 @@ class Command(BaseCommand): self.stdout.write(f' - {label}: user_id={user.pk} phone={user.phone or "-"} token={token}') self.stdout.write( '\nنکته: این توکن‌ها با ACCESS_TOKEN_TTL_SECONDS فعلی امضا شدن و بعد از انقضا ' - 'باید دوباره seed یا از /api/auth/session یا /api/auth/login گرفته بشن.' + 'باید دوباره seed یا از /api/auth/login گرفته بشن.' ) # -- reset ---------------------------------------------------------- diff --git a/accounts/schema.py b/accounts/schema.py index 1ee9b52..1836d1e 100644 --- a/accounts/schema.py +++ b/accounts/schema.py @@ -15,7 +15,7 @@ class TadrisinoTokenAuthenticationScheme(OpenApiAuthenticationExtension): 'scheme': 'bearer', 'bearerFormat': 'access_token', 'description': ( - 'توکن دسترسی که از /api/auth/session یا /api/auth/login گرفته می‌شه. ' + 'توکن دسترسی که از /api/auth/login گرفته می‌شه. ' 'در فیلد Value فقط خودِ توکن رو بذارید (بدون کلمه‌ی Bearer)؛ Swagger ' 'خودش اضافه می‌کنه.' ), diff --git a/accounts/serializers.py b/accounts/serializers.py index c1e3aa0..1232b4d 100644 --- a/accounts/serializers.py +++ b/accounts/serializers.py @@ -45,11 +45,6 @@ class IranMobileField(serializers.CharField): # ---- Request serializers ---- -class GuestSessionRequestSerializer(serializers.Serializer): - type = serializers.ChoiceField(choices=['guest']) - deviceId = serializers.CharField(max_length=64) - - class OTPCodeField(serializers.CharField): """ کدِ ورود: ۴ تا ۶ رقم (کدِ پیامکی ۶ رقمی است، کدهای TEST_LOGIN_PHONES ۴ تا ۶). رقمِ فارسی و @@ -157,14 +152,6 @@ class RequestOTPRequestSerializer(serializers.Serializer): # ---- Response serializers ---- -class GuestSessionResponseSerializer(serializers.Serializer): - userId = serializers.CharField() - token = serializers.CharField() - refreshToken = serializers.CharField() - isNewUser = serializers.BooleanField() - isGuest = serializers.BooleanField() - - class LoginResponseSerializer(serializers.Serializer): userId = serializers.CharField() token = serializers.CharField() diff --git a/accounts/testing.py b/accounts/testing.py new file mode 100644 index 0000000..cec1d6a --- /dev/null +++ b/accounts/testing.py @@ -0,0 +1,30 @@ +""" +ابزارِ تست: یک حسابِ مهمانِ قدیمی با نشست می‌سازد. + +ورودِ مهمان (`POST /api/auth/session`) حذف شد، اما ردیف‌های مهمانی که پیش‌تر ساخته شده‌اند +(حساب‌های دمو و کاربرانِ قبلی) هنوز در دیتابیس‌اند و رفتارِ ارتقایشان به حسابِ شماره‌دار +(ورود با شماره، حفظ پیشرفت، حذف حساب) باید تست شود. این تابع همان ردیفی را می‌سازد که آن اندپوینت می‌ساخت. +""" +import secrets + +from django.conf import settings +from django.utils import timezone + +from .authentication import issue_access_token +from .models import Session, User, hash_refresh_token + + +def guest_session(device_id): + """مثلِ پاسخِ قدیمیِ اندپوینت: userId, token, refreshToken, isNewUser, isGuest.""" + user, is_new = User.objects.get_or_create(device_id=device_id, is_guest=True, defaults={'name': ''}) + user.last_activity_at = timezone.now() + user.save(update_fields=['last_activity_at']) + refresh = secrets.token_hex(32) + Session.objects.create( + user=user, refresh_token=hash_refresh_token(refresh), device_id=device_id, + expires_at=timezone.now() + timezone.timedelta(days=settings.REFRESH_TOKEN_TTL_DAYS), + ) + return { + 'userId': str(user.id), 'token': issue_access_token(user.id), + 'refreshToken': refresh, 'isNewUser': is_new, 'isGuest': True, + } diff --git a/accounts/tests.py b/accounts/tests.py index 2ea3527..c1fd202 100644 --- a/accounts/tests.py +++ b/accounts/tests.py @@ -14,6 +14,7 @@ from rest_framework.throttling import ScopedRateThrottle from .authentication import issue_access_token from .models import OTPCode, Profile, Session, User, hash_refresh_token +from .testing import guest_session from .services import OTPDeliveryError, SMSDeliveryError, send_otp_sms @@ -834,38 +835,15 @@ class GuestSessionThrottleTests(TestCase): self.client = APIClient() cache.clear() - @override_settings( - REST_FRAMEWORK={ - **settings.REST_FRAMEWORK, - 'DEFAULT_THROTTLE_RATES': { - **settings.REST_FRAMEWORK['DEFAULT_THROTTLE_RATES'], - 'guest_session': '3/hour', - }, - } - ) - def test_repeated_guest_session_requests_are_eventually_throttled(self): - # override_settings(REST_FRAMEWORK=...) به‌تنهایی رفتارِ - # ScopedRateThrottle رو تغییر نمی‌ده: DRF مقدارِ THROTTLE_RATES رو - # یک‌بار موقعِ import، به‌عنوانِ class attribute، از دیکشنریِ اصلیِ - # settings.py می‌گیره؛ override_settings دیکشنریِ settings رو عوض - # می‌کنه ولی این class attribute هنوز به نسخه‌ی قدیمی اشاره می‌کنه. - # برای سنجیدنِ نرخِ واقعی، مستقیماً patch.dict روی همون دیکشنریِ - # زنده‌ی ScopedRateThrottle.THROTTLE_RATES لازمه. - with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'guest_session': '3/hour'}): - for i in range(3): - response = self.client.post( - '/api/auth/session', - {'type': 'guest', 'deviceId': f'device-{i}'}, - format='json', - ) - self.assertEqual(response.status_code, 200) - - blocked_response = self.client.post( - '/api/auth/session', - {'type': 'guest', 'deviceId': 'device-blocked'}, - format='json', - ) - self.assertEqual(blocked_response.status_code, 429) + def test_guest_login_endpoint_is_gone(self): + # ورودِ مهمان حذف شده: هر کاربر باید با شماره ثبت‌نام کند. قدیمی‌ها باید ۴۰۴ + # بگیرند، نه حسابِ مهمانِ ساخته‌شده. + before = User.objects.count() + response = self.client.post( + '/api/auth/session', {'type': 'guest', 'deviceId': 'device-x'}, format='json', + ) + self.assertEqual(response.status_code, 404) + self.assertEqual(User.objects.count(), before) class UserProfileViewTests(TestCase): @@ -1682,7 +1660,7 @@ class RefreshTokenStoredHashedTests(TestCase): def test_issued_tokens_are_stored_only_as_hash(self): client = APIClient() - body = client.post('/api/auth/session', {'type': 'guest', 'deviceId': 'hash-dev'}, format='json').json() + body = guest_session('hash-dev') raw = body['refreshToken'] self.assertFalse(Session.objects.filter(refresh_token=raw).exists()) self.assertTrue(Session.objects.filter(refresh_token=hash_refresh_token(raw)).exists()) diff --git a/accounts/tests_audit_fixes.py b/accounts/tests_audit_fixes.py index e6c51f6..2aac3ba 100644 --- a/accounts/tests_audit_fixes.py +++ b/accounts/tests_audit_fixes.py @@ -9,6 +9,7 @@ from django.test import TestCase, override_settings from rest_framework.test import APIClient from accounts.authentication import issue_access_token +from accounts.testing import guest_session from accounts.models import OTPCode, User from content.models import Choice, Grade, Lesson, Node, Question, Unit from progress.models import UserHearts, UserStats @@ -25,8 +26,7 @@ class LogoutTests(TestCase): cache.clear() def test_logout_kills_the_current_token_and_its_refresh(self): - guest = APIClient().post('/api/auth/session', {'type': 'guest', 'deviceId': 'dev-out'}, - format='json').json() + guest = guest_session('dev-out') client = APIClient() client.credentials(HTTP_AUTHORIZATION=f'Bearer {guest["token"]}') self.assertEqual(client.get('/api/user/profile').status_code, 200) @@ -40,8 +40,7 @@ class LogoutTests(TestCase): self.assertEqual(refreshed.status_code, 401) # ورودِ دوباره کار می‌کند - again = APIClient().post('/api/auth/session', {'type': 'guest', 'deviceId': 'dev-out'}, - format='json').json() + again = guest_session('dev-out') fresh = APIClient() fresh.credentials(HTTP_AUTHORIZATION=f'Bearer {again["token"]}') self.assertEqual(fresh.get('/api/user/profile').status_code, 200) @@ -72,8 +71,7 @@ class OtpTests(TestCase): class GuestSignupKeepsProgressTests(TestCase): def setUp(self): cache.clear() - guest = APIClient().post('/api/auth/session', {'type': 'guest', 'deviceId': 'dev-up'}, - format='json').json() + guest = guest_session('dev-up') self.guest_id = int(guest['userId']) UserStats.objects.create(user_id=self.guest_id, xp=120) self.client = APIClient() diff --git a/accounts/tests_guest_refresh.py b/accounts/tests_guest_refresh.py index 5b5981e..a699df9 100644 --- a/accounts/tests_guest_refresh.py +++ b/accounts/tests_guest_refresh.py @@ -7,18 +7,17 @@ from django.core.cache import cache from django.test import TestCase +from accounts.testing import guest_session + class GuestRefreshTokenTests(TestCase): def setUp(self): cache.clear() def _guest(self, device='device-abc'): - return self.client.post( - '/api/auth/session', {'type': 'guest', 'deviceId': device}, - content_type='application/json', - ).json() + return guest_session(device) - def test_guest_session_returns_a_refresh_token(self): + def test_guest_session_has_a_refresh_token(self): body = self._guest() self.assertTrue(body['refreshToken']) diff --git a/accounts/tests_security_review.py b/accounts/tests_security_review.py index 035fbb9..72d181c 100644 --- a/accounts/tests_security_review.py +++ b/accounts/tests_security_review.py @@ -14,6 +14,7 @@ from django.utils import timezone from rest_framework.test import APIClient from accounts.authentication import issue_access_token +from accounts.testing import guest_session from accounts.models import OTPCode, User from config.log_formatters import JSONLogFormatter @@ -30,7 +31,7 @@ def _otp_row(phone, code='482913'): def _guest(device): - return APIClient().post('/api/auth/session', {'type': 'guest', 'deviceId': device}, format='json').json() + return guest_session(device) class GuestUpgradeRevokesEarlierSessionsTests(TestCase): diff --git a/accounts/urls.py b/accounts/urls.py index 863e460..0d3003d 100644 --- a/accounts/urls.py +++ b/accounts/urls.py @@ -2,7 +2,6 @@ from django.urls import path from .views import ( DeleteAccountView, - GuestSessionView, LoginView, LogoutView, RefreshTokenView, @@ -12,7 +11,6 @@ from .views import ( ) urlpatterns = [ - path('auth/session', GuestSessionView.as_view(), name='auth-session'), path('auth/otp/request', RequestOTPView.as_view(), name='auth-otp-request'), path('auth/login', LoginView.as_view(), name='auth-login'), path('auth/refresh', RefreshTokenView.as_view(), name='auth-refresh'), diff --git a/accounts/views.py b/accounts/views.py index ae6bc25..15e6842 100644 --- a/accounts/views.py +++ b/accounts/views.py @@ -21,8 +21,6 @@ from .services import OTPDeliveryError, send_otp_sms from .serializers import ( DeleteAccountRequestSerializer, DeleteAccountResponseSerializer, - GuestSessionRequestSerializer, - GuestSessionResponseSerializer, LoginRequestSerializer, LoginResponseSerializer, LogoutRequestSerializer, @@ -43,7 +41,7 @@ logger = logging.getLogger(__name__) class GuestAwareAccountThrottle(ScopedRateThrottle): """ همان الگویِ GuestAwareContentThrottle (content/views.py)، - GuestAwareAIThrottle (ai_tutor/views.py) و بقیه؛ چون GuestSessionView + GuestAwareAIThrottle (ai_tutor/views.py) و بقیه؛ چون ورودِ مهمان (حذف‌شده؛ حساب‌های مهمانِ قبلی هنوز هستند) با هر deviceId جدید یک کاربرِ مهمانِ تازه (pk جدید) می‌سازه، یک ScopedRateThrottleِ سادهٔ per-user توسطِ چرخشِ حساب مهمان دورزدنی بود. اینجا برای SetUserGradeView (که مهمان‌ها هم می‌تونن صداش بزنن) @@ -64,58 +62,6 @@ class GuestAwareAccountThrottle(ScopedRateThrottle): return self.cache_format % {'scope': self.scope, 'ident': ident} -class GuestSessionView(APIView): - """POST /api/auth/session — ایجاد سشن مهمان""" - - throttle_scope = 'guest_session' - throttle_classes = [ScopedRateThrottle] - - @extend_schema( - request=GuestSessionRequestSerializer, - responses=GuestSessionResponseSerializer, - tags=['Identity & Profile (accounts)'], - ) - def post(self, request): - serializer = GuestSessionRequestSerializer(data=request.data) - serializer.is_valid(raise_exception=True) - device_id = serializer.validated_data['deviceId'] - - user, is_new_user = User.objects.get_or_create( - device_id=device_id, - is_guest=True, - defaults={'name': ''}, - ) - - # ثبتِ فعالیتِ کاربر: ایجاد/استفاده‌ی سشنِ مهمان یک سشنِ واقعیِ - # کاربره، پس last_activity_at به‌روزرسانی می‌شه (بدونِ تأثیر روی - # سایرِ رفتارِ endpoint). - user.last_activity_at = timezone.now() - user.save(update_fields=['last_activity_at']) - - token = issue_access_token(user.id) - - # مهمان هم refresh token می‌گیرد. قبلاً فقط توکنِ یک‌ساعته داشت؛ بعد از - # یک ساعت همه‌ی درخواست‌ها ۴۰۱ می‌شدند درحالی‌که اپ هنوز خودش را - # واردشده می‌دید. امنیتی کم نمی‌شود: خودِ deviceId از قبل برای گرفتنِ - # توکنِ تازه کافی بود. - refresh_token = secrets.token_hex(32) - Session.objects.create( - user=user, - refresh_token=hash_refresh_token(refresh_token), - device_id=device_id, - expires_at=timezone.now() + timezone.timedelta(days=settings.REFRESH_TOKEN_TTL_DAYS), - ) - - data = { - 'userId': str(user.id), - 'token': token, - 'refreshToken': refresh_token, - 'isNewUser': is_new_user, - 'isGuest': True, - } - return Response(GuestSessionResponseSerializer(data).data) - - class RequestOTPView(APIView): """ POST /api/auth/otp/request — درخواستِ کدِ ورود. @@ -650,7 +596,7 @@ class UserProfileView(APIView): def get(self, request): user = request.user # مدل Profile به‌صورت lazy ساخته می‌شه چون هیچ‌جای دیگه‌ای (نه - # GuestSessionView، نه LoginView) رکورد Profile رو از قبل نمی‌سازه؛ + # LoginView) رکورد Profile رو از قبل نمی‌سازه؛ # get_or_create این‌جا امن است چون فقط توسطِ کاربرِ همون رکورد صدا # زده می‌شه (OneToOne روی user_id جاری، نه ورودیِ خارجی). profile, _ = Profile.objects.get_or_create(user_id=user.id) diff --git a/config/settings.py b/config/settings.py index 19d8325..d12e4aa 100644 --- a/config/settings.py +++ b/config/settings.py @@ -481,7 +481,6 @@ REST_FRAMEWORK = { # در دیتابیس)؛ این‌ها فقط جلوی یک ماشینِ تنها را می‌گیرند. 'otp_request': os.environ.get('OTP_REQUEST_IP_RATE', '20/hour'), 'otp_login': os.environ.get('OTP_LOGIN_IP_RATE', '30/hour'), - 'guest_session': os.environ.get('GUEST_SESSION_IP_RATE', '60/hour'), 'bazaar_verify': '10/hour', 'bazaar_oauth': '20/hour', 'ai_ask': '20/hour', @@ -537,21 +536,10 @@ SPECTACULAR_SETTINGS = { 'Authorization: Bearer \n' '```\n' '\n' - 'دو راه برای گرفتنِ توکن هست:\n' - '\n' - '| مسیر | کاربرد |\n' - '|---|---|\n' - '| `POST /api/auth/session` | ورودِ مهمان با `deviceId` — بدونِ شماره، بلافاصله |\n' - '| `POST /api/auth/otp/request` + `POST /api/auth/login` | ورودِ کامل با شماره‌ی موبایل |\n' - '\n' - 'کاربرِ مهمان می‌تواند بعداً شماره‌اش را وصل کند و همان حساب را نگه ' - 'دارد. توکنِ دسترسی یک ساعت اعتبار دارد؛ با `POST /api/auth/refresh` ' - 'تازه‌اش کنید.\n' - '\n' - '`deviceId` هر رشته‌ای می‌تواند باشد و هر مقدارِ تازه یک حسابِ جدا ' - 'می‌سازد، پس برای تست لازم نیست منتظرِ چیزی بمانید. چند `deviceId` ' - 'از پیش‌آماده هم برای دیدنِ صفحه‌های داده‌دار وجود دارد — از تیمِ ' - 'بک‌اند بخواهید.\n' + 'توکن را فقط با شماره‌ی موبایل می‌گیرید: `POST /api/auth/otp/request` ' + 'و بعد `POST /api/auth/login`. ورودِ مهمان نداریم؛ هر کاربر حداقل ثبت‌نامِ ' + 'اولیه با شماره دارد. توکنِ دسترسی یک ساعت اعتبار دارد؛ با ' + '`POST /api/auth/refresh` تازه‌اش کنید.\n' '\n' '**در همین صفحه:** روی **Authorize** بزنید و *فقط خودِ توکن* را وارد ' 'کنید — کلمه‌ی `Bearer` را Swagger خودش اضافه می‌کند.\n' diff --git a/config/tests.py b/config/tests.py index 2442626..fcf91f2 100644 --- a/config/tests.py +++ b/config/tests.py @@ -202,18 +202,18 @@ class APIRequestLoggingMiddlewareTests(TestCase): def test_anonymous_api_request_is_logged_with_null_user_id(self): with self.assertLogs('config.middleware', level='INFO') as captured: response = self.client.post( - '/api/auth/session', - {'type': 'guest', 'deviceId': 'device-mw-log-anon-1'}, + '/api/auth/refresh', + {'refreshToken': 'not-a-real-token'}, format='json', ) - self.assertEqual(response.status_code, 200) + self.assertEqual(response.status_code, 401) self.assertEqual(len(captured.records), 1) message = captured.records[0].getMessage() self.assertIn('method=POST', message) - self.assertIn('path=/api/auth/session', message) - self.assertIn('status=200', message) + self.assertIn('path=/api/auth/refresh', message) + self.assertIn('status=401', message) self.assertIn('user_id=None', message) def test_non_api_request_is_not_logged(self): diff --git a/content/tests_flow.py b/content/tests_flow.py index fcb09ab..0719699 100644 --- a/content/tests_flow.py +++ b/content/tests_flow.py @@ -13,6 +13,7 @@ from django.test import TestCase from rest_framework.test import APIClient from accounts.models import User +from accounts.testing import guest_session from content.models import Choice, Grade, Lesson, Node, Question, Unit @@ -45,12 +46,9 @@ class BasicLearningFlowTests(TestCase): self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {token}') def test_a_new_user_can_go_from_signup_to_finishing_a_lesson(self): - # ۱. ورودِ مهمان — سریع‌ترین مسیرِ ورود که اپ می‌تواند بگیرد. - response = self.client.post( - '/api/auth/session', {'type': 'guest', 'deviceId': 'flow-device-1'}, format='json', - ) - self.assertEqual(response.status_code, 200, response.data) - self._auth(response.data['token']) + # ۱. حسابِ مهمانِ قدیمی (ورودِ مهمان حذف شده؛ این ردیف‌ها هنوز هستند). + session = guest_session('flow-device-1') + self._auth(session['token']) # ۲. صفحه‌ی «بیا باهم آشنا بشیم»: اسم + پایه. response = self.client.patch('/api/user/profile', {'name': 'سارا'}, format='json') @@ -112,10 +110,8 @@ class BasicLearningFlowTests(TestCase): def test_completing_twice_with_the_same_key_does_not_double_count(self): from content.models import UserLessonCompletion - response = self.client.post( - '/api/auth/session', {'type': 'guest', 'deviceId': 'flow-device-2'}, format='json', - ) - self._auth(response.data['token']) + session = guest_session('flow-device-2') + self._auth(session['token']) self.client.post('/api/user/grade', {'gradeId': self.grade.id}, format='json') self.client.post( f'/api/questions/{self.question.id}/answer', @@ -148,10 +144,8 @@ class BasicLearningFlowTests(TestCase): به‌روز می‌شود. بدونِ دانستنِ این، توسعه‌دهنده‌ی فرانت دنبالِ باگی می‌گردد که وجود ندارد. """ - response = self.client.post( - '/api/auth/session', {'type': 'guest', 'deviceId': 'flow-device-4'}, format='json', - ) - self._auth(response.data['token']) + session = guest_session('flow-device-4') + self._auth(session['token']) self.client.post('/api/user/grade', {'gradeId': self.grade.id}, format='json') answer = self.client.post( @@ -168,10 +162,8 @@ class BasicLearningFlowTests(TestCase): self.assertGreater(self.client.get('/api/user/summary').data['xp'], 0) def test_the_map_is_empty_but_healthy_before_a_grade_is_chosen(self): - response = self.client.post( - '/api/auth/session', {'type': 'guest', 'deviceId': 'flow-device-3'}, format='json', - ) - self._auth(response.data['token']) + session = guest_session('flow-device-3') + self._auth(session['token']) home = self.client.get('/api/home') self.assertEqual(home.status_code, 200) diff --git a/docs/operations.md b/docs/operations.md index ff3b1f2..278580e 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -55,9 +55,7 @@ curl https://mathapi.tadrisino2.ir/api/health ```bash curl -i https://mathapi.tadrisino2.ir/api/schema/ -curl -i -X POST https://mathapi.tadrisino2.ir/api/auth/session \ - -H 'Content-Type: application/json' \ - -d '{"type":"guest","deviceId":"verify-1"}' +curl -i https://mathapi.tadrisino2.ir/api/health ``` ## بکاپ و بازیابی @@ -254,22 +252,10 @@ curl -X POST https://mathapi.tadrisino2.ir/api/auth/login \ ### حسابِ مهمانِ تستِ فرانت -مسیرِ مهمان (`POST /api/auth/session` با یک `deviceId`) اصلاً به پیامک کار -ندارد و همیشه کار می‌کند — هر رشته‌ای به‌عنوانِ `deviceId` یک حسابِ تازه -می‌سازد. از ۲۰۲۶-۰۹-۲۴ پاسخش `refreshToken` هم دارد (مثلِ ورود با شماره)، پس -مهمان بعد از انقضای توکنِ یک‌ساعته با `POST /api/auth/refresh` ادامه می‌دهد. - -یک `deviceId` مشخص روی پروداکشن از قبل داده دارد: - -```bash -curl -X POST https://mathapi.tadrisino2.ir/api/auth/session \ - -H 'Content-Type: application/json' \ - -d '{"type":"guest","deviceId":"frontend-test-device"}' -``` - -کاربرِ شماره‌ی ۶، «حساب تست فرانت». رویش ۲۴ ردیفِ `Notification` و ۲۰ ردیفِ -`LeaderboardEntry` (هفتگی و کل، با ترتیبِ عمداً متفاوت) نشسته تا تیمِ فرانت -بتواند آن صفحه‌ها را قبل از وجودِ ترافیکِ واقعی ببیند. +ورودِ مهمان (`POST /api/auth/session`) حذف شده و دیگر با `frontend-test-device` توکن نمی‌دهد. ردیفِ +کاربرِ شماره‌ی ۶ («حساب تست فرانت»، `is_guest=True`) با ۲۴ ردیفِ `Notification` و ۲۰ ردیفِ +`LeaderboardEntry` هنوز روی پروداکشن است؛ برای دیدنِ آن صفحه‌ها با حسابِ دموی شماره‌دار +(`TEST_LOGIN_PHONES`) وارد شوید. ⚠️ **این داده دستی روی دیتابیسِ پروداکشن نوشته شده و در گیت نیست.** پیش از باز شدنِ سرویس روی کاربرانِ واقعی باید برود، وگرنه اعلان‌ها و لیدربردِ ساختگی را diff --git a/docs/security-before-production.md b/docs/security-before-production.md index 255bf8f..a41cb00 100644 --- a/docs/security-before-production.md +++ b/docs/security-before-production.md @@ -23,15 +23,15 @@ |---|---|---|---| | ۱ | کلیدِ امضای توکن روی سرور همان رشته‌ی داخلِ ریپو است | بحرانی — جعلِ ورود به‌جای هر کاربر | کد آماده؛ اعمال روی سرور | | ۲ | ۷ حسابِ دمو با کدِ ورودِ ثابت روی پروداکشن | بالا | `.env` سرور | -| ۳ | داده‌ی دمو و حسابِ مهمانِ عمومیِ `frontend-test-device` | بالا | دیتابیسِ سرور | +| ۳ | داده‌ی دمو و حسابِ مهمانِ نمایشیِ `frontend-test-device` | بالا | دیتابیسِ سرور | | ۴ | `/api/health` جزئیاتِ داخلی را به همه نشان می‌دهد | متوسط | کد | | ۵ | Swagger، ReDoc و schemaی کاملِ API عمومی‌اند | متوسط | کد | | ۷ | پنلِ `/admin/` روی دامنه‌ی عمومی | متوسط | nginx + کد | | ۸ | سرور بدونِ ArvanCloud روی پورتِ ۸۰ هم جواب می‌دهد | متوسط | ادمینِ سرور | | ۹ | کشِ مشترک (Redis) نیست؛ محدودیت‌ها per-worker‌اند | متوسط | `.env` سرور | -| ۱۰ | ورودِ مهمان فقط با `deviceId` است | کم (طراحی) | کد | +| ۱۰ | حساب‌های مهمانِ قدیمی (دمو و کاربرانِ پیش از حذفِ ورودِ مهمان) هنوز در دیتابیس‌اند | کم | دیتابیسِ سرور | | ۱۱ | کدِ OTP بی‌هش در دیتابیس و در فهرستِ پنل | کم | کد | -| ۱۲ | هزینه‌ی دستیارِ AI با ساختِ حسابِ مهمان قابلِ بالا بردن است | کم (مالی) | کد | +| ۱۲ | هزینه‌ی دستیارِ AI با ساختِ حسابِ تازه (هر شماره ۳ سوالِ رایگان) قابلِ بالا بردن است | کم (مالی) | کد | | ۱۴ | SQLite روی دیسکِ مشترکِ پُر، بدونِ بکاپِ خودکارِ بیرون از ماشین | کم (داده) | زیرساخت | | ۱۵ | دسترسیِ سرور: حسابِ مشترک، رمز، sudoِ کامل | بالا | ادمینِ سرور | | ۱۶ | فرانت توکن را در `localStorage` نگه می‌دارد | کم | فرانت | @@ -85,15 +85,15 @@ از تستِ دستیار؛ همراهِ بقیه پاک شوند. - **چیست:** کاربرهای ۶ (`deviceId = frontend-test-device`) و ۱۰ تا ۱۵، با درس، گفتگو، تیکت، خانواده و زیرمجموعه، روی دیتابیسِ پروداکشن ساخته شده‌اند. `frontend-test-device` - در ریپو نوشته شده؛ هر کس `POST /api/auth/session` با همین `deviceId` بزند، توکنِ همان - حساب را می‌گیرد. + در ریپو نوشته شده. `POST /api/auth/session` حذف شده، پس با آن دیگر توکن نمی‌دهد؛ ولی خودِ + ردیف‌ها (با `is_guest=True`) تا پاک نشوند در دیتابیس‌اند. - **خطر:** داده‌ی ساختگی در آمار و جدولِ رتبه‌بندیِ واقعی؛ یک حسابِ عمومی که هر کس می‌تواند با آن کار کند (ثبتِ تیکت، گفتگو با AI به هزینه‌ی ما). - **رفع:** پیش از کاربرِ واقعی پاک شوند. `purge_demo_data` این‌ها را **نمی‌گیرد** (از مسیرِ واقعیِ viewها ساخته شده‌اند)؛ باید با شناسه پاک شوند، پیش از آن بکاپ. همراهِ آن پیش‌فرضِ `DEMO_GUEST_DEVICE_IDS` در `config/settings.py` (که حذفِ این مهمان را با `POST /api/user/delete` می‌بندد) هم برداشته شود. -- **چطور بسنجید:** `POST /api/auth/session` با `frontend-test-device` باید `isNewUser: true` بدهد. +- **چطور بسنجید:** در پنل یا shell، `User.objects.filter(is_guest=True).count()` پس از پاک‌سازی باید صفر باشد. ## ۴. `/api/health` عمومی و پرجزئیات @@ -157,16 +157,15 @@ - **رفع:** `CACHE_URL=redis://127.0.0.1:6379/1` (db0 مالِ اپِ دیگری است) و ری‌استارت. - **چطور بسنجید:** `/api/health` → `checks.cache.backend` نباید `LocMemCache` باشد. -## ۱۰. ورودِ مهمان فقط با `deviceId` +## ۱۰. حساب‌های مهمانِ قدیمی -- **چیست:** `POST /api/auth/session` با هر `deviceId` یا حسابِ مهمانِ همان دستگاه را - برمی‌گرداند یا می‌سازد (`accounts/views.py::GuestSessionView`). هیچ رازِ دیگری در کار نیست. -- **خطر:** هر کس `deviceId`ِ یک مهمان را بداند (از `localStorage`، لاگ، اسکرین‌شات) وارد - حسابِ او می‌شود. بعد از ثبت‌نام با شماره این راه بسته می‌شود (مهمان دیگر مهمان نیست) و همه‌ی - نشست‌هایی که پیش از آن گرفته شده بودند باطل می‌شوند. شناسه‌ی دستگاه در پنل فقط برای ابرکاربر - نمایش داده می‌شود. -- **رفع:** برای مهمان هم یک رازِ تصادفیِ سمتِ سرور صادر شود و ورودِ دوباره با همان راز باشد، - نه با `deviceId`ِ خام. +- **چیست:** ورودِ مهمان (`POST /api/auth/session`) حذف شده و هر کاربرِ تازه با شماره ثبت‌نام می‌کند. + ردیف‌های مهمانِ پیشین (`User.is_guest=True`، شامل دموها) در دیتابیس مانده‌اند و کدِ ارتقای مهمان به + حسابِ شماره‌دار (`LoginView`) و throttleهای مهمان‌محور هنوز برایشان کار می‌کنند. +- **خطر:** مهمانِ قدیمی هنوز با `refreshToken` ذخیره‌شده در مرورگرش وارد می‌ماند؛ شناسه‌ی دستگاه دیگر + راهِ ورود نیست. +- **رفع:** پس از پاک‌سازیِ دموها (بند ۳) و دیدنِ این‌که مهمانِ واقعی نمانده، کدِ مهمان + (`is_guest`، `GuestAware*Throttle`، ارتقا) حذف شود. تا آن روز عمداً نگه داشته شده. ## ۱۱. کدِ OTP بی‌هش در دیتابیس @@ -179,8 +178,8 @@ ## ۱۲. هزینه‌ی دستیارِ AI - **چیست:** هر کاربر روزی ۳ سؤالِ رایگان دارد (`AI_DAILY_QUOTA_FREE`) - و بعد فقط با اعتبارِ خریده‌شده؛ ۲۰ در ساعت. مهمان‌ها بر اساسِ IP محدود می‌شوند ولی هر IP ساعتی - ۶۰ حسابِ مهمانِ تازه می‌سازد (`guest_session`)، و هر حسابِ تازه ۳ سؤالِ رایگانِ تازه دارد. سقفِ + و بعد فقط با اعتبارِ خریده‌شده؛ ۲۰ در ساعت. هر شماره‌ی تازه ۳ سؤالِ رایگان می‌گیرد و ساختنِ شماره با سقفِ پیامک + (بند ۲۴) محدود است. سقفِ روزانه‌ی کلِ سامانه (`AI_GLOBAL_DAILY_MAX`، ۳۰۰۰) خرجِ کل را محدود می‌کند. طولِ سؤال حداکثر ۲۰۰۰ نویسه است. - **خطر:** با چند IP، اعتبارِ GapGPT در چند ساعت تمام می‌شود (اعتبار یک بار در ۲۰۲۶-۰۹-۲۵ به صفر رسید، البته به دلیلِ دیگر). @@ -325,6 +324,7 @@ | حفاظ | تست | |---|---| +| ورودِ مهمان وجود ندارد (`POST /api/auth/session` ۴۰۴ می‌دهد و کاربر نمی‌سازد)؛ حسابِ مهمانِ قدیمی همچنان refresh و ارتقا دارد | `accounts/tests.py` (`test_guest_login_endpoint_is_gone`)، `accounts/tests_guest_refresh.py` | | پلنِ هر خرید روی سفارش؛ خرید اشتراکِ فعال را دست نمی‌زند؛ یک کدِ تخفیف پیش از درگاه؛ امتیاز فقط از جوابِ ثبت‌شده | `payment/tests_audit_fixes.py`، `accounts/tests_audit_fixes.py` | | لینکِ پرداخت (`/api/payment/orders/{id}/pay`) فقط با امضای کوتاه‌عمرِ همراهِ `paymentUrl` باز می‌شود | `payment/tests.py` (`PaymentRedirectViewTests`) | | توکنِ تمدید فقط به‌صورتِ هشِ SHA-256 ذخیره می‌شود | `accounts/tests.py` (`RefreshTokenStoredHashedTests`) | diff --git a/docs/technical-handbook.md b/docs/technical-handbook.md index b8f9447..5c19b47 100644 --- a/docs/technical-handbook.md +++ b/docs/technical-handbook.md @@ -448,7 +448,6 @@ with transaction.atomic(): | روش | مسیر | دسترسی | کار | |---|---|---|---| | **هویت و پروفایل — `accounts`** | | | | -| POST | `/api/auth/session` | عمومی | ساختِ کاربرِ مهمان با `deviceId` | | POST | `/api/auth/otp/request` | عمومی | فرستادنِ کدِ ورود | | POST | `/api/auth/login` | عمومی | ورود با شماره و کد | | POST | `/api/auth/refresh` | عمومی | توکنِ تازه با refresh token |