mirror of
https://github.com/inventree/InvenTree.git
synced 2025-04-30 20:46:47 +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."""
|
"""Admin class for the ApiToken model."""
|
||||||
|
|
||||||
list_display = ('token', 'user', 'name', 'expiry', 'active')
|
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):
|
def get_readonly_fields(self, request, obj=None):
|
||||||
"""Some fields are read-only after creation"""
|
"""Some fields are read-only after creation"""
|
||||||
|
|
||||||
|
ro = ['token', 'created', 'last_seen']
|
||||||
|
|
||||||
if obj:
|
if obj:
|
||||||
return ['token', 'user', 'expiry', 'name']
|
ro += ['user', 'expiry', 'name']
|
||||||
else:
|
|
||||||
return ['token']
|
return ro
|
||||||
|
|
||||||
|
|
||||||
class RuleSetInline(admin.TabularInline):
|
class RuleSetInline(admin.TabularInline):
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
"""DRF API definition for the 'users' app"""
|
"""DRF API definition for the 'users' app"""
|
||||||
|
|
||||||
|
import datetime
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from django.contrib.auth.models import Group, User
|
from django.contrib.auth.models import Group, User
|
||||||
@ -201,11 +202,22 @@ class GetAuthToken(APIView):
|
|||||||
|
|
||||||
name = ApiToken.sanitize_name(name)
|
name = ApiToken.sanitize_name(name)
|
||||||
|
|
||||||
# Delete any matching tokens
|
today = datetime.date.today()
|
||||||
ApiToken.objects.filter(user=user, name=name).delete()
|
|
||||||
|
|
||||||
# User is authenticated, and requesting a token against the provided name.
|
# Find existing token, which has not expired
|
||||||
token = ApiToken.objects.create(user=request.user, name=name)
|
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 = {
|
data = {
|
||||||
'token': token.key,
|
'token': token.key,
|
||||||
|
@ -1,5 +1,7 @@
|
|||||||
"""Custom token authentication class for InvenTree API"""
|
"""Custom token authentication class for InvenTree API"""
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from rest_framework import exceptions
|
from rest_framework import exceptions
|
||||||
@ -29,4 +31,9 @@ class ApiTokenAuthentication(TokenAuthentication):
|
|||||||
if token.expired:
|
if token.expired:
|
||||||
raise exceptions.AuthenticationFailed(_("Token has 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)
|
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
|
from rest_framework.authtoken.models import Token as AuthToken
|
||||||
|
|
||||||
import InvenTree.helpers
|
import InvenTree.helpers
|
||||||
|
import InvenTree.models
|
||||||
from InvenTree.ready import canAppAccessDatabase
|
from InvenTree.ready import canAppAccessDatabase
|
||||||
|
|
||||||
logger = logging.getLogger("inventree")
|
logger = logging.getLogger("inventree")
|
||||||
@ -39,7 +40,7 @@ def default_token_expiry():
|
|||||||
return datetime.datetime.now().date() + datetime.timedelta(days=365)
|
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:
|
"""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
|
- 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 = _('API Token')
|
||||||
verbose_name_plural = _('API Tokens')
|
verbose_name_plural = _('API Tokens')
|
||||||
abstract = False
|
abstract = False
|
||||||
unique_together = [
|
|
||||||
('user', 'name')
|
|
||||||
]
|
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
"""String representation uses the redacted token"""
|
"""String representation uses the redacted token"""
|
||||||
@ -93,6 +91,12 @@ class ApiToken(AuthToken):
|
|||||||
auto_now=False, auto_now_add=False,
|
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(
|
revoked = models.BooleanField(
|
||||||
default=False,
|
default=False,
|
||||||
verbose_name=_('Revoked'),
|
verbose_name=_('Revoked'),
|
||||||
|
@ -79,15 +79,23 @@ class UserTokenTests(InvenTreeAPITestCase):
|
|||||||
# If we re-generate a token, the value changes
|
# If we re-generate a token, the value changes
|
||||||
token = ApiToken.objects.filter(name='cat').first()
|
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
|
data = self.get(url, data={'name': 'cat'}, expected_code=200).data
|
||||||
|
|
||||||
self.assertNotEqual(data['token'], token.key)
|
self.assertNotEqual(data['token'], token.key)
|
||||||
|
|
||||||
# Check the old token is deleted
|
# A new token has been generated
|
||||||
self.assertEqual(ApiToken.objects.count(), 3)
|
self.assertEqual(ApiToken.objects.count(), 4)
|
||||||
with self.assertRaises(ApiToken.DoesNotExist):
|
|
||||||
token.refresh_from_db()
|
|
||||||
|
|
||||||
# Test with a really long name
|
# Test with a really long name
|
||||||
data = self.get(url, data={'name': 'cat' * 100}, expected_code=200).data
|
data = self.get(url, data={'name': 'cat' * 100}, expected_code=200).data
|
||||||
@ -95,6 +103,14 @@ class UserTokenTests(InvenTreeAPITestCase):
|
|||||||
# Name should be truncated
|
# Name should be truncated
|
||||||
self.assertEqual(len(data['name']), 100)
|
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):
|
def test_token_auth(self):
|
||||||
"""Test user token authentication"""
|
"""Test user token authentication"""
|
||||||
|
|
||||||
@ -112,6 +128,7 @@ class UserTokenTests(InvenTreeAPITestCase):
|
|||||||
# Grab the token, and update
|
# Grab the token, and update
|
||||||
token = ApiToken.objects.first()
|
token = ApiToken.objects.first()
|
||||||
self.assertEqual(token.key, token_key)
|
self.assertEqual(token.key, token_key)
|
||||||
|
self.assertIsNotNone(token.last_seen)
|
||||||
|
|
||||||
# Revoke the token
|
# Revoke the token
|
||||||
token.revoked = True
|
token.revoked = True
|
||||||
|
Loading…
x
Reference in New Issue
Block a user