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>
This commit is contained in:
2026-10-05 09:34:42 +03:30
parent fedd10ff22
commit 28b8527760
15 changed files with 422 additions and 41 deletions

View File

@@ -67,6 +67,13 @@ class DeviceIdForSuperuserOnly:
exclude = tuple(super().get_exclude(request, obj) or ())
return exclude if request.user.is_superuser else exclude + ('device_id',)
def lookup_allowed(self, lookup, value, request=None):
# جنگو هر فیلترِ querystring روی فیلدِ خودِ مدل را می‌پذیرد (?device_id__startswith=a)؛
# بی‌این، با شمردنِ نتیجه شناسه حرف‌به‌حرف درمی‌آید. جنگو برای فیلترِ ردشده ۴۰۰ می‌دهد.
if lookup.split('__')[0] == 'device_id' and not (request and request.user.is_superuser):
return False
return super().lookup_allowed(lookup, value, request)
@admin.register(User)
class UserAdmin(DeviceIdForSuperuserOnly, admin.ModelAdmin):
@@ -90,6 +97,13 @@ class UserAdmin(DeviceIdForSuperuserOnly, admin.ModelAdmin):
list_filter = ('is_guest',)
actions = ['send_sms_to_selected']
def get_readonly_fields(self, request, obj=None):
# شماره کلیدِ ورود است: کارمندی با change_user که شماره‌ی کاربری را به شماره‌ی خودش عوض
# کند، کد می‌گیرد و واردِ همان حساب می‌شود. is_guest هم همین‌طور ورود با شناسه‌ی دستگاه را
# روی حسابِ شماره‌دار باز می‌کند. هر دو فقط برای ابرکاربر قابلِ تغییرند.
fields = tuple(super().get_readonly_fields(request, obj))
return fields if request.user.is_superuser else fields + ('phone', 'is_guest')
# permissions=['change']: بدونِ این، کاربرِ فقط-view (گروهِ Supervisor)
# می‌تونست با POSTِ مستقیم این اکشن رو صدا بزنه بدونِ دیدنِ دکمه در UI؛
# این پارامتر باعث می‌شه ModelAdmin فقط برای change_user در دسترس بذارتش.
@@ -250,3 +264,37 @@ def _admin_get_urls_with_supervisor():
admin.site.get_urls = _admin_get_urls_with_supervisor
# حساب‌های کارمندانِ پنل (auth.User/Group جنگو، جدا از accounts.User). در پیش‌فرضِ جنگو کارمندی با
# change_group همه‌ی اجازه‌ها را به گروهِ خودش می‌دهد و با change_user (auth) تیکِ ابرکاربر را برای
# خودش می‌زند. ساختن/تغییر/حذفِ این دو فقط برای ابرکاربر است؛ دیدن همان اجازه‌ی view را می‌خواهد.
from django.contrib.auth import get_user_model # noqa: E402
from django.contrib.auth.admin import GroupAdmin as _DjangoGroupAdmin # noqa: E402
from django.contrib.auth.admin import UserAdmin as _DjangoUserAdmin # noqa: E402
from django.contrib.auth.models import Group # noqa: E402
class _SuperuserWritesOnly:
def has_add_permission(self, request):
return request.user.is_superuser
def has_change_permission(self, request, obj=None):
return request.user.is_superuser
def has_delete_permission(self, request, obj=None):
return request.user.is_superuser
class StaffUserAdmin(_SuperuserWritesOnly, _DjangoUserAdmin):
pass
class StaffGroupAdmin(_SuperuserWritesOnly, _DjangoGroupAdmin):
pass
admin.site.unregister(get_user_model())
admin.site.register(get_user_model(), StaffUserAdmin)
admin.site.unregister(Group)
admin.site.register(Group, StaffGroupAdmin)

View File

@@ -81,10 +81,11 @@ def from_gregorian(d: date) -> tuple[int, int, int]:
def parse(value: str) -> date:
"""«1385-04-12» (ارقامِ فارسی هم) → تاریخِ میلادی."""
from accounts.phone import _DIGITS
from accounts.phone import ascii_digits
parts = str(value).translate(_DIGITS).strip().replace('/', '-').split('-')
if len(parts) != 3 or not all(p.isdigit() for p in parts):
parts = ascii_digits(str(value)).strip().replace('/', '-').split('-')
# isascii: «²» برای isdigit رقم است ولی int() آن را نمی‌پذیرد (پیامِ خطای انگلیسیِ پایتون).
if len(parts) != 3 or not all(p.isascii() and p.isdigit() for p in parts):
raise ValueError('تاریخ باید به شکلِ سال-ماه-روز باشد، مثلاً 1385-04-12.')
return to_gregorian(int(parts[0]), int(parts[1]), int(parts[2]))

View File

@@ -11,15 +11,27 @@
(کلیدهای TEST_LOGIN_PHONES)؛ فیلدِ serializer در accounts/serializers.py است.
"""
import re
import unicodedata
_DIGITS = str.maketrans('۰۱۲۳۴۵۶۷۸۹٠١٢٣٤٥٦٧٨٩', '01234567890123456789')
_SEPARATORS = re.compile(r'[\s\-().\u200c\u200e\u200f]')
_MOBILE = re.compile(r'09\d{9}')
# [0-9] نه \d: در پایتون \d هر رقمِ یونیکد (تمام‌عرض، ریاضی، دوناگری…) را هم می‌گیرد.
_MOBILE = re.compile(r'09[0-9]{9}')
def ascii_digits(value: str) -> str:
"""
هر رقمِ دهدهیِ یونیکد (فارسی، عربی، تمام‌عرض «0»، ریاضی «𝟎»، …) → رقمِ لاتین.
بدونِ این «09123456789» شماره‌ی جدایی می‌شد با سقفِ پیامک و حسابِ جدا. «²» و «①» رقمِ
دهدهی نیستند و دست نمی‌خورند، پس شماره‌ای که آن‌ها را دارد رد می‌شود.
"""
return ''.join(str(unicodedata.decimal(ch)) if not ch.isascii() and ch.isdecimal() else ch
for ch in value)
def normalize_phone(value) -> str | None:
"""شکلِ یکتای ‎09XXXXXXXXX‎، یا None اگر موبایلِ ایران نباشد."""
s = _SEPARATORS.sub('', str(value or '').translate(_DIGITS))
s = _SEPARATORS.sub('', ascii_digits(str(value or '')))
if s.startswith('+98'):
s = '0' + s[3:]
elif s.startswith('0098'):

View File

@@ -1,14 +1,17 @@
import re
import unicodedata
from rest_framework import serializers
from .phone import normalize_phone
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'|(?:[0-9۰-۹٠-٩][\s\-._/\\|,،]*){5,})',
r'|(?:\d(?:[^\w@]|_)*){5,})',
re.IGNORECASE,
)
# «ig: name»، «insta name»، «آیدی name» و مانندش: کلیدواژه و هر چه بعدش آمده حذف می‌شود.
@@ -20,7 +23,9 @@ _HANDLE = re.compile(
def clean_display_name(value: str) -> str:
value = _HANDLE.sub(' ', value or '')
# NFKC: «①»، «²»، «𝟬» و «@» به رقم و @ِ معمولی برمی‌گردند تا الگوهای پایین آن‌ها را هم بگیرند؛
# نامِ ذخیره‌شده همین شکلِ نرمال است.
value = _HANDLE.sub(' ', unicodedata.normalize('NFKC', value or ''))
return ' '.join(_CONTACT.sub(' ', value).split())[:30]
@@ -45,9 +50,30 @@ class GuestSessionRequestSerializer(serializers.Serializer):
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 = serializers.CharField(max_length=6)
otp = OTPCodeField()
# فرانتِ فعلی نمی‌فرستد؛ اگر بیاید روی Session.device_id می‌نشیند. null یا بیش از ۶۴ نویسه ۴۰۰ است
# (پیش‌تر null به NOT NULL می‌خورد و ۵۰۰ می‌داد).
deviceId = serializers.CharField(max_length=64, required=False, allow_blank=True)
class RefreshRequestSerializer(serializers.Serializer):
@@ -57,6 +83,15 @@ class RefreshRequestSerializer(serializers.Serializer):
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
# همان بازه‌ای که پیش‌تر برای «سن» پذیرفته می‌شد. بزرگسال هم می‌آید (سینیور،
# ۲۰۲۶-۱۰-۰۱): کسی که فقط یک مبحثِ ریاضی را می‌خواهد یاد بگیرد.
@@ -182,7 +217,7 @@ class ProfileResponseSerializer(serializers.Serializer):
class DeleteAccountRequestSerializer(serializers.Serializer):
# کاربرِ شماره‌دار: کدِ پیامکیِ تازه (از POST /api/auth/otp/request با همان شماره).
otp = serializers.CharField(max_length=6, required=False, allow_blank=True)
otp = OTPCodeField(required=False, allow_blank=True)
# مهمان شماره‌ای ندارد که کد بگیرد؛ باید همین کلمه را تایپ کرده باشد.
confirm = serializers.CharField(max_length=10, required=False, allow_blank=True)

View File

@@ -348,12 +348,12 @@ class LoginPhoneRateLimitTests(TestCase):
for _ in range(3):
response = self.client.post(
'/api/auth/login', {'phone': phone, 'otp': 'wrong'}, format='json',
'/api/auth/login', {'phone': phone, 'otp': '000000'}, format='json',
)
self.assertEqual(response.status_code, 400)
blocked_response = self.client.post(
'/api/auth/login', {'phone': phone, 'otp': 'wrong'}, format='json',
'/api/auth/login', {'phone': phone, 'otp': '000000'}, format='json',
)
self.assertEqual(blocked_response.status_code, 429)
@@ -1476,6 +1476,8 @@ class AuthUserRowCacheTests(TestCase):
@override_settings(AUTH_USER_CACHE_TTL=30)
def test_set_grade_still_works_with_cache_on(self):
from content.models import Grade
Grade.objects.create(pk=3, title='g3', icon='', color='')
r = self.client.post(
'/api/user/grade', {'gradeId': 3}, format='json',
HTTP_AUTHORIZATION=f'Bearer {self.token}')

View File

@@ -177,3 +177,156 @@ class SupervisorDoesNotSeeDeviceIdsTests(TestCase):
self.assertNotContains(form, 'secret-device-123')
sessions = self.client.get('/admin/accounts/session/')
self.assertNotContains(sessions, 'secret-device-123')
def _staff_with(*perms, username='staff1'):
from django.contrib.auth.models import Permission
staff = get_user_model().objects.create_user(username, f'{username}@example.com', 'pw-for-tests-only', is_staff=True)
for app_label, codename in perms:
staff.user_permissions.add(Permission.objects.get(content_type__app_label=app_label, codename=codename))
return staff
class DeviceIdNotGuessableByAdminFilterTests(TestCase):
"""فیلترِ querystringِ changelist (?device_id__startswith=…) شناسه‌ی دستگاه را برای غیرِ ابرکاربر لو نمی‌دهد."""
def setUp(self):
call_command('setup_supervisor_group', verbosity=0)
self.guest = User.objects.create(device_id='abc-secret-999', is_guest=True)
from accounts.models import Session
Session.objects.create(user=self.guest, refresh_token='h' * 64, device_id='abc-secret-999',
expires_at=timezone.now() + timezone.timedelta(days=1))
staff = _staff_with(('accounts', 'view_session'))
staff.groups.add(Group.objects.get(name='Supervisor'))
self.client.force_login(staff)
def test_filter_on_device_id_is_refused(self):
for lookup in ('device_id__startswith', 'device_id__regex', 'device_id'):
r = self.client.get('/admin/accounts/user/', {lookup: 'abc'})
self.assertEqual(r.status_code, 400, lookup)
self.assertNotContains(r, f'/admin/accounts/user/{self.guest.pk}/change/', status_code=400)
r = self.client.get('/admin/accounts/session/', {'device_id__startswith': 'abc'})
self.assertEqual(r.status_code, 400)
def test_superuser_still_filters(self):
root = get_user_model().objects.create_superuser('root', 'root@example.com', 'pw-for-tests-only')
self.client.force_login(root)
r = self.client.get('/admin/accounts/user/', {'device_id__startswith': 'abc'})
self.assertContains(r, f'/admin/accounts/user/{self.guest.pk}/change/')
class StaffCannotSwapLoginPhoneTests(TestCase):
"""کارمندی با change_user شماره‌ی کاربر را عوض نمی‌کند؛ وگرنه با شماره‌ی خودش کد می‌گیرد و واردِ آن حساب می‌شود."""
def setUp(self):
cache.clear()
self.victim = User.objects.create(phone='09120000077', is_guest=False, name='victim')
self.client.force_login(_staff_with(('accounts', 'change_user'), ('accounts', 'view_user')))
def tearDown(self):
cache.clear()
def test_phone_and_is_guest_are_read_only(self):
r = self.client.post(f'/admin/accounts/user/{self.victim.pk}/change/', {
'phone': '09129999999', 'name': 'renamed', 'avatar': '', 'grade_id': '', 'is_guest': 'on'})
self.assertEqual(r.status_code, 302)
self.victim.refresh_from_db()
self.assertEqual(self.victim.name, 'renamed')
self.assertEqual(self.victim.phone, '09120000077')
self.assertFalse(self.victim.is_guest)
login = APIClient().post('/api/auth/login', {'phone': '09129999999', 'otp': _otp_row('09129999999')},
format='json')
self.assertNotEqual(login.json().get('userId'), str(self.victim.pk))
class StaffAccountsAdminSuperuserOnlyTests(TestCase):
"""auth.User/Group پنل: با change_group یا change_user (auth) کارمند خودش را ابرکاربر یا همه‌کاره نمی‌کند."""
def test_change_group_cannot_grant_all_permissions(self):
from django.contrib.auth.models import Permission
call_command('setup_supervisor_group', verbosity=0)
staff = _staff_with(('auth', 'change_group'), ('auth', 'view_group'))
group = Group.objects.get(name='Supervisor')
staff.groups.add(group)
self.client.force_login(staff)
r = self.client.post(f'/admin/auth/group/{group.pk}/change/',
{'name': 'Supervisor', 'permissions': list(Permission.objects.values_list('pk', flat=True))})
self.assertEqual(r.status_code, 403)
staff = get_user_model().objects.get(pk=staff.pk)
self.assertFalse(staff.has_perm('accounts.add_otpcode'))
def test_change_user_cannot_make_self_superuser(self):
staff = _staff_with(('auth', 'change_user'), ('auth', 'view_user'))
self.client.force_login(staff)
form = self.client.get(f'/admin/auth/user/{staff.pk}/change/')
self.assertEqual(form.status_code, 200)
self.assertNotContains(form, 'name="is_superuser"')
r = self.client.post(f'/admin/auth/user/{staff.pk}/change/', {
'username': staff.username, 'is_active': 'on', 'is_staff': 'on', 'is_superuser': 'on',
'last_login_0': '', 'last_login_1': '', 'date_joined_0': '2026-01-01', 'date_joined_1': '00:00:00'})
self.assertEqual(r.status_code, 403)
self.assertFalse(get_user_model().objects.get(pk=staff.pk).is_superuser)
class UnicodeInputsAreNormalisedOrRejectedTests(TestCase):
"""رقم‌های تمام‌عرض/ریاضی، کدِ غیرِ ASCII، پایه‌ی ناموجود و deviceIdِ null: ۴۰۰ یا نرمال‌شده، نه ۵۰۰ و نه حسابِ جدا."""
def setUp(self):
cache.clear()
self.addCleanup(cache.clear)
def test_fullwidth_and_math_digits_normalise_to_same_phone(self):
from accounts.phone import normalize_phone
for variant in ('09123456789', '𝟎𝟗𝟏𝟐𝟑𝟒𝟓𝟔𝟕𝟖𝟗', '۰۹۱۲۳۴۵۶۷۸۹', '+98 912 345 6789'):
self.assertEqual(normalize_phone(variant), '09123456789', variant)
self.assertIsNone(normalize_phone('0912345678²'))
def test_non_ascii_otp_is_400_not_500(self):
phone = '09120000099'
with override_settings(TEST_LOGIN_PHONES={phone: '123456'}):
for code in ('²²²²', '١٢٣٤٥٦٧'[:6] + 'x', 'abcd'):
r = APIClient().post('/api/auth/login', {'phone': phone, 'otp': code}, format='json')
self.assertEqual(r.status_code, 400, code)
def test_login_device_id_null_is_400(self):
phone = '09120000098'
code = _otp_row(phone)
r = APIClient().post('/api/auth/login', {'phone': phone, 'otp': code, 'deviceId': None}, format='json')
self.assertEqual(r.status_code, 400)
def test_unknown_or_huge_grade_is_400(self):
user = User.objects.create(phone='09120000097', is_guest=False)
app = _bearer(issue_access_token(user.id))
for gid in (99999, 10 ** 30):
self.assertEqual(app.post('/api/user/grade', {'gradeId': gid}, format='json').status_code, 400, gid)
def test_birth_date_with_superscript_digit_is_400(self):
user = User.objects.create(phone='09120000096', is_guest=False)
r = _bearer(issue_access_token(user.id)).patch('/api/user/profile', {'birthDate': '13²5-04-12'}, format='json')
self.assertEqual(r.status_code, 400)
def test_display_name_nfkc_contacts_removed(self):
from accounts.serializers import clean_display_name as clean
for sample in ('09123456789', '𝟎𝟗𝟏𝟐𝟑𝟒𝟓𝟔𝟕𝟖𝟗', '0912*345*6789', '@kid2014'):
self.assertEqual(clean(sample), '', sample)
self.assertEqual(clean('علی رضایی'), 'علی رضایی')
class DemoGuestNotUpgradableTests(TestCase):
"""مهمانِ نمایشیِ مشترک با ورودِ یک شماره به آن شماره تبدیل نمی‌شود؛ شماره‌ی تازه حسابِ تازه می‌گیرد."""
def test_demo_guest_keeps_its_identity(self):
cache.clear()
self.addCleanup(cache.clear)
s = _guest('frontend-test-device')
demo_id = int(s['userId'])
phone = '09120000095'
code = _otp_row(phone)
r = _bearer(s['token']).post('/api/auth/login', {'phone': phone, 'otp': code}, format='json')
self.assertEqual(r.status_code, 200)
self.assertNotEqual(int(r.json()['userId']), demo_id)
demo = User.objects.get(pk=demo_id)
self.assertIsNone(demo.phone)
self.assertTrue(demo.is_guest)

View File

@@ -390,7 +390,7 @@ class LoginView(APIView):
Session.objects.create(
user=user,
refresh_token=hash_refresh_token(refresh_token),
device_id=request.data.get('deviceId', ''),
device_id=serializer.validated_data.get('deviceId', ''),
expires_at=timezone.now() + timezone.timedelta(days=settings.REFRESH_TOKEN_TTL_DAYS),
)
access_token = issue_access_token(user.id)
@@ -414,15 +414,19 @@ def _upgradable_guest(request, phone: str):
"""
مهمانی که با توکنِ خودش درخواستِ ورود داده و این شماره هنوز حسابی ندارد.
شماره‌های TEST_LOGIN_PHONES عمداً کنار گذاشته می‌شوند: حساب‌های دمو با
همین شماره‌ها ساخته شده‌اند و ورودِ آزمایشی نباید حسابِ مهمانِ دمو
(`frontend-test-device`) را به یک شماره تبدیل کند.
شماره‌های TEST_LOGIN_PHONES و مهمان‌های DEMO_GUEST_DEVICE_IDS عمداً کنار گذاشته می‌شوند:
هر دو حسابِ نمایشیِ مشترک‌اند و ورود نباید یکی را به دیگری یا به شماره‌ی یک نفر تبدیل کند.
"""
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
# مهمانِ نمایشیِ عمومی (DEMO_GUEST_DEVICE_IDS) هم مالِ یک نفر نیست: بدونِ این، هر کس از
# نشستِ آن با شماره‌ی خودش وارد می‌شد کلِ حساب و داده‌ی دمو را به شماره‌ی خودش می‌برد و
# نشستِ بقیه را باطل می‌کرد. آن کاربر حسابِ تازه‌ی خودش را می‌گیرد.
if user.device_id in settings.DEMO_GUEST_DEVICE_IDS:
return None
return User.objects.filter(pk=user.pk, is_guest=True, phone__isnull=True).first()