diff --git a/src/backend/InvenTree/common/setting/system.py b/src/backend/InvenTree/common/setting/system.py index 087e494400eb..a216b5d1f5ac 100644 --- a/src/backend/InvenTree/common/setting/system.py +++ b/src/backend/InvenTree/common/setting/system.py @@ -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 @@ class SystemSetId: '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'), diff --git a/src/backend/InvenTree/common/setting/type.py b/src/backend/InvenTree/common/setting/type.py index 5ec029f99892..197153366318 100644 --- a/src/backend/InvenTree/common/setting/type.py +++ b/src/backend/InvenTree/common/setting/type.py @@ -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): diff --git a/src/backend/InvenTree/common/tests.py b/src/backend/InvenTree/common/tests.py index 4cc16136bd16..53a3f9e3462c 100644 --- a/src/backend/InvenTree/common/tests.py +++ b/src/backend/InvenTree/common/tests.py @@ -408,6 +408,8 @@ def run_settings_check(self, key, setting): 'requires_restart', 'after_save', 'before_save', + 'confirm', + 'confirm_text', ] for k in setting: diff --git a/src/frontend/lib/enums/ApiEndpoints.tsx b/src/frontend/lib/enums/ApiEndpoints.tsx index c46d50af7153..5c61f4e6ec32 100644 --- a/src/frontend/lib/enums/ApiEndpoints.tsx +++ b/src/frontend/lib/enums/ApiEndpoints.tsx @@ -20,6 +20,7 @@ export enum ApiEndpoints { user_simple_login = 'email/generate/', // User auth endpoints + auth_base = '/auth/', user_reset = 'auth/v1/auth/password/request', user_reset_set = 'auth/v1/auth/password/reset', auth_pwd_change = 'auth/v1/account/password/change', diff --git a/src/frontend/src/components/Boundary.tsx b/src/frontend/src/components/Boundary.tsx index 119b8fc9ebfd..13e00984c6e9 100644 --- a/src/frontend/src/components/Boundary.tsx +++ b/src/frontend/src/components/Boundary.tsx @@ -4,7 +4,9 @@ import { ErrorBoundary, type FallbackRender } from '@sentry/react'; import { IconExclamationCircle } from '@tabler/icons-react'; import { type ReactNode, useCallback } from 'react'; -function DefaultFallback({ title }: Readonly<{ title: string }>): ReactNode { +export function DefaultFallback({ + title +}: Readonly<{ title: string }>): ReactNode { return ( { return mail; }; +function checkMfaSetup(navigate?: NavigateFunction) { + api + .get(apiUrl(ApiEndpoints.auth_base)) + .then(() => {}) + .catch((err) => { + if (err?.response?.status == 401) { + if (navigate != undefined) { + navigate('/mfa-setup'); + } else { + alert( + 'MFA setup required, but no navigation possible - please reload the website' + ); + } + } + }); +} + function observeProfile() { // overwrite language and theme info in session with profile info @@ -446,6 +466,10 @@ function handleSuccessFullAuth( } setAuthenticated(); + // see if mfa registration is required + checkMfaSetup(navigate); + + // get required states fetchUserState().finally(() => { observeProfile(); fetchGlobalStates(navigate); diff --git a/src/frontend/src/pages/Index/Settings/AccountSettings/SecurityContent.tsx b/src/frontend/src/pages/Index/Settings/AccountSettings/SecurityContent.tsx index abcc51c0cde7..fa0b8156aa15 100644 --- a/src/frontend/src/pages/Index/Settings/AccountSettings/SecurityContent.tsx +++ b/src/frontend/src/pages/Index/Settings/AccountSettings/SecurityContent.tsx @@ -20,6 +20,7 @@ import { TextInput } from '@mantine/core'; import { hideNotification, showNotification } from '@mantine/notifications'; +import { ErrorBoundary } from '@sentry/react'; import { IconAlertCircle, IconAt, @@ -29,6 +30,7 @@ import { import { useQuery } from '@tanstack/react-query'; import { useCallback, useMemo, useState } from 'react'; import { useShallow } from 'zustand/react/shallow'; +import { DefaultFallback } from '../../../../components/Boundary'; import { StylishText } from '../../../../components/items/StylishText'; import { ProviderLogin, authApi } from '../../../../functions/auth'; import { useServerApiState } from '../../../../states/ServerApiState'; @@ -43,6 +45,13 @@ export function SecurityContent() { const user = useUserState(); + const onError = useCallback( + (error: unknown, componentStack: string | undefined, eventId: string) => { + console.error(`ERR: Error rendering component: ${error}`); + }, + [] + ); + return ( @@ -85,7 +94,12 @@ export function SecurityContent() { {t`Access Tokens`} - + } + onError={onError} + > + + {user.isSuperuser() && (