Token improvements (#13155)

- More secure masking of tokens
- Fix lowercase bug
- Use consistent date checking
This commit is contained in:
Oliver authored and GitHub committed 2026-11-02 10:33:19 +11:00
1 parent 61a2c2af5f
commit 1b8bdad061
6 files changed
+60 -10

No files matched your search

+11 -1
View File
@@ -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:]
@@ -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
@@ -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
+22
View File
@@ -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 = """<svg xmlns="http://www.w3.org/2000/svg" version="1.1" id="svg2" height="400" width="400">{0}
+2 -3
View File
@@ -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(
@@ -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