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

@@ -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: