From 4121d9a8a54bbc78bf784dea4720af6a81a47afb Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Tue, 3 Nov 2026 10:23:32 +0100 Subject: [PATCH] Merge commit from fork * fix basic auth without mfa * refactor order to use new basic auth class * remove session auth taht would bybpass mfa (mfa thing fixed in another session) * allign with other PRs prep for merge with https://github.com/inventree/InvenTree/pull/13169/ * fix schema generation --- docs/docs/settings/MFA.md | 2 ++ docs/docs/settings/error_codes.md | 5 +++ src/backend/InvenTree/InvenTree/schema.py | 7 ++++ src/backend/InvenTree/InvenTree/settings.py | 2 +- src/backend/InvenTree/order/api.py | 35 +++++-------------- src/backend/InvenTree/order/test_api.py | 21 +++++++++++ src/backend/InvenTree/users/api.py | 5 --- src/backend/InvenTree/users/authentication.py | 34 +++++++++++++++++- 8 files changed, 78 insertions(+), 33 deletions(-) diff --git a/docs/docs/settings/MFA.md b/docs/docs/settings/MFA.md index 80d8c4c526..ce19f540df 100644 --- a/docs/docs/settings/MFA.md +++ b/docs/docs/settings/MFA.md @@ -16,4 +16,6 @@ To make MFA mandatory for all users: ### Security Consideration +Password based [basic authentication](../api/index.md#basic-auth) against the API is refused for users with MFA configured, and for all users while MFA is enforced. API clients should use tokens (generated in the user interface) or OAuth2 instead. + A user can lock themselves out if they lose access to both the device with their TOTP app and their backup tokens. An admin can delete their tokens from the Database Admin interface (they exist under the 'TOTP devices' / 'static devices' models). This should be a last resort and only done by people knowledgeable about the [Database Admin interface](../settings/db_admin.md), as changes there might circumvent InvenTree's business and security logic. diff --git a/docs/docs/settings/error_codes.md b/docs/docs/settings/error_codes.md index 7b02ffc46e..5329a6ae36 100644 --- a/docs/docs/settings/error_codes.md +++ b/docs/docs/settings/error_codes.md @@ -262,6 +262,11 @@ 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-W20 +**Password authentication not allowed due to MFA - Backend** + +Password authentication is not allowed if the user has MFA enabled or if MFA is enforced globally. Use token or OAuth2 authentication instead. + #### INVE-W21 **Unsupported class removed from configuration - Backend** diff --git a/src/backend/InvenTree/InvenTree/schema.py b/src/backend/InvenTree/InvenTree/schema.py index dbca38c0e4..72d62cf625 100644 --- a/src/backend/InvenTree/InvenTree/schema.py +++ b/src/backend/InvenTree/InvenTree/schema.py @@ -5,6 +5,7 @@ from typing import Any, Optional, TypedDict from django.conf import settings +from drf_spectacular.authentication import BasicScheme from drf_spectacular.contrib.django_oauth_toolkit import DjangoOAuthToolkitScheme from drf_spectacular.drainage import warn from drf_spectacular.openapi import AutoSchema @@ -243,6 +244,12 @@ class ExtendedAutoSchema(AutoSchema): return data +class InvenTreeBasicAuthExtension(BasicScheme): + """Fix for custom basic authentication class.""" + + target_class = 'users.authentication.InvenTreeBasicAuthentication' + + def postprocess_schema_enums(result, generator, **kwargs): """Override call to drf-spectacular's enum postprocessor to filter out specific warnings.""" from drf_spectacular import drainage diff --git a/src/backend/InvenTree/InvenTree/settings.py b/src/backend/InvenTree/InvenTree/settings.py index 71bfbcbc2b..4eb54a1ed0 100644 --- a/src/backend/InvenTree/InvenTree/settings.py +++ b/src/backend/InvenTree/InvenTree/settings.py @@ -563,7 +563,7 @@ REST_FRAMEWORK = { 'DATETIME_FORMAT': '%Y-%m-%d %H:%M', 'DEFAULT_AUTHENTICATION_CLASSES': [ 'users.authentication.ApiTokenAuthentication', - 'rest_framework.authentication.BasicAuthentication', + 'users.authentication.InvenTreeBasicAuthentication', 'rest_framework.authentication.SessionAuthentication', 'users.authentication.ExtendedOAuth2Authentication', ], diff --git a/src/backend/InvenTree/order/api.py b/src/backend/InvenTree/order/api.py index 923aaa841d..08ae37a5eb 100644 --- a/src/backend/InvenTree/order/api.py +++ b/src/backend/InvenTree/order/api.py @@ -4,7 +4,6 @@ from decimal import Decimal from typing import cast from django.conf import settings -from django.contrib.auth import authenticate, login from django.contrib.auth.models import User from django.db import transaction from django.db.models import F, Q @@ -20,7 +19,7 @@ from drf_spectacular.types import OpenApiTypes from drf_spectacular.utils import extend_schema, extend_schema_field from rest_framework import status from rest_framework.decorators import action -from rest_framework.exceptions import NotFound +from rest_framework.exceptions import APIException, NotFound from rest_framework.response import Response import build.models @@ -71,6 +70,7 @@ from order.status_codes import ( TransferOrderStatusGroups, ) from part.models import Part +from users.authentication import InvenTreeBasicAuthentication from users.models import Owner from users.permissions import check_user_permission @@ -2519,36 +2519,19 @@ class OrderCalendarExport(ICalFeed): https://stackoverflow.com/questions/152248/can-i-use-http-basic-authentication-with-django https://www.djangosnippets.org/snippets/243/ """ - import base64 - import binascii - if request.user.is_authenticated: # Authenticated on first try - maybe normal browser call? if forbidden := self.check_permission(request, **kwargs): return forbidden return super().__call__(request, *args, **kwargs) - # No login yet - check in headers - if 'authorization' in request.headers: - auth = request.headers['authorization'].split() - if len(auth) == 2: - # NOTE: We are only support basic authentication for now. - # - if auth[0].lower() == 'basic': - try: - # Split on the first colon only - passwords may contain ':' - uname, passwd = ( - base64.b64decode(auth[1]).decode('utf-8').split(':', 1) - ) - except (binascii.Error, UnicodeDecodeError, ValueError): - # Malformed header - fall through to the 401 response - uname = passwd = None - - if uname is not None: - user = authenticate(username=uname, password=passwd) - if user is not None and user.is_active: - login(request, user) - request.user = user + # No login yet - check for basic auth in headers + try: + if auth := InvenTreeBasicAuthentication().authenticate(request): + request.user = auth[0] + except APIException: + # Malformed or refused credentials - fall through to the 401 response + pass # Check again if request.user.is_authenticated: diff --git a/src/backend/InvenTree/order/test_api.py b/src/backend/InvenTree/order/test_api.py index 68921e74f5..e8a4256c3a 100644 --- a/src/backend/InvenTree/order/test_api.py +++ b/src/backend/InvenTree/order/test_api.py @@ -9,6 +9,7 @@ from decimal import Decimal from typing import Optional from unittest import mock +from django.conf import settings from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError from django.db import connection @@ -25,6 +26,7 @@ from common.currency import currency_codes from common.models import InvenTreeCustomUserStateModel, InvenTreeSetting from common.settings import set_global_setting from company.models import Address, Company, Contact, SupplierPart, SupplierPriceBreak +from InvenTree.helpers_mfa import get_codes from InvenTree.unit_test import InvenTreeAPITestCase, stock_history from order import models from order.models import SalesOrderAllocation, SalesOrderLineItem, SalesOrderShipment @@ -1039,6 +1041,25 @@ class PurchaseOrderTest(OrderTest): self.assertEqual(response.status_code, 401) self.assertEqual(response['WWW-Authenticate'], 'Basic realm="api"') + def test_po_calendar_auth_mfa(self): + """Test that header authorization creates no session, and is refused for users with MFA.""" + self.client.logout() + url = reverse('api-po-so-calendar', kwargs={'ordertype': 'purchase-order'}) + base64_token = base64.b64encode( + f'{self.username}:{self.password}'.encode('ascii') + ).decode('ascii') + headers = {'authorization': f'basic {base64_token}'} + + response = self.client.get(url, format='json', headers=headers) + self.assertEqual(response.status_code, 200) + self.assertNotIn(settings.SESSION_COOKIE_NAME, response.cookies) + + get_codes(self.user) + + response = self.client.get(url, format='json', headers=headers) + self.assertEqual(response.status_code, 401) + self.assertEqual(response['WWW-Authenticate'], 'Basic realm="api"') + def test_po_calendar_no_permission(self): """Test that an authenticated user without purchase_order view permission is denied.""" self.clearRoles() diff --git a/src/backend/InvenTree/users/api.py b/src/backend/InvenTree/users/api.py index f42d6ed0d5..9b3a3cc0d0 100644 --- a/src/backend/InvenTree/users/api.py +++ b/src/backend/InvenTree/users/api.py @@ -1,6 +1,5 @@ """DRF API definition for the 'users' app.""" -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 from django.contrib.contenttypes.models import ContentType @@ -452,10 +451,6 @@ class GetAuthToken(GenericAPIView): if warning := check_email_warning(): data['warning'] = warning - # 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) diff --git a/src/backend/InvenTree/users/authentication.py b/src/backend/InvenTree/users/authentication.py index 9e6acc7e23..7591e083aa 100644 --- a/src/backend/InvenTree/users/authentication.py +++ b/src/backend/InvenTree/users/authentication.py @@ -1,13 +1,45 @@ """Custom token authentication class for InvenTree API.""" +from django.conf import settings from django.utils.translation import gettext_lazy as _ +from allauth.mfa.utils import is_mfa_enabled from oauth2_provider.contrib.rest_framework import OAuth2ProtectedResourceAuthentication from rest_framework import exceptions -from rest_framework.authentication import TokenAuthentication +from rest_framework.authentication import BasicAuthentication, TokenAuthentication import InvenTree.helpers import users.models +from common.settings import get_global_setting + + +def password_auth_allowed(user) -> bool: + """Check if a password alone is allowed to authenticate the user.""" + if is_mfa_enabled(user): + return False + return not ( + settings.MFA_ENABLED + and get_global_setting('LOGIN_ENFORCE_MFA', None, 'INVENTREE_LOGIN_ENFORCE_MFA') + ) + + +class InvenTreeBasicAuthentication(BasicAuthentication): + """Custom basic auth class. + + Checks: + - If MFA is required and dissalows basic auth + """ + + 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) + + if not password_auth_allowed(user): + raise exceptions.AuthenticationFailed( + _('INVE-W20: Password authentication not allowed due to MFA') + ) + + return (user, auth) def validate_api_token(raw_token: str) -> users.models.ApiToken: