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

@@ -51,7 +51,7 @@ class SendSMSForm(forms.Form):
class DeviceIdForSuperuserOnly:
"""
شناسه‌ی دستگاه برای مهمان تنها رمزِ ورود است (`POST /api/auth/session`)؛ پس فقط ابرکاربر
شناسه‌ی دستگاهِ حساب‌های مهمانِ قدیمی (که دیگر ساخته نمی‌شوند) تنها رمزِ ورودشان بود؛ پس فقط ابرکاربر
آن را در فهرست، جستجو و فرم می‌بیند، نه کارمندی با اجازه‌ی دیدن (گروهِ 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(
'\nنکته: این توکن‌ها با ACCESS_TOKEN_TTL_SECONDS فعلی امضا شدن و بعد از انقضا '
'باید دوباره seed یا از /api/auth/session یا /api/auth/login گرفته بشن.'
'باید دوباره seed یا از /api/auth/login گرفته بشن.'
)
# -- reset ----------------------------------------------------------

View File

@@ -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 '
'خودش اضافه می‌کنه.'
),

View File

@@ -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
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 .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())

View File

@@ -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()

View File

@@ -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'])

View File

@@ -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):

View File

@@ -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'),

View File

@@ -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)