mirror of
https://github.com/inventree/InvenTree.git
synced 2026-01-29 09:34:33 +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:
@@ -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',
|
||||
|
||||
@@ -25,7 +25,8 @@ export enum FlowEnum {
|
||||
MfaAuthenticate = 'mfa_authenticate',
|
||||
Reauthenticate = 'reauthenticate',
|
||||
MfaReauthenticate = 'mfa_reauthenticate',
|
||||
MfaTrust = 'mfa_trust'
|
||||
MfaTrust = 'mfa_trust',
|
||||
MfaRegister = 'mfa_register'
|
||||
}
|
||||
|
||||
export interface Flow {
|
||||
|
||||
@@ -34,6 +34,8 @@ export interface Setting {
|
||||
method?: string;
|
||||
required?: boolean;
|
||||
read_only?: boolean;
|
||||
confirm?: boolean;
|
||||
confirm_text?: string;
|
||||
}
|
||||
|
||||
export interface SettingChoice {
|
||||
|
||||
@@ -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 (
|
||||
<Alert
|
||||
color='red'
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { t } from '@lingui/core/macro';
|
||||
import {
|
||||
Button,
|
||||
Group,
|
||||
@@ -6,6 +7,7 @@ import {
|
||||
Stack,
|
||||
Switch,
|
||||
Text,
|
||||
Tooltip,
|
||||
useMantineColorScheme
|
||||
} from '@mantine/core';
|
||||
import { IconEdit } from '@tabler/icons-react';
|
||||
@@ -20,6 +22,24 @@ import { vars } from '../../theme';
|
||||
import { Boundary } from '../Boundary';
|
||||
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
|
||||
*/
|
||||
@@ -29,8 +49,8 @@ function SettingValue({
|
||||
onToggle
|
||||
}: Readonly<{
|
||||
setting: Setting;
|
||||
onEdit: (setting: Setting) => void;
|
||||
onToggle: (setting: Setting, value: boolean) => void;
|
||||
onEdit: (setting: Setting, confirmed: boolean) => void;
|
||||
onToggle: (setting: Setting, value: boolean, confirmed: boolean) => void;
|
||||
}>) {
|
||||
// Determine the text to display for the setting value
|
||||
const valueText: string = useMemo(() => {
|
||||
@@ -54,7 +74,9 @@ function SettingValue({
|
||||
// Launch the edit dialog for this setting
|
||||
const editSetting = useCallback(() => {
|
||||
if (!setting.read_only) {
|
||||
onEdit(setting);
|
||||
const confirm = confirmSettingChange(setting);
|
||||
if (!confirm.proceed) return;
|
||||
onEdit(setting, confirm.confirmed);
|
||||
}
|
||||
}, [setting, onEdit]);
|
||||
|
||||
@@ -62,7 +84,9 @@ function SettingValue({
|
||||
const toggleSetting = useCallback(
|
||||
(event: any) => {
|
||||
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]
|
||||
@@ -170,8 +194,8 @@ export function SettingItem({
|
||||
}: Readonly<{
|
||||
setting: Setting;
|
||||
shaded: boolean;
|
||||
onEdit: (setting: Setting) => void;
|
||||
onToggle: (setting: Setting, value: boolean) => void;
|
||||
onEdit: (setting: Setting, confirmed: boolean) => void;
|
||||
onToggle: (setting: Setting, value: boolean, confirmed: boolean) => void;
|
||||
}>) {
|
||||
const { colorScheme } = useMantineColorScheme();
|
||||
|
||||
@@ -192,7 +216,18 @@ export function SettingItem({
|
||||
<Text size='xs'>{setting.description}</Text>
|
||||
</Stack>
|
||||
<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>
|
||||
</Group>
|
||||
</Paper>
|
||||
|
||||
@@ -91,7 +91,7 @@ export function SettingList({
|
||||
|
||||
// Callback for editing a single setting instance
|
||||
const onValueEdit = useCallback(
|
||||
(setting: Setting) => {
|
||||
(setting: Setting, confirmed: boolean) => {
|
||||
setSetting(setting);
|
||||
editSettingModal.open();
|
||||
},
|
||||
@@ -100,13 +100,17 @@ export function SettingList({
|
||||
|
||||
// Callback for toggling a single boolean setting instance
|
||||
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
|
||||
.patch(
|
||||
apiUrl(settingsState.endpoint, setting.key, settingsState.pathParams),
|
||||
{
|
||||
value: value
|
||||
}
|
||||
data
|
||||
)
|
||||
.then(() => {
|
||||
notifications.hide('setting');
|
||||
|
||||
@@ -90,7 +90,7 @@ export async function doBasicLogin(
|
||||
|
||||
const host: string = getHost();
|
||||
|
||||
// Attempt login with
|
||||
// Attempt login with basic info
|
||||
await api
|
||||
.post(
|
||||
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) {
|
||||
await fetchUserState();
|
||||
// see if mfa registration is required
|
||||
await fetchGlobalStates(navigate);
|
||||
await fetchGlobalStates();
|
||||
observeProfile();
|
||||
} else if (!success) {
|
||||
clearUserState();
|
||||
@@ -238,6 +244,26 @@ export const doSimpleLogin = async (email: string) => {
|
||||
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() {
|
||||
// overwrite language and theme info in session with profile info
|
||||
|
||||
@@ -326,19 +352,14 @@ export async function handleMfaLogin(
|
||||
) {
|
||||
const { setAuthContext } = useServerApiState.getState();
|
||||
|
||||
const result = await authApi(
|
||||
apiUrl(ApiEndpoints.auth_login_2fa),
|
||||
undefined,
|
||||
'post',
|
||||
{
|
||||
code: values.code
|
||||
}
|
||||
)
|
||||
return await authApi(apiUrl(ApiEndpoints.auth_login_2fa), undefined, 'post', {
|
||||
code: values.code
|
||||
})
|
||||
.then((response) => {
|
||||
handleSuccessFullAuth(response, navigate, location, setError);
|
||||
return true;
|
||||
})
|
||||
.catch((err) => {
|
||||
.catch(async (err) => {
|
||||
// Already logged in, but with a different session
|
||||
if (err?.response?.status == 409) {
|
||||
notifications.show({
|
||||
@@ -354,11 +375,12 @@ export async function handleMfaLogin(
|
||||
);
|
||||
if (mfa_trust?.is_pending) {
|
||||
setAuthContext(err.response.data.data);
|
||||
authApi(apiUrl(ApiEndpoints.auth_trust), undefined, 'post', {
|
||||
await authApi(apiUrl(ApiEndpoints.auth_trust), undefined, 'post', {
|
||||
trust: values.remember ?? false
|
||||
}).then((response) => {
|
||||
handleSuccessFullAuth(response, navigate, location, setError);
|
||||
});
|
||||
return true;
|
||||
}
|
||||
} else {
|
||||
const errors = err.response?.data?.errors;
|
||||
@@ -371,7 +393,6 @@ export async function handleMfaLogin(
|
||||
}
|
||||
return false;
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -382,7 +403,7 @@ export async function handleMfaLogin(
|
||||
* - An existing CSRF cookie is stored in the browser
|
||||
*/
|
||||
export const checkLoginState = async (
|
||||
navigate: any,
|
||||
navigate: NavigateFunction,
|
||||
redirect?: any,
|
||||
no_redirect?: boolean
|
||||
) => {
|
||||
@@ -396,22 +417,25 @@ export const checkLoginState = async (
|
||||
const { isLoggedIn, fetchUserState } = useUserState.getState();
|
||||
|
||||
// Callback function when login is successful
|
||||
const loginSuccess = () => {
|
||||
const loginSuccess = async () => {
|
||||
setLoginChecked(true);
|
||||
showLoginNotification({
|
||||
title: t`Logged In`,
|
||||
message: t`Successfully logged in`
|
||||
});
|
||||
MfaSetupOk(navigate).then(async (isOk) => {
|
||||
if (isOk) {
|
||||
observeProfile();
|
||||
await fetchGlobalStates();
|
||||
|
||||
observeProfile();
|
||||
|
||||
fetchGlobalStates(navigate);
|
||||
followRedirect(navigate, redirect);
|
||||
followRedirect(navigate, redirect);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
if (isLoggedIn()) {
|
||||
// Already logged in
|
||||
loginSuccess();
|
||||
await loginSuccess();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -420,7 +444,7 @@ export const checkLoginState = async (
|
||||
await fetchUserState();
|
||||
|
||||
if (isLoggedIn()) {
|
||||
loginSuccess();
|
||||
await loginSuccess();
|
||||
} else if (!no_redirect) {
|
||||
setLoginChecked(true);
|
||||
navigate('/login', { state: redirect });
|
||||
@@ -429,8 +453,8 @@ export const checkLoginState = async (
|
||||
};
|
||||
|
||||
function handleSuccessFullAuth(
|
||||
response?: any,
|
||||
navigate?: NavigateFunction,
|
||||
response: any,
|
||||
navigate: NavigateFunction,
|
||||
location?: Location<any>,
|
||||
setError?: (message: string | undefined) => void
|
||||
) {
|
||||
@@ -447,12 +471,16 @@ function handleSuccessFullAuth(
|
||||
}
|
||||
setAuthenticated();
|
||||
|
||||
fetchUserState().finally(() => {
|
||||
observeProfile();
|
||||
fetchGlobalStates(navigate);
|
||||
// see if mfa registration is required
|
||||
MfaSetupOk(navigate).then(async (isOk) => {
|
||||
if (isOk) {
|
||||
await fetchUserState();
|
||||
observeProfile();
|
||||
await fetchGlobalStates();
|
||||
|
||||
if (navigate && location) {
|
||||
followRedirect(navigate, location?.state);
|
||||
if (location !== undefined) {
|
||||
followRedirect(navigate, location?.state);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -545,7 +573,12 @@ export function handleVerifyTotp(
|
||||
authApi(apiUrl(ApiEndpoints.auth_totp), undefined, 'post', {
|
||||
code: value
|
||||
}).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(
|
||||
navigate?: NavigateFunction,
|
||||
location?: Location<any>
|
||||
navigate: NavigateFunction,
|
||||
location: Location<any>
|
||||
) {
|
||||
const { setAuthContext } = useServerApiState.getState();
|
||||
|
||||
|
||||
@@ -28,12 +28,14 @@ import {
|
||||
} from '@tabler/icons-react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { api, queryClient } from '../../../../App';
|
||||
import { CopyButton } from '../../../../components/buttons/CopyButton';
|
||||
import { StylishText } from '../../../../components/items/StylishText';
|
||||
import { authApi } from '../../../../functions/auth';
|
||||
import { authApi, doLogout } from '../../../../functions/auth';
|
||||
import { useServerApiState } from '../../../../states/ServerApiState';
|
||||
import { useGlobalSettingsState } from '../../../../states/SettingsStates';
|
||||
import { QrRegistrationForm } from './QrRegistrationForm';
|
||||
import { parseDate } from './SecurityContent';
|
||||
|
||||
@@ -697,6 +699,7 @@ export default function MFASettings() {
|
||||
const [auth_config] = useServerApiState(
|
||||
useShallow((state) => [state.auth_config])
|
||||
);
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Fetch list of MFA methods currently configured for the user
|
||||
const { isLoading, data, refetch } = useQuery({
|
||||
@@ -708,6 +711,17 @@ export default function MFASettings() {
|
||||
.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
|
||||
const usedFactors: string[] = useMemo(() => {
|
||||
if (isLoading || !data) return [];
|
||||
@@ -921,14 +935,14 @@ export default function MFASettings() {
|
||||
opened={removeTOTPModalOpen}
|
||||
setOpen={setRemoveTOTPModalOpen}
|
||||
onReauthFlow={reauthenticate}
|
||||
onSuccess={refetch}
|
||||
onSuccess={refetchAfterRemoval}
|
||||
/>
|
||||
<RemoveWebauthnModal
|
||||
tokenId={webauthnToken}
|
||||
opened={removeWebauthnModalOpen}
|
||||
setOpen={setRemoveWebauthnModalOpen}
|
||||
onReauthFlow={reauthenticate}
|
||||
onSuccess={refetch}
|
||||
onSuccess={refetchAfterRemoval}
|
||||
/>
|
||||
<RegisterTOTPModal
|
||||
opened={registerTOTPModalOpen}
|
||||
|
||||
@@ -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 (
|
||||
<Stack>
|
||||
<Accordion multiple defaultValue={['email']}>
|
||||
@@ -85,7 +94,12 @@ export function SecurityContent() {
|
||||
<StylishText size='lg'>{t`Access Tokens`}</StylishText>
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<ApiTokenTable only_myself />
|
||||
<ErrorBoundary
|
||||
fallback={<DefaultFallback title={'API Table'} />}
|
||||
onError={onError}
|
||||
>
|
||||
<ApiTokenTable only_myself />
|
||||
</ErrorBoundary>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
{user.isSuperuser() && (
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { PluginProps } from '@lib/types/Plugins';
|
||||
import type { NavigateFunction } from 'react-router-dom';
|
||||
import { setApiDefaults } from '../App';
|
||||
import { useGlobalStatusState } from './GlobalStatusState';
|
||||
import { useIconState } from './IconState';
|
||||
@@ -45,9 +44,7 @@ export interface ServerAPIProps {
|
||||
* Refetch all global state information.
|
||||
* Necessary on login, or if locale is changed.
|
||||
*/
|
||||
export async function fetchGlobalStates(
|
||||
navigate?: NavigateFunction | undefined
|
||||
) {
|
||||
export async function fetchGlobalStates() {
|
||||
const { isLoggedIn } = useUserState.getState();
|
||||
|
||||
if (!isLoggedIn()) {
|
||||
|
||||
Reference in New Issue
Block a user