mirror of
https://github.com/inventree/InvenTree.git
synced 2025-11-13 19:36:46 +00:00
[UI] MFA Refactor (#10775)
* Install otpauth package * Add separate MFASettings components * Refresh methods after registering token * Simplify layout * Add modal for deleting TOTP code * Display recovery codes * Adjust text * Register webauthn * Add longer timeouts * Add workflow for removing webauthn * Cleanup SecurityContext.tsx * Add playwright testing for TOTP registration * Spelling fixes * Delete unused file * Better clipboard copy
This commit is contained in:
@@ -125,6 +125,7 @@
|
|||||||
"@vitejs/plugin-react": "^5.0.2",
|
"@vitejs/plugin-react": "^5.0.2",
|
||||||
"babel-plugin-macros": "^3.1.0",
|
"babel-plugin-macros": "^3.1.0",
|
||||||
"nyc": "^17.1.0",
|
"nyc": "^17.1.0",
|
||||||
|
"otpauth": "^9.4.1",
|
||||||
"path": "^0.12.7",
|
"path": "^0.12.7",
|
||||||
"rollup": "^4.0.0",
|
"rollup": "^4.0.0",
|
||||||
"rollup-plugin-license": "^3.5.3",
|
"rollup-plugin-license": "^3.5.3",
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,8 @@
|
|||||||
import { t } from '@lingui/core/macro';
|
import { t } from '@lingui/core/macro';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import { Divider, Text, TextInput } from '@mantine/core';
|
import { Divider, Group, Paper, Stack, Text, TextInput } from '@mantine/core';
|
||||||
import { QRCode } from '../../../../components/barcodes/QRCode';
|
import { QRCode } from '../../../../components/barcodes/QRCode';
|
||||||
|
import { CopyButton } from '../../../../components/buttons/CopyButton';
|
||||||
|
|
||||||
export function QrRegistrationForm({
|
export function QrRegistrationForm({
|
||||||
url,
|
url,
|
||||||
@@ -17,22 +18,32 @@ export function QrRegistrationForm({
|
|||||||
setValue: (value: string) => void;
|
setValue: (value: string) => void;
|
||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<>
|
<Stack gap='xs'>
|
||||||
<Divider />
|
<Divider />
|
||||||
<QRCode data={url} />
|
<QRCode data={url} />
|
||||||
|
<Paper withBorder p='sm' aria-label='otp-secret-container'>
|
||||||
|
<Stack gap='xs'>
|
||||||
<Text>
|
<Text>
|
||||||
<Trans>Secret</Trans>
|
<Trans>Secret</Trans>
|
||||||
<br />
|
</Text>
|
||||||
|
<Group justify='space-between'>
|
||||||
|
<Text size='sm' aria-label='otp-secret'>
|
||||||
{secret}
|
{secret}
|
||||||
</Text>
|
</Text>
|
||||||
|
<CopyButton value={secret} />
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
<TextInput
|
<TextInput
|
||||||
required
|
required
|
||||||
|
aria-label={'text-input-otp-code'}
|
||||||
label={t`One-Time Password`}
|
label={t`One-Time Password`}
|
||||||
description={t`Enter the TOTP code to ensure it registered correctly`}
|
description={t`Enter the TOTP code to ensure it registered correctly`}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(event) => setValue(event.currentTarget.value)}
|
onChange={(event) => setValue(event.currentTarget.value)}
|
||||||
error={error}
|
error={error}
|
||||||
/>
|
/>
|
||||||
</>
|
<Divider />
|
||||||
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { create } from '@github/webauthn-json/browser-ponyfill';
|
|
||||||
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
|
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
|
||||||
import { apiUrl } from '@lib/functions/Api';
|
import { apiUrl } from '@lib/functions/Api';
|
||||||
import { type AuthConfig, type AuthProvider, FlowEnum } from '@lib/types/Auth';
|
import type { AuthConfig, AuthProvider } from '@lib/types/Auth';
|
||||||
import { t } from '@lingui/core/macro';
|
import { t } from '@lingui/core/macro';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import {
|
import {
|
||||||
@@ -10,39 +9,32 @@ import {
|
|||||||
Alert,
|
Alert,
|
||||||
Badge,
|
Badge,
|
||||||
Button,
|
Button,
|
||||||
Code,
|
|
||||||
Grid,
|
Grid,
|
||||||
Group,
|
Group,
|
||||||
Loader,
|
Loader,
|
||||||
Modal,
|
|
||||||
Radio,
|
Radio,
|
||||||
SimpleGrid,
|
SimpleGrid,
|
||||||
Stack,
|
Stack,
|
||||||
Table,
|
Table,
|
||||||
Text,
|
Text,
|
||||||
TextInput,
|
TextInput
|
||||||
Tooltip
|
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import { useDisclosure } from '@mantine/hooks';
|
|
||||||
import { hideNotification, showNotification } from '@mantine/notifications';
|
import { hideNotification, showNotification } from '@mantine/notifications';
|
||||||
import {
|
import {
|
||||||
IconAlertCircle,
|
IconAlertCircle,
|
||||||
IconAt,
|
IconAt,
|
||||||
IconExclamationCircle,
|
|
||||||
IconRefresh,
|
IconRefresh,
|
||||||
IconX
|
IconX
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
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 { api } from '../../../../App';
|
|
||||||
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';
|
||||||
import { useUserState } from '../../../../states/UserState';
|
import { useUserState } from '../../../../states/UserState';
|
||||||
import { ApiTokenTable } from '../../../../tables/settings/ApiTokenTable';
|
import { ApiTokenTable } from '../../../../tables/settings/ApiTokenTable';
|
||||||
import { QrRegistrationForm } from './QrRegistrationForm';
|
import MFASettings from './MFASettings';
|
||||||
import { useReauth } from './useConfirm';
|
|
||||||
|
|
||||||
export function SecurityContent() {
|
export function SecurityContent() {
|
||||||
const [auth_config, sso_enabled] = useServerApiState(
|
const [auth_config, sso_enabled] = useServerApiState(
|
||||||
@@ -85,7 +77,7 @@ export function SecurityContent() {
|
|||||||
<StylishText size='lg'>{t`Multi-Factor Authentication`}</StylishText>
|
<StylishText size='lg'>{t`Multi-Factor Authentication`}</StylishText>
|
||||||
</Accordion.Control>
|
</Accordion.Control>
|
||||||
<Accordion.Panel>
|
<Accordion.Panel>
|
||||||
<MfaSection />
|
<MFASettings />
|
||||||
</Accordion.Panel>
|
</Accordion.Panel>
|
||||||
</Accordion.Item>
|
</Accordion.Item>
|
||||||
<Accordion.Item value='token'>
|
<Accordion.Item value='token'>
|
||||||
@@ -403,472 +395,5 @@ function ProviderSection({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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) =>
|
export const parseDate = (date: number) =>
|
||||||
date == null ? 'Never' : new Date(date * 1000).toLocaleString();
|
date == null ? 'Never' : new Date(date * 1000).toLocaleString();
|
||||||
|
|||||||
@@ -1,117 +0,0 @@
|
|||||||
import { t } from '@lingui/core/macro';
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
|
||||||
import { Button, Group, Modal, Stack, TextInput } from '@mantine/core';
|
|
||||||
import { type JSX, useState } from 'react';
|
|
||||||
|
|
||||||
/* Adapted from https://daveteu.medium.com/react-custom-confirmation-box-458cceba3f7b */
|
|
||||||
const createPromise = () => {
|
|
||||||
let resolver: any;
|
|
||||||
return [
|
|
||||||
new Promise((resolve) => {
|
|
||||||
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<((status: boolean) => void) | null>(
|
|
||||||
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);
|
|
||||||
if (resolver) {
|
|
||||||
resolver(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 = (): [
|
|
||||||
(props: InputProps) => Promise<[string, boolean]>,
|
|
||||||
() => JSX.Element
|
|
||||||
] => {
|
|
||||||
const [inputProps, setInputProps] = useState<InputProps>({
|
|
||||||
label: '',
|
|
||||||
name: '',
|
|
||||||
description: ''
|
|
||||||
});
|
|
||||||
|
|
||||||
const [value, setValue] = useState('');
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const [resolver, setResolver] = useState<{
|
|
||||||
resolve: (result: string, positive: boolean) => void;
|
|
||||||
} | null>(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);
|
|
||||||
if (resolver) {
|
|
||||||
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];
|
|
||||||
};
|
|
||||||
@@ -73,6 +73,7 @@ export const test = baseTest.extend({
|
|||||||
!url.includes('/api/user/token/') &&
|
!url.includes('/api/user/token/') &&
|
||||||
!url.includes('/api/auth/v1/auth/login') &&
|
!url.includes('/api/auth/v1/auth/login') &&
|
||||||
!url.includes('/api/auth/v1/auth/session') &&
|
!url.includes('/api/auth/v1/auth/session') &&
|
||||||
|
!url.includes('/api/auth/v1/account/authenticators/totp') &&
|
||||||
!url.includes('/api/auth/v1/account/password/change') &&
|
!url.includes('/api/auth/v1/account/password/change') &&
|
||||||
!url.includes('/api/barcode/') &&
|
!url.includes('/api/barcode/') &&
|
||||||
!url.includes('/favicon.ico') &&
|
!url.includes('/favicon.ico') &&
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { logoutUrl } from './defaults.js';
|
|||||||
import { navigate } from './helpers.js';
|
import { navigate } from './helpers.js';
|
||||||
import { doLogin } from './login.js';
|
import { doLogin } from './login.js';
|
||||||
|
|
||||||
|
import { TOTP } from 'otpauth';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Test various types of login failure
|
* Test various types of login failure
|
||||||
*/
|
*/
|
||||||
@@ -84,3 +86,74 @@ test('Login - Change Password', async ({ page }) => {
|
|||||||
await page.getByText('Password Changed').waitFor();
|
await page.getByText('Password Changed').waitFor();
|
||||||
await page.getByText('The password was set successfully').waitFor();
|
await page.getByText('The password was set successfully').waitFor();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Tests for assigning MFA tokens to users
|
||||||
|
test('Login - MFA - TOTP', async ({ page }) => {
|
||||||
|
await doLogin(page, {
|
||||||
|
username: 'noaccess',
|
||||||
|
password: 'youshallnotpass'
|
||||||
|
});
|
||||||
|
|
||||||
|
await navigate(page, 'settings/user/security', { waitUntil: 'networkidle' });
|
||||||
|
|
||||||
|
// Expand the MFA section
|
||||||
|
await page
|
||||||
|
.getByRole('button', { name: 'Multi-Factor Authentication' })
|
||||||
|
.click();
|
||||||
|
await page.getByRole('button', { name: 'add-factor-totp' }).click();
|
||||||
|
|
||||||
|
// Ensure the user starts without any codes
|
||||||
|
await page
|
||||||
|
.getByText('No multi-factor tokens configured for this account')
|
||||||
|
.waitFor();
|
||||||
|
|
||||||
|
// Try to submit with an empty code
|
||||||
|
await page.getByRole('textbox', { name: 'text-input-otp-code' }).fill('');
|
||||||
|
await page.getByRole('button', { name: 'Submit', exact: true }).click();
|
||||||
|
|
||||||
|
await page.getByText('This field is required.').waitFor();
|
||||||
|
|
||||||
|
// Try to submit with an invalid secret
|
||||||
|
await page
|
||||||
|
.getByRole('textbox', { name: 'text-input-otp-code' })
|
||||||
|
.fill('ABCDEF');
|
||||||
|
await page.getByRole('button', { name: 'Submit', exact: true }).click();
|
||||||
|
|
||||||
|
await page.getByText('Incorrect code.').waitFor();
|
||||||
|
|
||||||
|
// Submit a valid code
|
||||||
|
const secret = await page
|
||||||
|
.getByLabel('otp-secret', { exact: true })
|
||||||
|
.innerText();
|
||||||
|
|
||||||
|
// Construct a TOTP code based on the secret
|
||||||
|
const totp = new TOTP({
|
||||||
|
secret: secret,
|
||||||
|
digits: 6,
|
||||||
|
period: 30
|
||||||
|
});
|
||||||
|
|
||||||
|
const token = totp.generate();
|
||||||
|
|
||||||
|
await page.getByRole('textbox', { name: 'text-input-otp-code' }).fill(token);
|
||||||
|
await page.getByRole('button', { name: 'Submit', exact: true }).click();
|
||||||
|
await page.getByText('TOTP token registered successfully').waitFor();
|
||||||
|
|
||||||
|
// View recovery codes
|
||||||
|
await page.getByRole('button', { name: 'view-recovery-codes' }).click();
|
||||||
|
await page
|
||||||
|
.getByText('The following one time recovery codes are available')
|
||||||
|
.waitFor();
|
||||||
|
await page.getByRole('button', { name: 'Close' }).click();
|
||||||
|
|
||||||
|
// Remove TOTP token
|
||||||
|
await page.getByRole('button', { name: 'remove-totp' }).click();
|
||||||
|
await page.getByRole('button', { name: 'Remove', exact: true }).click();
|
||||||
|
|
||||||
|
await page.getByText('TOTP token removed successfully').waitFor();
|
||||||
|
|
||||||
|
// And, once again there should be no configured tokens
|
||||||
|
await page
|
||||||
|
.getByText('No multi-factor tokens configured for this account')
|
||||||
|
.waitFor();
|
||||||
|
});
|
||||||
|
|||||||
@@ -1260,6 +1260,11 @@
|
|||||||
resolved "https://registry.npmjs.org/@microsoft/tsdoc/-/tsdoc-0.15.1.tgz"
|
resolved "https://registry.npmjs.org/@microsoft/tsdoc/-/tsdoc-0.15.1.tgz"
|
||||||
integrity sha512-4aErSrCR/On/e5G2hDP0wjooqDdauzEbIq8hIkIe5pXV0rtWJZvdCEKL0ykZxex+IxIwBp0eGeV48hQN07dXtw==
|
integrity sha512-4aErSrCR/On/e5G2hDP0wjooqDdauzEbIq8hIkIe5pXV0rtWJZvdCEKL0ykZxex+IxIwBp0eGeV48hQN07dXtw==
|
||||||
|
|
||||||
|
"@noble/hashes@1.8.0":
|
||||||
|
version "1.8.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-1.8.0.tgz#cee43d801fcef9644b11b8194857695acd5f815a"
|
||||||
|
integrity sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==
|
||||||
|
|
||||||
"@octokit/auth-token@^4.0.0":
|
"@octokit/auth-token@^4.0.0":
|
||||||
version "4.0.0"
|
version "4.0.0"
|
||||||
resolved "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-4.0.0.tgz"
|
resolved "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-4.0.0.tgz"
|
||||||
@@ -3850,6 +3855,13 @@ ora@^5.1.0:
|
|||||||
strip-ansi "^6.0.0"
|
strip-ansi "^6.0.0"
|
||||||
wcwidth "^1.0.1"
|
wcwidth "^1.0.1"
|
||||||
|
|
||||||
|
otpauth@^9.4.1:
|
||||||
|
version "9.4.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/otpauth/-/otpauth-9.4.1.tgz#6c5d8cf82b441b2f9f2a2a64ba5e00b992f5f31f"
|
||||||
|
integrity sha512-+iVvys36CFsyXEqfNftQm1II7SW23W1wx9RwNk0Cd97lbvorqAhBDksb/0bYry087QMxjiuBS0wokdoZ0iUeAw==
|
||||||
|
dependencies:
|
||||||
|
"@noble/hashes" "1.8.0"
|
||||||
|
|
||||||
p-limit@^2.2.0:
|
p-limit@^2.2.0:
|
||||||
version "2.3.0"
|
version "2.3.0"
|
||||||
resolved "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz"
|
resolved "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz"
|
||||||
|
|||||||
Reference in New Issue
Block a user