diff --git a/InvenTree/InvenTree/api_version.py b/InvenTree/InvenTree/api_version.py index 14c180388cff..d272ac54e69e 100644 --- a/InvenTree/InvenTree/api_version.py +++ b/InvenTree/InvenTree/api_version.py @@ -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 diff --git a/InvenTree/InvenTree/middleware.py b/InvenTree/InvenTree/middleware.py index 6a365d82de8c..c34c5416e8ef 100644 --- a/InvenTree/InvenTree/middleware.py +++ b/InvenTree/InvenTree/middleware.py @@ -12,9 +12,9 @@ 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 @@ def __call__(self, request): # 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 diff --git a/InvenTree/InvenTree/settings.py b/InvenTree/InvenTree/settings.py index 32f6ebfc35d3..5630adf28272 100644 --- a/InvenTree/InvenTree/settings.py +++ b/InvenTree/InvenTree/settings.py @@ -232,7 +232,6 @@ # 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 @@ '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 @@ 'DEFAULT_METADATA_CLASS': 'InvenTree.metadata.InvenTreeMetadata', 'DEFAULT_RENDERER_CLASSES': [ 'rest_framework.renderers.JSONRenderer', - ] + ], + 'TOKEN_MODEL': 'users.models.ApiToken', } if DEBUG: diff --git a/InvenTree/users/admin.py b/InvenTree/users/admin.py index be7cf646afb3..a63100a5c3f3 100644 --- a/InvenTree/users/admin.py +++ b/InvenTree/users/admin.py @@ -9,11 +9,26 @@ 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 @@ class OwnerAdmin(admin.ModelAdmin): admin.site.register(User, InvenTreeUserAdmin) admin.site.register(Owner, OwnerAdmin) + +admin.site.register(ApiToken, ApiTokenAdmin) diff --git a/InvenTree/users/api.py b/InvenTree/users/api.py index 688b301fad53..51fd19314215 100644 --- a/InvenTree/users/api.py +++ b/InvenTree/users/api.py @@ -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({ + + 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, - }) - - 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) + '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 = [ diff --git a/InvenTree/users/authentication.py b/InvenTree/users/authentication.py new file mode 100644 index 000000000000..82d02a998204 --- /dev/null +++ b/InvenTree/users/authentication.py @@ -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) diff --git a/InvenTree/users/migrations/0008_apitoken.py b/InvenTree/users/migrations/0008_apitoken.py new file mode 100644 index 000000000000..52c4cc7edf1a --- /dev/null +++ b/InvenTree/users/migrations/0008_apitoken.py @@ -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')}, + }, + ), + ] diff --git a/InvenTree/users/models.py b/InvenTree/users/models.py index 89f619d6353a..a7eb4cd01631 100644 --- a/InvenTree/users/models.py +++ b/InvenTree/users/models.py @@ -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.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', diff --git a/InvenTree/users/test_api.py b/InvenTree/users/test_api.py index d22a2ada7bbb..94da4b5ead22 100644 --- a/InvenTree/users/test_api.py +++ b/InvenTree/users/test_api.py @@ -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 @@ def test_group_api(self): ) 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) diff --git a/InvenTree/users/tests.py b/InvenTree/users/tests.py index 2158209cf577..de1c443ef57e 100644 --- a/InvenTree/users/tests.py +++ b/InvenTree/users/tests.py @@ -5,10 +5,8 @@ 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 @@ def test_token(self): """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 @@ def test_token(self): 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) diff --git a/src/frontend/src/components/nav/NotificationDrawer.tsx b/src/frontend/src/components/nav/NotificationDrawer.tsx index e546d16d93c8..c9da406adcfe 100644 --- a/src/frontend/src/components/nav/NotificationDrawer.tsx +++ b/src/frontend/src/components/nav/NotificationDrawer.tsx @@ -81,12 +81,12 @@ export function NotificationDrawer({ - {notificationQuery.data?.results?.length == 0 && ( + {(notificationQuery.data?.results?.length ?? 0) == 0 && ( {t`You have no unread notifications.`} )} - {notificationQuery.data?.results.map((notification: any) => ( + {notificationQuery.data?.results?.map((notification: any) => ( {notification.target?.name ?? 'target'} diff --git a/src/frontend/src/functions/auth.tsx b/src/frontend/src/functions/auth.tsx index d76813872069..9f7a3657f86a 100644 --- a/src/frontend/src/functions/auth.tsx +++ b/src/frontend/src/functions/auth.tsx @@ -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) { diff --git a/tasks.py b/tasks.py index 8bd97d6ad349..03f11ad24f1e 100644 --- a/tasks.py +++ b/tasks.py @@ -35,7 +35,7 @@ def content_excludes(): excludes = [ "contenttypes", "auth.permission", - "authtoken.token", + "users.apitoken", "error_report.error", "admin.logentry", "django_q.schedule",