From f0f4a20f4e67a15491dec26ac6232366d362929f Mon Sep 17 00:00:00 2001 From: Oliver Date: Sat, 21 Oct 2023 15:10:11 +1100 Subject: [PATCH] More token tweaks (#5764) * Update ApiToken model - Add metadata - Remove unique_together requirement - Add last_seen field * Update admin page for token * Store metadata against token on creation * Track last-seen date * Allow match against existing valid token - If token is expired or revoked, create a new one - Prevents duplication of tokens * Update unit tests --- InvenTree/users/admin.py | 10 ++++--- InvenTree/users/api.py | 20 +++++++++++--- InvenTree/users/authentication.py | 7 +++++ .../migrations/0009_auto_20231020_2356.py | 27 +++++++++++++++++++ InvenTree/users/models.py | 12 ++++++--- InvenTree/users/test_api.py | 27 +++++++++++++++---- 6 files changed, 86 insertions(+), 17 deletions(-) create mode 100644 InvenTree/users/migrations/0009_auto_20231020_2356.py diff --git a/InvenTree/users/admin.py b/InvenTree/users/admin.py index a63100a5c3..7e5589e677 100644 --- a/InvenTree/users/admin.py +++ b/InvenTree/users/admin.py @@ -18,15 +18,17 @@ class ApiTokenAdmin(admin.ModelAdmin): """Admin class for the ApiToken model.""" list_display = ('token', 'user', 'name', 'expiry', 'active') - fields = ('token', 'user', 'name', 'revoked', 'expiry') + fields = ('token', 'user', 'name', 'created', 'last_seen', 'revoked', 'expiry', 'metadata') def get_readonly_fields(self, request, obj=None): """Some fields are read-only after creation""" + ro = ['token', 'created', 'last_seen'] + if obj: - return ['token', 'user', 'expiry', 'name'] - else: - return ['token'] + ro += ['user', 'expiry', 'name'] + + return ro class RuleSetInline(admin.TabularInline): diff --git a/InvenTree/users/api.py b/InvenTree/users/api.py index c1c12f23fa..114cc6a277 100644 --- a/InvenTree/users/api.py +++ b/InvenTree/users/api.py @@ -1,5 +1,6 @@ """DRF API definition for the 'users' app""" +import datetime import logging from django.contrib.auth.models import Group, User @@ -201,11 +202,22 @@ class GetAuthToken(APIView): name = ApiToken.sanitize_name(name) - # Delete any matching tokens - ApiToken.objects.filter(user=user, name=name).delete() + today = datetime.date.today() - # User is authenticated, and requesting a token against the provided name. - token = ApiToken.objects.create(user=request.user, name=name) + # Find existing token, which has not expired + token = ApiToken.objects.filter(user=user, name=name, revoked=False, expiry__gte=today).first() + + if not token: + # User is authenticated, and requesting a token against the provided name. + token = ApiToken.objects.create(user=request.user, name=name) + + # Add some metadata about the request + token.set_metadata('user_agent', request.META.get('HTTP_USER_AGENT', '')) + token.set_metadata('remote_addr', request.META.get('REMOTE_ADDR', '')) + token.set_metadata('remote_host', request.META.get('REMOTE_HOST', '')) + token.set_metadata('remote_user', request.META.get('REMOTE_USER', '')) + token.set_metadata('server_name', request.META.get('SERVER_NAME', '')) + token.set_metadata('server_port', request.META.get('SERVER_PORT', '')) data = { 'token': token.key, diff --git a/InvenTree/users/authentication.py b/InvenTree/users/authentication.py index 82d02a9982..659fafcc48 100644 --- a/InvenTree/users/authentication.py +++ b/InvenTree/users/authentication.py @@ -1,5 +1,7 @@ """Custom token authentication class for InvenTree API""" +import datetime + from django.utils.translation import gettext_lazy as _ from rest_framework import exceptions @@ -29,4 +31,9 @@ class ApiTokenAuthentication(TokenAuthentication): if token.expired: raise exceptions.AuthenticationFailed(_("Token has expired")) + if token.last_seen != datetime.date.today(): + # Update the last-seen date + token.last_seen = datetime.date.today() + token.save() + return (user, token) diff --git a/InvenTree/users/migrations/0009_auto_20231020_2356.py b/InvenTree/users/migrations/0009_auto_20231020_2356.py new file mode 100644 index 0000000000..7e1300a68d --- /dev/null +++ b/InvenTree/users/migrations/0009_auto_20231020_2356.py @@ -0,0 +1,27 @@ +# Generated by Django 3.2.21 on 2023-10-20 23:56 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0008_apitoken'), + ] + + operations = [ + migrations.AddField( + model_name='apitoken', + name='last_seen', + field=models.DateField(blank=True, help_text='Last time the token was used', null=True, verbose_name='Last Seen'), + ), + migrations.AddField( + model_name='apitoken', + name='metadata', + field=models.JSONField(blank=True, help_text='JSON metadata field, for use by external plugins', null=True, verbose_name='Plugin Metadata'), + ), + migrations.AlterUniqueTogether( + name='apitoken', + unique_together=set(), + ), + ] diff --git a/InvenTree/users/models.py b/InvenTree/users/models.py index ec8307b851..cce30ecc91 100644 --- a/InvenTree/users/models.py +++ b/InvenTree/users/models.py @@ -21,6 +21,7 @@ from django.utils.translation import gettext_lazy as _ from rest_framework.authtoken.models import Token as AuthToken import InvenTree.helpers +import InvenTree.models from InvenTree.ready import canAppAccessDatabase logger = logging.getLogger("inventree") @@ -39,7 +40,7 @@ def default_token_expiry(): return datetime.datetime.now().date() + datetime.timedelta(days=365) -class ApiToken(AuthToken): +class ApiToken(AuthToken, InvenTree.models.MetadataMixin): """Extends the default token model provided by djangorestframework.authtoken, as follows: - Adds an 'expiry' date - tokens can be set to expire after a certain date @@ -51,9 +52,6 @@ class ApiToken(AuthToken): verbose_name = _('API Token') verbose_name_plural = _('API Tokens') abstract = False - unique_together = [ - ('user', 'name') - ] def __str__(self): """String representation uses the redacted token""" @@ -93,6 +91,12 @@ class ApiToken(AuthToken): auto_now=False, auto_now_add=False, ) + last_seen = models.DateField( + blank=True, null=True, + verbose_name=_('Last Seen'), + help_text=_('Last time the token was used'), + ) + revoked = models.BooleanField( default=False, verbose_name=_('Revoked'), diff --git a/InvenTree/users/test_api.py b/InvenTree/users/test_api.py index db3f7f14e1..96039153db 100644 --- a/InvenTree/users/test_api.py +++ b/InvenTree/users/test_api.py @@ -79,15 +79,23 @@ class UserTokenTests(InvenTreeAPITestCase): # If we re-generate a token, the value changes token = ApiToken.objects.filter(name='cat').first() - # Request a *new* token with the same name + # Request the token with the same name + data = self.get(url, data={'name': 'cat'}, expected_code=200).data + + self.assertEqual(data['token'], token.key) + + self.assertEqual(ApiToken.objects.count(), 3) + + # Revoke the token, and then request again + token.revoked = True + token.save() + data = self.get(url, data={'name': 'cat'}, expected_code=200).data self.assertNotEqual(data['token'], token.key) - # Check the old token is deleted - self.assertEqual(ApiToken.objects.count(), 3) - with self.assertRaises(ApiToken.DoesNotExist): - token.refresh_from_db() + # A new token has been generated + self.assertEqual(ApiToken.objects.count(), 4) # Test with a really long name data = self.get(url, data={'name': 'cat' * 100}, expected_code=200).data @@ -95,6 +103,14 @@ class UserTokenTests(InvenTreeAPITestCase): # Name should be truncated self.assertEqual(len(data['name']), 100) + token.refresh_from_db() + + # Check that the metadata has been updated + keys = ['user_agent', 'remote_addr', 'remote_host', 'remote_user', 'server_name', 'server_port'] + + for k in keys: + self.assertIn(k, token.metadata) + def test_token_auth(self): """Test user token authentication""" @@ -112,6 +128,7 @@ class UserTokenTests(InvenTreeAPITestCase): # Grab the token, and update token = ApiToken.objects.first() self.assertEqual(token.key, token_key) + self.assertIsNotNone(token.last_seen) # Revoke the token token.revoked = True