Remove guest login: every user registers with a phone number
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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`) وارد کنید.
|
||||
ورودِ مهمان نداریم.
|
||||
|
||||
دستورهای پرکاربردِ دیگر:
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ class SendSMSForm(forms.Form):
|
||||
|
||||
class DeviceIdForSuperuserOnly:
|
||||
"""
|
||||
شناسهی دستگاه برای مهمان تنها رمزِ ورود است (`POST /api/auth/session`)؛ پس فقط ابرکاربر
|
||||
شناسهی دستگاهِ حسابهای مهمانِ قدیمی (که دیگر ساخته نمیشوند) تنها رمزِ ورودشان بود؛ پس فقط ابرکاربر
|
||||
آن را در فهرست، جستجو و فرم میبیند، نه کارمندی با اجازهی دیدن (گروهِ Supervisor).
|
||||
"""
|
||||
|
||||
|
||||
@@ -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 ----------------------------------------------------------
|
||||
|
||||
@@ -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 '
|
||||
'خودش اضافه میکنه.'
|
||||
),
|
||||
|
||||
@@ -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()
|
||||
|
||||
30
accounts/testing.py
Normal file
30
accounts/testing.py
Normal file
@@ -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,
|
||||
}
|
||||
@@ -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())
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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'])
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 <token>\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'
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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`) وارد شوید.
|
||||
|
||||
⚠️ **این داده دستی روی دیتابیسِ پروداکشن نوشته شده و در گیت نیست.** پیش از باز
|
||||
شدنِ سرویس روی کاربرانِ واقعی باید برود، وگرنه اعلانها و لیدربردِ ساختگی را
|
||||
|
||||
@@ -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`) |
|
||||
|
||||
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user