From 2dfe6b5f4195f97eb21f0425712696530f73f434 Mon Sep 17 00:00:00 2001 From: Oliver Date: Wed, 5 Nov 2025 22:54:47 +1100 Subject: [PATCH] [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 --- src/frontend/package.json | 1 + .../Settings/AccountSettings/MFASettings.tsx | 1005 +++++++++++++++++ .../AccountSettings/QrRegistrationForm.tsx | 27 +- .../AccountSettings/SecurityContent.tsx | 483 +------- .../Settings/AccountSettings/useConfirm.tsx | 117 -- src/frontend/tests/baseFixtures.ts | 1 + src/frontend/tests/pui_login.spec.ts | 73 ++ src/frontend/yarn.lock | 12 + 8 files changed, 1115 insertions(+), 604 deletions(-) create mode 100644 src/frontend/src/pages/Index/Settings/AccountSettings/MFASettings.tsx delete mode 100644 src/frontend/src/pages/Index/Settings/AccountSettings/useConfirm.tsx diff --git a/src/frontend/package.json b/src/frontend/package.json index 76aaefc739..29f04e6fe5 100644 --- a/src/frontend/package.json +++ b/src/frontend/package.json @@ -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", diff --git a/src/frontend/src/pages/Index/Settings/AccountSettings/MFASettings.tsx b/src/frontend/src/pages/Index/Settings/AccountSettings/MFASettings.tsx new file mode 100644 index 0000000000..72847198fc --- /dev/null +++ b/src/frontend/src/pages/Index/Settings/AccountSettings/MFASettings.tsx @@ -0,0 +1,1005 @@ +import { create } from '@github/webauthn-json/browser-ponyfill'; +import { ApiEndpoints } from '@lib/enums/ApiEndpoints'; +import { apiUrl } from '@lib/functions/Api'; +import { FlowEnum } from '@lib/types/Auth'; +import { t } from '@lingui/core/macro'; +import { Trans } from '@lingui/react/macro'; +import { + Alert, + Button, + Divider, + Group, + Loader, + Modal, + Paper, + PasswordInput, + SimpleGrid, + Stack, + Table, + Text, + Tooltip +} from '@mantine/core'; +import { showNotification } from '@mantine/notifications'; +import { + IconAlertCircle, + IconCircleCheck, + IconExclamationCircle, + IconInfoCircle +} from '@tabler/icons-react'; +import { useQuery } from '@tanstack/react-query'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useShallow } from 'zustand/react/shallow'; +import { api } from '../../../../App'; +import { CopyButton } from '../../../../components/buttons/CopyButton'; +import { StylishText } from '../../../../components/items/StylishText'; +import { authApi } from '../../../../functions/auth'; +import { useServerApiState } from '../../../../states/ServerApiState'; +import { QrRegistrationForm } from './QrRegistrationForm'; +import { parseDate } from './SecurityContent'; + +/** + * Extract the required re-authentication flow from an error response + */ +function getReauthFlow(err: any): FlowEnum | null { + const flows: any = err.response?.data?.data?.flows ?? []; + + if (flows.find((flow: any) => flow.id == FlowEnum.MfaReauthenticate)) { + return FlowEnum.MfaReauthenticate; + } else if (flows.find((flow: any) => flow.id == FlowEnum.Reauthenticate)) { + return FlowEnum.Reauthenticate; + } else { + // No match found + return null; + } +} + +/** + * Extract an error message from an allauth error response. + * + * The allauth flows have a particular response structure, + * which this function handles. + */ +function extractErrorMessage(err: any, defaultMsg: string): string { + const backupMsg = `${defaultMsg}: ${err.status}`; + + if (err.response?.data?.errors && err.response?.data?.errors.length > 0) { + return err.response?.data?.errors[0]?.message ?? backupMsg; + } + + return backupMsg; +} + +type ReauthInputProps = { + label: string; + name: string; + description: string; + url: string; +}; + +/** + * Internal modal component for re-authentication + */ +function ReauthenticateModalComponent({ + inputProps, + setOpen +}: { + inputProps: ReauthInputProps; + setOpen: (open: boolean) => void; +}) { + const { setAuthContext } = useServerApiState.getState(); + + const [value, setValue] = useState(''); + const [error, setError] = useState(''); + + const onSubmit = useCallback( + (value: string) => { + api + .post(inputProps.url, { + [inputProps.name]: value + }) + .then((response) => { + setAuthContext(response.data?.data); + showNotification({ + title: t`Reauthentication Succeeded`, + message: t`You have been reauthenticated successfully.`, + color: 'green', + icon: + }); + setOpen(false); + }) + .catch((error) => { + setError( + extractErrorMessage(error, t`Error during reauthentication`) + ); + showNotification({ + title: t`Reauthentication Failed`, + message: `${t`Failed to reauthenticate`}: ${error.status}`, + color: 'red', + icon: + }); + }); + }, + [inputProps] + ); + + return ( + + + } + title={t`Reauthenticate`} + > + {t`Reauthentiction is required to continue.`} + + setValue(event.target.value)} + /> + + + + + + ); +} + +function ReauthenticateModal({ + inputProps, + opened, + setOpen +}: { + inputProps: ReauthInputProps; + opened: boolean; + setOpen: (open: boolean) => void; +}) { + return ( + setOpen(false)} + title={{t`Reauthenticate`}} + > + + + ); +} + +/** + * Modal for re-authenticating with password + */ +function ReauthenticatePasswordModal({ + opened, + setOpen +}: { + opened: boolean; + setOpen: (open: boolean) => void; +}) { + return ( + + ); +} + +/** + * Modal for re-authenticating with TOTP code + */ +function ReauthenticateTOTPModal({ + opened, + setOpen +}: { + opened: boolean; + setOpen: (open: boolean) => void; +}) { + return ( + + ); +} + +/** + * Modal for removing a registered WebAuthn credential: + * - Deletes the WebAuthn credential from the server + * - Handles errors and re-authentication flows as needed + */ +function RemoveWebauthnModal({ + tokenId, + opened, + setOpen, + onReauthFlow, + onSuccess +}: { + opened: boolean; + tokenId: number | null; + setOpen: (open: boolean) => void; + onReauthFlow: (flow: FlowEnum) => void; + onSuccess: () => void; +}) { + const [processing, setProcessing] = useState(false); + const [error, setError] = useState(''); + + useEffect(() => { + setProcessing(false); + setError(''); + }, [opened]); + + const removeCredential = useCallback(() => { + setProcessing(true); + + if (!tokenId) { + return; + } + + authApi( + apiUrl(ApiEndpoints.auth_webauthn), + { + timeout: 30 * 1000 + }, + 'delete', + { + authenticators: [tokenId] + } + ) + .then((response) => { + showNotification({ + title: t`WebAuthn Credential Removed`, + message: t`WebAuthn credential removed successfully.`, + color: 'green', + icon: + }); + setOpen(false); + onSuccess(); + }) + .catch((error) => { + setError( + extractErrorMessage(error, t`Error removing WebAuthn credential`) + ); + + // A 401 error indicates that re-authentication is required + if (error.status === 401) { + const flow = getReauthFlow(error); + if (flow !== null) { + onReauthFlow(flow); + } + } + }) + .finally(() => { + setProcessing(false); + }); + }, [tokenId]); + + return ( + setOpen(false)} + title={ + {t`Remove WebAuthn Credential`} + } + > + + + } + title={t`Confirm Removal`} + > + Confirm removal of webauth credential + + {error && ( + } title={t`Error`}> + {error} + + )} + + + + + + + ); +} + +/** + * Modal for removing a registered TOTP token: + * - Deletes the TOTP token from the server + * - Handles errors and re-authentication flows as needed + */ +function RemoveTOTPModal({ + opened, + setOpen, + onReauthFlow, + onSuccess +}: { + opened: boolean; + setOpen: (open: boolean) => void; + onReauthFlow: (flow: FlowEnum) => void; + onSuccess: () => void; +}) { + const [processing, setProcessing] = useState(false); + const [error, setError] = useState(''); + + useEffect(() => { + setProcessing(false); + setError(''); + }, [opened]); + + const deleteToken = useCallback(() => { + setProcessing(true); + api + .delete(apiUrl(ApiEndpoints.auth_totp), { + timeout: 30 * 1000 + }) + .then((response) => { + showNotification({ + title: t`TOTP Removed`, + message: t`TOTP token removed successfully.`, + color: 'green', + icon: + }); + setOpen(false); + onSuccess(); + + return response.data; + }) + .catch((error) => { + setError(extractErrorMessage(error, t`Error removing TOTP token`)); + + // A 401 error indicates that re-authentication is required + if (error.status === 401) { + const flow = getReauthFlow(error); + if (flow !== null) { + onReauthFlow(flow); + } + } + }) + .finally(() => { + setProcessing(false); + }); + }, []); + + return ( + setOpen(false)} + title={{t`Remove TOTP Token`}} + > + + + } + title={t`Confirm Removal`} + > + Confirm removal of TOTP code + + {error && ( + } title={t`Error`}> + {error} + + )} + + + + + + + ); +} + +/** + * Modal for registering a new TOTP token + * - Fetches TOTP registration details from the server + * - Displays QR code and secret to the user + * - Accepts TOTP code input from the user + * - Submits TOTP code to the server for registration + * - Handles errors and re-authentication flows as needed + */ +function RegisterTOTPModal({ + opened, + setOpen, + onReauthFlow, + onSuccess +}: { + opened: boolean; + setOpen: (open: boolean) => void; + onReauthFlow: (flow: FlowEnum) => void; + onSuccess: () => void; +}) { + const [url, setUrl] = useState(''); + const [secret, setSecret] = useState(''); + const [value, setValue] = useState(''); + const [error, setError] = useState(''); + const [processing, setProcessing] = useState(false); + + // Query to fetch TOTP registration details + const totpQuery = useQuery({ + enabled: false, + queryKey: ['mfa-totp-registration'], + queryFn: async () => { + setUrl(''); + setSecret(''); + + return api + .get(apiUrl(ApiEndpoints.auth_totp)) + .then((_response) => { + // A successful response indicates that TOTP is already registered + // Close the modal and show an error + setOpen(false); + showNotification({ + title: t`TOTP Already Registered`, + message: t`A TOTP token is already registered for this account.`, + color: 'green' + }); + return {}; + }) + .catch((error) => { + switch (error.status) { + case 404: + // A 404 indicates that a new TOTP registration can be started + setUrl(error.response?.data?.meta?.totp_url ?? ''); + setSecret(error.response?.data?.meta?.secret ?? ''); + break; + default: + // Any other error is unexpected + showNotification({ + title: t`Error Fetching TOTP Registration`, + message: t`An unexpected error occurred while fetching TOTP registration data.`, + color: 'red' + }); + throw error; + } + + return null; + }); + } + }); + + // Retrieve TOTP QR code and secret from server, when modal is opened + useEffect(() => { + // Reset state + setUrl(''); + setSecret(''); + setValue(''); + setError(''); + setProcessing(false); + + if (opened) { + totpQuery.refetch(); + } + }, [opened]); + + // Function to submit TOTP code for registration + const submitCode = useCallback((code: string) => { + setProcessing(true); + setError(''); + + api + .post( + apiUrl(ApiEndpoints.auth_totp), + { + code: code + }, + { + timeout: 30 * 1000 + } + ) + .then((response) => { + showNotification({ + title: t`TOTP Registered`, + message: t`TOTP token registered successfully.`, + color: 'green', + icon: + }); + setOpen(false); + onSuccess(); + }) + .catch((error) => { + // Set error message + setError(extractErrorMessage(error, t`Error registering TOTP token`)); + + // A 401 error indicates that re-authentication is required + if (error.status === 401) { + const flow = getReauthFlow(error); + if (flow !== null) { + onReauthFlow(flow); + } + } + }) + .finally(() => { + setProcessing(false); + }); + }, []); + + return ( + setOpen(false)} + title={{t`Register TOTP Token`}} + > + + + + + + ); +} + +function RecoveryCodesModal({ + opened, + setOpen, + onReauthFlow +}: { + opened: boolean; + setOpen: (open: boolean) => void; + onReauthFlow: (flow: FlowEnum) => void; +}) { + const [error, setError] = useState(''); + + const recoveryCodesQuery = useQuery({ + enabled: false, + queryKey: ['mfa-recovery-codes'], + queryFn: async () => { + return api + .post(apiUrl(ApiEndpoints.auth_recovery), undefined, { + timeout: 30 * 1000 + }) + .catch((error) => { + setError( + extractErrorMessage(error, t`Error fetching recovery codes`) + ); + + // A 401 error indicates that re-authentication is required + if (error.status === 401) { + const flow = getReauthFlow(error); + if (flow !== null) { + setOpen(false); + onReauthFlow(flow); + } + } + + throw error; + }); + } + }); + + const unusedCodes = useMemo(() => { + return recoveryCodesQuery.data?.data?.data?.unused_codes ?? []; + }, [recoveryCodesQuery.data]); + + useEffect(() => { + setError(''); + + // Re-fetch codes on opened + if (opened) { + recoveryCodesQuery.refetch(); + } + }, [opened]); + + return ( + setOpen(false)} + title={{t`Recovery Codes`}} + > + + + {error && ( + } title={t`Error`}> + {error} + + )} + {recoveryCodesQuery.isFetching || recoveryCodesQuery.isLoading ? ( + + ) : ( + + } + title={t`Recovery Codes`} + > + + The following one time recovery codes are available for use + + + + + {unusedCodes.length > 0 ? ( + unusedCodes.map((code: string) => ( + + {code} + + )) + ) : ( + } + title={t`No Unused Codes`} + > + There are no available recovery codes + + )} + + + Copy recovery codes to clipboard + + + + + + )} + + + + + + + ); +} + +/** + * Section for adding new MFA methods for the user + */ +export default function MFASettings() { + const [auth_config] = useServerApiState( + useShallow((state) => [state.auth_config]) + ); + + // Fetch list of MFA methods currently configured for the user + const { isLoading, data, refetch } = useQuery({ + queryKey: ['mfa-list'], + queryFn: () => + api + .get(apiUrl(ApiEndpoints.auth_authenticators)) + .then((res) => res?.data?.data ?? []) + .catch(() => []) + }); + + // Memoize the list of currently used MFA factors + const usedFactors: string[] = useMemo(() => { + if (isLoading || !data) return []; + return data.map((token: any) => token.type); + }, [isLoading, data]); + + const [webauthnToken, setWebauthnToken] = useState(null); + + const [recoveryCodesOpen, setRecoveryCodesOpen] = useState(false); + const [reauthPassOpen, setReauthPassModalOpen] = useState(false); + const [reauthTOTPOpen, setReauthTOTPModalOpen] = useState(false); + const [registerTOTPModalOpen, setRegisterTOTPModalOpen] = + useState(false); + const [removeTOTPModalOpen, setRemoveTOTPModalOpen] = + useState(false); + const [removeWebauthnModalOpen, setRemoveWebauthnModalOpen] = + useState(false); + + // Callback function used to re-authenticate the user + const reauthenticate = useCallback((flow: FlowEnum) => { + switch (flow) { + case FlowEnum.Reauthenticate: + setReauthPassModalOpen(true); + break; + case FlowEnum.MfaReauthenticate: + setReauthTOTPModalOpen(true); + break; + default: + // Un-handled reauthentication flow + break; + } + }, []); + + const registerRecoveryCodes = useCallback(() => { + setRecoveryCodesOpen(true); + }, []); + + // Register a WebAuthn credential with the provided key + const registerWebauthn = useCallback((key: any) => { + create({ + publicKey: PublicKeyCredential.parseCreationOptionsFromJSON(key) + }).then((credential) => { + const credentialString: string = JSON.stringify(credential); + + api + .post( + apiUrl(ApiEndpoints.auth_webauthn), + { + name: 'Master Key', + credential: credentialString + }, + { + timeout: 30 * 1000 + } + ) + .then((response) => { + showNotification({ + title: t`WebAuthn Registered`, + message: t`WebAuthn credential registered successfully`, + color: 'green', + icon: + }); + refetch(); + }) + .catch((error) => { + const errorMsg = extractErrorMessage( + error, + t`Error registering WebAuthn credential` + ); + showNotification({ + title: t`WebAuthn Registration Failed`, + message: `${t`Failed to register WebAuthn credential`}: ${errorMsg}`, + color: 'red', + icon: + }); + }); + }); + }, []); + + // Request a WebAuthn registration challenge from the server + const requestWebauthn = useCallback(() => { + api + .get(apiUrl(ApiEndpoints.auth_webauthn)) + .then((response) => { + // Extract credential creation options from the response + const options = response.data?.data?.creation_options; + if (options) { + registerWebauthn(options.publicKey); + } + return response.data; + }) + .catch((error) => { + const errorMsg: string = extractErrorMessage( + error, + t`Error fetching WebAuthn registration` + ); + + // A 401 error indicates that re-authentication is required + if (error.status === 401) { + const flow = getReauthFlow(error); + if (flow !== null) { + reauthenticate(flow); + } + } else { + showNotification({ + title: t`Error`, + message: errorMsg, + color: 'red', + icon: + }); + } + + throw error; + }); + }, []); + + const removeTOTP = useCallback(() => { + setRemoveTOTPModalOpen(true); + }, []); + + const viewRecoveryCodes = useCallback(() => { + setRecoveryCodesOpen(true); + }, []); + + const removeWebauthn = useCallback((id: number) => { + setWebauthnToken(id); + setRemoveWebauthnModalOpen(true); + }, []); + + // Memoize the list of possible MFA factors that can be registered + const possibleFactors = useMemo(() => { + return [ + { + type: 'totp', + name: t`TOTP`, + description: t`Time-based One-Time Password`, + function: () => setRegisterTOTPModalOpen(true), + 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: requestWebauthn, + used: usedFactors?.includes('webauthn') + } + ].filter((factor) => { + return auth_config?.mfa?.supported_types.includes(factor.type); + }); + }, [usedFactors, auth_config]); + + const mfaRows = useMemo(() => { + return ( + data?.map((token: any) => ( + + {token.type} + {parseDate(token.last_used_at)} + {parseDate(token.created_at)} + + + {token.type == 'totp' && ( + + )} + {token.type == 'recovery_codes' && ( + + )} + {token.type == 'webauthn' && ( + + )} + + + + )) ?? [] + ); + }, [data]); + + return ( + <> + + + + + + + + {mfaRows.length > 0 ? ( + + + + + Type + + + Last used at + + + Created at + + + Actions + + + + {mfaRows} +
+ ) : ( + } + color='yellow' + > + No multi-factor tokens configured for this account + + )} + {possibleFactors.length > 0 ? ( + + {t`Register Authentication Method`} + {possibleFactors.map((factor) => ( + + + + ))} + + ) : ( + } + color='yellow' + > + There are no MFA methods available for configuration + + )} +
+ + ); +} diff --git a/src/frontend/src/pages/Index/Settings/AccountSettings/QrRegistrationForm.tsx b/src/frontend/src/pages/Index/Settings/AccountSettings/QrRegistrationForm.tsx index 68057545b7..47f6f85d90 100644 --- a/src/frontend/src/pages/Index/Settings/AccountSettings/QrRegistrationForm.tsx +++ b/src/frontend/src/pages/Index/Settings/AccountSettings/QrRegistrationForm.tsx @@ -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 ( - <> + - - Secret -
- {secret} -
+ + + + Secret + + + + {secret} + + + + + setValue(event.currentTarget.value)} error={error} /> - + +
); } diff --git a/src/frontend/src/pages/Index/Settings/AccountSettings/SecurityContent.tsx b/src/frontend/src/pages/Index/Settings/AccountSettings/SecurityContent.tsx index bcc2613f10..abcc51c0cd 100644 --- a/src/frontend/src/pages/Index/Settings/AccountSettings/SecurityContent.tsx +++ b/src/frontend/src/pages/Index/Settings/AccountSettings/SecurityContent.tsx @@ -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() { {t`Multi-Factor Authentication`} - + @@ -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) => ( - - {token.type} - {parseDate(token.last_used_at)} - {parseDate(token.created_at)} - - {token.type == 'totp' && ( - - )} - {token.type == 'recovery_codes' && ( - - )} - {token.type == 'webauthn' && ( - - )} - - - )); - }, [data, isLoading]); - - const usedFactors: string[] = useMemo(() => { - if (isLoading || !data) return []; - return data.map((token: any) => token.type); - }, [data]); - - if (isLoading) return ; - - return ( - <> - - - {data.length == 0 ? ( - - } - color='yellow' - > - No multi-factor tokens configured for this account - - - ) : ( - - - - - Type - - - Last used at - - - Created at - - - Actions - - - - {rows} -
- )} - - { - refetch(); - closeRecoveryCodes(); - }} - title={t`Recovery Codes`} - centered - > - - Unused Codes - - {recoveryCodes?.unused_codes?.join('\n')} - - - Used Codes - - {recoveryCodes?.used_codes?.join('\n')} - -
- - ); -} - -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: - }); - - 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: - }); - } - - // 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: - }); - 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(''); - - return ( - - - {t`Add Token`} - {possibleFactors.map((factor) => ( - - - - ))} - {t`Register TOTP Token`}} - > - - - - - - - ); -} - -async function runActionWithFallback( - action: () => Promise, - 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(); diff --git a/src/frontend/src/pages/Index/Settings/AccountSettings/useConfirm.tsx b/src/frontend/src/pages/Index/Settings/AccountSettings/useConfirm.tsx deleted file mode 100644 index e66a8d4cd4..0000000000 --- a/src/frontend/src/pages/Index/Settings/AccountSettings/useConfirm.tsx +++ /dev/null @@ -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 = () => ( - setOpen(false)}> - {label} - - - - ); - - 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({ - 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 = () => ( - setOpen(false)} - title={t`Reauthentication`} - > - - setValue(event.currentTarget.value)} - /> - - - - - - - ); - - return [getReauthText, ReauthModal]; -}; diff --git a/src/frontend/tests/baseFixtures.ts b/src/frontend/tests/baseFixtures.ts index d43b0fb44e..e8bcca39e2 100644 --- a/src/frontend/tests/baseFixtures.ts +++ b/src/frontend/tests/baseFixtures.ts @@ -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') && diff --git a/src/frontend/tests/pui_login.spec.ts b/src/frontend/tests/pui_login.spec.ts index 549ff55611..4b4ee7d19a 100644 --- a/src/frontend/tests/pui_login.spec.ts +++ b/src/frontend/tests/pui_login.spec.ts @@ -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(); +}); diff --git a/src/frontend/yarn.lock b/src/frontend/yarn.lock index e6f08e6190..cc4e4ee057 100644 --- a/src/frontend/yarn.lock +++ b/src/frontend/yarn.lock @@ -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"