Api token updates (#5664)

* Create new APIToken model

- Has custom 'name' field
- Has custom expiry date

* Add data migration to port across any existing user tokens

* Adds 'revoked' field - tokens can be manually revoked

* Update API token - allow multiple tokens per user

* Custom token auth handler

- Correctly handles revoked tokens
- Correctly handles expired tokens

* Update AuthRequiredMiddleware

- Check for token active status

* Token API endpoint improvements

- Can return tokens with custom names
- Return more information on the token too

* Consolidate migrations

* When requesting a token, overwrite inactive token for authenticated user

- An authenticated user must receive a token
- Unauthenticated users cannot do this

* Fix

* Use token name for frontend

* Force token expiry, and generate default expiry date

* Force generation of a new token when requested

* Reduce data exposed on token API endpoint

* Display redacted token in admin site

* Log when new token is created for user

* Add default value for token

- Allows raw token to be viewed in the admin interface when created
- After created, no longer visible
- Also provides ability to generate token with static prefix

* Fixes for admin interface

- Prevent user and expiry from being edited after creation

* Implement unit tests for token functionality

* Fix content exclude for import/export

* Fix typo

* Further tweaks

- Prevent editing of "name" field after creation
- Add isoformat date suffix to token

* Longer token requires longer database field!

* Fix other API tokens

* Remove 'delete' method from token API endpoint

* Bump API version
This commit is contained in:
Oliver 2023-10-20 14:06:06 +11:00 committed by GitHub
parent 25138300ff
commit 23ea746813
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 337 additions and 53 deletions

View File

@ -2,11 +2,15 @@
# InvenTree API version
INVENTREE_API_VERSION = 139
INVENTREE_API_VERSION = 140
"""
Increment this API version number whenever there is a significant change to the API that any clients need to know about
v140 -> 2023-10-20 : https://github.com/inventree/InvenTree/pull/5664
- Expand API token functionality
- Multiple API tokens can be generated per user
v139 -> 2023-10-11 : https://github.com/inventree/InvenTree/pull/5509
- Add new BarcodePOReceive endpoint to receive line items by scanning supplier barcodes

View File

@ -12,9 +12,9 @@ from django.urls import Resolver404, include, re_path, resolve, reverse_lazy
from allauth_2fa.middleware import (AllauthTwoFactorMiddleware,
BaseRequire2FAMiddleware)
from error_report.middleware import ExceptionProcessor
from rest_framework.authtoken.models import Token
from InvenTree.urls import frontendpatterns
from users.models import ApiToken
logger = logging.getLogger("inventree")
@ -75,13 +75,15 @@ class AuthRequiredMiddleware(object):
# Does the provided token match a valid user?
try:
token = Token.objects.get(key=token_key)
token = ApiToken.objects.get(key=token_key)
# Provide the user information to the request
request.user = token.user
authorized = True
if token.active and token.user:
except Token.DoesNotExist:
# Provide the user information to the request
request.user = token.user
authorized = True
except ApiToken.DoesNotExist:
logger.warning("Access denied for unknown token %s", token_key)
# No authorization was found for the request

View File

@ -232,7 +232,6 @@ INSTALLED_APPS = [
# Third part add-ons
'django_filters', # Extended filter functionality
'rest_framework', # DRF (Django Rest Framework)
'rest_framework.authtoken', # Token authentication for API
'corsheaders', # Cross-origin Resource Sharing for DRF
'crispy_forms', # Improved form rendering
'import_export', # Import / export tables to file
@ -433,7 +432,7 @@ REST_FRAMEWORK = {
'DEFAULT_AUTHENTICATION_CLASSES': (
'rest_framework.authentication.BasicAuthentication',
'rest_framework.authentication.SessionAuthentication',
'rest_framework.authentication.TokenAuthentication',
'users.authentication.ApiTokenAuthentication',
),
'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.LimitOffsetPagination',
'DEFAULT_PERMISSION_CLASSES': (
@ -445,7 +444,8 @@ REST_FRAMEWORK = {
'DEFAULT_METADATA_CLASS': 'InvenTree.metadata.InvenTreeMetadata',
'DEFAULT_RENDERER_CLASSES': [
'rest_framework.renderers.JSONRenderer',
]
],
'TOKEN_MODEL': 'users.models.ApiToken',
}
if DEBUG:

View File

@ -9,11 +9,26 @@ from django.contrib.auth.models import Group
from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _
from users.models import Owner, RuleSet
from users.models import ApiToken, Owner, RuleSet
User = get_user_model()
class ApiTokenAdmin(admin.ModelAdmin):
"""Admin class for the ApiToken model."""
list_display = ('token', 'user', 'name', 'expiry', 'active')
fields = ('token', 'user', 'name', 'revoked', 'expiry')
def get_readonly_fields(self, request, obj=None):
"""Some fields are read-only after creation"""
if obj:
return ['token', 'user', 'expiry', 'name']
else:
return ['token']
class RuleSetInline(admin.TabularInline):
"""Class for displaying inline RuleSet data in the Group admin page."""
@ -239,3 +254,5 @@ admin.site.unregister(User)
admin.site.register(User, InvenTreeUserAdmin)
admin.site.register(Owner, OwnerAdmin)
admin.site.register(ApiToken, ApiTokenAdmin)

View File

@ -1,21 +1,23 @@
"""DRF API definition for the 'users' app"""
import logging
from django.contrib.auth.models import Group, User
from django.core.exceptions import ObjectDoesNotExist
from django.urls import include, path, re_path
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework import permissions, status
from rest_framework.authtoken.models import Token
from rest_framework import exceptions, permissions
from rest_framework.response import Response
from rest_framework.views import APIView
from InvenTree.filters import InvenTreeSearchFilter
from InvenTree.mixins import ListAPI, RetrieveAPI, RetrieveUpdateAPI
from InvenTree.serializers import UserSerializer
from users.models import Owner, RuleSet, check_user_role
from users.models import ApiToken, Owner, RuleSet, check_user_role
from users.serializers import GroupSerializer, OwnerSerializer
logger = logging.getLogger('inventree')
class OwnerList(ListAPI):
"""List API endpoint for Owner model.
@ -187,25 +189,34 @@ class GetAuthToken(APIView):
def get(self, request, *args, **kwargs):
"""Return an API token if the user is authenticated
- If the user already has a token, return it
- Otherwise, create a new token
- If the user already has a matching token, delete it and create a new one
- Existing tokens are *never* exposed again via the API
- Once the token is provided, it can be used for auth until it expires
"""
if request.user.is_authenticated:
# Get the user token (or create one if it does not exist)
token, created = Token.objects.get_or_create(user=request.user)
return Response({
'token': token.key,
})
def delete(self, request):
"""User has requested deletion of API token"""
try:
request.user.auth_token.delete()
return Response({"success": "Successfully logged out."},
status=status.HTTP_202_ACCEPTED)
except (AttributeError, ObjectDoesNotExist):
return Response({"error": "Bad request"},
status=status.HTTP_400_BAD_REQUEST)
if request.user.is_authenticated:
user = request.user
name = request.query_params.get('name', '')
# Delete any matching tokens
ApiToken.objects.filter(user=user, name=name).delete()
# User is authenticated, and requesting a token against the provided name.
token = ApiToken.objects.create(user=request.user, name=name)
data = {
'token': token.key,
'name': token.name,
'expiry': token.expiry,
}
logger.info("Created new API token for user '%s' (name='%s')", user.username, name)
return Response(data)
else:
raise exceptions.NotAuthenticated()
user_urls = [

View File

@ -0,0 +1,32 @@
"""Custom token authentication class for InvenTree API"""
from django.utils.translation import gettext_lazy as _
from rest_framework import exceptions
from rest_framework.authentication import TokenAuthentication
from users.models import ApiToken
class ApiTokenAuthentication(TokenAuthentication):
"""Custom implementation of TokenAuthentication class, with custom features:
- Tokens can be revoked
- Tokens can expire
"""
model = ApiToken
def authenticate_credentials(self, key):
"""Adds additional checks to the default token authentication method."""
# If this runs without error, then the token is valid (so far)
(user, token) = super().authenticate_credentials(key)
if token.revoked:
raise exceptions.AuthenticationFailed(_("Token has been revoked"))
if token.expired:
raise exceptions.AuthenticationFailed(_("Token has expired"))
return (user, token)

View File

@ -0,0 +1,35 @@
# Generated by Django 3.2.22 on 2023-10-20 01:09
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import users.models
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('users', '0007_alter_ruleset_name'),
]
operations = [
migrations.CreateModel(
name='ApiToken',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created', models.DateTimeField(auto_now_add=True, verbose_name='Created')),
('key', models.CharField(db_index=True, default=users.models.default_token, max_length=100, unique=True, verbose_name='Key')),
('name', models.CharField(blank=True, help_text='Custom token name', max_length=100, verbose_name='Token Name')),
('expiry', models.DateField(default=users.models.default_token_expiry, help_text='Token expiry date', verbose_name='Expiry Date')),
('revoked', models.BooleanField(default=False, help_text='Token has been revoked', verbose_name='Revoked')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='api_tokens', to=settings.AUTH_USER_MODEL, verbose_name='User')),
],
options={
'verbose_name': 'API Token',
'verbose_name_plural': 'API Tokens',
'abstract': False,
'unique_together': {('user', 'name')},
},
),
]

View File

@ -1,7 +1,10 @@
"""Database model definitions for the 'users' app"""
import datetime
import logging
from django.conf import settings
from django.contrib import admin
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Group, Permission
from django.contrib.contenttypes.fields import GenericForeignKey
@ -15,11 +18,115 @@ from django.dispatch import receiver
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from rest_framework.authtoken.models import Token as AuthToken
from InvenTree.ready import canAppAccessDatabase
logger = logging.getLogger("inventree")
def default_token():
"""Generate a default value for the token"""
return ApiToken.generate_key()
def default_token_expiry():
"""Generate an expiry date for a newly created token"""
# TODO: Custom value for default expiry timeout
# TODO: For now, tokens last for 1 year
return datetime.datetime.now().date() + datetime.timedelta(days=365)
class ApiToken(AuthToken):
"""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 a 'name' field - tokens can be given a custom name (in addition to the user information)
"""
class Meta:
"""Metaclass defines model properties"""
verbose_name = _('API Token')
verbose_name_plural = _('API Tokens')
abstract = False
unique_together = [
('user', 'name')
]
def __str__(self):
"""String representation uses the redacted token"""
return self.token
@classmethod
def generate_key(cls, prefix='inv-'):
"""Generate a new token key - with custom prefix"""
# Suffix is the date of creation
suffix = '-' + str(datetime.datetime.now().date().isoformat().replace('-', ''))
return prefix + str(AuthToken.generate_key()) + suffix
# Override the 'key' field - force it to be unique
key = models.CharField(default=default_token, verbose_name=_('Key'), max_length=100, db_index=True, unique=True)
# Override the 'user' field, to allow multiple tokens per user
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
verbose_name=_('User'),
related_name='api_tokens',
)
name = models.CharField(
max_length=100,
blank=True,
verbose_name=_('Token Name'),
help_text=_('Custom token name'),
)
expiry = models.DateField(
default=default_token_expiry,
verbose_name=_('Expiry Date'),
help_text=_('Token expiry date'),
auto_now=False, auto_now_add=False,
)
revoked = models.BooleanField(
default=False,
verbose_name=_('Revoked'),
help_text=_('Token has been revoked'),
)
@property
@admin.display(description=_('Token'))
def token(self):
"""Provide a redacted version of the token.
The *raw* key value should never be displayed anywhere!
"""
# If the token has not yet been saved, return the raw key
if self.pk is None:
return self.key
M = len(self.key) - 20
return self.key[:8] + '*' * M + self.key[-12:]
@property
@admin.display(boolean=True, description=_('Expired'))
def expired(self):
"""Test if this token has expired"""
return self.expiry is not None and self.expiry < datetime.datetime.now().date()
@property
@admin.display(boolean=True, description=_('Active'))
def active(self):
"""Test if this token is active"""
return not self.revoked and not self.expired
class RuleSet(models.Model):
"""A RuleSet is somewhat like a superset of the django permission class, in that in encapsulates a bunch of permissions.
@ -58,8 +165,7 @@ class RuleSet(models.Model):
'auth_group',
'auth_user',
'auth_permission',
'authtoken_token',
'authtoken_tokenproxy',
'users_apitoken',
'users_ruleset',
'report_reportasset',
'report_reportsnippet',

View File

@ -1,9 +1,12 @@
"""API tests for various user / auth API endpoints"""
import datetime
from django.contrib.auth.models import Group, User
from django.urls import reverse
from InvenTree.unit_test import InvenTreeAPITestCase
from users.models import ApiToken
class UserAPITests(InvenTreeAPITestCase):
@ -51,3 +54,82 @@ class UserAPITests(InvenTreeAPITestCase):
)
self.assertIn('name', response.data)
class UserTokenTests(InvenTreeAPITestCase):
"""Tests for user token functionality"""
def test_token_generation(self):
"""Test user token generation"""
url = reverse('api-token')
self.assertEqual(ApiToken.objects.count(), 0)
# Generate multiple tokens with different names
for name in ['cat', 'dog', 'biscuit']:
data = self.get(url, data={'name': name}, expected_code=200).data
self.assertTrue(data['token'].startswith('inv-'))
self.assertEqual(data['name'], name)
# Check that the tokens were created
self.assertEqual(ApiToken.objects.count(), 3)
# If we re-generate a token, the value changes
token = ApiToken.objects.filter(name='cat').first()
# Request a *new* token with the same name
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()
def test_token_auth(self):
"""Test user token authentication"""
# Create a new token
token_key = self.get(url=reverse('api-token'), data={'name': 'test'}, expected_code=200).data['token']
# Check that we can use the token to authenticate
self.client.logout()
self.client.credentials(HTTP_AUTHORIZATION='Token ' + token_key)
me = reverse('api-user-me')
response = self.client.get(me, expected_code=200)
# Grab the token, and update
token = ApiToken.objects.first()
self.assertEqual(token.key, token_key)
# Revoke the token
token.revoked = True
token.save()
self.assertFalse(token.active)
response = self.client.get(me, expected_code=401)
self.assertIn('Token has been revoked', str(response.data))
# Expire the token
token.revoked = False
token.expiry = datetime.datetime.now().date() - datetime.timedelta(days=10)
token.save()
self.assertTrue(token.expired)
self.assertFalse(token.active)
response = self.client.get(me, expected_code=401)
self.assertIn('Token has expired', str(response.data))
# Re-enable the token
token.revoked = False
token.expiry = datetime.datetime.now().date() + datetime.timedelta(days=10)
token.save()
self.client.get(me, expected_code=200)

View File

@ -5,10 +5,8 @@ from django.contrib.auth.models import Group
from django.test import TestCase
from django.urls import reverse
from rest_framework.authtoken.models import Token
from InvenTree.unit_test import InvenTreeTestCase
from users.models import Owner, RuleSet
from users.models import ApiToken, Owner, RuleSet
class RuleSetModelTest(TestCase):
@ -242,7 +240,7 @@ class OwnerModelTest(InvenTreeTestCase):
"""Test token mechanisms."""
self.client.logout()
token = Token.objects.filter(user=self.user)
token = ApiToken.objects.filter(user=self.user)
# not authed
self.do_request(reverse('api-token'), {}, 401)
@ -252,15 +250,6 @@ class OwnerModelTest(InvenTreeTestCase):
response = self.do_request(reverse('api-token'), {})
self.assertEqual(response['token'], token.first().key)
# token delete
response = self.client.delete(reverse('api-token'), {}, format='json')
self.assertEqual(response.status_code, 202)
self.assertEqual(len(token), 0)
# token second delete
response = self.client.delete(reverse('api-token'), {}, format='json')
self.assertEqual(response.status_code, 400)
# test user is associated with token
response = self.do_request(reverse('api-user-me'), {}, 200)
response = self.do_request(reverse('api-user-me'), {'name': 'another-token'}, 200)
self.assertEqual(response['username'], self.username)

View File

@ -81,12 +81,12 @@ export function NotificationDrawer({
<Stack spacing="xs">
<Divider />
<LoadingOverlay visible={notificationQuery.isFetching} />
{notificationQuery.data?.results?.length == 0 && (
{(notificationQuery.data?.results?.length ?? 0) == 0 && (
<Alert color="green">
<Text size="sm">{t`You have no unread notifications.`}</Text>
</Alert>
)}
{notificationQuery.data?.results.map((notification: any) => (
{notificationQuery.data?.results?.map((notification: any) => (
<Group position="apart">
<Stack spacing="3">
<Text size="sm">{notification.target?.name ?? 'target'}</Text>

View File

@ -21,7 +21,10 @@ export const doClassicLogin = async (username: string, password: string) => {
.get(apiUrl(ApiPaths.user_token), {
auth: { username, password },
baseURL: host.toString(),
timeout: 5000
timeout: 5000,
params: {
name: 'inventree-web-app'
}
})
.then((response) => response.data.token)
.catch((error) => {
@ -114,7 +117,10 @@ export function handleReset(navigate: any, values: { email: string }) {
export function checkLoginState(navigate: any, redirect?: string) {
api
.get(apiUrl(ApiPaths.user_token), {
timeout: 5000
timeout: 5000,
params: {
name: 'inventree-web-app'
}
})
.then((val) => {
if (val.status === 200 && val.data.token) {

View File

@ -35,7 +35,7 @@ def content_excludes():
excludes = [
"contenttypes",
"auth.permission",
"authtoken.token",
"users.apitoken",
"error_report.error",
"admin.logentry",
"django_q.schedule",