- remove the family app from the root (separate parent accounts, invite codes, parental settings); progress.0006 drops its tables, permissions and migration history, and Profile.parent_phone, the two family notification types and ?userId= on /api/progress/nodes go with it - GET /api/user/weekly-report: last 7 days next to the 7 before (estimated minutes, questions, accuracy, islands, tutor questions) for the "parent report" page - profile birthDate in Jalali (accounts/jalali.py), ages 5-99, birthYear derived from it - GET /api/payment/invoices; orders now snapshot list price and plan title - tutor answers only math: server pre-filter, scope rule in the prompt (tutor-v11), refusal marker replaced with fixed text; self-harm gets a help line instead - streamed answers now record token usage (it was always 0) - daily tutor quota: AI_DAILY_QUOTA_FREE (default 3) vs Plan.ai_daily_quota - delete_users management command for wiping test accounts across every app - docs/ai-cost-and-guardrails.md: measured cost per question and guardrail results Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
628 lines
32 KiB
Python
628 lines
32 KiB
Python
import logging
|
||
import math
|
||
import secrets
|
||
import uuid
|
||
|
||
from django.conf import settings
|
||
from django.core.cache import cache
|
||
from django.db import transaction
|
||
from django.db.models import F
|
||
from django.utils import timezone
|
||
from drf_spectacular.utils import extend_schema
|
||
from rest_framework import status
|
||
from rest_framework.permissions import IsAuthenticated
|
||
from rest_framework.response import Response
|
||
from rest_framework.throttling import ScopedRateThrottle
|
||
from rest_framework.views import APIView
|
||
|
||
from .authentication import invalidate_user_cache, issue_access_token
|
||
from .models import OTPCode, Profile, Session, User
|
||
from .services import OTPDeliveryError, send_otp_sms
|
||
from .serializers import (
|
||
GuestSessionRequestSerializer,
|
||
GuestSessionResponseSerializer,
|
||
LoginRequestSerializer,
|
||
LoginResponseSerializer,
|
||
LogoutRequestSerializer,
|
||
LogoutResponseSerializer,
|
||
ProfileResponseSerializer,
|
||
RefreshRequestSerializer,
|
||
RefreshResponseSerializer,
|
||
RequestOTPRequestSerializer,
|
||
RequestOTPResponseSerializer,
|
||
SetGradeRequestSerializer,
|
||
UpdateProfileRequestSerializer,
|
||
SetGradeResponseSerializer,
|
||
)
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
class GuestAwareAccountThrottle(ScopedRateThrottle):
|
||
"""
|
||
همان الگویِ GuestAwareContentThrottle (content/views.py)،
|
||
GuestAwareAIThrottle (ai_tutor/views.py) و بقیه؛ چون GuestSessionView
|
||
با هر deviceId جدید یک کاربرِ مهمانِ تازه (pk جدید) میسازه، یک
|
||
ScopedRateThrottleِ سادهٔ per-user توسطِ چرخشِ حساب مهمان دورزدنی
|
||
بود. اینجا برای SetUserGradeView (که مهمانها هم میتونن صداش بزنن)
|
||
از همین کلید بر اساسِ IP برای مهمانها استفاده میشه؛ رفتارِ
|
||
throttle برای کاربرهای غیرمهمان (per-user، مثلِ قبل) دستنخورده
|
||
میمونه.
|
||
"""
|
||
|
||
def get_cache_key(self, request, view):
|
||
user = request.user
|
||
if user and user.is_authenticated and getattr(user, 'is_guest', False):
|
||
ident = f'guest-ip:{self.get_ident(request)}'
|
||
elif user and user.is_authenticated:
|
||
ident = user.pk
|
||
else:
|
||
ident = self.get_ident(request)
|
||
|
||
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=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 — درخواستِ کدِ ورود.
|
||
|
||
کد فقط با پیامک تحویل داده میشود. دو استثنا: در حالتِ DEBUG کد در فیلدِ
|
||
devOtp برمیگردد تا تستِ محلی به پیامکِ واقعی نیاز نداشته باشد، و برای
|
||
شمارههای TEST_LOGIN_PHONES کدِ ثابتِ از پیش تعریفشده معتبر است.
|
||
"""
|
||
|
||
throttle_scope = 'otp_request'
|
||
throttle_classes = [ScopedRateThrottle]
|
||
|
||
@extend_schema(
|
||
request=RequestOTPRequestSerializer,
|
||
responses=RequestOTPResponseSerializer,
|
||
tags=['Identity & Profile (accounts)'],
|
||
)
|
||
def post(self, request):
|
||
serializer = RequestOTPRequestSerializer(data=request.data)
|
||
serializer.is_valid(raise_exception=True)
|
||
phone = serializer.validated_data['phone']
|
||
|
||
# شمارهی تستِ تیم: کد ثابت و از قبل معلوم است، پس نه ردیفِ OTP تازهای
|
||
# لازم است نه پیامکی. cooldown و سقفِ ساعتی هم رد میشوند چون هر دو از
|
||
# گیرندهی واقعیِ پیامک محافظت میکنند و اینجا پیامکی در کار نیست.
|
||
#
|
||
# ⚠️ کد عمداً در پاسخ برنمیگردد، برخلافِ devOtp در حالتِ DEBUG: این
|
||
# مسیر روی سرورِ عمومی هم فعال است، پس برگرداندنش یعنی هر کسی که شماره
|
||
# را بداند میتواند وارد شود.
|
||
if phone in settings.TEST_LOGIN_PHONES:
|
||
logger.info('OTP request for configured test phone: phone_suffix=%s', phone[-4:])
|
||
data = {
|
||
'success': True,
|
||
'devOtp': '',
|
||
'note': 'این شمارهی تستِ تیم است؛ کدِ ثابتش در تنظیماتِ سرور تعریف شده و پیامکی ارسال نمیشود.',
|
||
}
|
||
return Response(RequestOTPResponseSerializer(data).data)
|
||
|
||
# حداقل فاصلهی زمانی بین دو درخواستِ همون شماره (burst protection)؛
|
||
# قبل از شمارندهی ساعتی چک میشه تا یک درخواستِ بلاکشده سهمیهی
|
||
# ساعتی رو مصرف نکنه. کلید مقدارِ epoch انقضا رو ذخیره میکنه تا
|
||
# retryAfter/هدرِ Retry-After بدونِ خواندنِ اضافه محاسبه بشه.
|
||
phone_cooldown_key = f'otp_request_phone_cooldown:{phone}'
|
||
cooldown_seconds = settings.OTP_REQUEST_PHONE_COOLDOWN_SECONDS
|
||
now_epoch = timezone.now().timestamp()
|
||
cooldown_expires_at_epoch = now_epoch + cooldown_seconds
|
||
if not cache.add(phone_cooldown_key, cooldown_expires_at_epoch, timeout=cooldown_seconds):
|
||
stored_expiry_epoch = cache.get(phone_cooldown_key)
|
||
if stored_expiry_epoch is None:
|
||
# بینِ شکستِ add و این get دقیقاً منقضی شده؛ عملاً دیگه
|
||
# cooldownی در کار نیست، اما برای رفتارِ یکدست یک
|
||
# retry_after حداقلی برمیگردونیم (کلاینت با تلاشِ بعدی
|
||
# مسیرِ عادی رو طی میکنه).
|
||
retry_after = 1
|
||
else:
|
||
retry_after = max(1, math.ceil(stored_expiry_epoch - now_epoch))
|
||
logger.warning(
|
||
'OTP request phone cooldown active: phone_suffix=%s retry_after_seconds=%s',
|
||
phone[-4:], retry_after,
|
||
)
|
||
return Response(
|
||
{
|
||
'detail': f'لطفاً {retry_after} ثانیه صبر کنید و دوباره درخواست کد بدهید.',
|
||
'retryAfter': retry_after,
|
||
},
|
||
status=status.HTTP_429_TOO_MANY_REQUESTS,
|
||
headers={'Retry-After': str(retry_after)},
|
||
)
|
||
|
||
# محدودیتِ صدورِ OTP بر اساسِ شماره، مستقل از throttleِ IP-محورِ بالا
|
||
# (وگرنه یه مهاجم میتونه بینِ چند IP پخش کنه). cache.add فقط وقتی
|
||
# کلید نیست مقدار میذاره، پس اتمیکه.
|
||
# شماره پیش از اینجا به 09XXXXXXXXX نرمال شده (accounts/phone.py)، پس
|
||
# املای دیگرِ همان شماره سهمِ جدا نمیگیرد.
|
||
phone_requests_key = f'otp_request_phone_attempts:{phone}'
|
||
if cache.add(phone_requests_key, 1, timeout=settings.OTP_REQUEST_PHONE_WINDOW_SECONDS):
|
||
attempt_count = 1
|
||
else:
|
||
try:
|
||
attempt_count = cache.incr(phone_requests_key)
|
||
except ValueError:
|
||
# بینِ add و incr دقیقاً منقضی شده؛ پنجره رو از نو شروع کن.
|
||
cache.set(phone_requests_key, 1, timeout=settings.OTP_REQUEST_PHONE_WINDOW_SECONDS)
|
||
attempt_count = 1
|
||
|
||
if attempt_count > settings.OTP_REQUEST_PHONE_MAX_ATTEMPTS:
|
||
logger.warning(
|
||
'OTP request phone limit exceeded: phone_suffix=%s attempt_count=%s max_attempts=%s',
|
||
phone[-4:], attempt_count, settings.OTP_REQUEST_PHONE_MAX_ATTEMPTS,
|
||
)
|
||
return Response(
|
||
{'detail': 'تعداد درخواستهای کد OTP برای این شماره بیش از حد مجاز است.'},
|
||
status=status.HTTP_429_TOO_MANY_REQUESTS,
|
||
)
|
||
|
||
# سقفهای دیتابیسی: cooldown و سقفِ ساعتیِ بالا در کشِ LocMem و مالِ هر
|
||
# workerاند (روی سرور ×۳). اینها از ردیفهای OTPCode شمرده میشوند و بینِ
|
||
# workerها مشترکاند. زمانِ صدور = expires_at − OTP_TTL_SECONDS (مدل
|
||
# created_at ندارد). بررسی و ساختِ ردیف در یک تراکنشاند؛ با
|
||
# transaction_mode=IMMEDIATE دو درخواستِ همزمان پشتِ هم اجرا میشوند
|
||
# (همزمانیِ بینِ دو workerِ واقعی با تست پوشش داده نشده).
|
||
# فراخوانیِ کاوهنگار عمداً بیرونِ تراکنش است تا قفلِ نوشتن حینِ شبکه نماند.
|
||
now = timezone.now()
|
||
ttl = timezone.timedelta(seconds=settings.OTP_TTL_SECONDS)
|
||
day_window_expiry = now - timezone.timedelta(days=1) + ttl
|
||
with transaction.atomic():
|
||
phone_codes = OTPCode.objects.filter(phone=phone)
|
||
last_expiry = phone_codes.order_by('-expires_at').values_list('expires_at', flat=True).first()
|
||
if cooldown_seconds and last_expiry is not None:
|
||
elapsed = (now - (last_expiry - ttl)).total_seconds()
|
||
if elapsed < cooldown_seconds:
|
||
retry_after = max(1, math.ceil(cooldown_seconds - elapsed))
|
||
return Response(
|
||
{
|
||
'detail': f'لطفاً {retry_after} ثانیه صبر کنید و دوباره درخواست کد بدهید.',
|
||
'retryAfter': retry_after,
|
||
},
|
||
status=status.HTTP_429_TOO_MANY_REQUESTS,
|
||
headers={'Retry-After': str(retry_after)},
|
||
)
|
||
if phone_codes.filter(expires_at__gt=day_window_expiry).count() >= settings.OTP_REQUEST_PHONE_DAILY_MAX:
|
||
logger.warning('OTP request phone daily limit exceeded: phone_suffix=%s', phone[-4:])
|
||
return Response(
|
||
{'detail': 'سقفِ روزانهی درخواستِ کد برای این شماره پر شده است؛ فردا دوباره تلاش کنید.'},
|
||
status=status.HTTP_429_TOO_MANY_REQUESTS,
|
||
)
|
||
if OTPCode.objects.filter(expires_at__gt=day_window_expiry).count() >= settings.OTP_SMS_GLOBAL_DAILY_MAX:
|
||
# یا حملهی SMS pumping است یا رشدِ واقعی؛ هر دو یعنی کسی باید نگاه کند.
|
||
logger.error(
|
||
'OTP global daily SMS cap reached: cap=%s phone_suffix=%s',
|
||
settings.OTP_SMS_GLOBAL_DAILY_MAX, phone[-4:],
|
||
)
|
||
return Response(
|
||
{'detail': 'ارسالِ پیامک موقتاً ممکن نیست؛ لطفاً کمی بعد دوباره تلاش کنید.'},
|
||
status=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||
)
|
||
# فقط آخرین کد صادرشده معتبر است.
|
||
OTPCode.objects.filter(phone=phone, is_used=False).update(is_used=True)
|
||
# ۶ رقم: با ۴ رقم و محدودیتِ تلاشِ per-worker، گرفتنِ حسابِ یک نفرِ مشخص
|
||
# روزی چند درصد شانس داشت. فرانت تا ۶ رقم را میپذیرد.
|
||
code = f'{secrets.randbelow(1_000_000):06d}'
|
||
otp_row = OTPCode.objects.create(phone=phone, code=code, expires_at=now + ttl)
|
||
try:
|
||
send_otp_sms(phone=phone, code=code)
|
||
except OTPDeliveryError:
|
||
# ارسال شکست خورده یعنی کاربر کدی نگرفته؛ cooldown رو پاک میکنیم
|
||
# تا مجبور نشه ۲ دقیقه بدونِ داشتنِ کد منتظر بمونه، و ردیفِ کد رو هم
|
||
# حذف میکنیم تا در cooldown و سقفِ روزانهی دیتابیسی شمرده نشه.
|
||
# سقفِ ساعتیِ کش و throttleِ IP دستنخورده میمونن (محدودیتِ حجم).
|
||
cache.delete(phone_cooldown_key)
|
||
otp_row.delete()
|
||
raise
|
||
|
||
# devOtp فقط در محیط dev برمیگردد؛ در پروداکشن کد فقط از طریق پیامک تحویل داده میشود.
|
||
note = 'در پروداکشن این کد پیامک میشه.'
|
||
if settings.DEV_MASTER_OTP is not None:
|
||
note = f'در پروداکشن این کد پیامک میشه؛ برای تست همیشه {settings.DEV_MASTER_OTP} هم قبول میشه.'
|
||
data = {
|
||
'success': True,
|
||
'devOtp': code if settings.DEBUG else '',
|
||
'note': note,
|
||
}
|
||
return Response(RequestOTPResponseSerializer(data).data)
|
||
|
||
|
||
class LoginView(APIView):
|
||
"""POST /api/auth/login — ورود با OTP"""
|
||
|
||
throttle_scope = 'otp_login'
|
||
throttle_classes = [ScopedRateThrottle]
|
||
|
||
@extend_schema(
|
||
request=LoginRequestSerializer,
|
||
responses=LoginResponseSerializer,
|
||
tags=['Identity & Profile (accounts)'],
|
||
)
|
||
def post(self, request):
|
||
serializer = LoginRequestSerializer(data=request.data)
|
||
serializer.is_valid(raise_exception=True)
|
||
phone = serializer.validated_data['phone']
|
||
otp = serializer.validated_data['otp']
|
||
|
||
# محدودیت تلاش ورود بر اساس شماره تلفن (مستقل از throttle IP-محور بالا).
|
||
phone_attempts_key = f'otp_login_phone_attempts:{phone}'
|
||
if cache.add(phone_attempts_key, 1, timeout=settings.OTP_LOGIN_PHONE_WINDOW_SECONDS):
|
||
attempt_count = 1
|
||
else:
|
||
try:
|
||
attempt_count = cache.incr(phone_attempts_key)
|
||
except ValueError:
|
||
# بینِ add و incr دقیقاً منقضی شده؛ پنجره رو از نو شروع کن.
|
||
cache.set(phone_attempts_key, 1, timeout=settings.OTP_LOGIN_PHONE_WINDOW_SECONDS)
|
||
attempt_count = 1
|
||
|
||
if attempt_count > settings.OTP_LOGIN_PHONE_MAX_ATTEMPTS:
|
||
# ⚠️ افزوده شده: لاگ هشدارِ امنیتی برای رصد/آلارمِ brute-force روی
|
||
# این endpoint. هیچ سکرتی (OTP، access/refresh token) لاگ نمیشه؛
|
||
# شمارهی تلفن هم کامل نوشته نمیشه، فقط ۴ رقم آخرش (برای
|
||
# correlation در تحلیل/پشتیبانی، بدون افشای کامل PII).
|
||
logger.warning(
|
||
'OTP login phone attempt limit exceeded: phone_suffix=%s attempt_count=%s max_attempts=%s',
|
||
phone[-4:], attempt_count, settings.OTP_LOGIN_PHONE_MAX_ATTEMPTS,
|
||
)
|
||
return Response(
|
||
{'detail': 'تعداد تلاشهای ورود برای این شماره بیش از حد مجاز است.'},
|
||
status=status.HTTP_429_TOO_MANY_REQUESTS,
|
||
)
|
||
|
||
# قفل ردیف OTP جلوی مصرف دوبارهی یک کد یکبارمصرف را در درخواستهای همزمان میگیرد.
|
||
with transaction.atomic():
|
||
valid = False
|
||
test_code = settings.TEST_LOGIN_PHONES.get(phone, '')
|
||
if settings.DEV_MASTER_OTP is not None and otp == settings.DEV_MASTER_OTP:
|
||
valid = True
|
||
elif test_code and secrets.compare_digest(test_code, otp):
|
||
# شمارهی تستِ تیم. compare_digest تا نتیجهی مقایسه از روی زمانِ
|
||
# پاسخ قابلِ استنتاج نباشه. سقفِ تلاشِ همین شماره بالاتر چک شده،
|
||
# پس حدسِ کد با brute-force بستهست.
|
||
logger.info('Login via configured test phone: phone_suffix=%s', phone[-4:])
|
||
valid = True
|
||
else:
|
||
otp_obj = OTPCode.objects.select_for_update().filter(
|
||
phone=phone, code=otp, is_used=False, expires_at__gte=timezone.now()
|
||
).order_by('-id').first()
|
||
if otp_obj:
|
||
otp_obj.is_used = True
|
||
otp_obj.save(update_fields=['is_used'])
|
||
valid = True
|
||
|
||
if not valid:
|
||
_count_failed_attempt(phone)
|
||
return Response({'detail': 'کد OTP نامعتبر یا منقضیشده است.'}, status=status.HTTP_400_BAD_REQUEST)
|
||
|
||
user = User.objects.filter(phone=phone).first()
|
||
created = False
|
||
guest = _upgradable_guest(request, phone) if user is None else None
|
||
if guest is not None:
|
||
# مهمانی که ثبتنام میکند همان کاربر میماند: امتیاز، استریک،
|
||
# جزیرهها و گفتگوها همه با همین user_id ذخیره شدهاند. پیشتر یک
|
||
# کاربرِ تازه و خالی ساخته میشد و پیشرفتِ مهمان جا میماند.
|
||
guest.phone = phone
|
||
guest.save(update_fields=['phone'])
|
||
user, created = guest, True
|
||
elif user is None:
|
||
user, created = User.objects.get_or_create(
|
||
phone=phone,
|
||
defaults={'is_guest': False, 'name': ''},
|
||
)
|
||
if user.is_guest:
|
||
user.is_guest = False
|
||
user.save(update_fields=['is_guest'])
|
||
|
||
# ثبتِ فعالیتِ کاربر برای ورودِ موفق.
|
||
user.last_activity_at = timezone.now()
|
||
user.save(update_fields=['last_activity_at'])
|
||
|
||
if created:
|
||
# آفرهای ثبتنام (مثل یک هفته رایگان) از طریق discount/receivers.py روی این سیگنال اعمال میشوند.
|
||
from .signals import user_registered
|
||
user_registered.send(sender=User, user_id=user.id)
|
||
|
||
refresh_token = uuid.uuid4().hex
|
||
Session.objects.create(
|
||
user=user,
|
||
refresh_token=refresh_token,
|
||
device_id=request.data.get('deviceId', ''),
|
||
expires_at=timezone.now() + timezone.timedelta(days=settings.REFRESH_TOKEN_TTL_DAYS),
|
||
)
|
||
access_token = issue_access_token(user.id)
|
||
|
||
data = {
|
||
'userId': str(user.id),
|
||
'token': access_token,
|
||
'refreshToken': refresh_token,
|
||
}
|
||
return Response(LoginResponseSerializer(data).data)
|
||
|
||
|
||
def _count_failed_attempt(phone: str) -> None:
|
||
"""حدسِ غلط روی کدهای فعالِ این شماره؛ کدی که به سقف برسد باطل میشود."""
|
||
active = OTPCode.objects.filter(phone=phone, is_used=False, expires_at__gte=timezone.now())
|
||
active.update(failed_attempts=F('failed_attempts') + 1)
|
||
active.filter(failed_attempts__gte=settings.OTP_MAX_FAILED_ATTEMPTS).update(is_used=True)
|
||
|
||
|
||
def _upgradable_guest(request, phone: str):
|
||
"""
|
||
مهمانی که با توکنِ خودش درخواستِ ورود داده و این شماره هنوز حسابی ندارد.
|
||
|
||
شمارههای TEST_LOGIN_PHONES عمداً کنار گذاشته میشوند: حسابهای دمو با
|
||
همین شمارهها ساخته شدهاند و ورودِ آزمایشی نباید حسابِ مهمانِ دمو
|
||
(`frontend-test-device`) را به یک شماره تبدیل کند.
|
||
"""
|
||
if phone in settings.TEST_LOGIN_PHONES:
|
||
return None
|
||
user = getattr(request, 'user', None)
|
||
if user is None or not getattr(user, 'is_authenticated', False) or not user.is_guest:
|
||
return None
|
||
return User.objects.filter(pk=user.pk, is_guest=True, phone__isnull=True).first()
|
||
|
||
|
||
class LogoutView(APIView):
|
||
"""
|
||
POST /api/auth/logout — خروج از حساب.
|
||
|
||
refreshTokenِ این دستگاه (اگر فرستاده شود) باطل میشود و access tokenهای
|
||
صادرشده *پیش از ثانیهی خروج* رد میشوند. دستگاههای دیگر با refreshِ
|
||
خودشان بیصدا توکنِ تازه میگیرند؛ فقط همین دستگاه بیرون میماند.
|
||
|
||
⚠️ مقایسه ثانیهای است: توکنی که در همان ثانیهی خروج صادر شده معتبر
|
||
میماند (بازتولید شد). با AUTH_USER_CACHE_TTL>0 و کشِ per-worker، کشِ
|
||
کاربر فقط در همین worker پاک میشود؛ پیشفرض ۰ است و آن حالت تست نشده.
|
||
"""
|
||
|
||
permission_classes = [IsAuthenticated]
|
||
|
||
@extend_schema(
|
||
request=LogoutRequestSerializer, responses=LogoutResponseSerializer,
|
||
tags=['Identity & Profile (accounts)'],
|
||
)
|
||
def post(self, request):
|
||
serializer = LogoutRequestSerializer(data=request.data)
|
||
serializer.is_valid(raise_exception=True)
|
||
refresh_token = serializer.validated_data.get('refreshToken') or ''
|
||
with transaction.atomic():
|
||
if refresh_token:
|
||
Session.objects.filter(
|
||
user_id=request.user.id, refresh_token=refresh_token,
|
||
).update(revoked=True)
|
||
User.objects.filter(pk=request.user.id).update(tokens_valid_after=timezone.now())
|
||
invalidate_user_cache(request.user.id)
|
||
return Response(LogoutResponseSerializer({'success': True}).data)
|
||
|
||
|
||
class RefreshTokenView(APIView):
|
||
"""POST /api/auth/refresh — تمدید توکن"""
|
||
|
||
# این endpoint احرازهویت نمیخواد (خودِ refresh token شناسه است)، پس
|
||
# ScopedRateThrottle طبیعتاً روی IP کلید میخوره. یک توکنِ رفرش عادتاً
|
||
# فقط گاهبهگاه (نه با فرکانسِ بالا) باید استفاده بشه؛ این throttle
|
||
# جلوی سوءاستفاده از این endpoint بهعنوان یک عملیاتِ پرتکرارِ
|
||
# دیتابیس/تولیدِ توکن رو میگیره، بدون تغییری در rotation یا محافظتِ
|
||
# اتمیکِ replay که پایینتر پیادهسازی شده.
|
||
throttle_scope = 'token_refresh'
|
||
throttle_classes = [ScopedRateThrottle]
|
||
|
||
@extend_schema(
|
||
request=RefreshRequestSerializer,
|
||
responses=RefreshResponseSerializer,
|
||
tags=['Identity & Profile (accounts)'],
|
||
)
|
||
def post(self, request):
|
||
serializer = RefreshRequestSerializer(data=request.data)
|
||
serializer.is_valid(raise_exception=True)
|
||
refresh_token = serializer.validated_data['refreshToken']
|
||
|
||
# UPDATE اتمیک جلوی استفادهی همزمان از یک refresh token را میگیرد (replay protection).
|
||
with transaction.atomic():
|
||
updated_count = Session.objects.filter(
|
||
refresh_token=refresh_token, revoked=False, expires_at__gte=timezone.now()
|
||
).update(revoked=True)
|
||
|
||
if not updated_count:
|
||
return Response({'detail': 'رفرشتوکن نامعتبر یا منقضیشده است.'}, status=status.HTTP_401_UNAUTHORIZED)
|
||
|
||
old_session = Session.objects.get(refresh_token=refresh_token)
|
||
|
||
# ثبتِ فعالیتِ کاربر برای رفرشِ موفقِ توکن.
|
||
User.objects.filter(pk=old_session.user_id).update(last_activity_at=timezone.now())
|
||
|
||
new_refresh_token = secrets.token_hex(32)
|
||
new_session = Session.objects.create(
|
||
user_id=old_session.user_id,
|
||
refresh_token=new_refresh_token,
|
||
device_id=old_session.device_id,
|
||
expires_at=timezone.now() + timezone.timedelta(days=settings.REFRESH_TOKEN_TTL_DAYS),
|
||
)
|
||
|
||
access_token = issue_access_token(new_session.user_id)
|
||
data = {
|
||
'token': access_token,
|
||
'refreshToken': new_refresh_token,
|
||
'expiresIn': settings.ACCESS_TOKEN_TTL_SECONDS,
|
||
}
|
||
return Response(RefreshResponseSerializer(data).data)
|
||
|
||
|
||
class SetUserGradeView(APIView):
|
||
"""POST /api/user/grade — ثبت پایهی کاربر (نیاز به احراز هویت)"""
|
||
|
||
permission_classes = [IsAuthenticated]
|
||
# بدونِ throttle، یک کاربرِ احرازهویتشده (از جمله مهمان) میتواند
|
||
# نامحدود درخواستِ نوشتن روی دیتابیس بفرستد. کلیدِ شمارش برای مهمانها
|
||
# باید IP باشد نه pk — دلیلش در GuestAwareAccountThrottle بالا.
|
||
throttle_scope = 'user_profile_write'
|
||
throttle_classes = [GuestAwareAccountThrottle]
|
||
|
||
@extend_schema(
|
||
request=SetGradeRequestSerializer,
|
||
responses=SetGradeResponseSerializer,
|
||
tags=['Identity & Profile (accounts)'],
|
||
)
|
||
def post(self, request):
|
||
serializer = SetGradeRequestSerializer(data=request.data)
|
||
serializer.is_valid(raise_exception=True)
|
||
grade_id = serializer.validated_data['gradeId']
|
||
|
||
user = request.user
|
||
user.grade_id = grade_id
|
||
user.save(update_fields=['grade_id'])
|
||
|
||
data = {'success': True, 'gradeId': grade_id}
|
||
return Response(SetGradeResponseSerializer(data).data)
|
||
|
||
|
||
def _profile_payload(user, profile) -> dict:
|
||
from . import jalali
|
||
|
||
return {
|
||
'userId': str(user.id),
|
||
'phone': user.phone,
|
||
'name': user.name,
|
||
'avatar': user.avatar,
|
||
'isGuest': user.is_guest,
|
||
'gradeId': user.grade_id,
|
||
'birthYear': profile.birth_year,
|
||
'birthDate': jalali.to_string(profile.birth_date) if profile.birth_date else None,
|
||
'bio': profile.bio,
|
||
}
|
||
|
||
|
||
class UserProfileView(APIView):
|
||
"""
|
||
GET /api/user/profile — اطلاعات پروفایل کاربر جاری
|
||
PATCH /api/user/profile — ویرایشِ همان اطلاعات (نیاز به احراز هویت)
|
||
|
||
PATCH برای صفحهی «بیا باهم آشنا بشیم» در اپ لازم است: آنجا اسم (و بعداً
|
||
آواتار) گرفته میشود و تا پیش از این هیچ مسیری برای نوشتنشان وجود نداشت —
|
||
فقط پایهی تحصیلی endpoint اختصاصیِ خودش را داشت.
|
||
"""
|
||
|
||
permission_classes = [IsAuthenticated]
|
||
# نوشتن است، پس همان throttleِ نوشتنِ پروفایل که SetUserGradeView دارد.
|
||
throttle_scope = 'user_profile_write'
|
||
throttle_classes = [GuestAwareAccountThrottle]
|
||
|
||
def get_throttles(self):
|
||
# GET خواندنی است و نباید سهمیهی نوشتن را مصرف کند.
|
||
if self.request.method == 'GET':
|
||
return []
|
||
return super().get_throttles()
|
||
|
||
@extend_schema(
|
||
responses=ProfileResponseSerializer,
|
||
tags=['Identity & Profile (accounts)'],
|
||
)
|
||
def get(self, request):
|
||
user = request.user
|
||
# مدل Profile بهصورت lazy ساخته میشه چون هیچجای دیگهای (نه
|
||
# GuestSessionView، نه LoginView) رکورد Profile رو از قبل نمیسازه؛
|
||
# get_or_create اینجا امن است چون فقط توسطِ کاربرِ همون رکورد صدا
|
||
# زده میشه (OneToOne روی user_id جاری، نه ورودیِ خارجی).
|
||
profile, _ = Profile.objects.get_or_create(user_id=user.id)
|
||
|
||
return Response(ProfileResponseSerializer(_profile_payload(user, profile)).data)
|
||
|
||
@extend_schema(
|
||
request=UpdateProfileRequestSerializer,
|
||
responses=ProfileResponseSerializer,
|
||
tags=['Identity & Profile (accounts)'],
|
||
)
|
||
def patch(self, request):
|
||
serializer = UpdateProfileRequestSerializer(data=request.data, partial=True)
|
||
serializer.is_valid(raise_exception=True)
|
||
v = serializer.validated_data
|
||
|
||
user = request.user
|
||
profile, _ = Profile.objects.get_or_create(user_id=user.id)
|
||
|
||
user_fields = {'name': 'name', 'avatar': 'avatar'}
|
||
profile_fields = {'birthYear': 'birth_year', 'birthDate': 'birth_date', 'bio': 'bio'}
|
||
|
||
changed_user = [attr for key, attr in user_fields.items() if key in v]
|
||
changed_profile = [attr for key, attr in profile_fields.items() if key in v]
|
||
|
||
for key, attr in user_fields.items():
|
||
if key in v:
|
||
setattr(user, attr, v[key])
|
||
for key, attr in profile_fields.items():
|
||
if key in v:
|
||
setattr(profile, attr, v[key])
|
||
# سال و تاریخ نباید از هم جدا بیفتند: تاریخ سال را میسازد، و سالِ تنها
|
||
# (اپِ قدیمی) تاریخی را که دیگر با آن نمیخواند پاک میکند.
|
||
if 'birthDate' in v:
|
||
from . import jalali
|
||
profile.birth_year = jalali.from_gregorian(v['birthDate'])[0] if v['birthDate'] else None
|
||
changed_profile.append('birth_year')
|
||
elif 'birthYear' in v and profile.birth_date is not None:
|
||
from . import jalali
|
||
if jalali.from_gregorian(profile.birth_date)[0] != v['birthYear']:
|
||
profile.birth_date = None
|
||
changed_profile.append('birth_date')
|
||
|
||
# update_fields: فقط همان ستونهایی که واقعاً عوض شدهاند نوشته شوند تا
|
||
# PATCH همزمان از یک دستگاهِ دیگر، فیلدهای دستنخورده را بازنویسی نکند.
|
||
if changed_user:
|
||
user.save(update_fields=changed_user)
|
||
if changed_profile:
|
||
profile.save(update_fields=changed_profile)
|
||
|
||
return Response(ProfileResponseSerializer(_profile_payload(user, profile)).data)
|