2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-06-17 12:35:46 +00:00

add mfa add and remove screens

This commit is contained in:
Matthias Mair
2025-01-12 04:58:22 +01:00
parent 72f89eaf15
commit 6fe06b536f
3 changed files with 429 additions and 27 deletions

View File

@ -24,6 +24,10 @@ export enum ApiEndpoints {
auth_session = 'auth/v1/auth/session',
auth_signup = 'auth/v1/auth/signup',
auth_authenticators = 'auth/v1/account/authenticators',
auth_recovery = 'auth/v1/account/authenticators/recovery-codes',
auth_mfa_reauthenticate = 'auth/v1/auth/2fa/reauthenticate',
auth_totp = 'auth/v1/account/authenticators/totp',
auth_reauthenticate = 'auth/v1/auth/reauthenticate',
auth_email = 'auth/v1/account/email',
auth_providers = 'auth/v1/account/providers',
auth_provider_redirect = 'auth/v1/auth/provider/redirect',

View File

@ -3,9 +3,11 @@ import {
Alert,
Badge,
Button,
Code,
Grid,
Group,
Loader,
Modal,
Radio,
Stack,
Table,
@ -13,16 +15,19 @@ import {
TextInput,
Title
} from '@mantine/core';
import { IconAlertCircle, IconAt } from '@tabler/icons-react';
import { useDisclosure } from '@mantine/hooks';
import { showNotification } from '@mantine/notifications';
import { IconAlertCircle, IconAt, IconX } from '@tabler/icons-react';
import { useQuery } from '@tanstack/react-query';
import { useMemo, useState } from 'react';
import { api } from '../../../../App';
import { QRCode } from '../../../../components/barcodes/QRCode';
import { YesNoButton } from '../../../../components/buttons/YesNoButton';
import { ApiEndpoints } from '../../../../enums/ApiEndpoints';
import { ProviderLogin, authApi } from '../../../../functions/auth';
import { apiUrl, useServerApiState } from '../../../../states/ApiState';
import type { AuthConfig, Provider } from '../../../../states/states';
import { useReauth } from './useConfirm';
export function SecurityContent() {
const [auth_config, sso_enabled, mfa_enabled] = useServerApiState((state) => [
@ -34,7 +39,7 @@ export function SecurityContent() {
return (
<Stack>
<Title order={5}>
<Trans>Email</Trans>
<Trans>Email Addresses</Trans>
</Title>
<EmailSection />
<Title order={5}>
@ -63,7 +68,7 @@ export function SecurityContent() {
color='yellow'
>
<Trans>
Multifactor authentication is not configured for your account{' '}
Multifactor authentication is not enabled for this server
</Trans>
</Alert>
)}
@ -103,7 +108,7 @@ function EmailSection() {
<Grid.Col span={6}>
{data.length == 0 ? (
<Text>
<Trans>Currently no emails are registered</Trans>
<Trans>Currently no email adreesses are registered</Trans>
</Text>
) : (
<Radio.Group
@ -285,7 +290,15 @@ function ProviderSection({
}
function MfaSection() {
const { isLoading, data } = useQuery({
const [getReauthText, ReauthModal] = useReauth();
const [recoveryCodes, setRecoveryCodes] = useState<
Recoverycodes | undefined
>();
const [
recoveryCodesOpen,
{ open: openRecoveryCodes, close: closeRecoveryCodes }
] = useDisclosure(false);
const { isLoading, data, refetch } = useQuery({
queryKey: ['mfa-list'],
queryFn: () =>
api
@ -293,6 +306,36 @@ function MfaSection() {
.then((res) => res.data.data)
});
function showRecoveryCodes(codes: Recoverycodes) {
setRecoveryCodes(codes);
openRecoveryCodes();
}
const removeTotp = () => {
runActionWithFallback(
() =>
authApi(apiUrl(ApiEndpoints.auth_totp), undefined, 'delete').then(
() => {
refetch();
return ResultType.success;
}
),
getReauthText
);
};
const viewRecoveryCodes = () => {
runActionWithFallback(
() =>
authApi(apiUrl(ApiEndpoints.auth_recovery), undefined, 'get').then(
(res) => {
showRecoveryCodes(res.data.data);
return ResultType.success;
}
),
getReauthText
);
};
const parseDate = (date: number) =>
date == null ? 'Never' : new Date(date * 1000).toLocaleString();
@ -303,39 +346,289 @@ function MfaSection() {
<Table.Td>{token.type}</Table.Td>
<Table.Td>{parseDate(token.last_used_at)}</Table.Td>
<Table.Td>{parseDate(token.created_at)}</Table.Td>
<Table.Td>
{token.type == 'totp' && (
<Button color='red' onClick={removeTotp}>
<Trans>Remove</Trans>
</Button>
)}
{token.type == 'recovery_codes' && (
<Button onClick={viewRecoveryCodes}>
<Trans>View</Trans>
</Button>
)}
</Table.Td>
</Table.Tr>
));
}, [data, isLoading]);
const usedFactors: string[] = useMemo(() => {
if (isLoading || !data) return [];
return data.map((token: any) => token.type);
}, [data]);
if (isLoading) return <Loader />;
if (data.length == 0)
return (
<Alert icon={<IconAlertCircle size='1rem' />} color='green'>
<Trans>No factors configured</Trans>
</Alert>
return (
<>
<ReauthModal />
<Grid>
<Grid.Col span={6}>
{data.length == 0 ? (
<Alert icon={<IconAlertCircle size='1rem' />} color='yellow'>
<Trans>No factors configured</Trans>
</Alert>
) : (
<Table stickyHeader striped highlightOnHover withTableBorder>
<Table.Thead>
<Table.Tr>
<Table.Th>
<Trans>Type</Trans>
</Table.Th>
<Table.Th>
<Trans>Last used at</Trans>
</Table.Th>
<Table.Th>
<Trans>Created at</Trans>
</Table.Th>
<Table.Th>
<Trans>Actions</Trans>
</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>{rows}</Table.Tbody>
</Table>
)}
</Grid.Col>
<Grid.Col span={6}>
<MfaAddSection
usedFactors={usedFactors}
refetch={refetch}
showRecoveryCodes={showRecoveryCodes}
/>
<Modal
opened={recoveryCodesOpen}
onClose={() => {
refetch();
closeRecoveryCodes();
}}
title={t`Recovery Codes`}
centered
>
<Title order={3}>
<Trans>Unused Codes</Trans>
</Title>
<Code>{recoveryCodes?.unused_codes?.join('\n')}</Code>
<Title order={3}>
<Trans>Used Codes</Trans>
</Title>
<Code>{recoveryCodes?.used_codes?.join('\n')}</Code>
</Modal>
</Grid.Col>
</Grid>
</>
);
}
enum ResultType {
success = 0,
reauth = 1,
mfareauth = 2,
error = 3
}
export interface Recoverycodes {
type: string;
created_at: number;
last_used_at: null;
total_code_count: number;
unused_code_count: number;
unused_codes: string[];
used_code_count: number;
used_codes: string[];
}
function MfaAddSection({
usedFactors,
refetch,
showRecoveryCodes
}: Readonly<{
usedFactors: string[];
refetch: () => void;
showRecoveryCodes: (codes: Recoverycodes) => void;
}>) {
const [totpQrOpen, { open: openTotpQr, close: closeTotpQr }] =
useDisclosure(false);
const [totpQr, setTotpQr] = useState<{ totp_url: string; secret: string }>();
const [value, setValue] = useState('');
const [getReauthText, ReauthModal] = useReauth();
const registerRecoveryCodes = async () => {
await runActionWithFallback(
() =>
authApi(apiUrl(ApiEndpoints.auth_recovery), undefined, 'post')
.then((res) => {
showRecoveryCodes(res.data.data);
return ResultType.success;
})
.catch((err) => {
showNotification({
title: t`Error while registering recovery codes`,
message: err.response.data.errors
.map((error: any) => error.message)
.join('\n'),
color: 'red',
icon: <IconX />
});
return ResultType.error;
}),
getReauthText
);
};
const registerTotp = async () => {
await runActionWithFallback(
() =>
authApi(apiUrl(ApiEndpoints.auth_totp), undefined, 'get')
.then(() => ResultType.error)
.catch((err) => {
if (err.status == 404 && err.response.data.meta.secret) {
setTotpQr(err.response.data.meta);
openTotpQr();
return ResultType.success;
}
return ResultType.error;
}),
getReauthText
);
};
const possibleFactors = useMemo(() => {
return [
{
type: 'totp',
name: t`TOTP`,
description: t`Time-based One-Time Password`,
function: registerTotp,
used: usedFactors?.includes('totp')
},
{
type: 'recovery_codes',
name: t`Recovery Codes`,
description: t`One-Time pre-generated recovery codes`,
function: registerRecoveryCodes,
used: usedFactors?.includes('recovery_codes')
}
];
}, [usedFactors]);
return (
<Table stickyHeader striped highlightOnHover withTableBorder>
<Table.Thead>
<Table.Tr>
<Table.Th>
<Trans>Type</Trans>
</Table.Th>
<Table.Th>
<Trans>Last used at</Trans>
</Table.Th>
<Table.Th>
<Trans>Created at</Trans>
</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>{rows}</Table.Tbody>
</Table>
<Stack>
<ReauthModal />
<Text>Add Factor</Text>
{possibleFactors.map((factor) => (
<Button
key={factor.type}
onClick={factor.function}
disabled={factor.used}
variant='outline'
>
{factor.name}
</Button>
))}
<Modal
opened={totpQrOpen}
onClose={closeTotpQr}
title={t`Register TOTP token`}
>
<Stack>
<QRCode data={totpQr?.totp_url} />
<Text>
<Trans>Secret</Trans>
<br />
{totpQr?.secret}
</Text>
<TextInput
required
label={t`One-Time Password`}
description={t`Enter the TOTP code to ensure it registered correctly`}
value={value}
onChange={(event) => setValue(event.currentTarget.value)}
/>
<Button
fullWidth
onClick={() =>
runActionWithFallback(
() =>
authApi(apiUrl(ApiEndpoints.auth_totp), undefined, 'post', {
code: value
}).then(() => {
closeTotpQr();
refetch();
return ResultType.success;
}),
getReauthText
)
}
>
<Trans>Submit</Trans>
</Button>
</Stack>
</Modal>
</Stack>
);
}
async function runActionWithFallback(
action: () => Promise<ResultType>,
getReauthText: (props: any) => any
) {
const rslt = await action().catch((err) => {
// check if we need to re-authenticate
if (err.status == 401) {
if (
err.response.data.data.flows.find(
(flow: any) => flow.id == 'mfa_reauthenticate'
)
) {
return ResultType.mfareauth;
} else if (
err.response.data.data.flows.find(
(flow: any) => flow.id == 'reauthenticate'
)
) {
return ResultType.reauth;
} else {
return ResultType.error;
}
} else {
return ResultType.error;
}
});
if (rslt == ResultType.mfareauth) {
authApi(apiUrl(ApiEndpoints.auth_mfa_reauthenticate), undefined, 'post', {
code: await getReauthText({
label: t`TOTP Code`,
name: 'TOTP',
description: t`Enter your TOTP or recovery code`
})
}).then(() => {
action();
});
} else if (rslt == ResultType.reauth) {
authApi(apiUrl(ApiEndpoints.auth_reauthenticate), undefined, 'post', {
password: await getReauthText({
label: t`Password`,
name: 'password',
description: t`Enter your password`
})
}).then(() => {
action();
});
}
}
function TokenSection() {
const { isLoading, data, refetch } = useQuery({
queryKey: ['token-list'],

View File

@ -0,0 +1,105 @@
import { Trans, t } from '@lingui/macro';
import { Button, Group, Modal, Stack, TextInput } from '@mantine/core';
import { useState } from 'react';
/* Adapted from https://daveteu.medium.com/react-custom-confirmation-box-458cceba3f7b */
const createPromise = () => {
let resolver: any;
return [
new Promise((resolve, reject) => {
resolver = resolve;
}),
resolver
];
};
/* Adapted from https://daveteu.medium.com/react-custom-confirmation-box-458cceba3f7b */
export const useConfirm = () => {
const [open, setOpen] = useState(false);
const [resolver, setResolver] = useState({ resolver: null });
const [label, setLabel] = useState('');
const getConfirmation = async (text: string) => {
setLabel(text);
setOpen(true);
const [promise, resolve] = await createPromise();
setResolver({ resolve });
return promise;
};
const onClick = async (status: boolean) => {
setOpen(false);
resolver.resolve(status);
};
const Confirmation = () => (
<Modal opened={open} onClose={() => setOpen(false)}>
{label}
<Button onClick={() => onClick(false)}> Cancel </Button>
<Button onClick={() => onClick(true)}> OK </Button>
</Modal>
);
return [getConfirmation, Confirmation];
};
type InputProps = {
label: string;
name: string;
description: string;
};
export const useReauth = () => {
const [inputProps, setInputProps] = useState<InputProps>({
label: '',
name: '',
description: ''
});
const [value, setValue] = useState('');
const [open, setOpen] = useState(false);
const [resolver, setResolver] = useState({ resolver: null });
const getReauthText = async (props: InputProps) => {
setInputProps(props);
setOpen(true);
const [promise, resolve] = await createPromise();
setResolver({ resolve });
return promise;
};
const onClick = async (result: string, positive: boolean) => {
setOpen(false);
resolver.resolve(result, positive);
};
const ReauthModal = () => (
<Modal
opened={open}
onClose={() => setOpen(false)}
title={t`Reauthentication`}
>
<Stack>
<TextInput
required
label={inputProps.label}
name={inputProps.name}
description={inputProps.description}
value={value}
onChange={(event) => setValue(event.currentTarget.value)}
/>
<Group justify='space-between'>
<Button onClick={() => onClick('', false)} color='red'>
<Trans>Cancel</Trans>
</Button>
<Button onClick={() => onClick(value, true)}>
<Trans>OK</Trans>
</Button>
</Group>
</Stack>
</Modal>
);
return [getReauthText, ReauthModal];
};