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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user