From 1b8bdad061f266522c2da8836fbcb9a754834de7 Mon Sep 17 00:00:00 2001 From: Oliver Date: Mon, 2 Nov 2026 10:33:19 +1100 Subject: [PATCH] Token improvements (#13155) - More secure masking of tokens - Fix lowercase bug - Use consistent date checking --- src/backend/InvenTree/InvenTree/helpers.py | 12 +++++++++- src/backend/InvenTree/InvenTree/middleware.py | 5 +++-- .../InvenTree/InvenTree/test_middleware.py | 17 ++++++++++++++ src/backend/InvenTree/InvenTree/tests.py | 22 +++++++++++++++++++ src/backend/InvenTree/users/api.py | 5 ++--- src/backend/InvenTree/users/authentication.py | 9 ++++---- 6 files changed, 60 insertions(+), 10 deletions(-) diff --git a/src/backend/InvenTree/InvenTree/helpers.py b/src/backend/InvenTree/InvenTree/helpers.py index 8f2fa7d447..d67d37f23b 100644 --- a/src/backend/InvenTree/InvenTree/helpers.py +++ b/src/backend/InvenTree/InvenTree/helpers.py @@ -1180,8 +1180,18 @@ def sanitize_token(token_value: str, front=8, back=12) -> str: Returns: The sanitized token string + + Note: + Values too short to keep the 'front' and 'back' sections visible + are almost entirely masked, so that the full value is never exposed. """ - middle = len(token_value) - (front + back) + length = len(token_value) + + if length <= front + back: + visible = min(front, length // 4) + return token_value[:visible] + '*' * (length - visible) + + middle = length - (front + back) return token_value[:front] + '*' * middle + token_value[-back:] diff --git a/src/backend/InvenTree/InvenTree/middleware.py b/src/backend/InvenTree/InvenTree/middleware.py index a13969f3f8..56afecdb3d 100644 --- a/src/backend/InvenTree/InvenTree/middleware.py +++ b/src/backend/InvenTree/InvenTree/middleware.py @@ -38,10 +38,11 @@ def get_token_from_request(request): for k in auth_keys: if auth_header := request.headers.get(k, None): - auth_header = auth_header.strip().lower().split() + # Only the scheme is case-insensitive - the token value must be preserved + auth_header = auth_header.split() if len(auth_header) > 1: - if auth_header[0].strip().lower().replace(':', '') in token_keys: + if auth_header[0].lower().replace(':', '') in token_keys: token = auth_header[1] return token diff --git a/src/backend/InvenTree/InvenTree/test_middleware.py b/src/backend/InvenTree/InvenTree/test_middleware.py index 026590209f..6cc99bdc47 100644 --- a/src/backend/InvenTree/InvenTree/test_middleware.py +++ b/src/backend/InvenTree/InvenTree/test_middleware.py @@ -163,6 +163,23 @@ class MiddlewareTests(InvenTreeTestCase): self.assertIn('Invalid token', '\n'.join(captured.output)) + def test_token_auth_case(self): + """The auth scheme is case-insensitive, but the token value is not.""" + target = reverse('auth-check') + + raw_token = ApiToken.objects.create(user=self.user, name='case-test').token + self.client.logout() + + for scheme in ['Token', 'TOKEN', 'token', 'bearer', 'Bearer:']: + self.check_path(target, auth_header=f'{scheme} {raw_token}') + + self.check_path(target, 401, auth_header=f'Token {raw_token.upper()}') + + # A (legacy) mixed-case key must not be lowercased before lookup + key = 'inv-' + 'AbCdEf0123456789' * 2 + ApiToken.objects.create(user=self.user, name='legacy', key=key) + self.check_path(target, auth_header=f'Token {key}') + def test_info_view_inactive_staff(self): """InfoView must not treat a token for an inactive staff user as staff.""" self.user.is_staff = True diff --git a/src/backend/InvenTree/InvenTree/tests.py b/src/backend/InvenTree/InvenTree/tests.py index e54c91e564..cdf27a7024 100644 --- a/src/backend/InvenTree/InvenTree/tests.py +++ b/src/backend/InvenTree/InvenTree/tests.py @@ -1716,6 +1716,28 @@ class BarcodeMixinTest(InvenTreeTestCase): class SanitizerTest(TestCase): """Simple tests for sanitizer functions.""" + def test_sanitize_token(self): + """Test that sanitize_token never exposes a full (or near-full) value.""" + sanitize = InvenTree.helpers.sanitize_token + + # Long values keep the front and back sections + value = 'inv-2-' + 'a' * 20 + '.' + 'b' * 40 + '-20261101' + result = sanitize(value) + self.assertEqual(len(result), len(value)) + self.assertTrue(result.startswith(value[:8])) + self.assertTrue(result.endswith(value[-12:])) + self.assertIn('*' * (len(value) - 20), result) + + # Short values are almost entirely masked + for value in ['', 'a', 'abcd123', 'x' * 20, 'y' * 21]: + result = sanitize(value) + self.assertEqual(len(result), len(value)) + if value: + self.assertNotEqual(result, value) + + self.assertEqual(sanitize('abcd123'), 'a******') + self.assertEqual(sanitize('0123456789abcdefghij'), '01234*' + '*' * 14) + def test_svg_sanitizer(self): """Test that SVGs are sanitized accordingly.""" valid_string = """{0} diff --git a/src/backend/InvenTree/users/api.py b/src/backend/InvenTree/users/api.py index 55e11a76a7..eb0bb84f3f 100644 --- a/src/backend/InvenTree/users/api.py +++ b/src/backend/InvenTree/users/api.py @@ -1,7 +1,5 @@ """DRF API definition for the 'users' app.""" -import datetime - from django.contrib.auth import get_user, login from django.contrib.auth.models import Group, User from django.contrib.auth.password_validation import password_changed, validate_password @@ -20,6 +18,7 @@ from rest_framework import exceptions from rest_framework.generics import DestroyAPIView, GenericAPIView from rest_framework.response import Response +import InvenTree.helpers import InvenTree.permissions from InvenTree.fields import InvenTreeOutputOption, OutputConfiguration from InvenTree.filters import SEARCH_ORDER_FILTER @@ -408,7 +407,7 @@ class GetAuthToken(GenericAPIView): name = ApiToken.sanitize_name(name) - today = datetime.date.today() + today = InvenTree.helpers.current_date() reissue_token = request.resolver_match.url_name == 'api-token' token = ApiToken.objects.filter( diff --git a/src/backend/InvenTree/users/authentication.py b/src/backend/InvenTree/users/authentication.py index 0d1910ba5b..2f9172e292 100644 --- a/src/backend/InvenTree/users/authentication.py +++ b/src/backend/InvenTree/users/authentication.py @@ -1,13 +1,12 @@ """Custom token authentication class for InvenTree API.""" -import datetime - from django.utils.translation import gettext_lazy as _ from oauth2_provider.contrib.rest_framework import OAuth2ProtectedResourceAuthentication from rest_framework import exceptions from rest_framework.authentication import TokenAuthentication +import InvenTree.helpers import users.models @@ -34,11 +33,13 @@ def validate_api_token(raw_token: str) -> users.models.ApiToken: if token.expired: raise exceptions.AuthenticationFailed(_('Token has expired')) - if token.last_seen != datetime.date.today(): + today = InvenTree.helpers.current_date() + + if token.last_seen != today: # Update the last-seen date # Note: Use update_fields to avoid clobbering concurrent changes to # other fields on this token (e.g. a concurrent revocation) - token.last_seen = datetime.date.today() + token.last_seen = today token.save(update_fields=['last_seen']) return token