mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
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:
parent
3b6c941f65
commit
f0f4a20f4e
@ -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):
|
||||
|
@ -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,
|
||||
|
@ -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)
|
||||
|
27
InvenTree/users/migrations/0009_auto_20231020_2356.py
Normal file
27
InvenTree/users/migrations/0009_auto_20231020_2356.py
Normal 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(),
|
||||
),
|
||||
]
|
@ -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'),
|
||||
|
@ -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
|
||||
|
Loading…
Reference in New Issue
Block a user