From a8a867400dc8cba936c1435ff5814245b3b33f90 Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Tue, 10 Nov 2026 03:32:38 +0100 Subject: [PATCH] feat(backend): enable disabling of basic authentication (#13169) * !feat(backend): disable basic authentication by default * align PR * add PR number * fix schema creation/discovery * mask basic auth * fix error code * reduce diff * adjust changelog message * fix test (needs basic auth which is now off by default) * switch to default on; retain warning * update test for new default * fix assertation --- .github/workflows/qc_checks.yaml | 1 + docs/docs/api/index.md | 6 +++++- docs/docs/settings/error_codes.md | 7 +++++++ docs/docs/start/config.md | 1 + src/backend/InvenTree/InvenTree/api.py | 5 ++++- .../InvenTree/InvenTree/api_version.py | 5 ++++- src/backend/InvenTree/InvenTree/settings.py | 9 ++++++++ src/backend/InvenTree/InvenTree/test_api.py | 14 +++++++++++++ src/backend/InvenTree/audit/tests.py | 1 + src/backend/InvenTree/order/api.py | 3 ++- src/backend/InvenTree/order/test_api.py | 21 ++++++++++++++++++- src/backend/InvenTree/users/authentication.py | 13 ++++++++++++ src/frontend/playwright.config.ts | 1 + src/frontend/src/components/nav/Alerts.tsx | 7 +++++++ src/frontend/src/states/states.tsx | 1 + 15 files changed, 90 insertions(+), 5 deletions(-) diff --git a/.github/workflows/qc_checks.yaml b/.github/workflows/qc_checks.yaml index 54bd5d2d35..7cf9939490 100644 --- a/.github/workflows/qc_checks.yaml +++ b/.github/workflows/qc_checks.yaml @@ -321,6 +321,7 @@ jobs: INVENTREE_PYTHON_TEST_SERVER: http://127.0.0.1:12345 INVENTREE_PYTHON_TEST_USERNAME: testuser INVENTREE_PYTHON_TEST_PASSWORD: testpassword + INVENTREE_BASIC_AUTH_ENABLED: true INVENTREE_SITE_URL: http://127.0.0.1:12345 INVENTREE_DEBUG: true INVENTREE_LOG_LEVEL: WARNING diff --git a/docs/docs/api/index.md b/docs/docs/api/index.md index bb10237eae..1a7655163c 100644 --- a/docs/docs/api/index.md +++ b/docs/docs/api/index.md @@ -49,6 +49,10 @@ Token authentication is recommended as it provides much faster API access and is Users can authenticate against the API using basic authentication - specifically a valid combination of `username` and `password` credentials. +!!! warning "Can be disabled" + Basic authentication can be disabled. This is controlled by the setting `INVENTREE_BASIC_AUTH_ENABLED` (or `basic_auth_enabled` in the [configuration file](../start/config.md#authentication)) to `False`. A warning ([INVE-W18](../settings/error_codes.md#inve-w18)) is logged and shown to staff users while it is enabled. + + Basic authentication attempts might run into rate limits during authentication on busy instances, as this is a likely place of attacks. Prefer Token or OAuth2 authentication instead. !!! warning "Multi factor authentication" @@ -63,7 +67,7 @@ Each user is assigned an authentication token which can be used to access the AP #### Requesting a Token -If a user does not already have an access token, they can request one via the user interface under the security user settings or the API interface, using a basic authentication request. +If a user does not already have an access token, they can request one via the user interface under the security user settings or the API interface, using a basic authentication request (only if [basic authentication](#basic-auth) is enabled). !!! warning "Tokens are only available once" Regardless of the request path used to obtain the token, it will only be displayed or provided once. Ensure that you copy and store it securely when it is first issued. Requesting a token with the same name will re-issue a new token, invalidating all previous ones of the same name. diff --git a/docs/docs/settings/error_codes.md b/docs/docs/settings/error_codes.md index 5329a6ae36..8af8713054 100644 --- a/docs/docs/settings/error_codes.md +++ b/docs/docs/settings/error_codes.md @@ -262,6 +262,13 @@ The `middleware` list provided in the configuration file does not include the au The middleware has been added automatically. To remove this warning, add it to the `middleware` list in your configuration file (after `django_structlog.middlewares.RequestMiddleware`). +#### INVE-W18 +**Basic authentication is enabled - Backend** + +Basic (username:password) authentication against the API is enabled via the `INVENTREE_BASIC_AUTH_ENABLED` / `basic_auth_enabled` setting. Sending the user password with every request increases the impact of leaked credentials and makes the API a simpler target for brute-force attacks. Basic authentication also bypasses the interactive login flow and does not work for users with multi-factor authentication enabled. + +It is recommended to disable basic authentication and use [Token or OAuth2 authentication](../api/index.md#authentication) instead - see the [Authentication settings](../start/config.md#authentication). + #### INVE-W20 **Password authentication not allowed due to MFA - Backend** diff --git a/docs/docs/start/config.md b/docs/docs/start/config.md index f35a58e6f7..57024767e0 100644 --- a/docs/docs/start/config.md +++ b/docs/docs/start/config.md @@ -453,6 +453,7 @@ InvenTree provides allowance for additional sign-in options. The following optio {{ configsetting("INVENTREE_MFA_ENABLED") }} Enable multi-factor authentication support for the InvenTree server | {{ configsetting("INVENTREE_MFA_SUPPORTED_TYPES") }} List of supported multi-factor authentication types | {{ configsetting("INVENTREE_USE_JWT") }} Enable support for JSON Web Tokens (JWT) for authentication | +{{ configsetting("INVENTREE_BASIC_AUTH_ENABLED") }} Enable/Disable basic (username:password) authentication against the API. See [Basic Auth](../api/index.md#basic-auth) | ### Single Sign On diff --git a/src/backend/InvenTree/InvenTree/api.py b/src/backend/InvenTree/InvenTree/api.py index 3549d27e59..0a31f8dbf9 100644 --- a/src/backend/InvenTree/InvenTree/api.py +++ b/src/backend/InvenTree/InvenTree/api.py @@ -355,7 +355,10 @@ class InfoView(APIView): 'LOGIN_ENABLE_PWD_FORGOT' ), }, - 'system_state': {'cors_allow_all': settings.CORS_ALLOW_ALL_ORIGINS}, + 'system_state': { + 'cors_allow_all': settings.CORS_ALLOW_ALL_ORIGINS, + 'basic_auth_enabled': settings.BASIC_AUTH_ENABLED if is_staff else None, + }, } if nonce := request.query_params.get('link_nonce'): data['link'] = calculate_proof(nonce) diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index 260eb57f91..d90f640c26 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -1,11 +1,14 @@ """InvenTree API version information.""" # InvenTree API version -INVENTREE_API_VERSION = 573 +INVENTREE_API_VERSION = 574 """Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" INVENTREE_API_TEXT = """ +v574 -> 2026-11-07 : https://github.com/inventree/InvenTree/pull/13169 + - breaking: Basic authentication can now be disabled - controlled with INVENTREE_BASIC_AUTH_ENABLED + v573 -> 2026-11-07 : https://github.com/inventree/InvenTree/pull/13221 - Mark audit log changes_detail field as nullable - Add warning field to ApiToken diff --git a/src/backend/InvenTree/InvenTree/settings.py b/src/backend/InvenTree/InvenTree/settings.py index 255de251cf..f43ed1be7e 100644 --- a/src/backend/InvenTree/InvenTree/settings.py +++ b/src/backend/InvenTree/InvenTree/settings.py @@ -585,6 +585,15 @@ TEMPLATES = [ } ] +# Basic (username:password) authentication against the API - disabled by default +BASIC_AUTH_ENABLED = get_boolean_setting( + 'INVENTREE_BASIC_AUTH_ENABLED', 'basic_auth_enabled', True +) +if BASIC_AUTH_ENABLED: + logger.warning( + 'INVE-W18: Basic (username:password) authentication is enabled for the API - this is not recommended' + ) + REST_FRAMEWORK = { 'EXCEPTION_HANDLER': 'InvenTree.exceptions.exception_handler', 'DATETIME_FORMAT': '%Y-%m-%d %H:%M', diff --git a/src/backend/InvenTree/InvenTree/test_api.py b/src/backend/InvenTree/InvenTree/test_api.py index 8e66bc3c95..1db8834f9d 100644 --- a/src/backend/InvenTree/InvenTree/test_api.py +++ b/src/backend/InvenTree/InvenTree/test_api.py @@ -273,6 +273,7 @@ class OAuth2ApplicationAPITests(InvenTreeAPITestCase): ) +@override_settings(BASIC_AUTH_ENABLED=True) class ApiAccessTests(InvenTreeAPITestCase): """Tests for various access scenarios with the InvenTree API.""" @@ -310,6 +311,14 @@ class ApiAccessTests(InvenTreeAPITestCase): self.get(url, expected_code=401) self.assertIsNone(self.token) + @override_settings(BASIC_AUTH_ENABLED=False) + def test_basic_auth_disabled(self): + """Test that basic auth is rejected if it is disabled.""" + self.basicAuth() + response = self.get(reverse('api-token'), expected_code=401) + self.assertNotIn('Basic', response.get('WWW-Authenticate', '')) + self.get(reverse('api-user-roles'), expected_code=401) + def test_token_success(self): """Test token auth works.""" self.tokenAuth() @@ -875,6 +884,11 @@ class GeneralApiTests(InvenTreeAPITestCase): self.assertIn('instance', data) self.assertEqual('InvenTree', data['server']) + self.assertTrue(data['system_state']['basic_auth_enabled']) + + with override_settings(BASIC_AUTH_ENABLED=False): + data = self.get(url, max_query_count=20).json() + self.assertFalse(data['system_state']['basic_auth_enabled']) # Test with token token = self.get(url=reverse('api-token'), max_query_count=40).data['token'] diff --git a/src/backend/InvenTree/audit/tests.py b/src/backend/InvenTree/audit/tests.py index bbbb71ca1c..68c784520f 100644 --- a/src/backend/InvenTree/audit/tests.py +++ b/src/backend/InvenTree/audit/tests.py @@ -482,6 +482,7 @@ class AuditAPITest(AuditTestMixin, InvenTreeAPITestCase): self.assertTrue(all(cids)) self.assertNotEqual(cids[0], cids[1]) + @override_settings(BASIC_AUTH_ENABLED=True) def test_basic_auth_actor(self): """Users authenticated by DRF (not middleware) are recorded as the actor.""" self.logout() diff --git a/src/backend/InvenTree/order/api.py b/src/backend/InvenTree/order/api.py index 08ae37a5eb..1da2e8e83e 100644 --- a/src/backend/InvenTree/order/api.py +++ b/src/backend/InvenTree/order/api.py @@ -2545,7 +2545,8 @@ class OrderCalendarExport(ICalFeed): response = JsonResponse({ 'detail': 'Authentication credentials were not provided.' }) - response['WWW-Authenticate'] = 'Basic realm="api"' + if settings.BASIC_AUTH_ENABLED: + response['WWW-Authenticate'] = 'Basic realm="api"' response.status_code = 401 return response diff --git a/src/backend/InvenTree/order/test_api.py b/src/backend/InvenTree/order/test_api.py index e8a4256c3a..61d6cecd77 100644 --- a/src/backend/InvenTree/order/test_api.py +++ b/src/backend/InvenTree/order/test_api.py @@ -13,7 +13,7 @@ from django.conf import settings from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError from django.db import connection -from django.test import TransactionTestCase, skipUnlessDBFeature +from django.test import TransactionTestCase, override_settings, skipUnlessDBFeature from django.test.utils import CaptureQueriesContext from django.urls import reverse @@ -995,6 +995,7 @@ class PurchaseOrderTest(OrderTest): resp_dict['detail'], 'Authentication credentials were not provided.' ) + @override_settings(BASIC_AUTH_ENABLED=True) def test_po_calendar_auth(self): """Test accessing calendar with header authorization.""" self.client.logout() @@ -1008,6 +1009,22 @@ class PurchaseOrderTest(OrderTest): ) self.assertEqual(response.status_code, 200) + @override_settings(BASIC_AUTH_ENABLED=False) + def test_po_calendar_auth_disabled(self): + """Test that header authorization is rejected if basic auth is disabled.""" + self.client.logout() + base64_token = base64.b64encode( + f'{self.username}:{self.password}'.encode('ascii') + ).decode('ascii') + response = self.client.get( + reverse('api-po-so-calendar', kwargs={'ordertype': 'purchase-order'}), + format='json', + headers={'authorization': f'basic {base64_token}'}, + ) + self.assertEqual(response.status_code, 401) + self.assertNotIn('WWW-Authenticate', response) + + @override_settings(BASIC_AUTH_ENABLED=True) def test_po_calendar_auth_password_colon(self): """Test header authorization with a password containing a colon.""" password = 'pass:word:123' @@ -1025,6 +1042,7 @@ class PurchaseOrderTest(OrderTest): ) self.assertEqual(response.status_code, 200) + @override_settings(BASIC_AUTH_ENABLED=True) def test_po_calendar_auth_malformed(self): """Test that malformed basic authorization headers return 401, not 500.""" self.client.logout() @@ -1041,6 +1059,7 @@ class PurchaseOrderTest(OrderTest): self.assertEqual(response.status_code, 401) self.assertEqual(response['WWW-Authenticate'], 'Basic realm="api"') + @override_settings(BASIC_AUTH_ENABLED=True) def test_po_calendar_auth_mfa(self): """Test that header authorization creates no session, and is refused for users with MFA.""" self.client.logout() diff --git a/src/backend/InvenTree/users/authentication.py b/src/backend/InvenTree/users/authentication.py index 9aa8214b9f..1fda61b417 100644 --- a/src/backend/InvenTree/users/authentication.py +++ b/src/backend/InvenTree/users/authentication.py @@ -33,8 +33,21 @@ class InvenTreeBasicAuthentication(BasicAuthentication): Checks: - If MFA is required and disallows basic auth + - Only works if BASIC_AUTH_ENABLED is True """ + def authenticate(self, request): + """Only attempt basic authentication if it is enabled.""" + if not settings.BASIC_AUTH_ENABLED: + return None + return super().authenticate(request) + + def authenticate_header(self, request): + """Only advertise basic authentication if it is enabled.""" + if not settings.BASIC_AUTH_ENABLED: + return '' + return super().authenticate_header(request) + def authenticate_credentials(self, userid, password, request=None): """Also check if MFA is required for the user.""" user, auth = super().authenticate_credentials(userid, password, request) diff --git a/src/frontend/playwright.config.ts b/src/frontend/playwright.config.ts index 305b49f7e9..99784272b2 100644 --- a/src/frontend/playwright.config.ts +++ b/src/frontend/playwright.config.ts @@ -127,6 +127,7 @@ export default defineConfig({ INVENTREE_SITE_URL: 'http://localhost:8000', INVENTREE_FRONTEND_API_HOST: 'http://localhost:8000', INVENTREE_CORS_ORIGIN_ALLOW_ALL: 'True', + INVENTREE_BASIC_AUTH_ENABLED: 'True', INVENTREE_COOKIE_SAMESITE: 'False', INVENTREE_LOGIN_ATTEMPTS: '3', INVENTREE_PLUGINS_MANDATORY: 'samplelocate', diff --git a/src/frontend/src/components/nav/Alerts.tsx b/src/frontend/src/components/nav/Alerts.tsx index 3450233073..a903512e59 100644 --- a/src/frontend/src/components/nav/Alerts.tsx +++ b/src/frontend/src/components/nav/Alerts.tsx @@ -156,6 +156,13 @@ export function getAlerts( code: 'INVE-W16', message: t`CORS is set to allow all origins.`, condition: server?.system_state?.cors_allow_all || false + }, + { + key: 'basic_auth', + title: t`Basic Authentication`, + code: 'INVE-W18', + message: t`Basic (username:password) authentication is enabled for the API.`, + condition: server?.system_state?.basic_auth_enabled || false } ]; diff --git a/src/frontend/src/states/states.tsx b/src/frontend/src/states/states.tsx index 6f593eedd6..49c3284d1f 100644 --- a/src/frontend/src/states/states.tsx +++ b/src/frontend/src/states/states.tsx @@ -42,6 +42,7 @@ export interface ServerAPIProps { }; system_state: { cors_allow_all: null | boolean; + basic_auth_enabled: null | boolean; } | null; }