mirror of
https://github.com/inventree/InvenTree.git
synced 2026-11-12 03:40:30 +00:00
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:
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
|
||||
|
||||
@@ -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',
|
||||
],
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user