mirror of
https://github.com/inventree/InvenTree.git
synced 2026-10-28 20:33:32 +00:00
feat(backend): add a way to proof instance id without sharing id / making id available (#13030)
* add a way to proof instance identification without publicly sharing id * fix test
This commit is contained in:
1 parent
d11b74fd85
commit
5bd0036ee1
7 files changed
+153
-3
No files matched your search
@@ -28,6 +28,7 @@ Configuration of basic server settings:
|
||||
{{ globalsetting("INVENTREE_INSTANCE_TITLE") }}
|
||||
{{ globalsetting("INVENTREE_INSTANCE_ID", default="Randomly generated value") }}
|
||||
{{ globalsetting("INVENTREE_ANNOUNCE_ID") }}
|
||||
{{ globalsetting("INVENTREE_LINK_PROOF") }}
|
||||
{{ globalsetting("INVENTREE_SHOW_SUPERUSER_BANNER") }}
|
||||
{{ globalsetting("INVENTREE_SHOW_ADMIN_BANNER") }}
|
||||
{{ globalsetting("INVENTREE_RESTRICT_ABOUT") }}
|
||||
|
||||
@@ -33,6 +33,7 @@ from common.settings import get_global_setting
|
||||
from InvenTree import helpers, ready
|
||||
from InvenTree.auth_overrides import registration_enabled
|
||||
from InvenTree.mixins import ListCreateAPI
|
||||
from InvenTree.proof import calculate_proof
|
||||
from InvenTree.tasks import batch_offload_tasks
|
||||
from plugin.base.event.events import batch_events
|
||||
from plugin.serializers import MetadataSerializer
|
||||
@@ -248,6 +249,9 @@ class InfoApiSerializer(serializers.Serializer):
|
||||
|
||||
server = serializers.CharField(read_only=True)
|
||||
id = serializers.CharField(read_only=True, allow_null=True)
|
||||
link = serializers.CharField(
|
||||
read_only=True, allow_null=True, help_text='Temporary identity proof'
|
||||
)
|
||||
version = serializers.CharField(read_only=True)
|
||||
instance = serializers.CharField(read_only=True)
|
||||
apiVersion = serializers.IntegerField(read_only=True) # noqa: N815
|
||||
@@ -286,11 +290,20 @@ class InfoView(APIView):
|
||||
return OrmQ.objects.count()
|
||||
|
||||
@extend_schema(
|
||||
parameters=[
|
||||
OpenApiParameter(
|
||||
name='link_nonce',
|
||||
type=str,
|
||||
location=OpenApiParameter.QUERY,
|
||||
required=False,
|
||||
description='Nonce for short-term identity proof',
|
||||
)
|
||||
],
|
||||
responses={
|
||||
200: OpenApiResponse(
|
||||
response=InfoApiSerializer, description='InvenTree server information'
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
def get(self, request, *args, **kwargs):
|
||||
"""Serve current server information."""
|
||||
@@ -343,6 +356,8 @@ class InfoView(APIView):
|
||||
},
|
||||
'system_state': {'cors_allow_all': settings.CORS_ALLOW_ALL_ORIGINS},
|
||||
}
|
||||
if nonce := request.query_params.get('link_nonce'):
|
||||
data['link'] = calculate_proof(nonce)
|
||||
|
||||
return JsonResponse(data)
|
||||
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
"""InvenTree API version information."""
|
||||
|
||||
# InvenTree API version
|
||||
INVENTREE_API_VERSION = 562
|
||||
INVENTREE_API_VERSION = 563
|
||||
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
|
||||
|
||||
INVENTREE_API_TEXT = """
|
||||
|
||||
v563 -> 2026-10-22 : https://github.com/inventree/InvenTree/pull/13030
|
||||
- Adds optional 'link_nonce' mechanism to the API root
|
||||
|
||||
v562 -> 2026-10-21 : https://github.com/inventree/InvenTree/pull/13034
|
||||
- Adds new "pricing" user role, covering the PartPricing, PartSellPriceBreak and PartInternalPriceBreak models
|
||||
- The "part" role no longer grants access to part pricing data
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Functions to use HMAC and HKDF for a limited time proof of identity based on the instance ID."""
|
||||
|
||||
import functools
|
||||
import hashlib
|
||||
import hmac
|
||||
import re
|
||||
from datetime import datetime as dt
|
||||
from datetime import timezone
|
||||
|
||||
from cryptography.hazmat.primitives import hashes
|
||||
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
|
||||
|
||||
from common.settings import get_global_setting
|
||||
from InvenTree.helpers import str2bool
|
||||
|
||||
PROOF_NONCE_REGEX = re.compile(r'[A-Za-z0-9_-]{1,64}')
|
||||
|
||||
|
||||
@functools.lru_cache
|
||||
def _link_subkey(instance_id: str) -> bytes:
|
||||
"""Use HKDF to calculate a subkey from the instance ID."""
|
||||
return HKDF(
|
||||
algorithm=hashes.SHA256(), length=32, salt=None, info=b'inventree-link-v1'
|
||||
).derive(instance_id.encode('utf-8'))
|
||||
|
||||
|
||||
def calculate_proof(
|
||||
nonce, instance: str | None = None, epoch: str | None = None
|
||||
) -> str | None:
|
||||
"""Return the proof for a nonce, optionally for a different instance ID."""
|
||||
if not nonce or not PROOF_NONCE_REGEX.fullmatch(nonce):
|
||||
return None
|
||||
|
||||
if not str2bool(
|
||||
get_global_setting(
|
||||
'INVENTREE_LINK_PROOF', environment_key='INVENTREE_LINK_PROOF'
|
||||
)
|
||||
):
|
||||
return None
|
||||
|
||||
# Get or use instance id; ensure we do not trigger generation
|
||||
instance_id = (
|
||||
instance
|
||||
if instance
|
||||
else get_global_setting('INVENTREE_INSTANCE_ID', backup_value='', create=False)
|
||||
)
|
||||
if not instance_id:
|
||||
return None
|
||||
|
||||
if epoch is None:
|
||||
epoch = dt.now(timezone.utc).strftime('%Y-%m')
|
||||
|
||||
_message = (epoch + nonce).encode('utf-8')
|
||||
return hmac.new(
|
||||
_link_subkey(str(instance_id)), _message, hashlib.sha256
|
||||
).hexdigest()[:32]
|
||||
@@ -1,12 +1,16 @@
|
||||
"""Low level tests for the InvenTree API."""
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
from base64 import b64encode
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
from unittest import mock
|
||||
from urllib.parse import parse_qs, urlencode, urlsplit
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.cache import cache
|
||||
from django.core.exceptions import AppRegistryNotReady
|
||||
from django.test import TestCase, override_settings
|
||||
from django.urls import reverse
|
||||
@@ -14,11 +18,14 @@ from django.urls import reverse
|
||||
from oauth2_provider.models import Application
|
||||
from rest_framework import status
|
||||
|
||||
from common.models import InvenTreeSetting
|
||||
from common.settings import set_global_setting
|
||||
from InvenTree.api import read_license_file
|
||||
from InvenTree.api_version import INVENTREE_API_VERSION
|
||||
from InvenTree.apps import DEFAULT_OIDC_APP_ID
|
||||
from InvenTree.exceptions import exception_handler
|
||||
from InvenTree.unit_test import InvenTreeAPITestCase, InvenTreeTestCase
|
||||
from InvenTree.proof import calculate_proof
|
||||
from InvenTree.unit_test import InvenTreeAPITestCase, InvenTreeTestCase, in_env_context
|
||||
from InvenTree.version import inventreeApiText, parse_version_text
|
||||
from users.models import ApiToken
|
||||
from users.ruleset import RULESET_NAMES
|
||||
@@ -898,6 +905,65 @@ class GeneralApiTests(InvenTreeAPITestCase):
|
||||
self.assertIn('inventree-ui-notification', keys)
|
||||
self.assertIn('inventreelabel', keys)
|
||||
|
||||
def test_info_view_link(self):
|
||||
"""Test the link proof in the 'info-view' endpoint."""
|
||||
instance_id = 'c0ffee00-1234-4abc-8def-0123456789ab'
|
||||
set_global_setting('INVENTREE_INSTANCE_ID', instance_id)
|
||||
self.client.logout()
|
||||
url = reverse('api-inventree-info')
|
||||
|
||||
prk = hmac.new(b'\x00' * 32, instance_id.encode(), hashlib.sha256).digest()
|
||||
key = hmac.new(prk, b'inventree-link-v1\x01', hashlib.sha256).digest()
|
||||
expected = hmac.new(key, b'2026-10abc_DEF-123', hashlib.sha256).hexdigest()
|
||||
self.assertEqual(expected[:32], '301acdcf01baec22a4f2f5391a55d002')
|
||||
|
||||
# overwrite time
|
||||
with mock.patch('InvenTree.proof.dt', wraps=datetime) as mock_dt:
|
||||
mock_dt.now.return_value = datetime(2026, 10, 14, tzinfo=timezone.utc)
|
||||
response = self.client.get(url, {'link_nonce': 'abc_DEF-123'})
|
||||
|
||||
data = response.json()
|
||||
self.assertEqual(data['link'], expected[:32])
|
||||
self.assertIsNone(data['id'])
|
||||
self.assertNotIn(instance_id, response.content.decode())
|
||||
|
||||
self.assertNotIn('link', self.client.get(url).json())
|
||||
|
||||
set_global_setting('INVENTREE_LINK_PROOF', False)
|
||||
response_data = self.client.get(url, {'link_nonce': 'abc'}).json()
|
||||
self.assertIsNone(response_data['link'])
|
||||
|
||||
def test_link_proof(self):
|
||||
"""Test the link proof helper."""
|
||||
set_global_setting('INVENTREE_INSTANCE_ID', 'abc')
|
||||
|
||||
# overwrite time
|
||||
with mock.patch('InvenTree.proof.dt', wraps=datetime) as mock_dt:
|
||||
mock_dt.now.return_value = datetime(2026, 10, 1, tzinfo=timezone.utc)
|
||||
link = calculate_proof('nonce')
|
||||
self.assertEqual(len(link), 32)
|
||||
self.assertEqual(link, calculate_proof('nonce'))
|
||||
self.assertNotEqual(link, calculate_proof('other'))
|
||||
self.assertIsNotNone(calculate_proof('a' * 64))
|
||||
|
||||
# broken data scenarios
|
||||
for nonce in [None, '', 'a' * 65, 'a.b', 'a b', 'abc\n', '\u00e4']:
|
||||
self.assertIsNone(calculate_proof(nonce), nonce)
|
||||
|
||||
with in_env_context({'INVENTREE_LINK_PROOF': 'False'}):
|
||||
self.assertIsNone(calculate_proof('nonce'))
|
||||
|
||||
mock_dt.now.return_value = datetime(2026, 11, 1, tzinfo=timezone.utc)
|
||||
self.assertNotEqual(link, calculate_proof('nonce'))
|
||||
|
||||
# missing id is not created; proof is not provided
|
||||
InvenTreeSetting.objects.filter(key='INVENTREE_INSTANCE_ID').delete()
|
||||
cache.clear()
|
||||
self.assertIsNone(calculate_proof('nonce'))
|
||||
self.assertFalse(
|
||||
InvenTreeSetting.objects.filter(key='INVENTREE_INSTANCE_ID').exists()
|
||||
)
|
||||
|
||||
def test_generic_metadata_lookup_field_injection(self):
|
||||
"""The generic metadata endpoint must not accept arbitrary lookup expressions.
|
||||
|
||||
|
||||
@@ -237,6 +237,14 @@ SYSTEM_SETTINGS: dict[str, InvenTreeSettingsKeyType] = {
|
||||
'default': False,
|
||||
'validator': bool,
|
||||
},
|
||||
'INVENTREE_LINK_PROOF': {
|
||||
'name': _('Allow Link Proof'),
|
||||
'description': _(
|
||||
'Allow other instances to verify the server identity without revealing the instance ID'
|
||||
),
|
||||
'default': True,
|
||||
'validator': bool,
|
||||
},
|
||||
'INVENTREE_INSTANCE': {
|
||||
'name': _('Server Instance Name'),
|
||||
'default': 'InvenTree',
|
||||
|
||||
@@ -52,6 +52,7 @@ export default function SystemSettings() {
|
||||
'INVENTREE_INSTANCE_TITLE',
|
||||
'INVENTREE_INSTANCE_ID',
|
||||
'INVENTREE_ANNOUNCE_ID',
|
||||
'INVENTREE_LINK_PROOF',
|
||||
'INVENTREE_SHOW_SUPERUSER_BANNER',
|
||||
'INVENTREE_SHOW_ADMIN_BANNER',
|
||||
'INVENTREE_RESTRICT_ABOUT',
|
||||
|
||||
Reference in new issue
Block a user