refactor: totp

feat-otp-email-verify
James Elliott 2023-03-06 06:33:02 +11:00
parent bf5272dc61
commit a1351b1473
No known key found for this signature in database
GPG Key ID: 0F1C4A096E857E49
7 changed files with 235 additions and 134 deletions

View File

@ -2,7 +2,8 @@
"Actions": "Actions",
"Add": "Add",
"Add Credential": "Add Credential",
"Added": "Added {{when, datetime}}",
"Added": "Added",
"Added when": "Added {{when, datetime}}",
"Are you sure you want to remove the WebAuthn credential from from your account": "Are you sure you want to remove the WebAuthn credential {{description}} from your account?",
"Attestation Type": "Attestation Type",
"Authenticator GUID": "Authenticator GUID",
@ -22,11 +23,12 @@
"Enter a description for this credential": "Enter a description for this credential",
"Extended WebAuthn credential information for security key": "Extended WebAuthn credential information for security key {{description}}",
"Identifier": "Identifier",
"Last Used": "Last Used {{when, datetime}}",
"Last Used": "Last Used",
"Last Used when": "Last Used {{when, datetime}}",
"Manage your security keys": "Manage your security keys",
"Name": "Name",
"No": "No",
"No Registered WebAuthn Credentials": "No Registered WebAuthn Credentials",
"No WebAuthn Credentials have been registered. If you'd like to register one click add.": "No WebAuthn Credentials have been registered. If you'd like to register one click add.",
"Overview": "Overview",
"Provide the details for the new security key": "Provide the details for the new security key",
"Register WebAuthn Credential": "Register WebAuthn Credential",

View File

@ -1,6 +0,0 @@
import { useRemoteCall } from "@hooks/RemoteCall";
import { getUserWebauthnDevices } from "@services/UserWebauthnDevices";
export function useUserWebauthnDevices() {
return useRemoteCall(getUserWebauthnDevices, []);
}

View File

@ -1,21 +1,22 @@
import React, { ReactNode, useEffect } from "react";
import React, { ReactNode, SyntheticEvent, useCallback, useEffect, useState } from "react";
import { Dashboard } from "@mui/icons-material";
import { Close, Dashboard } from "@mui/icons-material";
import MenuIcon from "@mui/icons-material/Menu";
import SystemSecurityUpdateGoodIcon from "@mui/icons-material/SystemSecurityUpdateGood";
import {
AppBar,
Box,
Button,
Drawer,
Grid,
Divider,
List,
ListItem,
ListItemButton,
ListItemIcon,
ListItemText,
SwipeableDrawer,
Toolbar,
Typography,
} from "@mui/material";
import IconButton from "@mui/material/IconButton";
import { useTranslation } from "react-i18next";
import { IndexRoute, SettingsRoute, SettingsTwoFactorAuthenticationSubRoute } from "@constants/Routes";
@ -33,8 +34,7 @@ const defaultDrawerWidth = 240;
const SettingsLayout = function (props: Props) {
const { t: translate } = useTranslation("settings");
const navigate = useRouterNavigate();
const [drawerOpen, setDrawerOpen] = useState(false);
useEffect(() => {
if (props.title) {
@ -54,72 +54,179 @@ const SettingsLayout = function (props: Props) {
const drawerWidth = props.drawerWidth === undefined ? defaultDrawerWidth : props.drawerWidth;
const handleToggleDrawer = (event: SyntheticEvent) => {
if (
event.nativeEvent instanceof KeyboardEvent &&
event.nativeEvent.type === "keydown" &&
(event.nativeEvent.key === "Tab" || event.nativeEvent.key === "Shift")
) {
return;
}
setDrawerOpen((state) => !state);
};
const container = window !== undefined ? () => window.document.body : undefined;
const drawer = (
<Box onClick={handleToggleDrawer} sx={{ textAlign: "center" }}>
<Typography variant="h6" sx={{ my: 2 }}>
{translate("Settings")}
</Typography>
<Divider />
<List>
{navItems.map((item) => (
<DrawerNavItem key={item.keyname} text={item.text} pathname={item.pathname} icon={item.icon} />
))}
</List>
</Box>
);
return (
<Box sx={{ display: "flex" }}>
<AppBar position="fixed" sx={{ zIndex: (theme) => theme.zIndex.drawer + 1 }}>
<Toolbar variant="dense">
<Typography style={{ flexGrow: 1 }}>Authelia {translate("Settings")}</Typography>
<Button
variant="contained"
color="success"
onClick={() => {
navigate(IndexRoute);
}}
<AppBar component={"nav"}>
<Toolbar>
<IconButton
edge="start"
color="inherit"
aria-label="open drawer"
onClick={handleToggleDrawer}
sx={{ mr: 2 }}
>
{translate("Close")}
</Button>
<MenuIcon />
</IconButton>
<Typography
variant="h6"
component={"div"}
sx={{ flexGrow: 1, display: { xs: "none", sm: drawerOpen ? "none" : "block" } }}
>
{translate("Settings")}
</Typography>
</Toolbar>
</AppBar>
<Drawer
variant="permanent"
sx={{
width: drawerWidth,
flexShrink: 0,
[`& .MuiDrawer-paper`]: { width: drawerWidth, boxSizing: "border-box" },
}}
>
<Toolbar variant="dense" />
<Box sx={{ overflow: "auto" }}>
<List>
<SettingsMenuItem pathname={SettingsRoute} text={translate("Overview")} icon={<Dashboard />} />
<SettingsMenuItem
pathname={`${SettingsRoute}${SettingsTwoFactorAuthenticationSubRoute}`}
text={translate("Two-Factor Authentication")}
icon={<SystemSecurityUpdateGoodIcon />}
/>
</List>
</Box>
</Drawer>
<Grid container id={props.id} spacing={0}>
<Grid item xs={12}>
<Box component="main" sx={{ flexGrow: 1, p: 3 }}>
<Toolbar variant="dense" />
{props.children}
</Box>
</Grid>
</Grid>
<Box component={"nav"}>
<SwipeableDrawer
container={container}
anchor={"left"}
open={drawerOpen}
onOpen={handleToggleDrawer}
onClose={handleToggleDrawer}
ModalProps={{
keepMounted: true,
}}
sx={{
display: { xs: "block" },
"& .MuiDrawer-paper": { boxSizing: "border-box", width: drawerWidth },
}}
>
{drawer}
</SwipeableDrawer>
</Box>
<Box component="main" sx={{ flexGrow: 1, p: 3 }}>
<Toolbar />
{props.children}
</Box>
</Box>
);
};
export default SettingsLayout;
interface SettingsMenuItemProps {
pathname: string;
interface NavItem {
keyname?: string;
text: string;
icon: ReactNode;
pathname: string;
icon?: ReactNode;
}
const SettingsMenuItem = function (props: SettingsMenuItemProps) {
const navItems: NavItem[] = [
{ keyname: "overview", text: "Overview", pathname: SettingsRoute, icon: <Dashboard color={"primary"} /> },
{
keyname: "twofactor",
text: "Two-Factor Authentication",
pathname: `${SettingsRoute}${SettingsTwoFactorAuthenticationSubRoute}`,
icon: <SystemSecurityUpdateGoodIcon color={"primary"} />,
},
{ keyname: "close", text: "Close", pathname: IndexRoute, icon: <Close color={"error"} /> },
];
const DrawerNavItem = function (props: NavItem) {
const selected = window.location.pathname === props.pathname || window.location.pathname === props.pathname + "/";
const navigate = useRouterNavigate();
const handleOnClick = useCallback(() => {
if (selected) {
return;
}
navigate(props.pathname);
}, [navigate, props, selected]);
return (
<ListItem disablePadding onClick={!selected ? () => navigate(props.pathname) : undefined}>
<ListItem disablePadding onClick={handleOnClick}>
<ListItemButton selected={selected}>
<ListItemIcon>{props.icon}</ListItemIcon>
{props.icon ? <ListItemIcon>{props.icon}</ListItemIcon> : null}
<ListItemText primary={props.text} />
</ListItemButton>
</ListItem>
);
};
/*
interface SettingsMenuProps {
isXL: boolean;
handleClickMenuItem: () => void;
handleToggleDrawer: (open: boolean) => (event: SyntheticEvent) => void;
}
const SettingsMenu = function (props: SettingsMenuProps) {
const { t: translate } = useTranslation("settings");
const navigate = useRouterNavigate();
return (
<Box
sx={{
p: 2,
height: 1,
}}
>
{props.isXL ? null : (
<Fragment>
<IconButton sx={{ mb: 2 }} onClick={props.handleToggleDrawer(false)}>
<Close />
</IconButton>
<Divider sx={{ mb: 2 }} />
</Fragment>
)}
<List sx={{ mb: 2 }}>
<SettingsMenuItem
pathname={SettingsRoute}
text={translate("Overview")}
icon={<Dashboard color={"primary"} />}
onClick={props.handleClickMenuItem}
/>
<SettingsMenuItem
pathname={`${SettingsRoute}${SettingsTwoFactorAuthenticationSubRoute}`}
text={translate("Two-Factor Authentication")}
icon={<SystemSecurityUpdateGoodIcon color={"primary"} />}
onClick={props.handleClickMenuItem}
/>
<ListItem
disablePadding
onClick={() => {
navigate(IndexRoute);
}}
>
<ListItemButton>
<ListItemIcon>
<Close color={"error"} />
</ListItemIcon>
<ListItemText primary={"Close"} />
</ListItemButton>
</ListItem>
</List>
</Box>
);
};
*/

View File

@ -11,7 +11,6 @@ import { deleteUserTOTPConfiguration } from "@services/UserInfoTOTPConfiguration
import DeleteDialog from "@views/Settings/TwoFactorAuthentication/DeleteDialog";
interface Props {
index: number;
config: UserInfoTOTPConfiguration;
handleRefresh: () => void;
}
@ -69,14 +68,14 @@ export default function TOTPDevice(props: Props) {
"Are you sure you want to remove the Time-based One Time Password from from your account",
)}
/>
<Stack direction="row" spacing={1} alignItems="center">
<Stack direction={"row"} spacing={1} alignItems={"center"}>
<QrCode2 fontSize="large" />
<Stack spacing={0} sx={{ minWidth: 400 }}>
<Box>
<Typography display="inline" sx={{ fontWeight: "bold" }}>
<Typography display={"inline"} sx={{ fontWeight: "bold" }}>
{props.config.issuer}
</Typography>
<Typography display="inline" variant="body2">
<Typography display={"inline"} variant={"body2"}>
{" (" +
translate("{{algorithm}}, {{digits}} digits, {{seconds}} seconds", {
algorithm: toAlgorithmString(props.config.algorithm),
@ -87,7 +86,7 @@ export default function TOTPDevice(props: Props) {
</Typography>
</Box>
<Typography variant={"caption"}>
{translate("Added", {
{translate("Added when", {
when: props.config.created_at,
formatParams: {
when: {
@ -103,7 +102,7 @@ export default function TOTPDevice(props: Props) {
<Typography variant={"caption"}>
{props.config.last_used_at === undefined
? translate("Never used")
: translate("Last Used", {
: translate("Last Used when", {
when: props.config.last_used_at,
formatParams: {
when: {
@ -117,7 +116,6 @@ export default function TOTPDevice(props: Props) {
})}
</Typography>
</Stack>
<Tooltip title={translate("Remove the Time-based One Time Password configuration")}>
<Button
variant={"outlined"}

View File

@ -1,6 +1,7 @@
import React, { Fragment, useState } from "react";
import { Button, Grid, Paper, Stack, Tooltip, Typography } from "@mui/material";
import { Button, Paper, Stack, Tooltip, Typography } from "@mui/material";
import Grid from "@mui/material/Unstable_Grid2";
import { useTranslation } from "react-i18next";
import { UserInfoTOTPConfiguration } from "@models/TOTPConfiguration";
@ -26,14 +27,14 @@ export default function TOTPPanel(props: Props) {
props.handleRefreshState();
}}
/>
<Paper variant="outlined" sx={{ p: 3 }}>
<Grid container spacing={1}>
<Grid item xs={12}>
<Typography variant="h5">{translate("One Time Password")}</Typography>
<Paper variant={"outlined"}>
<Grid container spacing={2} padding={2}>
<Grid xs={12} lg={8}>
<Typography variant={"h5"}>{translate("One Time Password")}</Typography>
</Grid>
{props.config === undefined || props.config === null ? (
<Fragment>
<Grid item xs={12}>
<Grid xs={2}>
<Tooltip
title={translate("Click to add a Time-based One Time Password to your account")}
>
@ -48,7 +49,7 @@ export default function TOTPPanel(props: Props) {
</Button>
</Tooltip>
</Grid>
<Grid item xs={12}>
<Grid xs={12}>
<Typography variant={"subtitle2"}>
{translate(
"The One Time Password has not been registered. If you'd like to register it click add.",
@ -57,9 +58,9 @@ export default function TOTPPanel(props: Props) {
</Grid>
</Fragment>
) : (
<Grid item xs={12}>
<Stack spacing={2}>
<TOTPDevice index={0} config={props.config} handleRefresh={props.handleRefreshState} />
<Grid xs={12}>
<Stack spacing={3}>
<TOTPDevice config={props.config} handleRefresh={props.handleRefreshState} />
</Stack>
</Grid>
)}

View File

@ -2,6 +2,7 @@ import React, { Fragment, useCallback, useEffect, useState } from "react";
import { IconDefinition, faCopy, faKey, faTimesCircle } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Visibility } from "@mui/icons-material";
import {
Box,
Button,
@ -9,11 +10,11 @@ import {
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
FormControl,
FormControlLabel,
FormLabel,
Grid,
IconButton,
Link,
Radio,
@ -26,6 +27,7 @@ import {
Typography,
} from "@mui/material";
import { red } from "@mui/material/colors";
import Grid from "@mui/material/Unstable_Grid2";
import makeStyles from "@mui/styles/makeStyles";
import classnames from "classnames";
import { QRCodeSVG } from "qrcode.react";
@ -69,6 +71,7 @@ export default function TOTPRegisterDialogController(props: Props) {
const [hasErrored, setHasErrored] = useState(false);
const [dialValue, setDialValue] = useState("");
const [dialState, setDialState] = useState(State.Idle);
const [totpSecretURLHidden, setTOTPSecretURLHidden] = useState(true);
const resetStates = () => {
setOptions(null);
@ -85,6 +88,7 @@ export default function TOTPRegisterDialogController(props: Props) {
setActiveStep(0);
setDialValue("");
setDialState(State.Idle);
setTOTPSecretURLHidden(true);
};
const handleClose = useCallback(() => {
@ -112,7 +116,7 @@ export default function TOTPRegisterDialogController(props: Props) {
};
useEffect(() => {
if (!props.open || activeStep !== 0) {
if (!props.open || activeStep !== 0 || options !== null) {
return;
}
@ -126,13 +130,14 @@ export default function TOTPRegisterDialogController(props: Props) {
setOptionPeriod(opts.period);
setOptionPeriods(opts.periods.map((period) => period.toString()));
})();
}, [props.open, activeStep]);
}, [props.open, activeStep, options]);
const handleSetStepPrevious = useCallback(() => {
if (activeStep === 0) {
return;
}
setShowAdvanced(false);
setActiveStep((prevState) => (prevState -= 1));
}, [activeStep]);
@ -141,6 +146,7 @@ export default function TOTPRegisterDialogController(props: Props) {
return;
}
setShowAdvanced(false);
setActiveStep((prevState) => (prevState += 1));
}, [activeStep]);
@ -213,7 +219,6 @@ export default function TOTPRegisterDialogController(props: Props) {
function SecretButton(text: string | undefined, action: string, icon: IconDefinition) {
return (
<IconButton
className={styles.secretButtons}
color="primary"
onClick={() => {
navigator.clipboard.writeText(`${text}`);
@ -232,21 +237,20 @@ export default function TOTPRegisterDialogController(props: Props) {
return (
<Fragment>
{options === null ? (
<Grid item xs={12}>
<Grid xs={12}>
<Typography>Loading...</Typography>
</Grid>
) : (
<Fragment>
<Grid item xs={12}>
<Grid container>
<Grid xs={12}>
<Typography>{translate("To begin select next")}</Typography>
</Grid>
<Grid item xs={12} hidden={hideAdvanced}>
<Grid xs={12} hidden={hideAdvanced}>
<Button variant={"outlined"} color={"warning"} onClick={toggleAdvanced}>
{showAdvanced ? translate("Hide Advanced") : translate("Show Advanced")}
</Button>
</Grid>
<Grid
item
xs={12}
hidden={hideAdvanced || !showAdvanced}
justifyContent={"center"}
@ -330,15 +334,15 @@ export default function TOTPRegisterDialogController(props: Props) {
</RadioGroup>
</FormControl>
</Grid>
</Fragment>
</Grid>
)}
</Fragment>
);
case 1:
return (
<Fragment>
<Grid item xs={12}>
<Box className={styles.googleAuthenticator}>
<Grid xs={12}>
<Box>
<Typography className={styles.googleAuthenticatorText}>
{translate("Need Google Authenticator?")}
</Typography>
@ -351,10 +355,10 @@ export default function TOTPRegisterDialogController(props: Props) {
/>
</Box>
</Grid>
<Grid item xs={12}>
<Grid xs={12}>
<Box className={classnames(qrcodeFuzzyStyle, styles.qrcodeContainer)}>
<Link href={totpSecretURL} underline="hover">
<QRCodeSVG value={totpSecretURL} className={styles.qrcode} size={256} />
<QRCodeSVG value={totpSecretURL} className={styles.qrcode} size={128} />
{!hasErrored && totpIsLoading ? (
<CircularProgress className={styles.loader} size={128} />
) : null}
@ -364,22 +368,20 @@ export default function TOTPRegisterDialogController(props: Props) {
</Link>
</Box>
</Grid>
<Grid item xs={12}>
<Grid xs={12}>
<Grid container spacing={2} justifyContent={"center"}>
<Grid item xs={12}>
{totpSecretURL !== "empty" ? (
<TextField
id="secret-url"
label={translate("Secret")}
className={styles.secret}
value={totpSecretURL}
InputProps={{
readOnly: true,
}}
/>
) : null}
<Grid xs={2}>
<IconButton
color="primary"
onClick={() => {
setTOTPSecretURLHidden((value) => !value);
}}
size="large"
>
<Visibility />
</IconButton>
</Grid>
<Grid item xs={2}>
<Grid xs={2}>
{totpSecretBase32
? SecretButton(
totpSecretBase32,
@ -388,18 +390,31 @@ export default function TOTPRegisterDialogController(props: Props) {
)
: null}
</Grid>
<Grid item xs={2}>
{totpSecretURL !== "empty"
<Grid xs={2}>
{totpSecretURL !== ""
? SecretButton(totpSecretURL, translate("OTP URL copied to clipboard"), faCopy)
: null}
</Grid>
<Grid xs={12}>
<TextField
id="secret-url"
label={translate("Secret")}
className={styles.secret}
value={totpSecretURL}
multiline={true}
hidden={totpSecretURLHidden || totpSecretURL === ""}
InputProps={{
readOnly: true,
}}
/>
</Grid>
</Grid>
</Grid>
</Fragment>
);
case 2:
return (
<Grid item xs={12}>
<Grid xs={12}>
<OTPDial
passcode={dialValue}
state={dialState}
@ -416,8 +431,11 @@ export default function TOTPRegisterDialogController(props: Props) {
<Dialog open={props.open} onClose={handleOnClose} maxWidth={"xs"} fullWidth={true}>
<DialogTitle>{translate("Register One Time Password (TOTP)")}</DialogTitle>
<DialogContent>
<DialogContentText sx={{ mb: 3 }}>
{translate("This dialog allows registration of the One-Time Password.")}
</DialogContentText>
<Grid container spacing={0} alignItems={"center"} justifyContent={"center"} textAlign={"center"}>
<Grid item xs={12}>
<Grid xs={12}>
<Stepper activeStep={activeStep}>
{steps.map((label, index) => {
const stepProps: { completed?: boolean } = {};
@ -432,7 +450,7 @@ export default function TOTPRegisterDialogController(props: Props) {
})}
</Stepper>
</Grid>
<Grid item xs={12}>
<Grid xs={12}>
<Grid container spacing={2} paddingY={3} justifyContent={"center"}>
{renderStep(activeStep)}
</Grid>
@ -440,23 +458,13 @@ export default function TOTPRegisterDialogController(props: Props) {
</Grid>
</DialogContent>
<DialogActions>
<Button
variant={"outlined"}
color={"primary"}
onClick={handleSetStepPrevious}
disabled={activeStep === 0}
>
<Button color={"primary"} onClick={handleSetStepPrevious} disabled={activeStep === 0}>
{translate("Previous")}
</Button>
<Button variant={"outlined"} color={"primary"} onClick={handleClose}>
<Button color={"error"} onClick={handleClose}>
{translate("Cancel")}
</Button>
<Button
variant={"outlined"}
color={"primary"}
onClick={handleSetStepNext}
disabled={activeStep === steps.length - 1}
>
<Button color={"primary"} onClick={handleSetStepNext} disabled={activeStep === steps.length - 1}>
{translate("Next")}
</Button>
</DialogActions>
@ -465,10 +473,6 @@ export default function TOTPRegisterDialogController(props: Props) {
}
const useStyles = makeStyles((theme: Theme) => ({
root: {
paddingTop: theme.spacing(4),
paddingBottom: theme.spacing(4),
},
qrcode: {
marginTop: theme.spacing(2),
marginBottom: theme.spacing(2),
@ -483,15 +487,10 @@ const useStyles = makeStyles((theme: Theme) => ({
marginBottom: theme.spacing(1),
width: "256px",
},
googleAuthenticator: {},
googleAuthenticatorText: {
fontSize: theme.typography.fontSize * 0.8,
},
googleAuthenticatorBadges: {},
secretButtons: {},
doneButton: {
width: "256px",
},
qrcodeContainer: {
position: "relative",
display: "inline-block",

View File

@ -4,7 +4,7 @@ import Grid from "@mui/material/Unstable_Grid2";
import { useNotifications } from "@hooks/NotificationsContext";
import { useUserInfoPOST } from "@hooks/UserInfo";
import { useUserInfoTOTPConfiguration, useUserInfoTOTPConfigurationOptional } from "@hooks/UserInfoTOTPConfiguration";
import { useUserInfoTOTPConfigurationOptional } from "@hooks/UserInfoTOTPConfiguration";
import { useUserWebAuthnDevices } from "@hooks/WebAuthnDevices";
import TOTPPanel from "@views/Settings/TwoFactorAuthentication/TOTPPanel";
import WebAuthnDevicesPanel from "@views/Settings/TwoFactorAuthentication/WebAuthnDevicesPanel";