diff --git a/accounts/apps.py b/accounts/apps.py index afae301..012c99e 100644 --- a/accounts/apps.py +++ b/accounts/apps.py @@ -8,3 +8,4 @@ class AccountsConfig(AppConfig): def ready(self): # Registers OpenApiAuthenticationExtension for the Swagger UI Authorize button. from . import schema # noqa: F401 + from . import receivers # noqa: F401 (باطل‌کردنِ کشِ ردیفِ کاربر) diff --git a/accounts/authentication.py b/accounts/authentication.py index 4328bfc..d3b7cbb 100644 --- a/accounts/authentication.py +++ b/accounts/authentication.py @@ -1,5 +1,6 @@ from django.conf import settings from django.core import signing +from django.core.cache import cache from rest_framework.authentication import BaseAuthentication from rest_framework.exceptions import AuthenticationFailed @@ -7,6 +8,48 @@ from .models import User ACCESS_TOKEN_SALT = 'tadrisino-access-token' +# فیلدهای غیرِ‌رابطه‌ایِ User که کش می‌شن؛ همه‌ی concrete fieldها تا بازسازیِ +# instance برای save(update_fields=...) امن بمونه. +_USER_CACHE_FIELDS = [f.attname for f in User._meta.concrete_fields] + + +def _auth_user_cache_key(user_id: int) -> str: + return f'auth:user:{user_id}' + + +def invalidate_user_cache(user_id: int) -> None: + cache.delete(_auth_user_cache_key(user_id)) + + +def _load_user_or_fail(user_id: int) -> User: + try: + return User.objects.get(pk=user_id) + except User.DoesNotExist: + raise AuthenticationFailed('کاربر پیدا نشد.') + + +def _get_request_user(user_id: int) -> User: + """ + ردیفِ کاربر برای request جاری. با AUTH_USER_CACHE_TTL>0 از کش خونده + می‌شه (یک SELECT کمتر در هر request authenticated). instanceِ بازسازی‌شده + کاملِ همه‌ی فیلدهاست، پس save(update_fields=[...]) روش امنه. + """ + ttl = getattr(settings, 'AUTH_USER_CACHE_TTL', 0) + if not ttl: + return _load_user_or_fail(user_id) + + key = _auth_user_cache_key(user_id) + data = cache.get(key) + if data is None: + user = _load_user_or_fail(user_id) + cache.set(key, {f: getattr(user, f) for f in _USER_CACHE_FIELDS}, ttl) + return user + + user = User(**data) + user._state.adding = False + user._state.db = 'default' + return user + def issue_access_token(user_id: int) -> str: """ @@ -48,13 +91,7 @@ class TadrisinoTokenAuthentication(BaseAuthentication): token = parts[1] user_id = decode_access_token(token) - - try: - user = User.objects.get(pk=user_id) - except User.DoesNotExist: - raise AuthenticationFailed('کاربر پیدا نشد.') - - return (user, token) + return (_get_request_user(user_id), token) def authenticate_header(self, request): return self.keyword diff --git a/accounts/receivers.py b/accounts/receivers.py new file mode 100644 index 0000000..f7c27da --- /dev/null +++ b/accounts/receivers.py @@ -0,0 +1,22 @@ +""" +باطل‌کردنِ کشِ ردیفِ کاربر (auth:user:) با هر تغییرِ User. + +این سیگنال‌ها همه‌ی مسیرهای نوشتنِ داخلِ اپ (SetUserGrade، لاگین، رفرش‌توکن، +last_activity_at، ...) را پوشش می‌دهند؛ فقط تغییری که کاملاً بیرون از ORM +اعمال شود (مثلاً UPDATE مستقیمِ SQL) تا سقفِ AUTH_USER_CACHE_TTL کهنه می‌ماند. +""" +from django.db.models.signals import post_delete, post_save +from django.dispatch import receiver + +from .authentication import invalidate_user_cache +from .models import User + + +@receiver(post_save, sender=User, dispatch_uid='accounts_invalidate_user_cache_on_save') +def _invalidate_on_save(sender, instance, **kwargs): + invalidate_user_cache(instance.pk) + + +@receiver(post_delete, sender=User, dispatch_uid='accounts_invalidate_user_cache_on_delete') +def _invalidate_on_delete(sender, instance, **kwargs): + invalidate_user_cache(instance.pk) diff --git a/accounts/tests.py b/accounts/tests.py index 19aa2be..a2f56d0 100644 --- a/accounts/tests.py +++ b/accounts/tests.py @@ -1430,3 +1430,66 @@ class SupervisorGroupAccessTests(TestCase): self.client.force_login(non_staff_supervisor) response = self.client.get(reverse('admin:accounts_user_changelist')) self.assertEqual(response.status_code, 302) + + +class AuthUserRowCacheTests(TestCase): + """کشِ کوتاهِ ردیفِ کاربر در TadrisinoTokenAuthentication.""" + + def setUp(self): + cache.clear() + self.client = APIClient() + self.user = User.objects.create(device_id='dev-authcache', is_guest=True, name='n') + self.token = issue_access_token(self.user.id) + # هر endpointِ authenticated؛ /api/user/hearts سبک است. + self.url = '/api/user/hearts' + + def _hit(self): + return self.client.get(self.url, HTTP_AUTHORIZATION=f'Bearer {self.token}') + + @override_settings(AUTH_USER_CACHE_TTL=0) + def test_disabled_by_default_loads_every_request(self): + with patch('accounts.authentication._load_user_or_fail', wraps=_orig_loader()) as m: + self._hit(); self._hit() + self.assertEqual(m.call_count, 2) + + @override_settings(AUTH_USER_CACHE_TTL=30) + def test_enabled_caches_after_first_load(self): + with patch('accounts.authentication._load_user_or_fail', wraps=_orig_loader()) as m: + self.assertEqual(self._hit().status_code, 200) + self.assertEqual(self._hit().status_code, 200) + self.assertEqual(m.call_count, 1) + + @override_settings(AUTH_USER_CACHE_TTL=30) + def test_user_save_invalidates_cache(self): + self._hit() # cache populated + with patch('accounts.authentication._load_user_or_fail', wraps=_orig_loader()) as m: + self.user.name = 'changed' + self.user.save(update_fields=['name']) # post_save → invalidate + self._hit() + self.assertEqual(m.call_count, 1) + + @override_settings(AUTH_USER_CACHE_TTL=30) + def test_set_grade_still_works_with_cache_on(self): + r = self.client.post( + '/api/user/grade', {'gradeId': 3}, format='json', + HTTP_AUTHORIZATION=f'Bearer {self.token}') + self.assertEqual(r.status_code, 200) + self.user.refresh_from_db() + self.assertEqual(self.user.grade_id, 3) + # درخواستِ بعدی باید گریدِ تازه را ببیند (کش پس از save باطل شده). + prof = self.client.get( + '/api/user/profile', HTTP_AUTHORIZATION=f'Bearer {self.token}') + self.assertEqual(prof.data['gradeId'], 3) + + +def _orig_loader(): + """نسخه‌ی اصلیِ لودر برای wraps کردنِ Mock (بدونِ recursion).""" + from accounts.models import User as _U + from rest_framework.exceptions import AuthenticationFailed + + def _load(user_id): + try: + return _U.objects.get(pk=user_id) + except _U.DoesNotExist: + raise AuthenticationFailed('کاربر پیدا نشد.') + return _load