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
This commit is contained in:
Oliver 2023-10-21 15:10:11 +11:00 committed by GitHub
parent 3b6c941f65
commit f0f4a20f4e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 86 additions and 17 deletions

View File

@ -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):

View File

@ -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,

View File

@ -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)

View File

@ -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(),
),
]

View File

@ -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'),

View File

@ -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