mirror of
https://github.com/inventree/InvenTree.git
synced 2026-01-29 17:43:55 +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 information."""
|
||||||
|
|
||||||
# InvenTree API version
|
# 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."""
|
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
|
||||||
|
|
||||||
INVENTREE_API_TEXT = """
|
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
|
v439 -> 2026-01-09 : https://github.com/inventree/InvenTree/pull/11092
|
||||||
- Add missing nullable annotations
|
- Add missing nullable annotations
|
||||||
|
|
||||||
|
|||||||
@@ -970,6 +970,22 @@ class BaseInvenTreeSetting(models.Model):
|
|||||||
|
|
||||||
return setting.get('model', None)
|
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]:
|
def model_filters(self) -> Optional[dict]:
|
||||||
"""Return the model filters associated with this setting."""
|
"""Return the model filters associated with this setting."""
|
||||||
setting = self.get_setting_definition(
|
setting = self.get_setting_definition(
|
||||||
|
|||||||
@@ -88,6 +88,18 @@ class SettingsSerializer(InvenTreeModelSerializer):
|
|||||||
|
|
||||||
choices = serializers.SerializerMethodField()
|
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_name = serializers.CharField(read_only=True, allow_null=True)
|
||||||
|
|
||||||
model_filters = serializers.DictField(read_only=True)
|
model_filters = serializers.DictField(read_only=True)
|
||||||
@@ -108,17 +120,26 @@ class SettingsSerializer(InvenTreeModelSerializer):
|
|||||||
|
|
||||||
typ = serializers.CharField(read_only=True)
|
typ = serializers.CharField(read_only=True)
|
||||||
|
|
||||||
def get_choices(self, obj) -> list:
|
confirm = serializers.BooleanField(
|
||||||
"""Returns the choices available for a given item."""
|
read_only=True,
|
||||||
results = []
|
help_text=_('Indicates if changing this setting requires confirmation'),
|
||||||
|
)
|
||||||
|
|
||||||
choices = obj.choices()
|
confirm_text = serializers.CharField(read_only=True)
|
||||||
|
|
||||||
if choices:
|
def is_valid(self, *, raise_exception=False):
|
||||||
for choice in choices:
|
"""Validate the setting, including confirmation if required."""
|
||||||
results.append({'value': choice[0], 'display_name': choice[1]})
|
ret = super().is_valid(raise_exception=raise_exception)
|
||||||
|
# Check if confirmation was provided if required
|
||||||
return results
|
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):
|
class GlobalSettingsSerializer(SettingsSerializer):
|
||||||
@@ -141,6 +162,8 @@ class GlobalSettingsSerializer(SettingsSerializer):
|
|||||||
'api_url',
|
'api_url',
|
||||||
'typ',
|
'typ',
|
||||||
'read_only',
|
'read_only',
|
||||||
|
'confirm',
|
||||||
|
'confirm_text',
|
||||||
]
|
]
|
||||||
|
|
||||||
read_only = serializers.SerializerMethodField(
|
read_only = serializers.SerializerMethodField(
|
||||||
@@ -184,6 +207,8 @@ class UserSettingsSerializer(SettingsSerializer):
|
|||||||
'model_name',
|
'model_name',
|
||||||
'api_url',
|
'api_url',
|
||||||
'typ',
|
'typ',
|
||||||
|
'confirm',
|
||||||
|
'confirm_text',
|
||||||
]
|
]
|
||||||
|
|
||||||
user = serializers.PrimaryKeyRelatedField(read_only=True)
|
user = serializers.PrimaryKeyRelatedField(read_only=True)
|
||||||
@@ -232,6 +257,8 @@ class GenericReferencedSettingSerializer(SettingsSerializer):
|
|||||||
'typ',
|
'typ',
|
||||||
'units',
|
'units',
|
||||||
'required',
|
'required',
|
||||||
|
'confirm',
|
||||||
|
'confirm_text',
|
||||||
]
|
]
|
||||||
|
|
||||||
# set Meta class
|
# set Meta class
|
||||||
|
|||||||
@@ -106,6 +106,20 @@ def reload_plugin_registry(setting):
|
|||||||
registry.reload_plugins(full_reload=True, force_reload=True, collect=True)
|
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:
|
def barcode_plugins() -> list:
|
||||||
"""Return a list of plugin choices which can be used for barcode generation."""
|
"""Return a list of plugin choices which can be used for barcode generation."""
|
||||||
try:
|
try:
|
||||||
@@ -1007,6 +1021,11 @@ SYSTEM_SETTINGS: dict[str, InvenTreeSettingsKeyType] = {
|
|||||||
'description': _('Users must use multifactor security.'),
|
'description': _('Users must use multifactor security.'),
|
||||||
'default': False,
|
'default': False,
|
||||||
'validator': bool,
|
'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': {
|
'PLUGIN_ON_STARTUP': {
|
||||||
'name': _('Check plugins 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)
|
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)
|
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)
|
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
|
name: str
|
||||||
@@ -46,6 +48,8 @@ class SettingsKeyType(TypedDict, total=False):
|
|||||||
protected: bool
|
protected: bool
|
||||||
required: bool
|
required: bool
|
||||||
model: str
|
model: str
|
||||||
|
confirm: bool
|
||||||
|
confirm_text: str
|
||||||
|
|
||||||
|
|
||||||
class InvenTreeSettingsKeyType(SettingsKeyType):
|
class InvenTreeSettingsKeyType(SettingsKeyType):
|
||||||
|
|||||||
@@ -408,6 +408,8 @@ class SettingsTest(InvenTreeTestCase):
|
|||||||
'requires_restart',
|
'requires_restart',
|
||||||
'after_save',
|
'after_save',
|
||||||
'before_save',
|
'before_save',
|
||||||
|
'confirm',
|
||||||
|
'confirm_text',
|
||||||
]
|
]
|
||||||
|
|
||||||
for k in setting:
|
for k in setting:
|
||||||
@@ -641,6 +643,18 @@ class GlobalSettingsApiTest(InvenTreeAPITestCase):
|
|||||||
setting.refresh_from_db()
|
setting.refresh_from_db()
|
||||||
self.assertEqual(setting.value, val)
|
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):
|
def test_api_detail(self):
|
||||||
"""Test that we can access the detail view for a setting based on the <key>."""
|
"""Test that we can access the detail view for a setting based on the <key>."""
|
||||||
# These keys are invalid, and should return 404
|
# These keys are invalid, and should return 404
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export enum ApiEndpoints {
|
|||||||
user_simple_login = 'email/generate/',
|
user_simple_login = 'email/generate/',
|
||||||
|
|
||||||
// User auth endpoints
|
// User auth endpoints
|
||||||
|
auth_base = '/auth/',
|
||||||
user_reset = 'auth/v1/auth/password/request',
|
user_reset = 'auth/v1/auth/password/request',
|
||||||
user_reset_set = 'auth/v1/auth/password/reset',
|
user_reset_set = 'auth/v1/auth/password/reset',
|
||||||
auth_pwd_change = 'auth/v1/account/password/change',
|
auth_pwd_change = 'auth/v1/account/password/change',
|
||||||
|
|||||||
@@ -25,7 +25,8 @@ export enum FlowEnum {
|
|||||||
MfaAuthenticate = 'mfa_authenticate',
|
MfaAuthenticate = 'mfa_authenticate',
|
||||||
Reauthenticate = 'reauthenticate',
|
Reauthenticate = 'reauthenticate',
|
||||||
MfaReauthenticate = 'mfa_reauthenticate',
|
MfaReauthenticate = 'mfa_reauthenticate',
|
||||||
MfaTrust = 'mfa_trust'
|
MfaTrust = 'mfa_trust',
|
||||||
|
MfaRegister = 'mfa_register'
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Flow {
|
export interface Flow {
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ export interface Setting {
|
|||||||
method?: string;
|
method?: string;
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
read_only?: boolean;
|
read_only?: boolean;
|
||||||
|
confirm?: boolean;
|
||||||
|
confirm_text?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SettingChoice {
|
export interface SettingChoice {
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import { ErrorBoundary, type FallbackRender } from '@sentry/react';
|
|||||||
import { IconExclamationCircle } from '@tabler/icons-react';
|
import { IconExclamationCircle } from '@tabler/icons-react';
|
||||||
import { type ReactNode, useCallback } from 'react';
|
import { type ReactNode, useCallback } from 'react';
|
||||||
|
|
||||||
function DefaultFallback({ title }: Readonly<{ title: string }>): ReactNode {
|
export function DefaultFallback({
|
||||||
|
title
|
||||||
|
}: Readonly<{ title: string }>): ReactNode {
|
||||||
return (
|
return (
|
||||||
<Alert
|
<Alert
|
||||||
color='red'
|
color='red'
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { t } from '@lingui/core/macro';
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Group,
|
Group,
|
||||||
@@ -6,6 +7,7 @@ import {
|
|||||||
Stack,
|
Stack,
|
||||||
Switch,
|
Switch,
|
||||||
Text,
|
Text,
|
||||||
|
Tooltip,
|
||||||
useMantineColorScheme
|
useMantineColorScheme
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import { IconEdit } from '@tabler/icons-react';
|
import { IconEdit } from '@tabler/icons-react';
|
||||||
@@ -20,6 +22,24 @@ import { vars } from '../../theme';
|
|||||||
import { Boundary } from '../Boundary';
|
import { Boundary } from '../Boundary';
|
||||||
import { RenderInstance } from '../render/Instance';
|
import { RenderInstance } from '../render/Instance';
|
||||||
|
|
||||||
|
type ConfirmResult = {
|
||||||
|
requires_confirmation: boolean;
|
||||||
|
confirmed: boolean;
|
||||||
|
proceed?: boolean;
|
||||||
|
};
|
||||||
|
function confirmSettingChange(setting: Setting): ConfirmResult {
|
||||||
|
if (setting.confirm) {
|
||||||
|
const confirmed = window.confirm(
|
||||||
|
setting.confirm_text || t`Do you want to proceed to change this setting?`
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
requires_confirmation: true,
|
||||||
|
confirmed: confirmed || false,
|
||||||
|
proceed: confirmed
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { requires_confirmation: false, confirmed: false, proceed: true };
|
||||||
|
}
|
||||||
/**
|
/**
|
||||||
* Render a single setting value
|
* Render a single setting value
|
||||||
*/
|
*/
|
||||||
@@ -29,8 +49,8 @@ function SettingValue({
|
|||||||
onToggle
|
onToggle
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
setting: Setting;
|
setting: Setting;
|
||||||
onEdit: (setting: Setting) => void;
|
onEdit: (setting: Setting, confirmed: boolean) => void;
|
||||||
onToggle: (setting: Setting, value: boolean) => void;
|
onToggle: (setting: Setting, value: boolean, confirmed: boolean) => void;
|
||||||
}>) {
|
}>) {
|
||||||
// Determine the text to display for the setting value
|
// Determine the text to display for the setting value
|
||||||
const valueText: string = useMemo(() => {
|
const valueText: string = useMemo(() => {
|
||||||
@@ -54,7 +74,9 @@ function SettingValue({
|
|||||||
// Launch the edit dialog for this setting
|
// Launch the edit dialog for this setting
|
||||||
const editSetting = useCallback(() => {
|
const editSetting = useCallback(() => {
|
||||||
if (!setting.read_only) {
|
if (!setting.read_only) {
|
||||||
onEdit(setting);
|
const confirm = confirmSettingChange(setting);
|
||||||
|
if (!confirm.proceed) return;
|
||||||
|
onEdit(setting, confirm.confirmed);
|
||||||
}
|
}
|
||||||
}, [setting, onEdit]);
|
}, [setting, onEdit]);
|
||||||
|
|
||||||
@@ -62,7 +84,9 @@ function SettingValue({
|
|||||||
const toggleSetting = useCallback(
|
const toggleSetting = useCallback(
|
||||||
(event: any) => {
|
(event: any) => {
|
||||||
if (!setting.read_only) {
|
if (!setting.read_only) {
|
||||||
onToggle(setting, event.currentTarget.checked);
|
const confirm = confirmSettingChange(setting);
|
||||||
|
if (!confirm.proceed) return;
|
||||||
|
onToggle(setting, event.currentTarget.checked, confirm.confirmed);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[setting, onToggle]
|
[setting, onToggle]
|
||||||
@@ -170,8 +194,8 @@ export function SettingItem({
|
|||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
setting: Setting;
|
setting: Setting;
|
||||||
shaded: boolean;
|
shaded: boolean;
|
||||||
onEdit: (setting: Setting) => void;
|
onEdit: (setting: Setting, confirmed: boolean) => void;
|
||||||
onToggle: (setting: Setting, value: boolean) => void;
|
onToggle: (setting: Setting, value: boolean, confirmed: boolean) => void;
|
||||||
}>) {
|
}>) {
|
||||||
const { colorScheme } = useMantineColorScheme();
|
const { colorScheme } = useMantineColorScheme();
|
||||||
|
|
||||||
@@ -192,7 +216,18 @@ export function SettingItem({
|
|||||||
<Text size='xs'>{setting.description}</Text>
|
<Text size='xs'>{setting.description}</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
<Boundary label={`setting-value-${setting.key}`}>
|
<Boundary label={`setting-value-${setting.key}`}>
|
||||||
<SettingValue setting={setting} onEdit={onEdit} onToggle={onToggle} />
|
<Group gap='xs' justify='right'>
|
||||||
|
{setting.confirm && (
|
||||||
|
<Tooltip label={t`This setting requires confirmation`}>
|
||||||
|
<IconEdit color={vars.colors.yellow[7]} size={16} />
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
<SettingValue
|
||||||
|
setting={setting}
|
||||||
|
onEdit={onEdit}
|
||||||
|
onToggle={onToggle}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
</Boundary>
|
</Boundary>
|
||||||
</Group>
|
</Group>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ export function SettingList({
|
|||||||
|
|
||||||
// Callback for editing a single setting instance
|
// Callback for editing a single setting instance
|
||||||
const onValueEdit = useCallback(
|
const onValueEdit = useCallback(
|
||||||
(setting: Setting) => {
|
(setting: Setting, confirmed: boolean) => {
|
||||||
setSetting(setting);
|
setSetting(setting);
|
||||||
editSettingModal.open();
|
editSettingModal.open();
|
||||||
},
|
},
|
||||||
@@ -100,13 +100,17 @@ export function SettingList({
|
|||||||
|
|
||||||
// Callback for toggling a single boolean setting instance
|
// Callback for toggling a single boolean setting instance
|
||||||
const onValueToggle = useCallback(
|
const onValueToggle = useCallback(
|
||||||
(setting: Setting, value: boolean) => {
|
(setting: Setting, value: boolean, confirmed: boolean) => {
|
||||||
|
let data: any = {
|
||||||
|
value: value
|
||||||
|
};
|
||||||
|
if (confirmed) {
|
||||||
|
data = { ...data, manual_confirm: true };
|
||||||
|
}
|
||||||
api
|
api
|
||||||
.patch(
|
.patch(
|
||||||
apiUrl(settingsState.endpoint, setting.key, settingsState.pathParams),
|
apiUrl(settingsState.endpoint, setting.key, settingsState.pathParams),
|
||||||
{
|
data
|
||||||
value: value
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
notifications.hide('setting');
|
notifications.hide('setting');
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ export async function doBasicLogin(
|
|||||||
|
|
||||||
const host: string = getHost();
|
const host: string = getHost();
|
||||||
|
|
||||||
// Attempt login with
|
// Attempt login with basic info
|
||||||
await api
|
await api
|
||||||
.post(
|
.post(
|
||||||
apiUrl(ApiEndpoints.auth_login),
|
apiUrl(ApiEndpoints.auth_login),
|
||||||
@@ -147,10 +147,16 @@ export async function doBasicLogin(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// see if mfa registration is required
|
||||||
|
if (loginDone) {
|
||||||
|
// stop further processing if mfa setup is required
|
||||||
|
if (!(await MfaSetupOk(navigate))) loginDone = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// we are successfully logged in - gather required states for app
|
||||||
if (loginDone) {
|
if (loginDone) {
|
||||||
await fetchUserState();
|
await fetchUserState();
|
||||||
// see if mfa registration is required
|
await fetchGlobalStates();
|
||||||
await fetchGlobalStates(navigate);
|
|
||||||
observeProfile();
|
observeProfile();
|
||||||
} else if (!success) {
|
} else if (!success) {
|
||||||
clearUserState();
|
clearUserState();
|
||||||
@@ -238,6 +244,26 @@ export const doSimpleLogin = async (email: string) => {
|
|||||||
return mail;
|
return mail;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function MfaSetupOk(navigate: NavigateFunction) {
|
||||||
|
return api
|
||||||
|
.get(apiUrl(ApiEndpoints.auth_base))
|
||||||
|
.then(() => {
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (err?.response?.status == 401) {
|
||||||
|
const mfa_register = err.response.data.id == FlowEnum.MfaRegister;
|
||||||
|
if (mfa_register && navigate != undefined) {
|
||||||
|
navigate('/mfa-setup');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function observeProfile() {
|
function observeProfile() {
|
||||||
// overwrite language and theme info in session with profile info
|
// overwrite language and theme info in session with profile info
|
||||||
|
|
||||||
@@ -326,19 +352,14 @@ export async function handleMfaLogin(
|
|||||||
) {
|
) {
|
||||||
const { setAuthContext } = useServerApiState.getState();
|
const { setAuthContext } = useServerApiState.getState();
|
||||||
|
|
||||||
const result = await authApi(
|
return await authApi(apiUrl(ApiEndpoints.auth_login_2fa), undefined, 'post', {
|
||||||
apiUrl(ApiEndpoints.auth_login_2fa),
|
code: values.code
|
||||||
undefined,
|
})
|
||||||
'post',
|
|
||||||
{
|
|
||||||
code: values.code
|
|
||||||
}
|
|
||||||
)
|
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
handleSuccessFullAuth(response, navigate, location, setError);
|
handleSuccessFullAuth(response, navigate, location, setError);
|
||||||
return true;
|
return true;
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch(async (err) => {
|
||||||
// Already logged in, but with a different session
|
// Already logged in, but with a different session
|
||||||
if (err?.response?.status == 409) {
|
if (err?.response?.status == 409) {
|
||||||
notifications.show({
|
notifications.show({
|
||||||
@@ -354,11 +375,12 @@ export async function handleMfaLogin(
|
|||||||
);
|
);
|
||||||
if (mfa_trust?.is_pending) {
|
if (mfa_trust?.is_pending) {
|
||||||
setAuthContext(err.response.data.data);
|
setAuthContext(err.response.data.data);
|
||||||
authApi(apiUrl(ApiEndpoints.auth_trust), undefined, 'post', {
|
await authApi(apiUrl(ApiEndpoints.auth_trust), undefined, 'post', {
|
||||||
trust: values.remember ?? false
|
trust: values.remember ?? false
|
||||||
}).then((response) => {
|
}).then((response) => {
|
||||||
handleSuccessFullAuth(response, navigate, location, setError);
|
handleSuccessFullAuth(response, navigate, location, setError);
|
||||||
});
|
});
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const errors = err.response?.data?.errors;
|
const errors = err.response?.data?.errors;
|
||||||
@@ -371,7 +393,6 @@ export async function handleMfaLogin(
|
|||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -382,7 +403,7 @@ export async function handleMfaLogin(
|
|||||||
* - An existing CSRF cookie is stored in the browser
|
* - An existing CSRF cookie is stored in the browser
|
||||||
*/
|
*/
|
||||||
export const checkLoginState = async (
|
export const checkLoginState = async (
|
||||||
navigate: any,
|
navigate: NavigateFunction,
|
||||||
redirect?: any,
|
redirect?: any,
|
||||||
no_redirect?: boolean
|
no_redirect?: boolean
|
||||||
) => {
|
) => {
|
||||||
@@ -396,22 +417,25 @@ export const checkLoginState = async (
|
|||||||
const { isLoggedIn, fetchUserState } = useUserState.getState();
|
const { isLoggedIn, fetchUserState } = useUserState.getState();
|
||||||
|
|
||||||
// Callback function when login is successful
|
// Callback function when login is successful
|
||||||
const loginSuccess = () => {
|
const loginSuccess = async () => {
|
||||||
setLoginChecked(true);
|
setLoginChecked(true);
|
||||||
showLoginNotification({
|
showLoginNotification({
|
||||||
title: t`Logged In`,
|
title: t`Logged In`,
|
||||||
message: t`Successfully logged in`
|
message: t`Successfully logged in`
|
||||||
});
|
});
|
||||||
|
MfaSetupOk(navigate).then(async (isOk) => {
|
||||||
|
if (isOk) {
|
||||||
|
observeProfile();
|
||||||
|
await fetchGlobalStates();
|
||||||
|
|
||||||
observeProfile();
|
followRedirect(navigate, redirect);
|
||||||
|
}
|
||||||
fetchGlobalStates(navigate);
|
});
|
||||||
followRedirect(navigate, redirect);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isLoggedIn()) {
|
if (isLoggedIn()) {
|
||||||
// Already logged in
|
// Already logged in
|
||||||
loginSuccess();
|
await loginSuccess();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -420,7 +444,7 @@ export const checkLoginState = async (
|
|||||||
await fetchUserState();
|
await fetchUserState();
|
||||||
|
|
||||||
if (isLoggedIn()) {
|
if (isLoggedIn()) {
|
||||||
loginSuccess();
|
await loginSuccess();
|
||||||
} else if (!no_redirect) {
|
} else if (!no_redirect) {
|
||||||
setLoginChecked(true);
|
setLoginChecked(true);
|
||||||
navigate('/login', { state: redirect });
|
navigate('/login', { state: redirect });
|
||||||
@@ -429,8 +453,8 @@ export const checkLoginState = async (
|
|||||||
};
|
};
|
||||||
|
|
||||||
function handleSuccessFullAuth(
|
function handleSuccessFullAuth(
|
||||||
response?: any,
|
response: any,
|
||||||
navigate?: NavigateFunction,
|
navigate: NavigateFunction,
|
||||||
location?: Location<any>,
|
location?: Location<any>,
|
||||||
setError?: (message: string | undefined) => void
|
setError?: (message: string | undefined) => void
|
||||||
) {
|
) {
|
||||||
@@ -447,12 +471,16 @@ function handleSuccessFullAuth(
|
|||||||
}
|
}
|
||||||
setAuthenticated();
|
setAuthenticated();
|
||||||
|
|
||||||
fetchUserState().finally(() => {
|
// see if mfa registration is required
|
||||||
observeProfile();
|
MfaSetupOk(navigate).then(async (isOk) => {
|
||||||
fetchGlobalStates(navigate);
|
if (isOk) {
|
||||||
|
await fetchUserState();
|
||||||
|
observeProfile();
|
||||||
|
await fetchGlobalStates();
|
||||||
|
|
||||||
if (navigate && location) {
|
if (location !== undefined) {
|
||||||
followRedirect(navigate, location?.state);
|
followRedirect(navigate, location?.state);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -545,7 +573,12 @@ export function handleVerifyTotp(
|
|||||||
authApi(apiUrl(ApiEndpoints.auth_totp), undefined, 'post', {
|
authApi(apiUrl(ApiEndpoints.auth_totp), undefined, 'post', {
|
||||||
code: value
|
code: value
|
||||||
}).then(() => {
|
}).then(() => {
|
||||||
followRedirect(navigate, location?.state);
|
showNotification({
|
||||||
|
title: t`MFA Setup successful`,
|
||||||
|
message: t`MFA via TOTP has been set up successfully; you will need to login again.`,
|
||||||
|
color: 'green'
|
||||||
|
});
|
||||||
|
doLogout(navigate);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -689,8 +722,8 @@ export function handleChangePassword(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function handleWebauthnLogin(
|
export async function handleWebauthnLogin(
|
||||||
navigate?: NavigateFunction,
|
navigate: NavigateFunction,
|
||||||
location?: Location<any>
|
location: Location<any>
|
||||||
) {
|
) {
|
||||||
const { setAuthContext } = useServerApiState.getState();
|
const { setAuthContext } = useServerApiState.getState();
|
||||||
|
|
||||||
|
|||||||
@@ -28,12 +28,14 @@ import {
|
|||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
import { api, queryClient } from '../../../../App';
|
import { api, queryClient } from '../../../../App';
|
||||||
import { CopyButton } from '../../../../components/buttons/CopyButton';
|
import { CopyButton } from '../../../../components/buttons/CopyButton';
|
||||||
import { StylishText } from '../../../../components/items/StylishText';
|
import { StylishText } from '../../../../components/items/StylishText';
|
||||||
import { authApi } from '../../../../functions/auth';
|
import { authApi, doLogout } from '../../../../functions/auth';
|
||||||
import { useServerApiState } from '../../../../states/ServerApiState';
|
import { useServerApiState } from '../../../../states/ServerApiState';
|
||||||
|
import { useGlobalSettingsState } from '../../../../states/SettingsStates';
|
||||||
import { QrRegistrationForm } from './QrRegistrationForm';
|
import { QrRegistrationForm } from './QrRegistrationForm';
|
||||||
import { parseDate } from './SecurityContent';
|
import { parseDate } from './SecurityContent';
|
||||||
|
|
||||||
@@ -697,6 +699,7 @@ export default function MFASettings() {
|
|||||||
const [auth_config] = useServerApiState(
|
const [auth_config] = useServerApiState(
|
||||||
useShallow((state) => [state.auth_config])
|
useShallow((state) => [state.auth_config])
|
||||||
);
|
);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
// Fetch list of MFA methods currently configured for the user
|
// Fetch list of MFA methods currently configured for the user
|
||||||
const { isLoading, data, refetch } = useQuery({
|
const { isLoading, data, refetch } = useQuery({
|
||||||
@@ -708,6 +711,17 @@ export default function MFASettings() {
|
|||||||
.catch(() => [])
|
.catch(() => [])
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const refetchAfterRemoval = () => {
|
||||||
|
refetch();
|
||||||
|
if (
|
||||||
|
data == undefined &&
|
||||||
|
useGlobalSettingsState.getState().isSet('LOGIN_ENFORCE_MFA')
|
||||||
|
) {
|
||||||
|
console.log('MFA enforced but no MFA methods remain - logging out now');
|
||||||
|
doLogout(navigate);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Memoize the list of currently used MFA factors
|
// Memoize the list of currently used MFA factors
|
||||||
const usedFactors: string[] = useMemo(() => {
|
const usedFactors: string[] = useMemo(() => {
|
||||||
if (isLoading || !data) return [];
|
if (isLoading || !data) return [];
|
||||||
@@ -921,14 +935,14 @@ export default function MFASettings() {
|
|||||||
opened={removeTOTPModalOpen}
|
opened={removeTOTPModalOpen}
|
||||||
setOpen={setRemoveTOTPModalOpen}
|
setOpen={setRemoveTOTPModalOpen}
|
||||||
onReauthFlow={reauthenticate}
|
onReauthFlow={reauthenticate}
|
||||||
onSuccess={refetch}
|
onSuccess={refetchAfterRemoval}
|
||||||
/>
|
/>
|
||||||
<RemoveWebauthnModal
|
<RemoveWebauthnModal
|
||||||
tokenId={webauthnToken}
|
tokenId={webauthnToken}
|
||||||
opened={removeWebauthnModalOpen}
|
opened={removeWebauthnModalOpen}
|
||||||
setOpen={setRemoveWebauthnModalOpen}
|
setOpen={setRemoveWebauthnModalOpen}
|
||||||
onReauthFlow={reauthenticate}
|
onReauthFlow={reauthenticate}
|
||||||
onSuccess={refetch}
|
onSuccess={refetchAfterRemoval}
|
||||||
/>
|
/>
|
||||||
<RegisterTOTPModal
|
<RegisterTOTPModal
|
||||||
opened={registerTOTPModalOpen}
|
opened={registerTOTPModalOpen}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
TextInput
|
TextInput
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import { hideNotification, showNotification } from '@mantine/notifications';
|
import { hideNotification, showNotification } from '@mantine/notifications';
|
||||||
|
import { ErrorBoundary } from '@sentry/react';
|
||||||
import {
|
import {
|
||||||
IconAlertCircle,
|
IconAlertCircle,
|
||||||
IconAt,
|
IconAt,
|
||||||
@@ -29,6 +30,7 @@ import {
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { useCallback, useMemo, useState } from 'react';
|
import { useCallback, useMemo, useState } from 'react';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
import { DefaultFallback } from '../../../../components/Boundary';
|
||||||
import { StylishText } from '../../../../components/items/StylishText';
|
import { StylishText } from '../../../../components/items/StylishText';
|
||||||
import { ProviderLogin, authApi } from '../../../../functions/auth';
|
import { ProviderLogin, authApi } from '../../../../functions/auth';
|
||||||
import { useServerApiState } from '../../../../states/ServerApiState';
|
import { useServerApiState } from '../../../../states/ServerApiState';
|
||||||
@@ -43,6 +45,13 @@ export function SecurityContent() {
|
|||||||
|
|
||||||
const user = useUserState();
|
const user = useUserState();
|
||||||
|
|
||||||
|
const onError = useCallback(
|
||||||
|
(error: unknown, componentStack: string | undefined, eventId: string) => {
|
||||||
|
console.error(`ERR: Error rendering component: ${error}`);
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack>
|
<Stack>
|
||||||
<Accordion multiple defaultValue={['email']}>
|
<Accordion multiple defaultValue={['email']}>
|
||||||
@@ -85,7 +94,12 @@ export function SecurityContent() {
|
|||||||
<StylishText size='lg'>{t`Access Tokens`}</StylishText>
|
<StylishText size='lg'>{t`Access Tokens`}</StylishText>
|
||||||
</Accordion.Control>
|
</Accordion.Control>
|
||||||
<Accordion.Panel>
|
<Accordion.Panel>
|
||||||
<ApiTokenTable only_myself />
|
<ErrorBoundary
|
||||||
|
fallback={<DefaultFallback title={'API Table'} />}
|
||||||
|
onError={onError}
|
||||||
|
>
|
||||||
|
<ApiTokenTable only_myself />
|
||||||
|
</ErrorBoundary>
|
||||||
</Accordion.Panel>
|
</Accordion.Panel>
|
||||||
</Accordion.Item>
|
</Accordion.Item>
|
||||||
{user.isSuperuser() && (
|
{user.isSuperuser() && (
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import type { PluginProps } from '@lib/types/Plugins';
|
import type { PluginProps } from '@lib/types/Plugins';
|
||||||
import type { NavigateFunction } from 'react-router-dom';
|
|
||||||
import { setApiDefaults } from '../App';
|
import { setApiDefaults } from '../App';
|
||||||
import { useGlobalStatusState } from './GlobalStatusState';
|
import { useGlobalStatusState } from './GlobalStatusState';
|
||||||
import { useIconState } from './IconState';
|
import { useIconState } from './IconState';
|
||||||
@@ -45,9 +44,7 @@ export interface ServerAPIProps {
|
|||||||
* Refetch all global state information.
|
* Refetch all global state information.
|
||||||
* Necessary on login, or if locale is changed.
|
* Necessary on login, or if locale is changed.
|
||||||
*/
|
*/
|
||||||
export async function fetchGlobalStates(
|
export async function fetchGlobalStates() {
|
||||||
navigate?: NavigateFunction | undefined
|
|
||||||
) {
|
|
||||||
const { isLoggedIn } = useUserState.getState();
|
const { isLoggedIn } = useUserState.getState();
|
||||||
|
|
||||||
if (!isLoggedIn()) {
|
if (!isLoggedIn()) {
|
||||||
|
|||||||
Reference in New Issue
Block a user