mirror of
https://github.com/inventree/InvenTree.git
synced 2025-11-13 11:26:42 +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",
|
||||
"babel-plugin-macros": "^3.1.0",
|
||||
"nyc": "^17.1.0",
|
||||
"otpauth": "^9.4.1",
|
||||
"path": "^0.12.7",
|
||||
"rollup": "^4.0.0",
|
||||
"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 { 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 { CopyButton } from '../../../../components/buttons/CopyButton';
|
||||
|
||||
export function QrRegistrationForm({
|
||||
url,
|
||||
@@ -17,22 +18,32 @@ export function QrRegistrationForm({
|
||||
setValue: (value: string) => void;
|
||||
}>) {
|
||||
return (
|
||||
<>
|
||||
<Stack gap='xs'>
|
||||
<Divider />
|
||||
<QRCode data={url} />
|
||||
<Paper withBorder p='sm' aria-label='otp-secret-container'>
|
||||
<Stack gap='xs'>
|
||||
<Text>
|
||||
<Trans>Secret</Trans>
|
||||
<br />
|
||||
</Text>
|
||||
<Group justify='space-between'>
|
||||
<Text size='sm' aria-label='otp-secret'>
|
||||
{secret}
|
||||
</Text>
|
||||
<CopyButton value={secret} />
|
||||
</Group>
|
||||
</Stack>
|
||||
</Paper>
|
||||
<TextInput
|
||||
required
|
||||
aria-label={'text-input-otp-code'}
|
||||
label={t`One-Time Password`}
|
||||
description={t`Enter the TOTP code to ensure it registered correctly`}
|
||||
value={value}
|
||||
onChange={(event) => setValue(event.currentTarget.value)}
|
||||
error={error}
|
||||
/>
|
||||
</>
|
||||
<Divider />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
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 type { AuthConfig, AuthProvider } from '@lib/types/Auth';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import {
|
||||
@@ -10,39 +9,32 @@ import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Code,
|
||||
Grid,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
Radio,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
TextInput,
|
||||
Tooltip
|
||||
TextInput
|
||||
} 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';
|
||||
import MFASettings from './MFASettings';
|
||||
|
||||
export function SecurityContent() {
|
||||
const [auth_config, sso_enabled] = useServerApiState(
|
||||
@@ -85,7 +77,7 @@ export function SecurityContent() {
|
||||
<StylishText size='lg'>{t`Multi-Factor Authentication`}</StylishText>
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<MfaSection />
|
||||
<MFASettings />
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
<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) =>
|
||||
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/auth/v1/auth/login') &&
|
||||
!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/barcode/') &&
|
||||
!url.includes('/favicon.ico') &&
|
||||
|
||||
@@ -3,6 +3,8 @@ import { logoutUrl } from './defaults.js';
|
||||
import { navigate } from './helpers.js';
|
||||
import { doLogin } from './login.js';
|
||||
|
||||
import { TOTP } from 'otpauth';
|
||||
|
||||
/**
|
||||
* 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('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"
|
||||
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":
|
||||
version "4.0.0"
|
||||
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"
|
||||
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:
|
||||
version "2.3.0"
|
||||
resolved "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz"
|
||||
|
||||
Reference in New Issue
Block a user