From fda3204e335d6a087a62eac51b62f9a17689a5a7 Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Sat, 14 Mar 2026 02:02:49 +0100 Subject: [PATCH] fix: low-privilege user token creation (#11492) * [bug] Users cannot create their own API tokens Fixes #11486 * fix detection of metadata * make easier to read * add handler for IsAuthenticated * use correct method * fix style see #11487 * add frontend test * make test more reliable? --- src/backend/InvenTree/InvenTree/metadata.py | 18 +++++- src/backend/InvenTree/users/api.py | 61 +++++++++---------- .../src/tables/settings/ApiTokenTable.tsx | 4 +- src/frontend/tests/pui_settings.spec.ts | 24 ++++++++ 4 files changed, 72 insertions(+), 35 deletions(-) diff --git a/src/backend/InvenTree/InvenTree/metadata.py b/src/backend/InvenTree/InvenTree/metadata.py index c45c20e832..86cbc91229 100644 --- a/src/backend/InvenTree/InvenTree/metadata.py +++ b/src/backend/InvenTree/InvenTree/metadata.py @@ -5,7 +5,7 @@ from django.http import Http404 from django.urls import reverse import structlog -from rest_framework import exceptions, serializers +from rest_framework import exceptions, permissions, serializers from rest_framework.fields import empty from rest_framework.metadata import SimpleMetadata from rest_framework.request import clone_request @@ -131,10 +131,26 @@ class InvenTreeMetadata(SimpleMetadata): # Remove any HTTP methods that the user does not have permission for for method, permission in rolemap.items(): + # general model / role permission result = check_user_permission(user, self.model, permission) or ( role_required and check_user_role(user, role_required, permission) ) + # check if simple IsAuthenticated permission class is used + if not result: + result = ( + view.permission_classes + and len(view.permission_classes) == 1 + and any( + perm + in [ + permissions.IsAuthenticated, + InvenTree.permissions.IsAuthenticatedOrReadScope, + ] + for perm in view.permission_classes + ) + ) + if method in actions and not result: del actions[method] diff --git a/src/backend/InvenTree/users/api.py b/src/backend/InvenTree/users/api.py index 42201ed095..37434fbcff 100644 --- a/src/backend/InvenTree/users/api.py +++ b/src/backend/InvenTree/users/api.py @@ -367,49 +367,44 @@ class GetAuthToken(GenericAPIView): - 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: - user = request.user - name = request.query_params.get('name', '') + if not request.user.is_authenticated: + raise exceptions.NotAuthenticated() # pragma: no cover - name = ApiToken.sanitize_name(name) + user = request.user + name = request.query_params.get('name', '') - today = datetime.date.today() + name = ApiToken.sanitize_name(name) - # Find existing token, which has not expired - token = ApiToken.objects.filter( - user=user, name=name, revoked=False, expiry__gte=today - ).first() + today = datetime.date.today() - if not token: - # 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() - logger.info( - "Created new API token for user '%s' (name='%s')", - user.username, - name, - ) + if not token: + # User is authenticated, and requesting a token against the provided name. + token = ApiToken.objects.create(user=request.user, name=name) + + logger.info( + "Created new API token for user '%s' (name='%s')", user.username, name + ) # Add some metadata about the request - token.set_metadata('user_agent', request.headers.get('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', '')) + token.set_metadata('user_agent', request.headers.get('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, 'name': token.name, 'expiry': token.expiry} + data = {'token': token.key, 'name': token.name, 'expiry': token.expiry} - # Ensure that the users session is logged in - if not get_user(request).is_authenticated: - login( - request, user, backend='django.contrib.auth.backends.ModelBackend' - ) + # Ensure that the users session is logged in + if not get_user(request).is_authenticated: + login(request, user, backend='django.contrib.auth.backends.ModelBackend') - return Response(data) - - else: - raise exceptions.NotAuthenticated() # pragma: no cover + return Response(data) class TokenMixin: diff --git a/src/frontend/src/tables/settings/ApiTokenTable.tsx b/src/frontend/src/tables/settings/ApiTokenTable.tsx index 9736dded12..5a70d1ec66 100644 --- a/src/frontend/src/tables/settings/ApiTokenTable.tsx +++ b/src/frontend/src/tables/settings/ApiTokenTable.tsx @@ -26,7 +26,8 @@ export function ApiTokenTable({ const [opened, { open, close }] = useDisclosure(false); const generateToken = useCreateApiFormModal({ - url: ApiEndpoints.user_tokens, + url: ApiEndpoints.user_token, + method: 'GET', title: t`Generate Token`, fields: { name: {} }, successMessage: t`Token generated`, @@ -178,6 +179,7 @@ export function ApiTokenTable({ onClose={close} title={{t`Token`}} centered + data-testid='generated-api-token' > diff --git a/src/frontend/tests/pui_settings.spec.ts b/src/frontend/tests/pui_settings.spec.ts index f30415af2b..abc50732ce 100644 --- a/src/frontend/tests/pui_settings.spec.ts +++ b/src/frontend/tests/pui_settings.spec.ts @@ -543,3 +543,27 @@ async function testColorPicker(page, ref: string) { await page.mouse.click(box.x + box.width / 2, box.y + box.height + 25); await page.getByText('Color Mode').click(); } + +test('Settings - Auth - Tokens', async ({ browser }) => { + const page = await doCachedLogin(browser, { + username: 'allaccess', + password: 'nolimits', + url: 'settings/user/' + }); + + await page.getByRole('tab', { name: 'Security' }).click(); + await page.getByRole('button', { name: 'Access Tokens' }).click(); + await page + .getByRole('button', { name: 'action-button-generate-token' }) + .click(); + await page + .getByRole('textbox', { name: 'text-field-name' }) + .fill('testtoken'); + await page.getByRole('button', { name: 'Submit', exact: true }).click(); + await page.getByText('Tokens are only shown once').waitFor(); + await page + .getByTestId('generated-api-token') + .locator('.mantine-CloseButton-root') + .click(); + await page.getByRole('cell', { name: 'testtoken' }).waitFor(); +});