From a28ac2167c179853d9f55f60c292409e57319845 Mon Sep 17 00:00:00 2001 From: javad Date: Fri, 9 Oct 2026 11:33:00 +0330 Subject: [PATCH] fix refresh token --- apps/jwt_auth/test_refresh_locking.py | 42 +++++++++++++++++++++++++++ apps/jwt_auth/views.py | 4 ++- 2 files changed, 45 insertions(+), 1 deletion(-) create mode 100644 apps/jwt_auth/test_refresh_locking.py diff --git a/apps/jwt_auth/test_refresh_locking.py b/apps/jwt_auth/test_refresh_locking.py new file mode 100644 index 0000000..c1cca33 --- /dev/null +++ b/apps/jwt_auth/test_refresh_locking.py @@ -0,0 +1,42 @@ +"""Refresh locking must work on PostgreSQL despite nullable role relations.""" +from django.db import connection +from django.test import skipUnlessDBFeature +from django.test.utils import CaptureQueriesContext + +from apps.cuser.models import CuserRole +from apps.jwt_auth.models import JwtToken +from apps.jwt_auth.test_regressions import RegressionFixtures + + +class RefreshLockingTests(RegressionFixtures): + @skipUnlessDBFeature('has_select_for_update_of') + def test_refresh_locks_only_session_and_rejects_reuse(self): + tokens = self.use_role() + with CaptureQueriesContext(connection) as queries: + response = self.post('token/refresh/', {'refresh': tokens['refresh']}) + self.assertEqual(response.status_code, 200) + locking = [query['sql'] for query in queries if 'FOR UPDATE' in query['sql'].upper()] + self.assertEqual(len(locking), 1) + # The actual endpoint SQL retains LEFT JOINs for nullable relations but + # targets only the JWT row in its PostgreSQL locking clause. + self.assertIn('LEFT OUTER JOIN', locking[0].upper()) + expected = 'FOR UPDATE OF ' + connection.ops.quote_name(JwtToken._meta.db_table) + self.assertEqual(locking[0].split('FOR UPDATE', 1)[1].strip(), expected.split('FOR UPDATE', 1)[1].strip()) + self.assertNotEqual(response.data['refresh'], tokens['refresh']) + self.assertEqual(JwtToken.objects.count(), 1) + self.assertEqual(self.post('token/refresh/', {'refresh': tokens['refresh']}).status_code, 401) + + def test_refresh_for_role_without_workspace_is_unauthorized(self): + tokens = self.use_role() + CuserRole.objects.filter(pk=self.role.pk).update(sys_ws=None) + response = self.post('token/refresh/', {'refresh': tokens['refresh']}) + self.assertEqual(response.status_code, 401) + self.assertIn('فضای کاری', str(response.data)) + self.assertEqual(JwtToken.objects.get().refresh, tokens['refresh']) + + def test_refresh_for_role_without_user_is_unauthorized(self): + tokens = self.use_role() + CuserRole.objects.filter(pk=self.role.pk).update(cuser=None) + response = self.post('token/refresh/', {'refresh': tokens['refresh']}) + self.assertEqual(response.status_code, 401) + self.assertEqual(JwtToken.objects.get().refresh, tokens['refresh']) diff --git a/apps/jwt_auth/views.py b/apps/jwt_auth/views.py index 29a34e9..a0b5414 100644 --- a/apps/jwt_auth/views.py +++ b/apps/jwt_auth/views.py @@ -120,7 +120,9 @@ class CustomTokenRefreshView(PublicAuthView): refresh_value = serializer.validated_data['refresh'] try: refresh = RefreshToken(refresh_value) - session = JwtToken.objects.select_for_update().select_related( + # Nullable user/workspace joins must not participate in PostgreSQL's + # row lock. The session alone serializes refresh-token rotation. + session = JwtToken.objects.select_for_update(of=('self',)).select_related( 'cuser_role__cuser', 'cuser_role__role_type', 'cuser_role__sys_ws', ).get(refresh=refresh_value, is_blocked=False) except (TokenError, JwtToken.DoesNotExist):