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/` |
|
| پنلِ ادمین | `/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`) وارد کنید.
|
||||||
|
ورودِ مهمان نداریم.
|
||||||
|
|
||||||
دستورهای پرکاربردِ دیگر:
|
دستورهای پرکاربردِ دیگر:
|
||||||
|
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ class SendSMSForm(forms.Form):
|
|||||||
|
|
||||||
class DeviceIdForSuperuserOnly:
|
class DeviceIdForSuperuserOnly:
|
||||||
"""
|
"""
|
||||||
شناسهی دستگاه برای مهمان تنها رمزِ ورود است (`POST /api/auth/session`)؛ پس فقط ابرکاربر
|
شناسهی دستگاهِ حسابهای مهمانِ قدیمی (که دیگر ساخته نمیشوند) تنها رمزِ ورودشان بود؛ پس فقط ابرکاربر
|
||||||
آن را در فهرست، جستجو و فرم میبیند، نه کارمندی با اجازهی دیدن (گروهِ Supervisor).
|
آن را در فهرست، جستجو و فرم میبیند، نه کارمندی با اجازهی دیدن (گروهِ 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(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 ----------------------------------------------------------
|
||||||
|
|||||||
@@ -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 '
|
||||||
'خودش اضافه میکنه.'
|
'خودش اضافه میکنه.'
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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
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 .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())
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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'])
|
||||||
|
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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` (هفتگی و کل، با ترتیبِ عمداً متفاوت) نشسته تا تیمِ فرانت
|
|
||||||
بتواند آن صفحهها را قبل از وجودِ ترافیکِ واقعی ببیند.
|
|
||||||
|
|
||||||
⚠️ **این داده دستی روی دیتابیسِ پروداکشن نوشته شده و در گیت نیست.** پیش از باز
|
⚠️ **این داده دستی روی دیتابیسِ پروداکشن نوشته شده و در گیت نیست.** پیش از باز
|
||||||
شدنِ سرویس روی کاربرانِ واقعی باید برود، وگرنه اعلانها و لیدربردِ ساختگی را
|
شدنِ سرویس روی کاربرانِ واقعی باید برود، وگرنه اعلانها و لیدربردِ ساختگی را
|
||||||
|
|||||||
@@ -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`) |
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|||||||
Reference in New Issue
Block a user