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
This commit is contained in:
Matthias Mair authored and GitHub committed 2026-11-03 20:23:32 +11:00
1 parent 16fa2ee5cd
commit 4121d9a8a5
8 files changed
+78 -33

No files matched your search

+2
View File
@@ -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.
+5
View File
@@ -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**
@@ -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
+1 -1
View File
@@ -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',
],
+9 -26
View File
@@ -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:
+21
View File
@@ -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()
-5
View File
@@ -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)
+33 -1
View File
@@ -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: