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