Files
MathApp/accounts/serializers.py
Zareian 28b8527760 Security review: staff admin lockdown, unicode-safe login/phone/grade inputs, demo guest not upgradable, home cache not floodable
- accounts admin: phone/is_guest read-only for non-superusers, device_id filter lookups refused,
  auth.User/Group admin writable only by superusers
- campaigns: delivery log needs view permission
- phone/OTP/birth date: any Unicode digit normalised or rejected with 400; unknown gradeId is 400
- shared demo guest is never turned into a person's account by logging in
- /api/home with unknown gradeId is no longer cached (LocMemCache flood reset throttle counters)
- /api/progress/nodes: one query, huge unit is 400
- BazaarCredential token filter lookups refused

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 09:34:42 +03:30

235 lines
10 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import re
import unicodedata
from rest_framework import serializers
from .phone import ascii_digits, normalize_phone
# لینک، آیدیِ شبکه‌ی اجتماعی، و هر رشته‌ی ۵ رقمی یا بیشتر با هر جداکننده‌ی غیرِ حرفی (فاصله،
# خط‌تیره، ستاره، ایموجی…). \d در پایتون هر رقمِ یونیکد است (فارسی، عربی، تمام‌عرض، ریاضی).
# این نام در رتبه‌بندی به بچه‌های دیگر نشان داده می‌شود. حرف بینِ رقم‌ها («0x9x1…») و عدد با
# حروف («صفر نه یک…») گرفته نمی‌شود.
_CONTACT = re.compile(
r'(https?://\S+|www\.\S+|\S+\.(?:com|ir|org|net|me)\S*|@\w+'
r'|(?:\d(?:[^\w@]|_)*){5,})',
re.IGNORECASE,
)
# «ig: name»، «insta name»، «آیدی name» و مانندش: کلیدواژه و هر چه بعدش آمده حذف می‌شود.
_HANDLE = re.compile(
r'(?:\b(?:ig|insta(?:gram)?|tele(?:gram)?|tg|id|whats ?app|rubika|eitaa|bale)\b'
r'|اینستا\S*|(?:تلگرام|تل|آیدی|ایدی|واتساپ|روبیکا|ایتا)(?!\w)|بله\s*:)\s*[::]?.*',
re.IGNORECASE,
)
def clean_display_name(value: str) -> str:
# NFKC: «①»، «²»، «𝟬» و «@» به رقم و @ِ معمولی برمی‌گردند تا الگوهای پایین آن‌ها را هم بگیرند؛
# نامِ ذخیره‌شده همین شکلِ نرمال است.
value = _HANDLE.sub(' ', unicodedata.normalize('NFKC', value or ''))
return ' '.join(_CONTACT.sub(' ', value).split())[:30]
class IranMobileField(serializers.CharField):
"""CharField که خروجی‌اش همیشه ‎09XXXXXXXXX‎ است."""
def __init__(self, **kwargs):
kwargs.setdefault('max_length', 20)
super().__init__(**kwargs)
def to_internal_value(self, data):
phone = normalize_phone(super().to_internal_value(data))
if phone is None:
raise serializers.ValidationError('شماره‌ی موبایل معتبر نیست؛ مثلاً 09121234567.')
return phone
# ---- Request serializers ----
class GuestSessionRequestSerializer(serializers.Serializer):
type = serializers.ChoiceField(choices=['guest'])
deviceId = serializers.CharField(max_length=64)
class OTPCodeField(serializers.CharField):
"""
کدِ ورود: ۴ تا ۶ رقم (کدِ پیامکی ۶ رقمی است، کدهای TEST_LOGIN_PHONES ۴ تا ۶). رقمِ فارسی و
دیگر رقم‌های یونیکد به لاتین برمی‌گردند؛ هر چیزِ دیگر ۴۰۰ است. بدونِ این، کدِ غیرِ ASCII برای
شماره‌ی تست به secrets.compare_digest می‌رسید و TypeError (۵۰۰) می‌داد.
"""
def __init__(self, **kwargs):
kwargs.setdefault('max_length', 6)
super().__init__(**kwargs)
def to_internal_value(self, data):
code = ascii_digits(super().to_internal_value(data))
if not re.fullmatch(r'[0-9]{4,6}', code):
raise serializers.ValidationError('کد باید ۴ تا ۶ رقم باشد.')
return code
class LoginRequestSerializer(serializers.Serializer):
phone = IranMobileField()
otp = OTPCodeField()
# فرانتِ فعلی نمی‌فرستد؛ اگر بیاید روی Session.device_id می‌نشیند. null یا بیش از ۶۴ نویسه ۴۰۰ است
# (پیش‌تر null به NOT NULL می‌خورد و ۵۰۰ می‌داد).
deviceId = serializers.CharField(max_length=64, required=False, allow_blank=True)
class RefreshRequestSerializer(serializers.Serializer):
refreshToken = serializers.CharField()
class SetGradeRequestSerializer(serializers.Serializer):
gradeId = serializers.IntegerField()
def validate_gradeId(self, value):
# پایه‌ی ناموجود ۴۰۰ است، نه ذخیره‌ی بی‌صدا (نقشه‌ی خالی). عددِ بزرگ‌تر از ستونِ SQLite هم
# پیش‌تر هنگامِ ذخیره OverflowError (۵۰۰) می‌داد؛ exists() آن را بی‌خطا «نیست» می‌بیند.
from content.models import Grade
if not Grade.objects.filter(pk=value).exists():
raise serializers.ValidationError('این پایه وجود ندارد.')
return value
# همان بازه‌ای که پیش‌تر برای «سن» پذیرفته می‌شد. بزرگسال هم می‌آید (سینیور،
# ۲۰۲۶-۱۰-۰۱): کسی که فقط یک مبحثِ ریاضی را می‌خواهد یاد بگیرد.
MIN_AGE = 5
MAX_AGE = 99
class UpdateProfileRequestSerializer(serializers.Serializer):
"""
ورودی PATCH /api/user/profile — همه‌ی فیلدها اختیاری‌اند و فقط همان‌هایی
که فرستاده شوند تغییر می‌کنند (partial update واقعی).
`phone` و `isGuest` عمداً اینجا نیستند: شماره فقط از مسیرِ OTP عوض
می‌شود و مهمان‌بودن را کاربر نباید بتواند خودش تغییر دهد.
"""
name = serializers.CharField(max_length=100, required=False, allow_blank=True)
avatar = serializers.CharField(max_length=100, required=False, allow_blank=True)
def validate_name(self, value):
"""
نام در جدولِ رتبه‌بندی به بچه‌های دیگر نشان داده می‌شود؛ شماره، لینک و
آیدی (راهِ تماس گرفتن با یک کودکِ غریبه) از آن حذف می‌شود. عمداً خطا
نمی‌دهد تا صفحه‌ی پروفایلِ اپ گیر نکند؛ فقط بقیه‌ی نام ذخیره می‌شود.
"""
return clean_display_name(value)
# فقط سال (روشِ قدیمِ اپ). birthDate دقیق‌تر است و ترجیح دارد.
birthYear = serializers.IntegerField(required=False, allow_null=True,
min_value=1300, max_value=1500)
# تاریخِ تولدِ شمسی «1385-04-12». سرور میلادی ذخیره می‌کند و birthYear را هم
# از آن پر می‌کند؛ تبدیل فقط در accounts/jalali.py است.
birthDate = serializers.CharField(required=False, allow_null=True, max_length=10)
bio = serializers.CharField(max_length=200, required=False, allow_blank=True)
def validate_birthDate(self, value):
if value in (None, ''):
return None
from django.utils import timezone
from . import jalali
try:
born = jalali.parse(value)
except ValueError as exc:
raise serializers.ValidationError(str(exc))
today = timezone.localdate()
age = today.year - born.year - ((today.month, today.day) < (born.month, born.day))
if not MIN_AGE <= age <= MAX_AGE:
raise serializers.ValidationError(f'سن باید بینِ {MIN_AGE} و {MAX_AGE} سال باشد.')
return born
def validate(self, attrs):
if not attrs:
raise serializers.ValidationError('حداقل یکی از فیلدها باید ارسال شود.')
if 'birthDate' in attrs and 'birthYear' in attrs:
raise serializers.ValidationError('یکی از birthDate یا birthYear را بفرستید، نه هر دو را.')
return attrs
class RequestOTPRequestSerializer(serializers.Serializer):
"""اپ کمکی برای گرفتن OTP (در سند اصلی نبود، برای تست‌پذیری Swagger اضافه شد)."""
phone = IranMobileField()
# ---- 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()
refreshToken = serializers.CharField()
class RefreshResponseSerializer(serializers.Serializer):
token = serializers.CharField()
refreshToken = serializers.CharField()
expiresIn = serializers.IntegerField()
class GradeSerializer(serializers.Serializer):
id = serializers.IntegerField()
title = serializers.CharField()
icon = serializers.CharField()
color = serializers.CharField()
class GradesListResponseSerializer(serializers.Serializer):
grades = GradeSerializer(many=True)
class SetGradeResponseSerializer(serializers.Serializer):
success = serializers.BooleanField()
gradeId = serializers.IntegerField()
class RequestOTPResponseSerializer(serializers.Serializer):
success = serializers.BooleanField()
devOtp = serializers.CharField()
note = serializers.CharField()
class ProfileResponseSerializer(serializers.Serializer):
"""پاسخ GET /api/user/profile — فقط داده‌ی لازم برای تب Profile اپ موبایل."""
userId = serializers.CharField()
# شناسه‌ی نمایشی («User482917»، accounts/public_id.py)؛ اپ آن را زیرِ نام نشان می‌دهد
# و پشتیبانی با همین در پنلِ ادمین حساب را پیدا می‌کند.
publicId = serializers.CharField()
phone = serializers.CharField(allow_null=True)
name = serializers.CharField()
avatar = serializers.CharField()
isGuest = serializers.BooleanField()
gradeId = serializers.IntegerField(allow_null=True)
birthYear = serializers.IntegerField(allow_null=True)
birthDate = serializers.CharField(allow_null=True, help_text='شمسی، «1385-04-12»؛ null اگر فقط سال داده شده.')
bio = serializers.CharField()
class DeleteAccountRequestSerializer(serializers.Serializer):
# کاربرِ شماره‌دار: کدِ پیامکیِ تازه (از POST /api/auth/otp/request با همان شماره).
otp = OTPCodeField(required=False, allow_blank=True)
# مهمان شماره‌ای ندارد که کد بگیرد؛ باید همین کلمه را تایپ کرده باشد.
confirm = serializers.CharField(max_length=10, required=False, allow_blank=True)
class DeleteAccountResponseSerializer(serializers.Serializer):
success = serializers.BooleanField()
class LogoutRequestSerializer(serializers.Serializer):
refreshToken = serializers.CharField(required=False, allow_blank=True)
class LogoutResponseSerializer(serializers.Serializer):
success = serializers.BooleanField()