2
0
mirror of https://github.com/inventree/InvenTree.git synced 2026-02-06 13:25:53 +00:00

fix: MFA enforce flows / interactions (#10796)

* Add a explicit confirm when MFA Enforcing is turned on

https://github.com/inventree/InvenTree/issues/10754

* add error boundary for the case of a login enforcement

* ensure registration setup is redirected to

* fix auth url

* adjust error boundary

* update test

* be more specific in enforcement flow

* ensure we log the admin also out immidiatly after removing all mfa

* small cleanup

* sml chg

* fix execution order issues

* clean up args

* cleanup

* add test for mfa change logout

* fix IP in test

* add option to require an explicit confirm

* adapt ui to ask before patching

* bump API version
This commit is contained in:
Matthias Mair
2026-01-15 23:33:10 +01:00
committed by GitHub
parent 07e1a72261
commit 9fa40ae572
16 changed files with 250 additions and 64 deletions

View File

@@ -1,11 +1,14 @@
"""InvenTree API version information."""
# InvenTree API version
INVENTREE_API_VERSION = 439
INVENTREE_API_VERSION = 440
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """
v440 -> 2026-01-15 : https://github.com/inventree/InvenTree/pull/10796
- Adds confirm and confirm_text to all settings
v439 -> 2026-01-09 : https://github.com/inventree/InvenTree/pull/11092
- Add missing nullable annotations

View File

@@ -970,6 +970,22 @@ class BaseInvenTreeSetting(models.Model):
return setting.get('model', None)
def confirm(self) -> bool:
"""Return if this setting requires confirmation on change."""
setting = self.get_setting_definition(
self.key, **self.get_filters_for_instance()
)
return setting.get('confirm', False)
def confirm_text(self) -> str:
"""Return the confirmation text for this setting, if provided."""
setting = self.get_setting_definition(
self.key, **self.get_filters_for_instance()
)
return setting.get('confirm_text', '')
def model_filters(self) -> Optional[dict]:
"""Return the model filters associated with this setting."""
setting = self.get_setting_definition(

View File

@@ -88,6 +88,18 @@ class SettingsSerializer(InvenTreeModelSerializer):
choices = serializers.SerializerMethodField()
def get_choices(self, obj) -> list:
"""Returns the choices available for a given item."""
results = []
choices = obj.choices()
if choices:
for choice in choices:
results.append({'value': choice[0], 'display_name': choice[1]})
return results
model_name = serializers.CharField(read_only=True, allow_null=True)
model_filters = serializers.DictField(read_only=True)
@@ -108,17 +120,26 @@ class SettingsSerializer(InvenTreeModelSerializer):
typ = serializers.CharField(read_only=True)
def get_choices(self, obj) -> list:
"""Returns the choices available for a given item."""
results = []
confirm = serializers.BooleanField(
read_only=True,
help_text=_('Indicates if changing this setting requires confirmation'),
)
choices = obj.choices()
confirm_text = serializers.CharField(read_only=True)
if choices:
for choice in choices:
results.append({'value': choice[0], 'display_name': choice[1]})
return results
def is_valid(self, *, raise_exception=False):
"""Validate the setting, including confirmation if required."""
ret = super().is_valid(raise_exception=raise_exception)
# Check if confirmation was provided if required
if self.instance.confirm():
req_data = self.context['request'].data
if not 'manual_confirm' in req_data or not req_data['manual_confirm']:
raise serializers.ValidationError({
'manual_confirm': _(
'This setting requires confirmation before changing. Please confirm the change.'
)
})
return ret
class GlobalSettingsSerializer(SettingsSerializer):
@@ -141,6 +162,8 @@ class GlobalSettingsSerializer(SettingsSerializer):
'api_url',
'typ',
'read_only',
'confirm',
'confirm_text',
]
read_only = serializers.SerializerMethodField(
@@ -184,6 +207,8 @@ class UserSettingsSerializer(SettingsSerializer):
'model_name',
'api_url',
'typ',
'confirm',
'confirm_text',
]
user = serializers.PrimaryKeyRelatedField(read_only=True)
@@ -232,6 +257,8 @@ class GenericReferencedSettingSerializer(SettingsSerializer):
'typ',
'units',
'required',
'confirm',
'confirm_text',
]
# set Meta class

View File

@@ -106,6 +106,20 @@ def reload_plugin_registry(setting):
registry.reload_plugins(full_reload=True, force_reload=True, collect=True)
def enforce_mfa(setting):
"""Enforce multifactor authentication for all users."""
from allauth.usersessions.models import UserSession
from common.models import logger
logger.info(
'Enforcing multifactor authentication for all users by signing out all sessions.'
)
for session in UserSession.objects.all():
session.end()
logger.info('All user sessions have been ended.')
def barcode_plugins() -> list:
"""Return a list of plugin choices which can be used for barcode generation."""
try:
@@ -1007,6 +1021,11 @@ SYSTEM_SETTINGS: dict[str, InvenTreeSettingsKeyType] = {
'description': _('Users must use multifactor security.'),
'default': False,
'validator': bool,
'confirm': True,
'confirm_text': _(
'Enabling this setting will require all users to set up multifactor authentication. All sessions will be disconnected immediately.'
),
'after_save': enforce_mfa,
},
'PLUGIN_ON_STARTUP': {
'name': _('Check plugins on startup'),

View File

@@ -32,6 +32,8 @@ class SettingsKeyType(TypedDict, total=False):
protected: Protected values are not returned to the client, instead "***" is returned (optional, default: False)
required: Is this setting required to work, can be used in combination with .check_all_settings(...) (optional, default: False)
model: Auto create a dropdown menu to select an associated model instance (e.g. 'company.company', 'auth.user' and 'auth.group' are possible too, optional)
confirm: Require an explicit confirmation before changing the setting (optional, default: False)
confirm_text: Text to display in the confirmation dialog (optional)
"""
name: str
@@ -46,6 +48,8 @@ class SettingsKeyType(TypedDict, total=False):
protected: bool
required: bool
model: str
confirm: bool
confirm_text: str
class InvenTreeSettingsKeyType(SettingsKeyType):

View File

@@ -408,6 +408,8 @@ class SettingsTest(InvenTreeTestCase):
'requires_restart',
'after_save',
'before_save',
'confirm',
'confirm_text',
]
for k in setting:
@@ -641,6 +643,18 @@ class GlobalSettingsApiTest(InvenTreeAPITestCase):
setting.refresh_from_db()
self.assertEqual(setting.value, val)
def test_mfa_change(self):
"""Test that changes in LOGIN_ENFORCE_MFA are handled correctly."""
# Setup admin users
self.user.usersession_set.create(ip='192.168.1.1')
self.assertEqual(self.user.usersession_set.count(), 1)
# Enable enforced MFA
set_global_setting('LOGIN_ENFORCE_MFA', True)
# There should be no user sessions now
self.assertEqual(self.user.usersession_set.count(), 0)
def test_api_detail(self):
"""Test that we can access the detail view for a setting based on the <key>."""
# These keys are invalid, and should return 404