mirror of
https://github.com/inventree/InvenTree.git
synced 2026-11-10 02:47:49 +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
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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