From e918d84f602c443d92594d42733dda3c10b35acc Mon Sep 17 00:00:00 2001 From: Oliver Date: Tue, 28 Jul 2026 23:50:34 +1000 Subject: [PATCH] Token save fix (#12491) - Only update 'last_seen' field --- src/backend/InvenTree/users/authentication.py | 4 +- src/backend/InvenTree/users/test_api.py | 39 +++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/src/backend/InvenTree/users/authentication.py b/src/backend/InvenTree/users/authentication.py index d9ccbafb5c..973e1ba92f 100644 --- a/src/backend/InvenTree/users/authentication.py +++ b/src/backend/InvenTree/users/authentication.py @@ -34,8 +34,10 @@ class ApiTokenAuthentication(TokenAuthentication): if token.last_seen != datetime.date.today(): # Update the last-seen date + # Note: Use update_fields to avoid clobbering concurrent changes to + # other fields on this token (e.g. a concurrent revocation) token.last_seen = datetime.date.today() - token.save() + token.save(update_fields=['last_seen']) return (user, token) diff --git a/src/backend/InvenTree/users/test_api.py b/src/backend/InvenTree/users/test_api.py index 20cd9488d8..7622e9815f 100644 --- a/src/backend/InvenTree/users/test_api.py +++ b/src/backend/InvenTree/users/test_api.py @@ -1,6 +1,7 @@ """API tests for various user / auth API endpoints.""" import datetime +from unittest import mock from django.contrib.auth.models import Group, User from django.urls import reverse @@ -473,6 +474,44 @@ class UserTokenTests(InvenTreeAPITestCase): self.client.get(me, expected_code=200) + def test_token_last_seen_no_clobber(self): + """Regression test: updating token.last_seen must overwrite other fields. + + Simulates a revoke landing in the window between this request's token + lookup and its last_seen save, by revoking the token (directly against + the database) from inside a patched ApiToken.save(). + """ + token_key = self.get( + url=reverse('api-token'), data={'name': 'race'}, expected_code=200 + ).data['token'] + + token = ApiToken.objects.get(key=token_key) + + # Force last_seen to be 'stale' so the auth backend attempts to update it + ApiToken.objects.filter(pk=token.pk).update( + last_seen=datetime.date.today() - datetime.timedelta(days=1) + ) + + original_save = ApiToken.save + + def revoke_then_save(self, *args, **kwargs): + # Simulate a concurrent request revoking this token, via a direct + # DB write, right before this request's last_seen save lands + ApiToken.objects.filter(pk=self.pk).update(revoked=True) + return original_save(self, *args, **kwargs) + + self.client.logout() + self.client.credentials(HTTP_AUTHORIZATION='Token ' + token_key) + + with mock.patch.object(ApiToken, 'save', revoke_then_save): + self.client.get(reverse('api-user-me'), expected_code=200) + + token.refresh_from_db() + self.assertTrue( + token.revoked, + 'Concurrent revoke must not be clobbered by the last_seen update', + ) + def test_token_api(self): """Test the token API.""" url = reverse('api-token-list')