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
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 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 v139 -> 2023-10-11 : https://github.com/inventree/InvenTree/pull/5509
- Add new BarcodePOReceive endpoint to receive line items by scanning supplier barcodes - 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, from allauth_2fa.middleware import (AllauthTwoFactorMiddleware,
BaseRequire2FAMiddleware) BaseRequire2FAMiddleware)
from error_report.middleware import ExceptionProcessor from error_report.middleware import ExceptionProcessor
from rest_framework.authtoken.models import Token
from InvenTree.urls import frontendpatterns from InvenTree.urls import frontendpatterns
from users.models import ApiToken
logger = logging.getLogger("inventree") logger = logging.getLogger("inventree")
@ -75,13 +75,15 @@ class AuthRequiredMiddleware(object):
# Does the provided token match a valid user? # Does the provided token match a valid user?
try: try:
token = Token.objects.get(key=token_key) token = ApiToken.objects.get(key=token_key)
# Provide the user information to the request if token.active and token.user:
request.user = token.user
authorized = True
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) logger.warning("Access denied for unknown token %s", token_key)
# No authorization was found for the request # No authorization was found for the request

View File

@ -232,7 +232,6 @@ INSTALLED_APPS = [
# Third part add-ons # Third part add-ons
'django_filters', # Extended filter functionality 'django_filters', # Extended filter functionality
'rest_framework', # DRF (Django Rest Framework) 'rest_framework', # DRF (Django Rest Framework)
'rest_framework.authtoken', # Token authentication for API
'corsheaders', # Cross-origin Resource Sharing for DRF 'corsheaders', # Cross-origin Resource Sharing for DRF
'crispy_forms', # Improved form rendering 'crispy_forms', # Improved form rendering
'import_export', # Import / export tables to file 'import_export', # Import / export tables to file
@ -433,7 +432,7 @@ REST_FRAMEWORK = {
'DEFAULT_AUTHENTICATION_CLASSES': ( 'DEFAULT_AUTHENTICATION_CLASSES': (
'rest_framework.authentication.BasicAuthentication', 'rest_framework.authentication.BasicAuthentication',
'rest_framework.authentication.SessionAuthentication', 'rest_framework.authentication.SessionAuthentication',
'rest_framework.authentication.TokenAuthentication', 'users.authentication.ApiTokenAuthentication',
), ),
'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.LimitOffsetPagination', 'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.LimitOffsetPagination',
'DEFAULT_PERMISSION_CLASSES': ( 'DEFAULT_PERMISSION_CLASSES': (
@ -445,7 +444,8 @@ REST_FRAMEWORK = {
'DEFAULT_METADATA_CLASS': 'InvenTree.metadata.InvenTreeMetadata', 'DEFAULT_METADATA_CLASS': 'InvenTree.metadata.InvenTreeMetadata',
'DEFAULT_RENDERER_CLASSES': [ 'DEFAULT_RENDERER_CLASSES': [
'rest_framework.renderers.JSONRenderer', 'rest_framework.renderers.JSONRenderer',
] ],
'TOKEN_MODEL': 'users.models.ApiToken',
} }
if DEBUG: 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.safestring import mark_safe
from django.utils.translation import gettext_lazy as _ 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() 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 RuleSetInline(admin.TabularInline):
"""Class for displaying inline RuleSet data in the Group admin page.""" """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(User, InvenTreeUserAdmin)
admin.site.register(Owner, OwnerAdmin) admin.site.register(Owner, OwnerAdmin)
admin.site.register(ApiToken, ApiTokenAdmin)

View File

@ -1,21 +1,23 @@
"""DRF API definition for the 'users' app""" """DRF API definition for the 'users' app"""
import logging
from django.contrib.auth.models import Group, User from django.contrib.auth.models import Group, User
from django.core.exceptions import ObjectDoesNotExist
from django.urls import include, path, re_path from django.urls import include, path, re_path
from django_filters.rest_framework import DjangoFilterBackend from django_filters.rest_framework import DjangoFilterBackend
from rest_framework import permissions, status from rest_framework import exceptions, permissions
from rest_framework.authtoken.models import Token
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.views import APIView from rest_framework.views import APIView
from InvenTree.filters import InvenTreeSearchFilter from InvenTree.filters import InvenTreeSearchFilter
from InvenTree.mixins import ListAPI, RetrieveAPI, RetrieveUpdateAPI from InvenTree.mixins import ListAPI, RetrieveAPI, RetrieveUpdateAPI
from InvenTree.serializers import UserSerializer 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 from users.serializers import GroupSerializer, OwnerSerializer
logger = logging.getLogger('inventree')
class OwnerList(ListAPI): class OwnerList(ListAPI):
"""List API endpoint for Owner model. """List API endpoint for Owner model.
@ -187,25 +189,34 @@ class GetAuthToken(APIView):
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
"""Return an API token if the user is authenticated """Return an API token if the user is authenticated
- If the user already has a token, return it - If the user already has a matching token, delete it and create a new one
- Otherwise, create a new token - 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): if request.user.is_authenticated:
"""User has requested deletion of API token"""
try: user = request.user
request.user.auth_token.delete() name = request.query_params.get('name', '')
return Response({"success": "Successfully logged out."},
status=status.HTTP_202_ACCEPTED) # Delete any matching tokens
except (AttributeError, ObjectDoesNotExist): ApiToken.objects.filter(user=user, name=name).delete()
return Response({"error": "Bad request"},
status=status.HTTP_400_BAD_REQUEST) # 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 = [ 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""" """Database model definitions for the 'users' app"""
import datetime
import logging import logging
from django.conf import settings
from django.contrib import admin
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.contrib.auth.models import Group, Permission from django.contrib.auth.models import Group, Permission
from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.fields import GenericForeignKey
@ -15,11 +18,115 @@ from django.dispatch import receiver
from django.urls import reverse from django.urls import reverse
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from rest_framework.authtoken.models import Token as AuthToken
from InvenTree.ready import canAppAccessDatabase from InvenTree.ready import canAppAccessDatabase
logger = logging.getLogger("inventree") 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): class RuleSet(models.Model):
"""A RuleSet is somewhat like a superset of the django permission class, in that in encapsulates a bunch of permissions. """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_group',
'auth_user', 'auth_user',
'auth_permission', 'auth_permission',
'authtoken_token', 'users_apitoken',
'authtoken_tokenproxy',
'users_ruleset', 'users_ruleset',
'report_reportasset', 'report_reportasset',
'report_reportsnippet', 'report_reportsnippet',

View File

@ -1,9 +1,12 @@
"""API tests for various user / auth API endpoints""" """API tests for various user / auth API endpoints"""
import datetime
from django.contrib.auth.models import Group, User from django.contrib.auth.models import Group, User
from django.urls import reverse from django.urls import reverse
from InvenTree.unit_test import InvenTreeAPITestCase from InvenTree.unit_test import InvenTreeAPITestCase
from users.models import ApiToken
class UserAPITests(InvenTreeAPITestCase): class UserAPITests(InvenTreeAPITestCase):
@ -51,3 +54,82 @@ class UserAPITests(InvenTreeAPITestCase):
) )
self.assertIn('name', response.data) 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.test import TestCase
from django.urls import reverse from django.urls import reverse
from rest_framework.authtoken.models import Token
from InvenTree.unit_test import InvenTreeTestCase from InvenTree.unit_test import InvenTreeTestCase
from users.models import Owner, RuleSet from users.models import ApiToken, Owner, RuleSet
class RuleSetModelTest(TestCase): class RuleSetModelTest(TestCase):
@ -242,7 +240,7 @@ class OwnerModelTest(InvenTreeTestCase):
"""Test token mechanisms.""" """Test token mechanisms."""
self.client.logout() self.client.logout()
token = Token.objects.filter(user=self.user) token = ApiToken.objects.filter(user=self.user)
# not authed # not authed
self.do_request(reverse('api-token'), {}, 401) self.do_request(reverse('api-token'), {}, 401)
@ -252,15 +250,6 @@ class OwnerModelTest(InvenTreeTestCase):
response = self.do_request(reverse('api-token'), {}) response = self.do_request(reverse('api-token'), {})
self.assertEqual(response['token'], token.first().key) 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 # 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) self.assertEqual(response['username'], self.username)

View File

@ -81,12 +81,12 @@ export function NotificationDrawer({
<Stack spacing="xs"> <Stack spacing="xs">
<Divider /> <Divider />
<LoadingOverlay visible={notificationQuery.isFetching} /> <LoadingOverlay visible={notificationQuery.isFetching} />
{notificationQuery.data?.results?.length == 0 && ( {(notificationQuery.data?.results?.length ?? 0) == 0 && (
<Alert color="green"> <Alert color="green">
<Text size="sm">{t`You have no unread notifications.`}</Text> <Text size="sm">{t`You have no unread notifications.`}</Text>
</Alert> </Alert>
)} )}
{notificationQuery.data?.results.map((notification: any) => ( {notificationQuery.data?.results?.map((notification: any) => (
<Group position="apart"> <Group position="apart">
<Stack spacing="3"> <Stack spacing="3">
<Text size="sm">{notification.target?.name ?? 'target'}</Text> <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), { .get(apiUrl(ApiPaths.user_token), {
auth: { username, password }, auth: { username, password },
baseURL: host.toString(), baseURL: host.toString(),
timeout: 5000 timeout: 5000,
params: {
name: 'inventree-web-app'
}
}) })
.then((response) => response.data.token) .then((response) => response.data.token)
.catch((error) => { .catch((error) => {
@ -114,7 +117,10 @@ export function handleReset(navigate: any, values: { email: string }) {
export function checkLoginState(navigate: any, redirect?: string) { export function checkLoginState(navigate: any, redirect?: string) {
api api
.get(apiUrl(ApiPaths.user_token), { .get(apiUrl(ApiPaths.user_token), {
timeout: 5000 timeout: 5000,
params: {
name: 'inventree-web-app'
}
}) })
.then((val) => { .then((val) => {
if (val.status === 200 && val.data.token) { if (val.status === 200 && val.data.token) {

View File

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