2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-10-29 20:30:39 +00:00
Files
InvenTree/src/frontend/src/pages/Index/Settings/AccountSettings/SecurityContent.tsx
Matthias Mair 2e4b1d65f7 feat(frontend): add passkey/webauthn for secondary MFA (#9729)
* bump allauth

* add trust

* add device trust handling

* fix style

* [FR] Add passkey as a factor
Fixes #4002

* add registration

* allow better testing

* add mfa context

* fix login

* add changelog entry

* fix registration

* remove multi device packages

* move to helper

* handle mfa trust

* simplify page fnc
2025-10-28 18:52:39 +11:00

875 lines
25 KiB
TypeScript

import { create } from '@github/webauthn-json/browser-ponyfill';
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { apiUrl } from '@lib/functions/Api';
import { type AuthConfig, type AuthProvider, FlowEnum } from '@lib/types/Auth';
import { t } from '@lingui/core/macro';
import { Trans } from '@lingui/react/macro';
import {
Accordion,
ActionIcon,
Alert,
Badge,
Button,
Code,
Grid,
Group,
Loader,
Modal,
Radio,
SimpleGrid,
Stack,
Table,
Text,
TextInput,
Tooltip
} from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import { hideNotification, showNotification } from '@mantine/notifications';
import {
IconAlertCircle,
IconAt,
IconExclamationCircle,
IconRefresh,
IconX
} from '@tabler/icons-react';
import { useQuery } from '@tanstack/react-query';
import { useCallback, useMemo, useState } from 'react';
import { useShallow } from 'zustand/react/shallow';
import { api } from '../../../../App';
import { StylishText } from '../../../../components/items/StylishText';
import { ProviderLogin, authApi } from '../../../../functions/auth';
import { useServerApiState } from '../../../../states/ServerApiState';
import { useUserState } from '../../../../states/UserState';
import { ApiTokenTable } from '../../../../tables/settings/ApiTokenTable';
import { QrRegistrationForm } from './QrRegistrationForm';
import { useReauth } from './useConfirm';
export function SecurityContent() {
const [auth_config, sso_enabled] = useServerApiState(
useShallow((state) => [state.auth_config, state.sso_enabled])
);
const user = useUserState();
return (
<Stack>
<Accordion multiple defaultValue={['email']}>
<Accordion.Item value='email'>
<Accordion.Control>
<StylishText size='lg'>{t`Email Addresses`}</StylishText>
</Accordion.Control>
<Accordion.Panel>
<EmailSection />
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value='sso'>
<Accordion.Control>
<StylishText size='lg'>{t`Single Sign On`}</StylishText>
</Accordion.Control>
<Accordion.Panel>
{sso_enabled() ? (
<ProviderSection auth_config={auth_config} />
) : (
<Alert
icon={<IconAlertCircle size='1rem' />}
title={t`Not enabled`}
color='yellow'
>
<Trans>Single Sign On is not enabled for this server </Trans>
</Alert>
)}
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value='mfa'>
<Accordion.Control>
<StylishText size='lg'>{t`Multi-Factor Authentication`}</StylishText>
</Accordion.Control>
<Accordion.Panel>
<MfaSection />
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value='token'>
<Accordion.Control>
<StylishText size='lg'>{t`Access Tokens`}</StylishText>
</Accordion.Control>
<Accordion.Panel>
<ApiTokenTable only_myself />
</Accordion.Panel>
</Accordion.Item>
{user.isSuperuser() && (
<Accordion.Item value='session'>
<Accordion.Control>
<StylishText size='lg'>{t`Session Information`}</StylishText>
</Accordion.Control>
<Accordion.Panel>
<AuthContextSection />
</Accordion.Panel>
</Accordion.Item>
)}
</Accordion>
</Stack>
);
}
function AuthContextSection() {
const [auth_context, setAuthContext] = useServerApiState(
useShallow((state) => [state.auth_context, state.setAuthContext])
);
const fetchAuthContext = useCallback(() => {
authApi(apiUrl(ApiEndpoints.auth_session)).then((resp) => {
setAuthContext(resp.data.data);
});
}, [setAuthContext]);
return (
<Stack gap='xs'>
<Group>
<ActionIcon
onClick={fetchAuthContext}
variant='transparent'
aria-label='refresh-auth-context'
>
<IconRefresh />
</ActionIcon>
</Group>
<Table>
<Table.Thead>
<Table.Tr>
<Table.Th>{t`Timestamp`}</Table.Th>
<Table.Th>{t`Method`}</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{auth_context?.methods?.map((method: any, index: number) => (
<Table.Tr key={`auth-method-${index}`}>
<Table.Td>{parseDate(method.at)}</Table.Td>
<Table.Td>{method.method}</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Stack>
);
}
function EmailSection() {
const [selectedEmail, setSelectedEmail] = useState<string>('');
const [newEmailValue, setNewEmailValue] = useState('');
const { isLoading, data, refetch } = useQuery({
queryKey: ['emails'],
queryFn: () =>
authApi(apiUrl(ApiEndpoints.auth_email)).then((res) => res.data.data)
});
const emailAvailable = useMemo(() => {
return data == undefined || data.length == 0;
}, [data]);
function runServerAction(
action: 'patch' | 'post' | 'put' | 'delete' = 'post',
data?: any
) {
const vals: any = data || { email: selectedEmail };
return authApi(apiUrl(ApiEndpoints.auth_email), undefined, action, vals)
.then(() => {
refetch();
})
.catch((err) => {
hideNotification('email-error');
showNotification({
id: 'email-error',
title: t`Error`,
message: t`Error while updating email`,
color: 'red'
});
});
}
if (isLoading) return <Loader />;
return (
<SimpleGrid cols={{ xs: 1, md: 2 }} spacing='sm'>
{emailAvailable ? (
<Stack gap='xs'>
<Alert
icon={<IconAlertCircle size='1rem' />}
title={t`Not Configured`}
color='yellow'
>
<Trans>Currently no email addresses are registered.</Trans>
</Alert>
</Stack>
) : (
<Radio.Group
value={selectedEmail}
onChange={setSelectedEmail}
name='email_accounts'
label={t`The following email addresses are associated with your account:`}
>
<Stack mt='xs'>
{data.map((email: any) => (
<Radio
key={email.email}
value={String(email.email)}
label={
<Group justify='space-apart'>
{email.email}
<Group justify='right'>
{email.primary && (
<Badge color='blue'>
<Trans>Primary</Trans>
</Badge>
)}
{email.verified ? (
<Badge color='green'>
<Trans>Verified</Trans>
</Badge>
) : (
<Badge color='yellow'>
<Trans>Unverified</Trans>
</Badge>
)}
</Group>
</Group>
}
/>
))}
<Group>
<Button
onClick={() =>
runServerAction('patch', {
email: selectedEmail,
primary: true
})
}
disabled={!selectedEmail}
>
<Trans>Make Primary</Trans>
</Button>
<Button
onClick={() => runServerAction('put')}
disabled={!selectedEmail}
>
<Trans>Re-send Verification</Trans>
</Button>
<Button
onClick={() => runServerAction('delete')}
disabled={!selectedEmail}
color='red'
>
<Trans>Remove</Trans>
</Button>
</Group>
</Stack>
</Radio.Group>
)}
<Stack>
<StylishText size='md'>{t`Add Email Address`}</StylishText>
<TextInput
label={t`E-Mail`}
placeholder={t`E-Mail address`}
leftSection={<IconAt />}
aria-label='email-address-input'
value={newEmailValue}
onChange={(event) => setNewEmailValue(event.currentTarget.value)}
/>
<Button
aria-label='email-address-submit'
onClick={() =>
runServerAction('post', { email: newEmailValue }).catch((err) => {
if (err.status == 400) {
showNotification({
title: t`Error while adding email`,
message: err.response.data.errors
.map((error: any) => error.message)
.join('\n'),
color: 'red',
icon: <IconX />
});
}
})
}
>
<Trans>Add Email</Trans>
</Button>
</Stack>
</SimpleGrid>
);
}
function ProviderButton({ provider }: Readonly<{ provider: AuthProvider }>) {
return (
<Button
key={provider.id}
variant='outline'
onClick={() => ProviderLogin(provider, 'connect')}
>
<Group justify='space-between'>{provider.name}</Group>
</Button>
);
}
function ProviderSection({
auth_config
}: Readonly<{ auth_config: AuthConfig | undefined }>) {
const [value, setValue] = useState<string>('');
const { isLoading, data, refetch } = useQuery({
queryKey: ['provider-list'],
queryFn: () =>
authApi(apiUrl(ApiEndpoints.auth_providers))
.then((res) => res?.data?.data ?? [])
.catch(() => [])
});
const availableProviders = useMemo(() => {
if (!auth_config || !data) return [];
const configuredProviders = data.map((item: any) => item.provider.id);
return auth_config.socialaccount.providers.filter(
(provider: any) => !configuredProviders.includes(provider.id)
);
}, [auth_config, data]);
function removeProvider() {
const [uid, provider] = value.split('$');
authApi(apiUrl(ApiEndpoints.auth_providers), undefined, 'delete', {
provider,
account: uid
})
.then(() => {
refetch();
})
.catch((res) => console.log(res.data));
}
if (isLoading) return <Loader />;
return (
<Grid>
<Grid.Col span={6}>
{data.length == 0 ? (
<Stack gap='xs'>
<Alert
icon={<IconAlertCircle size='1rem' />}
title={t`Not Configured`}
color='yellow'
>
<Trans>There are no providers connected to this account.</Trans>
</Alert>
</Stack>
) : (
<Stack>
<Radio.Group
value={value}
onChange={setValue}
name='sso_accounts'
label={t`You can sign in to your account using any of the following providers`}
>
<Stack mt='xs'>
{data.map((link: any) => (
<Radio
key={link.uid}
value={[link.uid, link.provider.id].join('$')}
label={`${link.provider.name}: ${link.display}`}
/>
))}
</Stack>
</Radio.Group>
<Button onClick={removeProvider}>
<Trans>Remove Provider Link</Trans>
</Button>
</Stack>
)}
</Grid.Col>
<Grid.Col span={6}>
<Stack>
<Text>Add SSO Account</Text>
{availableProviders === undefined ? (
<Text>
<Trans>Loading</Trans>
</Text>
) : (
<Stack gap='xs'>
{availableProviders.map((provider: any) => (
<ProviderButton key={provider.id} provider={provider} />
))}
</Stack>
)}
</Stack>
</Grid.Col>
</Grid>
);
}
function MfaSection() {
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
.get(apiUrl(ApiEndpoints.auth_authenticators))
.then((res) => res?.data?.data ?? [])
.catch(() => [])
});
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 removeWebauthn = (code: string) => {
runActionWithFallback(
() =>
authApi(apiUrl(ApiEndpoints.auth_webauthn), undefined, 'delete', {
authenticators: [code]
}).then(() => {
refetch();
return ResultType.success;
}),
getReauthText
);
};
const rows = useMemo(() => {
if (isLoading || !data) return null;
return data.map((token: any) => (
<Table.Tr key={`${token.created_at}-${token.type}`}>
<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>
)}
{token.type == 'webauthn' && (
<Button
color='red'
onClick={() => {
removeWebauthn(token.id);
}}
>
<Trans>Remove</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 />;
return (
<>
<ReauthModal />
<SimpleGrid cols={{ xs: 1, md: 2 }} spacing='sm'>
{data.length == 0 ? (
<Stack gap='xs'>
<Alert
title={t`Not Configured`}
icon={<IconAlertCircle size='1rem' />}
color='yellow'
>
<Trans>No multi-factor tokens configured for this account</Trans>
</Alert>
</Stack>
) : (
<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>
)}
<MfaAddSection
usedFactors={usedFactors}
refetch={refetch}
showRecoveryCodes={showRecoveryCodes}
/>
<Modal
opened={recoveryCodesOpen}
onClose={() => {
refetch();
closeRecoveryCodes();
}}
title={t`Recovery Codes`}
centered
>
<StylishText size='lg'>
<Trans>Unused Codes</Trans>
</StylishText>
<Code>{recoveryCodes?.unused_codes?.join('\n')}</Code>
<StylishText size='lg'>
<Trans>Used Codes</Trans>
</StylishText>
<Code>{recoveryCodes?.used_codes?.join('\n')}</Code>
</Modal>
</SimpleGrid>
</>
);
}
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 [auth_config] = useServerApiState(
useShallow((state) => [state.auth_config])
);
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 registerWebauthn = async () => {
let data: any = {};
await runActionWithFallback(
() =>
authApi(apiUrl(ApiEndpoints.auth_webauthn), undefined, 'get').then(
(res) => {
data = res.data?.data;
if (data?.creation_options) {
return ResultType.success;
} else {
return ResultType.error;
}
}
),
getReauthText
);
if (data?.creation_options == undefined) {
showNotification({
title: t`Error while registering WebAuthn authenticator`,
message: t`Please reload page and try again.`,
color: 'red',
icon: <IconX />
});
}
// register the webauthn authenticator with the browser
const resp = await create({
publicKey: PublicKeyCredential.parseCreationOptionsFromJSON(
data.creation_options.publicKey
)
});
authApi(apiUrl(ApiEndpoints.auth_webauthn), undefined, 'post', {
name: 'Master Key',
credential: JSON.stringify(resp)
})
.then(() => {
showNotification({
title: t`WebAuthn authenticator registered successfully`,
message: t`You can now use this authenticator for multi-factor authentication.`,
color: 'green'
});
refetch();
return ResultType.success;
})
.catch((err) => {
showNotification({
title: t`Error while registering WebAuthn authenticator`,
message: err.response.data.errors
.map((error: any) => error.message)
.join('\n'),
color: 'red',
icon: <IconX />
});
return ResultType.error;
});
};
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')
},
{
type: 'webauthn',
name: t`WebAuthn`,
description: t`Web Authentication (WebAuthn) is a web standard for secure authentication`,
function: registerWebauthn,
used: usedFactors?.includes('webauthn')
}
].filter((factor) => {
return auth_config?.mfa?.supported_types.includes(factor.type);
});
}, [usedFactors, auth_config]);
const [totpError, setTotpError] = useState<string>('');
return (
<Stack>
<ReauthModal />
<StylishText size='md'>{t`Add Token`}</StylishText>
{possibleFactors.map((factor) => (
<Tooltip label={factor.description} key={factor.type}>
<Button
onClick={factor.function}
disabled={factor.used}
variant='outline'
>
{factor.name}
</Button>
</Tooltip>
))}
<Modal
opened={totpQrOpen}
onClose={closeTotpQr}
title={<StylishText size='lg'>{t`Register TOTP Token`}</StylishText>}
>
<Stack>
<QrRegistrationForm
url={totpQr?.totp_url ?? ''}
secret={totpQr?.secret ?? ''}
value={value}
error={totpError}
setValue={setValue}
/>
<Button
fullWidth
onClick={() =>
runActionWithFallback(
() =>
authApi(apiUrl(ApiEndpoints.auth_totp), undefined, 'post', {
code: value
})
.then(() => {
setTotpError('');
closeTotpQr();
refetch();
return ResultType.success;
})
.catch((error) => {
const errorMsg = t`Error registering TOTP token`;
setTotpError(
error.response?.data?.errors[0]?.message ?? errorMsg
);
hideNotification('totp-error');
showNotification({
id: 'totp-error',
title: t`Error`,
message: errorMsg,
color: 'red',
icon: <IconExclamationCircle />
});
return ResultType.error;
}),
getReauthText
)
}
>
<Trans>Submit</Trans>
</Button>
</Stack>
</Modal>
</Stack>
);
}
async function runActionWithFallback(
action: () => Promise<ResultType>,
getReauthText: (props: any) => any
) {
const { setAuthContext, setMfaContext, mfa_context } =
useServerApiState.getState();
const result = await action().catch((err) => {
setAuthContext(err.response.data?.data);
// check if we need to re-authenticate
if (err.status == 401) {
const mfaFlow = err.response.data.data.flows.find(
(flow: any) => flow.id == FlowEnum.MfaReauthenticate
);
if (mfaFlow) {
setMfaContext(mfaFlow);
return ResultType.mfareauth;
} else if (
err.response.data.data.flows.find(
(flow: any) => flow.id == FlowEnum.Reauthenticate
)
) {
return ResultType.reauth;
} else {
return ResultType.error;
}
} else {
return ResultType.error;
}
});
// run the re-authentication flows as needed
if (result == ResultType.mfareauth) {
const mfa_types = mfa_context?.types || [];
const mfaCode = await getReauthText({
label: t`TOTP Code`,
name: 'TOTP',
description: t`Enter one of your codes: ${mfa_types}`
});
authApi(apiUrl(ApiEndpoints.auth_mfa_reauthenticate), undefined, 'post', {
code: mfaCode
})
.then((response) => {
setAuthContext(response.data?.data);
action();
})
.catch((err) => {
setAuthContext(err.response.data?.data);
});
} else if (result == ResultType.reauth) {
const passwordInput = await getReauthText({
label: t`Password`,
name: 'password',
description: t`Enter your password`
});
authApi(apiUrl(ApiEndpoints.auth_reauthenticate), undefined, 'post', {
password: passwordInput
})
.then((response) => {
setAuthContext(response.data?.data);
action();
})
.catch((err) => {
setAuthContext(err.response.data?.data);
});
}
}
export const parseDate = (date: number) =>
date == null ? 'Never' : new Date(date * 1000).toLocaleString();