diff --git a/internal/storage/migrations/V0010.WebAuthnMultiCookieDomain.sqlite.down.sql b/internal/storage/migrations/V0010.WebAuthnMultiCookieDomain.sqlite.down.sql index 63102d931..e91ba66c0 100644 --- a/internal/storage/migrations/V0010.WebAuthnMultiCookieDomain.sqlite.down.sql +++ b/internal/storage/migrations/V0010.WebAuthnMultiCookieDomain.sqlite.down.sql @@ -1,6 +1,9 @@ ALTER TABLE webauthn_devices RENAME TO _bkp_DOWN_V0008_webauthn_devices; +DROP INDEX IF EXISTS webauthn_devices_kid_key; +DROP INDEX IF EXISTS webauthn_devices_lookup_key; + CREATE TABLE IF NOT EXISTS webauthn_devices ( id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, diff --git a/web/src/hooks/WebAuthnDevices.ts b/web/src/hooks/WebAuthnDevices.ts new file mode 100644 index 000000000..bfc792a58 --- /dev/null +++ b/web/src/hooks/WebAuthnDevices.ts @@ -0,0 +1,6 @@ +import { useRemoteCall } from "@hooks/RemoteCall"; +import { getUserWebAuthnDevices } from "@services/UserWebAuthnDevices"; + +export function useUserWebAuthnDevices() { + return useRemoteCall(getUserWebAuthnDevices, []); +} diff --git a/web/src/services/UserWebAuthnDevices.ts b/web/src/services/UserWebAuthnDevices.ts index 774f11195..670dbae4b 100644 --- a/web/src/services/UserWebAuthnDevices.ts +++ b/web/src/services/UserWebAuthnDevices.ts @@ -2,7 +2,12 @@ import { WebAuthnDevice } from "@models/WebAuthn"; import { WebAuthnDevicesPath } from "@services/Api"; import { GetWithOptionalData } from "@services/Client"; -// getWebAuthnDevices returns the list of webauthn devices for the authenticated user. -export async function getWebAuthnDevices(): Promise { - return GetWithOptionalData(WebAuthnDevicesPath); +export async function getUserWebAuthnDevices(): Promise { + const res = await GetWithOptionalData(WebAuthnDevicesPath); + + if (res === null) { + return []; + } + + return res; } diff --git a/web/src/services/WebAuthn.ts b/web/src/services/WebAuthn.ts index e5fa650af..bd4deee71 100644 --- a/web/src/services/WebAuthn.ts +++ b/web/src/services/WebAuthn.ts @@ -245,7 +245,7 @@ export async function finishRegistration(response: RegistrationResponseJSON) { return result; } -export async function deleteDevice(deviceID: string) { +export async function deleteUserWebAuthnDevice(deviceID: string) { return await axios({ method: "DELETE", url: `${WebAuthnDevicePath}/${deviceID}`, @@ -253,7 +253,7 @@ export async function deleteDevice(deviceID: string) { }); } -export async function updateDevice(deviceID: string, description: string) { +export async function updateUserWebAuthnDevice(deviceID: string, description: string) { return await axios({ method: "PUT", url: `${WebAuthnDevicePath}/${deviceID}`, diff --git a/web/src/views/Settings/TwoFactorAuthentication/WebAuthnDeviceDeleteDialog.tsx b/web/src/views/Settings/TwoFactorAuthentication/DeleteDialog.tsx similarity index 52% rename from web/src/views/Settings/TwoFactorAuthentication/WebAuthnDeviceDeleteDialog.tsx rename to web/src/views/Settings/TwoFactorAuthentication/DeleteDialog.tsx index ab1e7d449..2aa8b2aef 100644 --- a/web/src/views/Settings/TwoFactorAuthentication/WebAuthnDeviceDeleteDialog.tsx +++ b/web/src/views/Settings/TwoFactorAuthentication/DeleteDialog.tsx @@ -3,39 +3,33 @@ import React from "react"; import { Button, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle } from "@mui/material"; import { useTranslation } from "react-i18next"; -import { WebAuthnDevice } from "@models/WebAuthn"; - interface Props { open: boolean; - device: WebAuthnDevice; + title: string; + text: string; handleClose: (ok: boolean) => void; } -export default function WebAuthnDeviceDeleteDialog(props: Props) { +export default function DeleteDialog(props: Props) { const { t: translate } = useTranslation("settings"); const handleCancel = () => { props.handleClose(false); }; + const handleRemove = () => { + props.handleClose(true); + }; + return ( - {translate("Remove WebAuthn Credential")} + {props.title} - - {translate("Are you sure you want to remove the WebAuthn credential from from your account", { - description: props.device.description, - })} - + {props.text} - diff --git a/web/src/views/Settings/TwoFactorAuthentication/TwoFactorAuthenticationView.tsx b/web/src/views/Settings/TwoFactorAuthentication/TwoFactorAuthenticationView.tsx index ae564a0f0..fafd150de 100644 --- a/web/src/views/Settings/TwoFactorAuthentication/TwoFactorAuthenticationView.tsx +++ b/web/src/views/Settings/TwoFactorAuthentication/TwoFactorAuthenticationView.tsx @@ -1,19 +1,64 @@ -import React from "react"; +import React, { useEffect, useState } from "react"; -import { Grid } from "@mui/material"; +import Grid from "@mui/material/Unstable_Grid2"; -import { AutheliaState } from "@services/State"; -import WebAuthnDevices from "@views/Settings/TwoFactorAuthentication/WebAuthnDevices"; +import { useNotifications } from "@hooks/NotificationsContext"; +import { useUserInfoPOST } from "@hooks/UserInfo"; +import { useUserWebAuthnDevices } from "@hooks/WebAuthnDevices"; +import WebAuthnDevicesPanel from "@views/Settings/TwoFactorAuthentication/WebAuthnDevicesPanel"; -interface Props { - state: AutheliaState; -} +interface Props {} export default function TwoFactorAuthSettings(props: Props) { + const [refreshState, setRefreshState] = useState(0); + const { createErrorNotification } = useNotifications(); + const [userInfo, fetchUserInfo, , fetchUserInfoError] = useUserInfoPOST(); + const [userWebAuthnDevices, fetchUserWebAuthnDevices, , fetchUserWebAuthnDevicesError] = useUserWebAuthnDevices(); + const [hasTOTP, setHasTOTP] = useState(false); + const [hasWebAuthn, setHasWebAuthn] = useState(false); + + const handleRefreshState = () => { + setRefreshState((refreshState) => refreshState + 1); + }; + + useEffect(() => { + fetchUserInfo(); + }, [fetchUserInfo, refreshState]); + + useEffect(() => { + if (userInfo === undefined) { + return; + } + + if (userInfo.has_webauthn !== hasWebAuthn) { + setHasWebAuthn(userInfo.has_webauthn); + } + + if (userInfo.has_totp !== hasTOTP) { + setHasTOTP(userInfo.has_totp); + } + }, [hasTOTP, hasWebAuthn, userInfo]); + + useEffect(() => { + fetchUserWebAuthnDevices(); + }, [fetchUserWebAuthnDevices, hasWebAuthn]); + + useEffect(() => { + if (fetchUserInfoError) { + createErrorNotification("There was an issue retrieving user preferences"); + } + }, [fetchUserInfoError, createErrorNotification]); + + useEffect(() => { + if (fetchUserWebAuthnDevicesError) { + createErrorNotification("There was an issue retrieving One Time Password Configuration"); + } + }, [fetchUserWebAuthnDevicesError, createErrorNotification]); + return ( - - + + ); diff --git a/web/src/views/Settings/TwoFactorAuthentication/WebAuthnDeviceDetailsDialog.tsx b/web/src/views/Settings/TwoFactorAuthentication/WebAuthnDeviceDetailsDialog.tsx index 0c40fbccb..b32f2d044 100644 --- a/web/src/views/Settings/TwoFactorAuthentication/WebAuthnDeviceDetailsDialog.tsx +++ b/web/src/views/Settings/TwoFactorAuthentication/WebAuthnDeviceDetailsDialog.tsx @@ -1,8 +1,7 @@ -import React, { useState } from "react"; +import React, { Fragment, useState } from "react"; import { Check, ContentCopy } from "@mui/icons-material"; import { - Box, Button, CircularProgress, Dialog, @@ -10,10 +9,11 @@ import { DialogContent, DialogContentText, DialogTitle, - Stack, + Divider, Tooltip, Typography, } from "@mui/material"; +import Grid from "@mui/material/Unstable_Grid2"; import { useTranslation } from "react-i18next"; import { WebAuthnDevice, toTransportName } from "@models/WebAuthn"; @@ -24,7 +24,7 @@ interface Props { handleClose: () => void; } -export default function WebAuthnDetailsDeleteDialog(props: Props) { +export default function WebAuthnDeviceDetailsDialog(props: Props) { const { t: translate } = useTranslation("settings"); return ( @@ -36,16 +36,19 @@ export default function WebAuthnDetailsDeleteDialog(props: Props) { description: props.device.description, })} - - - - - - - + + + + + + + + + + + + + - + - + + + @@ -97,6 +137,7 @@ export default function WebAuthnDetailsDeleteDialog(props: Props) { interface PropertyTextProps { name: string; value: string; + xs?: number; } function PropertyCopyButton(props: PropertyTextProps) { @@ -144,11 +185,11 @@ function PropertyCopyButton(props: PropertyTextProps) { function PropertyText(props: PropertyTextProps) { return ( - + {`${props.name}: `} {props.value} - + ); } diff --git a/web/src/views/Settings/TwoFactorAuthentication/WebAuthnDeviceItem.tsx b/web/src/views/Settings/TwoFactorAuthentication/WebAuthnDeviceItem.tsx index 4ca1fb2d0..bdccb1cc4 100644 --- a/web/src/views/Settings/TwoFactorAuthentication/WebAuthnDeviceItem.tsx +++ b/web/src/views/Settings/TwoFactorAuthentication/WebAuthnDeviceItem.tsx @@ -1,16 +1,18 @@ -import React, { Fragment, useState } from "react"; +import React, { useState } from "react"; import { Fingerprint } from "@mui/icons-material"; import DeleteIcon from "@mui/icons-material/Delete"; import EditIcon from "@mui/icons-material/Edit"; import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined"; -import { Box, Button, CircularProgress, Paper, Stack, Tooltip, Typography } from "@mui/material"; +import { CircularProgress, Paper, Stack, Tooltip, Typography } from "@mui/material"; +import IconButton from "@mui/material/IconButton"; +import Grid from "@mui/material/Unstable_Grid2"; import { useTranslation } from "react-i18next"; import { useNotifications } from "@hooks/NotificationsContext"; import { WebAuthnDevice } from "@models/WebAuthn"; -import { deleteDevice, updateDevice } from "@services/WebAuthn"; -import WebAuthnDeviceDeleteDialog from "@views/Settings/TwoFactorAuthentication/WebAuthnDeviceDeleteDialog"; +import { deleteUserWebAuthnDevice, updateUserWebAuthnDevice } from "@services/WebAuthn"; +import DeleteDialog from "@views/Settings/TwoFactorAuthentication/DeleteDialog"; import WebAuthnDeviceDetailsDialog from "@views/Settings/TwoFactorAuthentication/WebAuthnDeviceDetailsDialog"; import WebAuthnDeviceEditDialog from "@views/Settings/TwoFactorAuthentication/WebAuthnDeviceEditDialog"; @@ -41,7 +43,7 @@ export default function WebAuthnDeviceItem(props: Props) { setLoadingEdit(true); - const response = await updateDevice(props.device.id, name); + const response = await updateUserWebAuthnDevice(props.device.id, name); setLoadingEdit(false); @@ -73,7 +75,7 @@ export default function WebAuthnDeviceItem(props: Props) { setLoadingDelete(true); - const response = await deleteDevice(props.device.id); + const response = await deleteUserWebAuthnDevice(props.device.id); setLoadingDelete(false); @@ -97,101 +99,102 @@ export default function WebAuthnDeviceItem(props: Props) { }; return ( - + + { + setShowDialogDetails(false); + }} + /> + + - - { - setShowDialogDetails(false); - }} - /> - - - - - - - - {props.device.description} - - {` (${props.device.attestation_type.toUpperCase()})`} - - - {translate("Added", { - when: new Date(props.device.created_at), - formatParams: { - when: { - hour: "numeric", - minute: "numeric", - year: "numeric", - month: "long", - day: "numeric", + + + + + + + + {props.device.description} + + {` (${props.device.attestation_type.toUpperCase()})`} + + + + + {translate("Added when", { + when: new Date(props.device.created_at), + formatParams: { + when: { + hour: "numeric", + minute: "numeric", + year: "numeric", + month: "long", + day: "numeric", + }, }, - }, - })} - - - {props.device.last_used_at === undefined - ? translate("Never used") - : translate("Last Used", { - when: new Date(props.device.last_used_at), - formatParams: { - when: { - hour: "numeric", - minute: "numeric", - year: "numeric", - month: "long", - day: "numeric", + })} + + + + + {props.device.last_used_at === undefined + ? translate("Never used") + : translate("Last Used when", { + when: new Date(props.device.last_used_at), + formatParams: { + when: { + hour: "numeric", + minute: "numeric", + year: "numeric", + month: "long", + day: "numeric", + }, }, - }, - })} - + })} + + + + + + + + setShowDialogDetails(true)}> + + + + + setShowDialogEdit(true)} + > + {loadingEdit ? : } + + + + setShowDialogDelete(true)} + > + {loadingDelete ? : } + + - - - - - - - - - - - - + + - + ); } diff --git a/web/src/views/Settings/TwoFactorAuthentication/WebAuthnDeviceRegisterDialog.tsx b/web/src/views/Settings/TwoFactorAuthentication/WebAuthnDeviceRegisterDialog.tsx index 6ace70eb1..de5fa4e37 100644 --- a/web/src/views/Settings/TwoFactorAuthentication/WebAuthnDeviceRegisterDialog.tsx +++ b/web/src/views/Settings/TwoFactorAuthentication/WebAuthnDeviceRegisterDialog.tsx @@ -8,7 +8,6 @@ import { DialogContent, DialogContentText, DialogTitle, - Grid, Step, StepLabel, Stepper, @@ -16,6 +15,7 @@ import { Theme, Typography, } from "@mui/material"; +import Grid from "@mui/material/Unstable_Grid2"; import makeStyles from "@mui/styles/makeStyles"; import { PublicKeyCredentialCreationOptionsJSON } from "@simplewebauthn/typescript-types"; import { useTranslation } from "react-i18next"; @@ -30,7 +30,6 @@ const steps = ["Description", "Verification"]; interface Props { open: boolean; - onClose: () => void; setCancelled: () => void; } @@ -65,7 +64,7 @@ const WebAuthnDeviceRegisterDialog = function (props: Props) { }, [props]); const performCredentialCreation = useCallback(async () => { - if (options === null) { + if (!props.open || options === null) { return; } @@ -106,10 +105,10 @@ const WebAuthnDeviceRegisterDialog = function (props: Props) { "Failed to register your device. The identity verification process might have timed out.", ); } - }, [options, createErrorNotification, handleClose]); + }, [props.open, options, createErrorNotification, handleClose]); useEffect(() => { - if (state !== WebAuthnTouchState.Failure || activeStep !== 0 || !props.open) { + if (!props.open || state !== WebAuthnTouchState.Failure || activeStep !== 0) { return; } @@ -126,40 +125,48 @@ const WebAuthnDeviceRegisterDialog = function (props: Props) { })(); }, [props.open, activeStep, options, performCredentialCreation]); - const handleNext = useCallback(async () => { - if (credentialDescription.length === 0 || credentialDescription.length > 64) { - setErrorDescription(true); - createErrorNotification( - translate("The Description must be more than 1 character and less than 64 characters."), - ); - + const handleNext = useCallback(() => { + if (!props.open) { return; } - const res = await getAttestationCreationOptions(credentialDescription); - - switch (res.status) { - case 200: - if (res.options) { - setOptions(res.options); - } else { - throw new Error( - "Credential Creation Options Request succeeded but Credential Creation Options is empty.", - ); - } - - break; - case 409: + (async function () { + if (credentialDescription.length === 0 || credentialDescription.length > 64) { setErrorDescription(true); - createErrorNotification(translate("A WebAuthn Credential with that Description already exists.")); - - break; - default: createErrorNotification( - translate("Error occurred obtaining the WebAuthn Credential creation options."), + translate("The Description must be more than 1 character and less than 64 characters."), ); - } - }, [createErrorNotification, credentialDescription, translate]); + + return; + } + + const res = await getAttestationCreationOptions(credentialDescription); + + switch (res.status) { + case 200: + if (res.options) { + setOptions(res.options); + } else { + throw new Error( + "Credential Creation Options Request succeeded but Credential Creation Options is empty.", + ); + } + + break; + case 409: + setErrorDescription(true); + createErrorNotification(translate("A WebAuthn Credential with that Description already exists.")); + + break; + default: + createErrorNotification( + translate("Error occurred obtaining the WebAuthn Credential creation options."), + ); + } + + await performCredentialCreation(); + })(); + }, [createErrorNotification, credentialDescription, performCredentialCreation, props.open, translate]); const handleCredentialDescription = useCallback( (description: string) => { @@ -184,7 +191,7 @@ const WebAuthnDeviceRegisterDialog = function (props: Props) { {translate("Enter a description for this credential")} - + { - if (activeStep === 0 || !props.open) { + if (!props.open || activeStep === 1) { return; } @@ -242,7 +249,7 @@ const WebAuthnDeviceRegisterDialog = function (props: Props) { )} - + {steps.map((label, index) => { const stepProps: { completed?: boolean } = {}; @@ -257,9 +264,7 @@ const WebAuthnDeviceRegisterDialog = function (props: Props) { })} - - {renderStep(activeStep)} - + {renderStep(activeStep)} diff --git a/web/src/views/Settings/TwoFactorAuthentication/WebAuthnDevices.tsx b/web/src/views/Settings/TwoFactorAuthentication/WebAuthnDevices.tsx deleted file mode 100644 index 2072924c7..000000000 --- a/web/src/views/Settings/TwoFactorAuthentication/WebAuthnDevices.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import React, { Fragment, Suspense, useState } from "react"; - -import { Box, Button, Paper, Stack, Tooltip, Typography } from "@mui/material"; -import { useTranslation } from "react-i18next"; - -import { AutheliaState } from "@services/State"; -import LoadingPage from "@views/LoadingPage/LoadingPage"; -import WebAuthnDeviceRegisterDialog from "@views/Settings/TwoFactorAuthentication/WebAuthnDeviceRegisterDialog"; -import WebAuthnDevicesStack from "@views/Settings/TwoFactorAuthentication/WebAuthnDevicesStack"; - -interface Props { - state: AutheliaState; -} - -export default function WebAuthnDevices(props: Props) { - const { t: translate } = useTranslation("settings"); - - const [showWebAuthnDeviceRegisterDialog, setShowWebAuthnDeviceRegisterDialog] = useState(false); - const [refreshState, setRefreshState] = useState(0); - - const handleIncrementRefreshState = () => { - setRefreshState((refreshState) => refreshState + 1); - }; - - return ( - - { - handleIncrementRefreshState(); - }} - setCancelled={() => { - setShowWebAuthnDeviceRegisterDialog(false); - handleIncrementRefreshState(); - }} - /> - - - - - {translate("WebAuthn Credentials")} - - - - - - - }> - - - - - - - ); -} diff --git a/web/src/views/Settings/TwoFactorAuthentication/WebAuthnDevicesPanel.tsx b/web/src/views/Settings/TwoFactorAuthentication/WebAuthnDevicesPanel.tsx new file mode 100644 index 000000000..233d94b24 --- /dev/null +++ b/web/src/views/Settings/TwoFactorAuthentication/WebAuthnDevicesPanel.tsx @@ -0,0 +1,66 @@ +import React, { Fragment, useState } from "react"; + +import { Button, Paper, Tooltip, Typography } from "@mui/material"; +import Grid from "@mui/material/Unstable_Grid2"; +import { useTranslation } from "react-i18next"; + +import { WebAuthnDevice } from "@models/WebAuthn"; +import WebAuthnDeviceRegisterDialog from "@views/Settings/TwoFactorAuthentication/WebAuthnDeviceRegisterDialog"; +import WebAuthnDevicesStack from "@views/Settings/TwoFactorAuthentication/WebAuthnDevicesStack"; + +interface Props { + devices: WebAuthnDevice[] | undefined; + handleRefreshState: () => void; +} + +export default function WebAuthnDevicesPanel(props: Props) { + const { t: translate } = useTranslation("settings"); + + const [showRegisterDialog, setShowRegisterDialog] = useState(false); + + return ( + + { + setShowRegisterDialog(false); + props.handleRefreshState(); + }} + /> + + + + {translate("WebAuthn Credentials")} + + + + + + + + {props.devices === undefined || props.devices.length === 0 ? ( + + {translate( + "No WebAuthn Credentials have been registered. If you'd like to register one click add.", + )} + + ) : ( + + )} + + + + + ); +} diff --git a/web/src/views/Settings/TwoFactorAuthentication/WebAuthnDevicesStack.tsx b/web/src/views/Settings/TwoFactorAuthentication/WebAuthnDevicesStack.tsx index fcef3934b..3d9463451 100644 --- a/web/src/views/Settings/TwoFactorAuthentication/WebAuthnDevicesStack.tsx +++ b/web/src/views/Settings/TwoFactorAuthentication/WebAuthnDevicesStack.tsx @@ -1,41 +1,21 @@ -import React, { Fragment, useEffect, useState } from "react"; +import React from "react"; -import { Stack, Typography } from "@mui/material"; -import { useTranslation } from "react-i18next"; +import Grid from "@mui/material/Unstable_Grid2"; import { WebAuthnDevice } from "@models/WebAuthn"; -import { getWebAuthnDevices } from "@services/UserWebAuthnDevices"; import WebAuthnDeviceItem from "@views/Settings/TwoFactorAuthentication/WebAuthnDeviceItem"; interface Props { - refreshState: number; - incrementRefreshState: () => void; + devices: WebAuthnDevice[]; + handleRefreshState: () => void; } export default function WebAuthnDevicesStack(props: Props) { - const { t: translate } = useTranslation("settings"); - - const [devices, setDevices] = useState(null); - - useEffect(() => { - (async function () { - setDevices(null); - const devices = await getWebAuthnDevices(); - setDevices(devices); - })(); - }, [props.refreshState]); - return ( - - {devices !== null && devices.length !== 0 ? ( - - {devices.map((x, idx) => ( - - ))} - - ) : ( - {translate("No Registered WebAuthn Credentials")} - )} - + + {props.devices.map((x, idx) => ( + + ))} + ); }