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:
@@ -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
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user