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 ()) exclude = tuple(super().get_exclude(request, obj) or ())
return exclude if request.user.is_superuser else exclude + ('device_id',) 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) @admin.register(User)
class UserAdmin(DeviceIdForSuperuserOnly, admin.ModelAdmin): class UserAdmin(DeviceIdForSuperuserOnly, admin.ModelAdmin):
@@ -90,6 +97,13 @@ class UserAdmin(DeviceIdForSuperuserOnly, admin.ModelAdmin):
list_filter = ('is_guest',) list_filter = ('is_guest',)
actions = ['send_sms_to_selected'] 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) # permissions=['change']: بدونِ این، کاربرِ فقط-view (گروهِ Supervisor)
# می‌تونست با POSTِ مستقیم این اکشن رو صدا بزنه بدونِ دیدنِ دکمه در UI؛ # می‌تونست با POSTِ مستقیم این اکشن رو صدا بزنه بدونِ دیدنِ دکمه در UI؛
# این پارامتر باعث می‌شه ModelAdmin فقط برای change_user در دسترس بذارتش. # این پارامتر باعث می‌شه ModelAdmin فقط برای change_user در دسترس بذارتش.
@@ -250,3 +264,37 @@ def _admin_get_urls_with_supervisor():
admin.site.get_urls = _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: def parse(value: str) -> date:
"""«1385-04-12» (ارقامِ فارسی هم) → تاریخِ میلادی.""" """«1385-04-12» (ارقامِ فارسی هم) → تاریخِ میلادی."""
from accounts.phone import _DIGITS from accounts.phone import ascii_digits
parts = str(value).translate(_DIGITS).strip().replace('/', '-').split('-') parts = ascii_digits(str(value)).strip().replace('/', '-').split('-')
if len(parts) != 3 or not all(p.isdigit() for p in parts): # isascii: «²» برای isdigit رقم است ولی int() آن را نمی‌پذیرد (پیامِ خطای انگلیسیِ پایتون).
if len(parts) != 3 or not all(p.isascii() and p.isdigit() for p in parts):
raise ValueError('تاریخ باید به شکلِ سال-ماه-روز باشد، مثلاً 1385-04-12.') raise ValueError('تاریخ باید به شکلِ سال-ماه-روز باشد، مثلاً 1385-04-12.')
return to_gregorian(int(parts[0]), int(parts[1]), int(parts[2])) 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 است. (کلیدهای TEST_LOGIN_PHONES)؛ فیلدِ serializer در accounts/serializers.py است.
""" """
import re import re
import unicodedata
_DIGITS = str.maketrans('۰۱۲۳۴۵۶۷۸۹٠١٢٣٤٥٦٧٨٩', '01234567890123456789')
_SEPARATORS = re.compile(r'[\s\-().\u200c\u200e\u200f]') _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: def normalize_phone(value) -> str | None:
"""شکلِ یکتای ‎09XXXXXXXXX‎، یا None اگر موبایلِ ایران نباشد.""" """شکلِ یکتای ‎09XXXXXXXXX‎، یا None اگر موبایلِ ایران نباشد."""
s = _SEPARATORS.sub('', str(value or '').translate(_DIGITS)) s = _SEPARATORS.sub('', ascii_digits(str(value or '')))
if s.startswith('+98'): if s.startswith('+98'):
s = '0' + s[3:] s = '0' + s[3:]
elif s.startswith('0098'): elif s.startswith('0098'):

View File

@@ -1,14 +1,17 @@
import re import re
import unicodedata
from rest_framework import serializers from rest_framework import serializers
from .phone import normalize_phone from .phone import ascii_digits, normalize_phone
# لینک، آیدیِ شبکه‌ی اجتماعی، و هر رشته‌ی ۵ رقمی یا بیشتر (شماره‌ی تلفن با # لینک، آیدیِ شبکه‌ی اجتماعی، و هر رشته‌ی ۵ رقمی یا بیشتر با هر جداکننده‌ی غیرِ حرفی (فاصله،
# فاصله/خط‌تیره هم) — ارقامِ فارسی و عربی هم. این نام در رتبه‌بندی به بچه‌های دیگر نشان داده می‌شود. # خط‌تیره، ستاره، ایموجی…). \d در پایتون هر رقمِ یونیکد است (فارسی، عربی، تمام‌عرض، ریاضی).
# این نام در رتبه‌بندی به بچه‌های دیگر نشان داده می‌شود. حرف بینِ رقم‌ها («0x9x1…») و عدد با
# حروف («صفر نه یک…») گرفته نمی‌شود.
_CONTACT = re.compile( _CONTACT = re.compile(
r'(https?://\S+|www\.\S+|\S+\.(?:com|ir|org|net|me)\S*|@\w+' r'(https?://\S+|www\.\S+|\S+\.(?:com|ir|org|net|me)\S*|@\w+'
r'|(?:[0-9۰-۹٠-٩][\s\-._/\\|,،]*){5,})', r'|(?:\d(?:[^\w@]|_)*){5,})',
re.IGNORECASE, re.IGNORECASE,
) )
# «ig: name»، «insta name»، «آیدی name» و مانندش: کلیدواژه و هر چه بعدش آمده حذف می‌شود. # «ig: name»، «insta name»، «آیدی name» و مانندش: کلیدواژه و هر چه بعدش آمده حذف می‌شود.
@@ -20,7 +23,9 @@ _HANDLE = re.compile(
def clean_display_name(value: str) -> str: 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] return ' '.join(_CONTACT.sub(' ', value).split())[:30]
@@ -45,9 +50,30 @@ class GuestSessionRequestSerializer(serializers.Serializer):
deviceId = serializers.CharField(max_length=64) 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): class LoginRequestSerializer(serializers.Serializer):
phone = IranMobileField() 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): class RefreshRequestSerializer(serializers.Serializer):
@@ -57,6 +83,15 @@ class RefreshRequestSerializer(serializers.Serializer):
class SetGradeRequestSerializer(serializers.Serializer): class SetGradeRequestSerializer(serializers.Serializer):
gradeId = serializers.IntegerField() 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): class DeleteAccountRequestSerializer(serializers.Serializer):
# کاربرِ شماره‌دار: کدِ پیامکیِ تازه (از POST /api/auth/otp/request با همان شماره). # کاربرِ شماره‌دار: کدِ پیامکیِ تازه (از 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) confirm = serializers.CharField(max_length=10, required=False, allow_blank=True)

View File

@@ -348,12 +348,12 @@ class LoginPhoneRateLimitTests(TestCase):
for _ in range(3): for _ in range(3):
response = self.client.post( 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) self.assertEqual(response.status_code, 400)
blocked_response = self.client.post( 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) self.assertEqual(blocked_response.status_code, 429)
@@ -1476,6 +1476,8 @@ class AuthUserRowCacheTests(TestCase):
@override_settings(AUTH_USER_CACHE_TTL=30) @override_settings(AUTH_USER_CACHE_TTL=30)
def test_set_grade_still_works_with_cache_on(self): 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( r = self.client.post(
'/api/user/grade', {'gradeId': 3}, format='json', '/api/user/grade', {'gradeId': 3}, format='json',
HTTP_AUTHORIZATION=f'Bearer {self.token}') HTTP_AUTHORIZATION=f'Bearer {self.token}')

View File

@@ -177,3 +177,156 @@ class SupervisorDoesNotSeeDeviceIdsTests(TestCase):
self.assertNotContains(form, 'secret-device-123') self.assertNotContains(form, 'secret-device-123')
sessions = self.client.get('/admin/accounts/session/') sessions = self.client.get('/admin/accounts/session/')
self.assertNotContains(sessions, 'secret-device-123') 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( Session.objects.create(
user=user, user=user,
refresh_token=hash_refresh_token(refresh_token), 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), expires_at=timezone.now() + timezone.timedelta(days=settings.REFRESH_TOKEN_TTL_DAYS),
) )
access_token = issue_access_token(user.id) access_token = issue_access_token(user.id)
@@ -414,15 +414,19 @@ def _upgradable_guest(request, phone: str):
""" """
مهمانی که با توکنِ خودش درخواستِ ورود داده و این شماره هنوز حسابی ندارد. مهمانی که با توکنِ خودش درخواستِ ورود داده و این شماره هنوز حسابی ندارد.
شماره‌های TEST_LOGIN_PHONES عمداً کنار گذاشته می‌شوند: حساب‌های دمو با شماره‌های TEST_LOGIN_PHONES و مهمان‌های DEMO_GUEST_DEVICE_IDS عمداً کنار گذاشته می‌شوند:
همین شماره‌ها ساخته شده‌اند و ورودِ آزمایشی نباید حسابِ مهمانِ دمو هر دو حسابِ نمایشیِ مشترک‌اند و ورود نباید یکی را به دیگری یا به شماره‌ی یک نفر تبدیل کند.
(`frontend-test-device`) را به یک شماره تبدیل کند.
""" """
if phone in settings.TEST_LOGIN_PHONES: if phone in settings.TEST_LOGIN_PHONES:
return None return None
user = getattr(request, 'user', None) user = getattr(request, 'user', None)
if user is None or not getattr(user, 'is_authenticated', False) or not user.is_guest: if user is None or not getattr(user, 'is_authenticated', False) or not user.is_guest:
return None 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() return User.objects.filter(pk=user.pk, is_guest=True, phone__isnull=True).first()

View File

@@ -33,8 +33,8 @@ class MessageCampaignAdmin(admin.ModelAdmin):
@admin.register(CampaignDelivery) @admin.register(CampaignDelivery)
class CampaignDeliveryAdmin(admin.ModelAdmin): class CampaignDeliveryAdmin(admin.ModelAdmin):
""" """
نمایشِ read-only: لیست و صفحه‌ی جزئیاتِ هر رکورد همیشه قابلِ نمایشِ read-only: لیست و صفحه‌ی جزئیات فقط با اجازه‌ی view_campaigndelivery
مشاهده‌ست (has_view_permission صریحاً True)، ولی هیچ راهی برای (رفتارِ پیش‌فرضِ جنگو)، و هیچ راهی برای
افزودن، ویرایش یا حذفِ رکورد از ادمین وجود نداره تا وضعیتِ تحویل افزودن، ویرایش یا حذفِ رکورد از ادمین وجود نداره تا وضعیتِ تحویل
(که فقط باید توسطِ campaigns.services.execute_campaign آپدیت بشه) (که فقط باید توسطِ campaigns.services.execute_campaign آپدیت بشه)
از ادمین دست‌کاری نشه. از ادمین دست‌کاری نشه.
@@ -46,13 +46,6 @@ class CampaignDeliveryAdmin(admin.ModelAdmin):
fields = ('campaign', 'user_id', 'channel', 'status', 'error_message', 'created_at') fields = ('campaign', 'user_id', 'channel', 'status', 'error_message', 'created_at')
readonly_fields = fields readonly_fields = fields
def has_view_permission(self, request, obj=None):
# صریحاً True: بدونِ override، fallbackِ پیش‌فرضِ Django به کاربرِ
# staffِ بدونِ پرمیشنِ view/change حتی اجازه‌ی دیدنِ این صفحه‌ی
# read-only رو هم نمی‌ده — یعنی مدل عملاً غیرقابلِ‌دسترس می‌شه، نه
# فقط view-only.
return True
def has_add_permission(self, request): def has_add_permission(self, request):
return False return False

View File

@@ -0,0 +1,30 @@
"""
حفاظ‌های بازبینیِ امنیتی (کمپین‌ها در پنل).
"""
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Permission
from django.test import TestCase
from .models import CampaignDelivery, MessageCampaign
class CampaignDeliveryNeedsViewPermissionTests(TestCase):
"""لاگِ تحویلِ کمپین (user_id، متنِ خطای ارسال) فقط با view_campaigndelivery دیده می‌شود، نه برای هر کارمند."""
def setUp(self):
campaign = MessageCampaign.objects.create(title='t', body='b', channel='sms')
self.delivery = CampaignDelivery.objects.create(campaign=campaign, user_id=5, channel='sms',
status='failed', error_message='e')
self.staff = get_user_model().objects.create_user('st', 'st@example.com', 'pw-for-tests-only', is_staff=True)
self.client.force_login(self.staff)
def test_staff_without_permission_gets_403(self):
self.assertEqual(self.client.get('/admin/campaigns/campaigndelivery/').status_code, 403)
self.assertEqual(
self.client.get(f'/admin/campaigns/campaigndelivery/{self.delivery.pk}/change/').status_code, 403)
def test_view_permission_still_read_only(self):
self.staff.user_permissions.add(
Permission.objects.get(content_type__app_label='campaigns', codename='view_campaigndelivery'))
self.assertEqual(self.client.get('/admin/campaigns/campaigndelivery/').status_code, 200)
self.assertEqual(self.client.get('/admin/campaigns/campaigndelivery/add/').status_code, 403)

View File

@@ -174,6 +174,9 @@ class APIRequestLoggingMiddlewareTests(TestCase):
user = User.objects.create(device_id='device-mw-log-1', is_guest=True, name='') user = User.objects.create(device_id='device-mw-log-1', is_guest=True, name='')
token = issue_access_token(user.id) token = issue_access_token(user.id)
from content.models import Grade
Grade.objects.create(pk=10, title='g10', icon='', color='')
with self.assertLogs('config.middleware', level='INFO') as captured: with self.assertLogs('config.middleware', level='INFO') as captured:
response = self.client.post( response = self.client.post(
'/api/user/grade', '/api/user/grade',

View File

@@ -1,4 +1,3 @@
from drf_spectacular.utils import extend_schema_field
from rest_framework import serializers from rest_framework import serializers
from .models import Grade, QuestionReport from .models import Grade, QuestionReport
@@ -215,15 +214,11 @@ class NodeProgressSerializer(serializers.Serializer):
choices=['not_started', 'in_progress', 'completed', 'abandoned'], choices=['not_started', 'in_progress', 'completed', 'abandoned'],
) )
lessonsCompleted = serializers.IntegerField(source='lessons_completed_count') lessonsCompleted = serializers.IntegerField(source='lessons_completed_count')
lessonsTotal = serializers.SerializerMethodField() lessonsTotal = serializers.IntegerField(source='lessons_total')
startedAt = serializers.DateTimeField(source='started_at', allow_null=True) startedAt = serializers.DateTimeField(source='started_at', allow_null=True)
completedAt = serializers.DateTimeField(source='completed_at', allow_null=True) completedAt = serializers.DateTimeField(source='completed_at', allow_null=True)
lastActivityAt = serializers.DateTimeField(source='last_activity_at', allow_null=True) lastActivityAt = serializers.DateTimeField(source='last_activity_at', allow_null=True)
@extend_schema_field(serializers.IntegerField)
def get_lessonsTotal(self, obj):
return obj.node.lessons.count()
class NodeProgressListResponseSerializer(serializers.Serializer): class NodeProgressListResponseSerializer(serializers.Serializer):
progress = NodeProgressSerializer(many=True) progress = NodeProgressSerializer(many=True)

View File

@@ -78,3 +78,77 @@ class NonAsciiDigitsAre400Tests(TestCase):
self.assertEqual(r.status_code, 400) self.assertEqual(r.status_code, 400)
ok = self.app.post('/api/support/bug-reports', {'title': 't', 'description': 'x' * 5000}, format='json') ok = self.app.post('/api/support/bug-reports', {'title': 't', 'description': 'x' * 5000}, format='json')
self.assertIn(ok.status_code, (200, 201)) self.assertIn(ok.status_code, (200, 201))
class JunkGradeIdsDoNotFlushCacheTests(TestCase):
"""
هر ?gradeId=ِ ناموجود در /api/home یک کلیدِ کش می‌ساخت؛ ۴۰۰ عددِ ساختگی LocMemCache را پر و
شمارنده‌ی سقفِ ورودِ ادمین را پاک می‌کرد (یازدهمین تلاش دوباره ۲۰۰ می‌گرفت، نه ۴۲۹).
"""
def setUp(self):
from unittest import mock
from rest_framework.throttling import ScopedRateThrottle
cache.clear()
self.addCleanup(cache.clear)
# فقط برای سرعتِ تست؛ با سقفِ واقعی (۱۲۰ در دقیقه) همین کار حدودِ سه دقیقه طول می‌کشید.
patcher = mock.patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'read': '100000/min'})
patcher.start()
self.addCleanup(patcher.stop)
user = User.objects.create(phone='09120009911', is_guest=False)
self.app = APIClient()
self.app.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(user.id)}')
def test_admin_login_limit_survives_junk_grade_ids(self):
anon = APIClient()
for _ in range(10):
anon.post('/admin/login/', {'username': 'x', 'password': 'y'})
self.assertEqual(anon.post('/admin/login/', {'username': 'x', 'password': 'y'}).status_code, 429)
for n in range(1000, 1400):
self.assertEqual(self.app.get('/api/home', {'gradeId': n}).status_code, 404)
self.assertEqual(anon.post('/admin/login/', {'username': 'x', 'password': 'y'}).status_code, 429)
def test_unknown_grade_is_not_cached_and_new_grade_still_loads(self):
self.app.get('/api/home', {'gradeId': 777})
self.assertFalse(any('grade:777' in k for k in cache._cache)) # LocMemCache در تست‌ها
grade = Grade.objects.create(title='تازه', icon='', color='')
self.assertEqual(self.app.get('/api/home', {'gradeId': grade.id}).status_code, 200)
class ProgressNodesInputAndQueriesTests(TestCase):
"""?unit= بزرگ‌تر از INTEGERِ SQLite ۵۰۰ می‌داد؛ lessonsTotal برای هر ردیف یک کوئریِ جدا بود."""
def setUp(self):
cache.clear()
self.user = User.objects.create(phone='09120009912', is_guest=False)
self.app = APIClient()
self.app.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(self.user.id)}')
def test_huge_unit_is_400(self):
self.assertEqual(self.app.get('/api/progress/nodes', {'unit': '9' * 25}).status_code, 400)
def test_query_count_does_not_grow_with_rows(self):
from django.db import connection
from django.test.utils import CaptureQueriesContext
from content.models import NodeProgress
unit = Unit.objects.create(grade=Grade.objects.create(title='g', icon='', color=''), title='u', order=1)
def add_nodes(start, n):
for i in range(start, start + n):
node = Node.objects.create(unit=unit, title=f'n{i}', order=i)
Lesson.objects.create(node=node, title='l1')
Lesson.objects.create(node=node, title='l2')
NodeProgress.objects.create(user_id=self.user.id, node=node)
add_nodes(0, 2)
with CaptureQueriesContext(connection) as small:
self.app.get('/api/progress/nodes')
add_nodes(2, 8)
with CaptureQueriesContext(connection) as big:
r = self.app.get('/api/progress/nodes')
self.assertEqual(len(big), len(small))
self.assertEqual([row['lessonsTotal'] for row in r.json()['progress']], [2] * 10)

View File

@@ -122,14 +122,16 @@ def _grade_tree(grade_id: int):
"""اسکلتِ گریدِ [{id,title,nodes:[{id,title,lesson_ids}]}] — بدونِ status/دیتای کاربر. """اسکلتِ گریدِ [{id,title,nodes:[{id,title,lesson_ids}]}] — بدونِ status/دیتای کاربر.
None یعنی گریدِ ناموجود؛ [] یعنی گریدِ موجودِ بدونِ واحد.""" None یعنی گریدِ ناموجود؛ [] یعنی گریدِ موجودِ بدونِ واحد."""
# tree2: هر node حالا `playable_ids` هم دارد؛ کلیدِ ساختارِ قبلی باطل شد. # tree2: هر node حالا `playable_ids` هم دارد؛ کلیدِ ساختارِ قبلی باطل شد.
# پایه‌ی ناموجود کش نمی‌شود: gradeId از query string می‌آید و هر عددِ تازه یک کلیدِ تازه
# می‌ساخت. LocMemCache (تا CACHE_URL نیست) بعد از ۳۰۰ کلید یک‌سومِ کهنه‌ها را دور می‌ریزد و
# شمارنده‌های throttle (سقفِ ورودِ ادمین، OTP بر اساسِ IP) هم در همین کش‌اند؛ با چند صد
# gradeIdِ ساختگی سقفِ ورودِ ادمین صفر می‌شد (بازتولید شد، tests_security_review).
if grade_id not in {g['id'] for g in _grades_payload()['grades']}:
return None
key = f'content:tree2:grade:{grade_id}:v{curriculum_version()}' key = f'content:tree2:grade:{grade_id}:v{curriculum_version()}'
cached = cache.get(key) cached = cache.get(key)
if cached is not None: if cached is not None:
return None if cached is False else cached return cached
if not Grade.objects.filter(pk=grade_id).exists():
cache.set(key, False, _CACHE_TTL)
return None
units = ( units = (
Grade.objects.get(pk=grade_id) Grade.objects.get(pk=grade_id)
@@ -738,10 +740,15 @@ class NodeProgressListView(APIView):
tags=['Curriculum (content)'], tags=['Curriculum (content)'],
) )
def get(self, request): def get(self, request):
qs = NodeProgress.objects.filter(user_id=request.user.id).select_related('node') # lessons_total یک‌جا در همین کوئری؛ پیش‌تر سریالایزر برای هر ردیف یک COUNT جدا می‌زد.
qs = (
NodeProgress.objects.filter(user_id=request.user.id).select_related('node')
.annotate(lessons_total=Count('node__lessons'))
)
unit_id = request.query_params.get('unit') unit_id = request.query_params.get('unit')
if unit_id: if unit_id:
if not (unit_id.isascii() and unit_id.isdigit()): # سقفِ طول: عددِ بزرگ‌تر از INTEGERِ SQLite روی فیلترِ node__unit_id خطای ۵۰۰ می‌داد.
if not (unit_id.isascii() and unit_id.isdigit() and len(unit_id) <= 9):
raise ValidationError({'unit': 'شناسه‌ی فصل باید عدد باشد.'}) raise ValidationError({'unit': 'شناسه‌ی فصل باید عدد باشد.'})
qs = qs.filter(node__unit_id=int(unit_id)) qs = qs.filter(node__unit_id=int(unit_id))

View File

@@ -122,6 +122,12 @@ class BazaarCredentialAdmin(admin.ModelAdmin):
def connected(self, obj): def connected(self, obj):
return bool(obj.refresh_token) return bool(obj.refresh_token)
def lookup_allowed(self, lookup, value, request=None):
# فیلترِ querystring (?refresh_token__startswith=a) با شمردنِ نتیجه توکن را حرف‌به‌حرف لو می‌دهد.
if lookup.split('__')[0] in ('refresh_token', 'pending_state'):
return False
return super().lookup_allowed(lookup, value, request)
def changelist_view(self, request, extra_context=None): def changelist_view(self, request, extra_context=None):
BazaarCredential.objects.get_or_create(pk=1) BazaarCredential.objects.get_or_create(pk=1)
return super().changelist_view(request, extra_context) return super().changelist_view(request, extra_context)

View File

@@ -227,3 +227,21 @@ class DiscountDuringPaymentRequestTests(_Base):
self.assertEqual(order.amount, self.pack.price // 2) self.assertEqual(order.amount, self.pack.price // 2)
self.assertEqual(requested, [self.pack.price, self.pack.price // 2]) self.assertEqual(requested, [self.pack.price, self.pack.price // 2])
self.assertEqual(order.gateway_authority, 'A-2') self.assertEqual(order.gateway_authority, 'A-2')
class BazaarTokenNotGuessableByAdminFilterTests(TestCase):
"""فیلترِ querystring روی refresh_token/pending_state توکنِ بازار را حرف‌به‌حرف لو نمی‌دهد."""
def test_filter_is_refused(self):
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Permission
from .models import BazaarCredential
BazaarCredential.objects.update_or_create(pk=1, defaults={'refresh_token': 'TOPSECRET', 'pending_state': 'st8'})
staff = get_user_model().objects.create_user('bz', 'bz@example.com', 'pw-for-tests-only', is_staff=True)
staff.user_permissions.add(Permission.objects.get(content_type__app_label='payment', codename='view_bazaarcredential'))
self.client.force_login(staff)
self.assertEqual(self.client.get('/admin/payment/bazaarcredential/').status_code, 200)
for lookup in ('refresh_token__startswith', 'pending_state__startswith', 'refresh_token__regex'):
r = self.client.get('/admin/payment/bazaarcredential/', {lookup: 'T'})
self.assertEqual(r.status_code, 400, lookup)