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:
2026-10-05 17:38:18 +03:30
parent 03e81abe7e
commit 8835e01595
18 changed files with 101 additions and 198 deletions

View File

@@ -40,9 +40,10 @@ python manage.py runserver
| پنلِ ادمین | `/admin/` | | پنلِ ادمین | `/admin/` |
| سلامتِ سرویس | `/api/health` | | سلامتِ سرویس | `/api/health` |
**اولین درخواست در Swagger:** `POST /api/auth/session` با **اولین درخواست در Swagger:** در حالتِ `DJANGO_DEBUG=1` با `POST /api/auth/otp/request`
`{"type":"guest","deviceId":"d_test1"}` بزنید، `token` را بردارید، روی شماره‌ای بفرستید و با `POST /api/auth/login` و کدِ `DEV_MASTER_OTP` وارد شوید؛ `token` را
**Authorize** کلیک کنید و همان رشته را (بدونِ کلمه‌ی `Bearer`) وارد کنید. بردارید، روی **Authorize** کلیک کنید و همان رشته را (بدونِ کلمه‌ی `Bearer`) وارد کنید.
ورودِ مهمان نداریم.
دستورهای پرکاربردِ دیگر: دستورهای پرکاربردِ دیگر:

View File

@@ -51,7 +51,7 @@ class SendSMSForm(forms.Form):
class DeviceIdForSuperuserOnly: class DeviceIdForSuperuserOnly:
""" """
شناسه‌ی دستگاه برای مهمان تنها رمزِ ورود است (`POST /api/auth/session`)؛ پس فقط ابرکاربر شناسه‌ی دستگاهِ حساب‌های مهمانِ قدیمی (که دیگر ساخته نمی‌شوند) تنها رمزِ ورودشان بود؛ پس فقط ابرکاربر
آن را در فهرست، جستجو و فرم می‌بیند، نه کارمندی با اجازه‌ی دیدن (گروهِ Supervisor). آن را در فهرست، جستجو و فرم می‌بیند، نه کارمندی با اجازه‌ی دیدن (گروهِ Supervisor).
""" """

View File

@@ -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(f' - {label}: user_id={user.pk} phone={user.phone or "-"} token={token}')
self.stdout.write( self.stdout.write(
'\nنکته: این توکن‌ها با ACCESS_TOKEN_TTL_SECONDS فعلی امضا شدن و بعد از انقضا ' '\nنکته: این توکن‌ها با ACCESS_TOKEN_TTL_SECONDS فعلی امضا شدن و بعد از انقضا '
'باید دوباره seed یا از /api/auth/session یا /api/auth/login گرفته بشن.' 'باید دوباره seed یا از /api/auth/login گرفته بشن.'
) )
# -- reset ---------------------------------------------------------- # -- reset ----------------------------------------------------------

View File

@@ -15,7 +15,7 @@ class TadrisinoTokenAuthenticationScheme(OpenApiAuthenticationExtension):
'scheme': 'bearer', 'scheme': 'bearer',
'bearerFormat': 'access_token', 'bearerFormat': 'access_token',
'description': ( 'description': (
'توکن دسترسی که از /api/auth/session یا /api/auth/login گرفته می‌شه. ' 'توکن دسترسی که از /api/auth/login گرفته می‌شه. '
'در فیلد Value فقط خودِ توکن رو بذارید (بدون کلمه‌ی Bearer)؛ Swagger ' 'در فیلد Value فقط خودِ توکن رو بذارید (بدون کلمه‌ی Bearer)؛ Swagger '
'خودش اضافه می‌کنه.' 'خودش اضافه می‌کنه.'
), ),

View File

@@ -45,11 +45,6 @@ class IranMobileField(serializers.CharField):
# ---- Request serializers ---- # ---- Request serializers ----
class GuestSessionRequestSerializer(serializers.Serializer):
type = serializers.ChoiceField(choices=['guest'])
deviceId = serializers.CharField(max_length=64)
class OTPCodeField(serializers.CharField): class OTPCodeField(serializers.CharField):
""" """
کدِ ورود: ۴ تا ۶ رقم (کدِ پیامکی ۶ رقمی است، کدهای TEST_LOGIN_PHONES ۴ تا ۶). رقمِ فارسی و کدِ ورود: ۴ تا ۶ رقم (کدِ پیامکی ۶ رقمی است، کدهای TEST_LOGIN_PHONES ۴ تا ۶). رقمِ فارسی و
@@ -157,14 +152,6 @@ class RequestOTPRequestSerializer(serializers.Serializer):
# ---- Response serializers ---- # ---- 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): class LoginResponseSerializer(serializers.Serializer):
userId = serializers.CharField() userId = serializers.CharField()
token = serializers.CharField() token = serializers.CharField()

30
accounts/testing.py Normal file
View 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,
}

View File

@@ -14,6 +14,7 @@ from rest_framework.throttling import ScopedRateThrottle
from .authentication import issue_access_token from .authentication import issue_access_token
from .models import OTPCode, Profile, Session, User, hash_refresh_token from .models import OTPCode, Profile, Session, User, hash_refresh_token
from .testing import guest_session
from .services import OTPDeliveryError, SMSDeliveryError, send_otp_sms from .services import OTPDeliveryError, SMSDeliveryError, send_otp_sms
@@ -834,38 +835,15 @@ class GuestSessionThrottleTests(TestCase):
self.client = APIClient() self.client = APIClient()
cache.clear() cache.clear()
@override_settings( def test_guest_login_endpoint_is_gone(self):
REST_FRAMEWORK={ # ورودِ مهمان حذف شده: هر کاربر باید با شماره ثبت‌نام کند. قدیمی‌ها باید ۴۰۴
**settings.REST_FRAMEWORK, # بگیرند، نه حسابِ مهمانِ ساخته‌شده.
'DEFAULT_THROTTLE_RATES': { before = User.objects.count()
**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( response = self.client.post(
'/api/auth/session', '/api/auth/session', {'type': 'guest', 'deviceId': 'device-x'}, format='json',
{'type': 'guest', 'deviceId': f'device-{i}'},
format='json',
) )
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 404)
self.assertEqual(User.objects.count(), before)
blocked_response = self.client.post(
'/api/auth/session',
{'type': 'guest', 'deviceId': 'device-blocked'},
format='json',
)
self.assertEqual(blocked_response.status_code, 429)
class UserProfileViewTests(TestCase): class UserProfileViewTests(TestCase):
@@ -1682,7 +1660,7 @@ class RefreshTokenStoredHashedTests(TestCase):
def test_issued_tokens_are_stored_only_as_hash(self): def test_issued_tokens_are_stored_only_as_hash(self):
client = APIClient() client = APIClient()
body = client.post('/api/auth/session', {'type': 'guest', 'deviceId': 'hash-dev'}, format='json').json() body = guest_session('hash-dev')
raw = body['refreshToken'] raw = body['refreshToken']
self.assertFalse(Session.objects.filter(refresh_token=raw).exists()) self.assertFalse(Session.objects.filter(refresh_token=raw).exists())
self.assertTrue(Session.objects.filter(refresh_token=hash_refresh_token(raw)).exists()) self.assertTrue(Session.objects.filter(refresh_token=hash_refresh_token(raw)).exists())

View File

@@ -9,6 +9,7 @@ from django.test import TestCase, override_settings
from rest_framework.test import APIClient from rest_framework.test import APIClient
from accounts.authentication import issue_access_token from accounts.authentication import issue_access_token
from accounts.testing import guest_session
from accounts.models import OTPCode, User from accounts.models import OTPCode, User
from content.models import Choice, Grade, Lesson, Node, Question, Unit from content.models import Choice, Grade, Lesson, Node, Question, Unit
from progress.models import UserHearts, UserStats from progress.models import UserHearts, UserStats
@@ -25,8 +26,7 @@ class LogoutTests(TestCase):
cache.clear() cache.clear()
def test_logout_kills_the_current_token_and_its_refresh(self): def test_logout_kills_the_current_token_and_its_refresh(self):
guest = APIClient().post('/api/auth/session', {'type': 'guest', 'deviceId': 'dev-out'}, guest = guest_session('dev-out')
format='json').json()
client = APIClient() client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f'Bearer {guest["token"]}') client.credentials(HTTP_AUTHORIZATION=f'Bearer {guest["token"]}')
self.assertEqual(client.get('/api/user/profile').status_code, 200) self.assertEqual(client.get('/api/user/profile').status_code, 200)
@@ -40,8 +40,7 @@ class LogoutTests(TestCase):
self.assertEqual(refreshed.status_code, 401) self.assertEqual(refreshed.status_code, 401)
# ورودِ دوباره کار می‌کند # ورودِ دوباره کار می‌کند
again = APIClient().post('/api/auth/session', {'type': 'guest', 'deviceId': 'dev-out'}, again = guest_session('dev-out')
format='json').json()
fresh = APIClient() fresh = APIClient()
fresh.credentials(HTTP_AUTHORIZATION=f'Bearer {again["token"]}') fresh.credentials(HTTP_AUTHORIZATION=f'Bearer {again["token"]}')
self.assertEqual(fresh.get('/api/user/profile').status_code, 200) self.assertEqual(fresh.get('/api/user/profile').status_code, 200)
@@ -72,8 +71,7 @@ class OtpTests(TestCase):
class GuestSignupKeepsProgressTests(TestCase): class GuestSignupKeepsProgressTests(TestCase):
def setUp(self): def setUp(self):
cache.clear() cache.clear()
guest = APIClient().post('/api/auth/session', {'type': 'guest', 'deviceId': 'dev-up'}, guest = guest_session('dev-up')
format='json').json()
self.guest_id = int(guest['userId']) self.guest_id = int(guest['userId'])
UserStats.objects.create(user_id=self.guest_id, xp=120) UserStats.objects.create(user_id=self.guest_id, xp=120)
self.client = APIClient() self.client = APIClient()

View File

@@ -7,18 +7,17 @@
from django.core.cache import cache from django.core.cache import cache
from django.test import TestCase from django.test import TestCase
from accounts.testing import guest_session
class GuestRefreshTokenTests(TestCase): class GuestRefreshTokenTests(TestCase):
def setUp(self): def setUp(self):
cache.clear() cache.clear()
def _guest(self, device='device-abc'): def _guest(self, device='device-abc'):
return self.client.post( return guest_session(device)
'/api/auth/session', {'type': 'guest', 'deviceId': device},
content_type='application/json',
).json()
def test_guest_session_returns_a_refresh_token(self): def test_guest_session_has_a_refresh_token(self):
body = self._guest() body = self._guest()
self.assertTrue(body['refreshToken']) self.assertTrue(body['refreshToken'])

View File

@@ -14,6 +14,7 @@ from django.utils import timezone
from rest_framework.test import APIClient from rest_framework.test import APIClient
from accounts.authentication import issue_access_token from accounts.authentication import issue_access_token
from accounts.testing import guest_session
from accounts.models import OTPCode, User from accounts.models import OTPCode, User
from config.log_formatters import JSONLogFormatter from config.log_formatters import JSONLogFormatter
@@ -30,7 +31,7 @@ def _otp_row(phone, code='482913'):
def _guest(device): def _guest(device):
return APIClient().post('/api/auth/session', {'type': 'guest', 'deviceId': device}, format='json').json() return guest_session(device)
class GuestUpgradeRevokesEarlierSessionsTests(TestCase): class GuestUpgradeRevokesEarlierSessionsTests(TestCase):

View File

@@ -2,7 +2,6 @@ from django.urls import path
from .views import ( from .views import (
DeleteAccountView, DeleteAccountView,
GuestSessionView,
LoginView, LoginView,
LogoutView, LogoutView,
RefreshTokenView, RefreshTokenView,
@@ -12,7 +11,6 @@ from .views import (
) )
urlpatterns = [ urlpatterns = [
path('auth/session', GuestSessionView.as_view(), name='auth-session'),
path('auth/otp/request', RequestOTPView.as_view(), name='auth-otp-request'), path('auth/otp/request', RequestOTPView.as_view(), name='auth-otp-request'),
path('auth/login', LoginView.as_view(), name='auth-login'), path('auth/login', LoginView.as_view(), name='auth-login'),
path('auth/refresh', RefreshTokenView.as_view(), name='auth-refresh'), path('auth/refresh', RefreshTokenView.as_view(), name='auth-refresh'),

View File

@@ -21,8 +21,6 @@ from .services import OTPDeliveryError, send_otp_sms
from .serializers import ( from .serializers import (
DeleteAccountRequestSerializer, DeleteAccountRequestSerializer,
DeleteAccountResponseSerializer, DeleteAccountResponseSerializer,
GuestSessionRequestSerializer,
GuestSessionResponseSerializer,
LoginRequestSerializer, LoginRequestSerializer,
LoginResponseSerializer, LoginResponseSerializer,
LogoutRequestSerializer, LogoutRequestSerializer,
@@ -43,7 +41,7 @@ logger = logging.getLogger(__name__)
class GuestAwareAccountThrottle(ScopedRateThrottle): class GuestAwareAccountThrottle(ScopedRateThrottle):
""" """
همان الگویِ GuestAwareContentThrottle (content/views.py)، همان الگویِ GuestAwareContentThrottle (content/views.py)،
GuestAwareAIThrottle (ai_tutor/views.py) و بقیه؛ چون GuestSessionView GuestAwareAIThrottle (ai_tutor/views.py) و بقیه؛ چون ورودِ مهمان (حذف‌شده؛ حساب‌های مهمانِ قبلی هنوز هستند)
با هر deviceId جدید یک کاربرِ مهمانِ تازه (pk جدید) می‌سازه، یک با هر deviceId جدید یک کاربرِ مهمانِ تازه (pk جدید) می‌سازه، یک
ScopedRateThrottleِ سادهٔ per-user توسطِ چرخشِ حساب مهمان دورزدنی ScopedRateThrottleِ سادهٔ per-user توسطِ چرخشِ حساب مهمان دورزدنی
بود. اینجا برای SetUserGradeView (که مهمان‌ها هم می‌تونن صداش بزنن) بود. اینجا برای SetUserGradeView (که مهمان‌ها هم می‌تونن صداش بزنن)
@@ -64,58 +62,6 @@ class GuestAwareAccountThrottle(ScopedRateThrottle):
return self.cache_format % {'scope': self.scope, 'ident': ident} 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): class RequestOTPView(APIView):
""" """
POST /api/auth/otp/request — درخواستِ کدِ ورود. POST /api/auth/otp/request — درخواستِ کدِ ورود.
@@ -650,7 +596,7 @@ class UserProfileView(APIView):
def get(self, request): def get(self, request):
user = request.user user = request.user
# مدل Profile به‌صورت lazy ساخته می‌شه چون هیچ‌جای دیگه‌ای (نه # مدل Profile به‌صورت lazy ساخته می‌شه چون هیچ‌جای دیگه‌ای (نه
# GuestSessionView، نه LoginView) رکورد Profile رو از قبل نمی‌سازه؛ # LoginView) رکورد Profile رو از قبل نمی‌سازه؛
# get_or_create این‌جا امن است چون فقط توسطِ کاربرِ همون رکورد صدا # get_or_create این‌جا امن است چون فقط توسطِ کاربرِ همون رکورد صدا
# زده می‌شه (OneToOne روی user_id جاری، نه ورودیِ خارجی). # زده می‌شه (OneToOne روی user_id جاری، نه ورودیِ خارجی).
profile, _ = Profile.objects.get_or_create(user_id=user.id) profile, _ = Profile.objects.get_or_create(user_id=user.id)

View File

@@ -481,7 +481,6 @@ REST_FRAMEWORK = {
# در دیتابیس)؛ این‌ها فقط جلوی یک ماشینِ تنها را می‌گیرند. # در دیتابیس)؛ این‌ها فقط جلوی یک ماشینِ تنها را می‌گیرند.
'otp_request': os.environ.get('OTP_REQUEST_IP_RATE', '20/hour'), 'otp_request': os.environ.get('OTP_REQUEST_IP_RATE', '20/hour'),
'otp_login': os.environ.get('OTP_LOGIN_IP_RATE', '30/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_verify': '10/hour',
'bazaar_oauth': '20/hour', 'bazaar_oauth': '20/hour',
'ai_ask': '20/hour', 'ai_ask': '20/hour',
@@ -537,21 +536,10 @@ SPECTACULAR_SETTINGS = {
'Authorization: Bearer <token>\n' 'Authorization: Bearer <token>\n'
'```\n' '```\n'
'\n' '\n'
'دو راه برای گرفتنِ توکن هست:\n' 'توکن را فقط با شماره‌ی موبایل می‌گیرید: `POST /api/auth/otp/request` '
'\n' 'و بعد `POST /api/auth/login`. ورودِ مهمان نداریم؛ هر کاربر حداقل ثبت‌نامِ '
'| مسیر | کاربرد |\n' 'اولیه با شماره دارد. توکنِ دسترسی یک ساعت اعتبار دارد؛ با '
'|---|---|\n' '`POST /api/auth/refresh` تازه‌اش کنید.\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'
'\n' '\n'
'**در همین صفحه:** روی **Authorize** بزنید و *فقط خودِ توکن* را وارد ' '**در همین صفحه:** روی **Authorize** بزنید و *فقط خودِ توکن* را وارد '
'کنید — کلمه‌ی `Bearer` را Swagger خودش اضافه می‌کند.\n' 'کنید — کلمه‌ی `Bearer` را Swagger خودش اضافه می‌کند.\n'

View File

@@ -202,18 +202,18 @@ class APIRequestLoggingMiddlewareTests(TestCase):
def test_anonymous_api_request_is_logged_with_null_user_id(self): def test_anonymous_api_request_is_logged_with_null_user_id(self):
with self.assertLogs('config.middleware', level='INFO') as captured: with self.assertLogs('config.middleware', level='INFO') as captured:
response = self.client.post( response = self.client.post(
'/api/auth/session', '/api/auth/refresh',
{'type': 'guest', 'deviceId': 'device-mw-log-anon-1'}, {'refreshToken': 'not-a-real-token'},
format='json', format='json',
) )
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 401)
self.assertEqual(len(captured.records), 1) self.assertEqual(len(captured.records), 1)
message = captured.records[0].getMessage() message = captured.records[0].getMessage()
self.assertIn('method=POST', message) self.assertIn('method=POST', message)
self.assertIn('path=/api/auth/session', message) self.assertIn('path=/api/auth/refresh', message)
self.assertIn('status=200', message) self.assertIn('status=401', message)
self.assertIn('user_id=None', message) self.assertIn('user_id=None', message)
def test_non_api_request_is_not_logged(self): def test_non_api_request_is_not_logged(self):

View File

@@ -13,6 +13,7 @@ from django.test import TestCase
from rest_framework.test import APIClient from rest_framework.test import APIClient
from accounts.models import User from accounts.models import User
from accounts.testing import guest_session
from content.models import Choice, Grade, Lesson, Node, Question, Unit 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}') self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {token}')
def test_a_new_user_can_go_from_signup_to_finishing_a_lesson(self): def test_a_new_user_can_go_from_signup_to_finishing_a_lesson(self):
# ۱. ورودِ مهمان — سریع‌ترین مسیرِ ورود که اپ می‌تواند بگیرد. # ۱. حسابِ مهمانِ قدیمی (ورودِ مهمان حذف شده؛ این ردیف‌ها هنوز هستند).
response = self.client.post( session = guest_session('flow-device-1')
'/api/auth/session', {'type': 'guest', 'deviceId': 'flow-device-1'}, format='json', self._auth(session['token'])
)
self.assertEqual(response.status_code, 200, response.data)
self._auth(response.data['token'])
# ۲. صفحه‌ی «بیا باهم آشنا بشیم»: اسم + پایه. # ۲. صفحه‌ی «بیا باهم آشنا بشیم»: اسم + پایه.
response = self.client.patch('/api/user/profile', {'name': 'سارا'}, format='json') 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): def test_completing_twice_with_the_same_key_does_not_double_count(self):
from content.models import UserLessonCompletion from content.models import UserLessonCompletion
response = self.client.post( session = guest_session('flow-device-2')
'/api/auth/session', {'type': 'guest', 'deviceId': 'flow-device-2'}, format='json', self._auth(session['token'])
)
self._auth(response.data['token'])
self.client.post('/api/user/grade', {'gradeId': self.grade.id}, format='json') self.client.post('/api/user/grade', {'gradeId': self.grade.id}, format='json')
self.client.post( self.client.post(
f'/api/questions/{self.question.id}/answer', f'/api/questions/{self.question.id}/answer',
@@ -148,10 +144,8 @@ class BasicLearningFlowTests(TestCase):
به‌روز می‌شود. بدونِ دانستنِ این، توسعه‌دهنده‌ی فرانت دنبالِ باگی به‌روز می‌شود. بدونِ دانستنِ این، توسعه‌دهنده‌ی فرانت دنبالِ باگی
می‌گردد که وجود ندارد. می‌گردد که وجود ندارد.
""" """
response = self.client.post( session = guest_session('flow-device-4')
'/api/auth/session', {'type': 'guest', 'deviceId': 'flow-device-4'}, format='json', self._auth(session['token'])
)
self._auth(response.data['token'])
self.client.post('/api/user/grade', {'gradeId': self.grade.id}, format='json') self.client.post('/api/user/grade', {'gradeId': self.grade.id}, format='json')
answer = self.client.post( answer = self.client.post(
@@ -168,10 +162,8 @@ class BasicLearningFlowTests(TestCase):
self.assertGreater(self.client.get('/api/user/summary').data['xp'], 0) 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): def test_the_map_is_empty_but_healthy_before_a_grade_is_chosen(self):
response = self.client.post( session = guest_session('flow-device-3')
'/api/auth/session', {'type': 'guest', 'deviceId': 'flow-device-3'}, format='json', self._auth(session['token'])
)
self._auth(response.data['token'])
home = self.client.get('/api/home') home = self.client.get('/api/home')
self.assertEqual(home.status_code, 200) self.assertEqual(home.status_code, 200)

View File

@@ -55,9 +55,7 @@ curl https://mathapi.tadrisino2.ir/api/health
```bash ```bash
curl -i https://mathapi.tadrisino2.ir/api/schema/ curl -i https://mathapi.tadrisino2.ir/api/schema/
curl -i -X POST https://mathapi.tadrisino2.ir/api/auth/session \ curl -i https://mathapi.tadrisino2.ir/api/health
-H 'Content-Type: application/json' \
-d '{"type":"guest","deviceId":"verify-1"}'
``` ```
## بکاپ و بازیابی ## بکاپ و بازیابی
@@ -254,22 +252,10 @@ curl -X POST https://mathapi.tadrisino2.ir/api/auth/login \
### حسابِ مهمانِ تستِ فرانت ### حسابِ مهمانِ تستِ فرانت
مسیرِ مهمان (`POST /api/auth/session` با یک `deviceId`) اصلاً به پیامک کار ورودِ مهمان (`POST /api/auth/session`) حذف شده و دیگر با `frontend-test-device` توکن نمی‌دهد. ردیفِ
ندارد و همیشه کار می‌کند — هر رشته‌ای به‌عنوانِ `deviceId` یک حسابِ تازه کاربرِ شماره‌ی ۶ («حساب تست فرانت»، `is_guest=True`) با ۲۴ ردیفِ `Notification` و ۲۰ ردیفِ
می‌سازد. از ۲۰۲۶-۰۹-۲۴ پاسخش `refreshToken` هم دارد (مثلِ ورود با شماره)، پس `LeaderboardEntry` هنوز روی پروداکشن است؛ برای دیدنِ آن صفحه‌ها با حسابِ دموی شماره‌دار
مهمان بعد از انقضای توکنِ یک‌ساعته با `POST /api/auth/refresh` ادامه می‌دهد. (`TEST_LOGIN_PHONES`) وارد شوید.
یک `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` (هفتگی و کل، با ترتیبِ عمداً متفاوت) نشسته تا تیمِ فرانت
بتواند آن صفحه‌ها را قبل از وجودِ ترافیکِ واقعی ببیند.
⚠️ **این داده دستی روی دیتابیسِ پروداکشن نوشته شده و در گیت نیست.** پیش از باز ⚠️ **این داده دستی روی دیتابیسِ پروداکشن نوشته شده و در گیت نیست.** پیش از باز
شدنِ سرویس روی کاربرانِ واقعی باید برود، وگرنه اعلان‌ها و لیدربردِ ساختگی را شدنِ سرویس روی کاربرانِ واقعی باید برود، وگرنه اعلان‌ها و لیدربردِ ساختگی را

View File

@@ -23,15 +23,15 @@
|---|---|---|---| |---|---|---|---|
| ۱ | کلیدِ امضای توکن روی سرور همان رشته‌ی داخلِ ریپو است | بحرانی — جعلِ ورود به‌جای هر کاربر | کد آماده؛ اعمال روی سرور | | ۱ | کلیدِ امضای توکن روی سرور همان رشته‌ی داخلِ ریپو است | بحرانی — جعلِ ورود به‌جای هر کاربر | کد آماده؛ اعمال روی سرور |
| ۲ | ۷ حسابِ دمو با کدِ ورودِ ثابت روی پروداکشن | بالا | `.env` سرور | | ۲ | ۷ حسابِ دمو با کدِ ورودِ ثابت روی پروداکشن | بالا | `.env` سرور |
| ۳ | داده‌ی دمو و حسابِ مهمانِ عمومیِ `frontend-test-device` | بالا | دیتابیسِ سرور | | ۳ | داده‌ی دمو و حسابِ مهمانِ نمایشیِ `frontend-test-device` | بالا | دیتابیسِ سرور |
| ۴ | `/api/health` جزئیاتِ داخلی را به همه نشان می‌دهد | متوسط | کد | | ۴ | `/api/health` جزئیاتِ داخلی را به همه نشان می‌دهد | متوسط | کد |
| ۵ | Swagger، ReDoc و schemaی کاملِ API عمومی‌اند | متوسط | کد | | ۵ | Swagger، ReDoc و schemaی کاملِ API عمومی‌اند | متوسط | کد |
| ۷ | پنلِ `/admin/` روی دامنه‌ی عمومی | متوسط | nginx + کد | | ۷ | پنلِ `/admin/` روی دامنه‌ی عمومی | متوسط | nginx + کد |
| ۸ | سرور بدونِ ArvanCloud روی پورتِ ۸۰ هم جواب می‌دهد | متوسط | ادمینِ سرور | | ۸ | سرور بدونِ ArvanCloud روی پورتِ ۸۰ هم جواب می‌دهد | متوسط | ادمینِ سرور |
| ۹ | کشِ مشترک (Redis) نیست؛ محدودیت‌ها per-worker‌اند | متوسط | `.env` سرور | | ۹ | کشِ مشترک (Redis) نیست؛ محدودیت‌ها per-worker‌اند | متوسط | `.env` سرور |
| ۱۰ | ورودِ مهمان فقط با `deviceId` است | کم (طراحی) | کد | | ۱۰ | حساب‌های مهمانِ قدیمی (دمو و کاربرانِ پیش از حذفِ ورودِ مهمان) هنوز در دیتابیس‌اند | کم | دیتابیسِ سرور |
| ۱۱ | کدِ OTP بی‌هش در دیتابیس و در فهرستِ پنل | کم | کد | | ۱۱ | کدِ OTP بی‌هش در دیتابیس و در فهرستِ پنل | کم | کد |
| ۱۲ | هزینه‌ی دستیارِ AI با ساختِ حسابِ مهمان قابلِ بالا بردن است | کم (مالی) | کد | | ۱۲ | هزینه‌ی دستیارِ AI با ساختِ حسابِ تازه (هر شماره ۳ سوالِ رایگان) قابلِ بالا بردن است | کم (مالی) | کد |
| ۱۴ | SQLite روی دیسکِ مشترکِ پُر، بدونِ بکاپِ خودکارِ بیرون از ماشین | کم (داده) | زیرساخت | | ۱۴ | SQLite روی دیسکِ مشترکِ پُر، بدونِ بکاپِ خودکارِ بیرون از ماشین | کم (داده) | زیرساخت |
| ۱۵ | دسترسیِ سرور: حسابِ مشترک، رمز، sudoِ کامل | بالا | ادمینِ سرور | | ۱۵ | دسترسیِ سرور: حسابِ مشترک، رمز، sudoِ کامل | بالا | ادمینِ سرور |
| ۱۶ | فرانت توکن را در `localStorage` نگه می‌دارد | کم | فرانت | | ۱۶ | فرانت توکن را در `localStorage` نگه می‌دارد | کم | فرانت |
@@ -85,15 +85,15 @@
از تستِ دستیار؛ همراهِ بقیه پاک شوند. از تستِ دستیار؛ همراهِ بقیه پاک شوند.
- **چیست:** کاربرهای ۶ (`deviceId = frontend-test-device`) و ۱۰ تا ۱۵، با درس، گفتگو، - **چیست:** کاربرهای ۶ (`deviceId = frontend-test-device`) و ۱۰ تا ۱۵، با درس، گفتگو،
تیکت، خانواده و زیرمجموعه، روی دیتابیسِ پروداکشن ساخته شده‌اند. `frontend-test-device` تیکت، خانواده و زیرمجموعه، روی دیتابیسِ پروداکشن ساخته شده‌اند. `frontend-test-device`
در ریپو نوشته شده؛ هر کس `POST /api/auth/session` با همین `deviceId` بزند، توکنِ همان در ریپو نوشته شده. `POST /api/auth/session` حذف شده، پس با آن دیگر توکن نمی‌دهد؛ ولی خودِ
حساب را می‌گیرد. ردیف‌ها (با `is_guest=True`) تا پاک نشوند در دیتابیس‌اند.
- **خطر:** داده‌ی ساختگی در آمار و جدولِ رتبه‌بندیِ واقعی؛ یک حسابِ عمومی که هر کس - **خطر:** داده‌ی ساختگی در آمار و جدولِ رتبه‌بندیِ واقعی؛ یک حسابِ عمومی که هر کس
می‌تواند با آن کار کند (ثبتِ تیکت، گفتگو با AI به هزینه‌ی ما). می‌تواند با آن کار کند (ثبتِ تیکت، گفتگو با AI به هزینه‌ی ما).
- **رفع:** پیش از کاربرِ واقعی پاک شوند. `purge_demo_data` این‌ها را **نمی‌گیرد** (از مسیرِ - **رفع:** پیش از کاربرِ واقعی پاک شوند. `purge_demo_data` این‌ها را **نمی‌گیرد** (از مسیرِ
واقعیِ viewها ساخته شده‌اند)؛ باید با شناسه پاک شوند، پیش از آن بکاپ. همراهِ آن پیش‌فرضِ واقعیِ viewها ساخته شده‌اند)؛ باید با شناسه پاک شوند، پیش از آن بکاپ. همراهِ آن پیش‌فرضِ
`DEMO_GUEST_DEVICE_IDS` در `config/settings.py` (که حذفِ این مهمان را با `POST /api/user/delete` `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` عمومی و پرجزئیات ## ۴. `/api/health` عمومی و پرجزئیات
@@ -157,16 +157,15 @@
- **رفع:** `CACHE_URL=redis://127.0.0.1:6379/1` (db0 مالِ اپِ دیگری است) و ری‌استارت. - **رفع:** `CACHE_URL=redis://127.0.0.1:6379/1` (db0 مالِ اپِ دیگری است) و ری‌استارت.
- **چطور بسنجید:** `/api/health` → `checks.cache.backend` نباید `LocMemCache` باشد. - **چطور بسنجید:** `/api/health` → `checks.cache.backend` نباید `LocMemCache` باشد.
## ۱۰. ورودِ مهمان فقط با `deviceId` ## ۱۰. حساب‌های مهمانِ قدیمی
- **چیست:** `POST /api/auth/session` با هر `deviceId` یا حسابِ مهمانِ همان دستگاه را - **چیست:** ورودِ مهمان (`POST /api/auth/session`) حذف شده و هر کاربرِ تازه با شماره ثبت‌نام می‌کند.
برمی‌گرداند یا می‌سازد (`accounts/views.py::GuestSessionView`). هیچ رازِ دیگری در کار نیست. ردیف‌های مهمانِ پیشین (`User.is_guest=True`، شامل دموها) در دیتابیس مانده‌اند و کدِ ارتقای مهمان به
- **خطر:** هر کس `deviceId`ِ یک مهمان را بداند (از `localStorage`، لاگ، اسکرین‌شات) وارد حسابِ شماره‌دار (`LoginView`) و throttleهای مهمان‌محور هنوز برایشان کار می‌کنند.
حسابِ او می‌شود. بعد از ثبت‌نام با شماره این راه بسته می‌شود (مهمان دیگر مهمان نیست) و همه‌ی - **خطر:** مهمانِ قدیمی هنوز با `refreshToken` ذخیره‌شده در مرورگرش وارد می‌ماند؛ شناسه‌ی دستگاه دیگر
نشست‌هایی که پیش از آن گرفته شده بودند باطل می‌شوند. شناسه‌ی دستگاه در پنل فقط برای ابرکاربر راهِ ورود نیست.
نمایش داده می‌شود. - **رفع:** پس از پاک‌سازیِ دموها (بند ۳) و دیدنِ این‌که مهمانِ واقعی نمانده، کدِ مهمان
- **رفع:** برای مهمان هم یک رازِ تصادفیِ سمتِ سرور صادر شود و ورودِ دوباره با همان راز باشد، (`is_guest`، `GuestAware*Throttle`، ارتقا) حذف شود. تا آن روز عمداً نگه داشته شده.
نه با `deviceId`ِ خام.
## ۱۱. کدِ OTP بی‌هش در دیتابیس ## ۱۱. کدِ OTP بی‌هش در دیتابیس
@@ -179,8 +178,8 @@
## ۱۲. هزینه‌ی دستیارِ AI ## ۱۲. هزینه‌ی دستیارِ AI
- **چیست:** هر کاربر روزی ۳ سؤالِ رایگان دارد (`AI_DAILY_QUOTA_FREE`) - **چیست:** هر کاربر روزی ۳ سؤالِ رایگان دارد (`AI_DAILY_QUOTA_FREE`)
و بعد فقط با اعتبارِ خریده‌شده؛ ۲۰ در ساعت. مهمان‌ها بر اساسِ IP محدود می‌شوند ولی هر IP ساعتی و بعد فقط با اعتبارِ خریده‌شده؛ ۲۰ در ساعت. هر شماره‌ی تازه ۳ سؤالِ رایگان می‌گیرد و ساختنِ شماره با سقفِ پیامک
۶۰ حسابِ مهمانِ تازه می‌سازد (`guest_session`)، و هر حسابِ تازه ۳ سؤالِ رایگانِ تازه دارد. سقفِ (بند ۲۴) محدود است. سقفِ
روزانه‌ی کلِ سامانه (`AI_GLOBAL_DAILY_MAX`، ۳۰۰۰) خرجِ کل را محدود می‌کند. طولِ سؤال حداکثر ۲۰۰۰ نویسه است. روزانه‌ی کلِ سامانه (`AI_GLOBAL_DAILY_MAX`، ۳۰۰۰) خرجِ کل را محدود می‌کند. طولِ سؤال حداکثر ۲۰۰۰ نویسه است.
- **خطر:** با چند IP، اعتبارِ GapGPT در چند ساعت تمام می‌شود (اعتبار یک بار در ۲۰۲۶-۰۹-۲۵ - **خطر:** با چند 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` | | پلنِ هر خرید روی سفارش؛ خرید اشتراکِ فعال را دست نمی‌زند؛ یک کدِ تخفیف پیش از درگاه؛ امتیاز فقط از جوابِ ثبت‌شده | `payment/tests_audit_fixes.py`، `accounts/tests_audit_fixes.py` |
| لینکِ پرداخت (`/api/payment/orders/{id}/pay`) فقط با امضای کوتاه‌عمرِ همراهِ `paymentUrl` باز می‌شود | `payment/tests.py` (`PaymentRedirectViewTests`) | | لینکِ پرداخت (`/api/payment/orders/{id}/pay`) فقط با امضای کوتاه‌عمرِ همراهِ `paymentUrl` باز می‌شود | `payment/tests.py` (`PaymentRedirectViewTests`) |
| توکنِ تمدید فقط به‌صورتِ هشِ SHA-256 ذخیره می‌شود | `accounts/tests.py` (`RefreshTokenStoredHashedTests`) | | توکنِ تمدید فقط به‌صورتِ هشِ SHA-256 ذخیره می‌شود | `accounts/tests.py` (`RefreshTokenStoredHashedTests`) |

View File

@@ -448,7 +448,6 @@ with transaction.atomic():
| روش | مسیر | دسترسی | کار | | روش | مسیر | دسترسی | کار |
|---|---|---|---| |---|---|---|---|
| **هویت و پروفایل — `accounts`** | | | | | **هویت و پروفایل — `accounts`** | | | |
| POST | `/api/auth/session` | عمومی | ساختِ کاربرِ مهمان با `deviceId` |
| POST | `/api/auth/otp/request` | عمومی | فرستادنِ کدِ ورود | | POST | `/api/auth/otp/request` | عمومی | فرستادنِ کدِ ورود |
| POST | `/api/auth/login` | عمومی | ورود با شماره و کد | | POST | `/api/auth/login` | عمومی | ورود با شماره و کد |
| POST | `/api/auth/refresh` | عمومی | توکنِ تازه با refresh token | | POST | `/api/auth/refresh` | عمومی | توکنِ تازه با refresh token |