feat: implement a ui for supporting multiple u2f devices

pull/4372/head
Clément Michaud 2022-10-30 09:52:49 +01:00
parent d094534a28
commit a69ba22f46
13 changed files with 313 additions and 48 deletions

View File

@ -0,0 +1,21 @@
package handlers
import (
"github.com/authelia/authelia/v4/internal/middlewares"
)
func WebauthnDevicesGet(ctx *middlewares.AutheliaCtx) {
userSession := ctx.GetSession()
devices, err := ctx.Providers.StorageProvider.LoadWebauthnDevicesByUsername(ctx, userSession.Username)
if err != nil {
ctx.Error(err, messageOperationFailed)
return
}
if err = ctx.SetJSONBody(devices); err != nil {
ctx.Error(err, messageOperationFailed)
return
}
}

View File

@ -133,19 +133,19 @@ func NewWebauthnDeviceFromCredential(rpid, username, description string, credent
// WebauthnDevice represents a Webauthn Device in the database storage. // WebauthnDevice represents a Webauthn Device in the database storage.
type WebauthnDevice struct { type WebauthnDevice struct {
ID int `db:"id"` ID int `db:"id" json:"id"`
CreatedAt time.Time `db:"created_at"` CreatedAt time.Time `db:"created_at" json:"created_at"`
LastUsedAt *time.Time `db:"last_used_at"` LastUsedAt *time.Time `db:"last_used_at" json:"last_used_at"`
RPID string `db:"rpid"` RPID string `db:"rpid" json:"rpid"`
Username string `db:"username"` Username string `db:"username" json:"username"`
Description string `db:"description"` Description string `db:"description" json:"description"`
KID Base64 `db:"kid"` KID Base64 `db:"kid" json:"kid"`
PublicKey []byte `db:"public_key"` PublicKey []byte `db:"public_key" json:"public_key"`
AttestationType string `db:"attestation_type"` AttestationType string `db:"attestation_type" json:"attestation_type"`
Transport string `db:"transport"` Transport string `db:"transport" json:"transport"`
AAGUID uuid.UUID `db:"aaguid"` AAGUID uuid.UUID `db:"aaguid" json:"aaguid"`
SignCount uint32 `db:"sign_count"` SignCount uint32 `db:"sign_count" json:"sign_count"`
CloneWarning bool `db:"clone_warning"` CloneWarning bool `db:"clone_warning" json:"clone_warning"`
} }
// UpdateSignInInfo adjusts the values of the WebauthnDevice after a sign in. // UpdateSignInInfo adjusts the values of the WebauthnDevice after a sign in.

View File

@ -188,6 +188,9 @@ func handleRouter(config schema.Configuration, providers middlewares.Providers)
r.POST("/api/user/info", middleware1FA(handlers.UserInfoPOST)) r.POST("/api/user/info", middleware1FA(handlers.UserInfoPOST))
r.POST("/api/user/info/2fa_method", middleware1FA(handlers.MethodPreferencePOST)) r.POST("/api/user/info/2fa_method", middleware1FA(handlers.MethodPreferencePOST))
// Management of the webauthn devices.
r.GET("/api/webauthn/devices", middleware1FA(handlers.WebauthnDevicesGet))
if !config.TOTP.Disable { if !config.TOTP.Disable {
// TOTP related endpoints. // TOTP related endpoints.
r.GET("/api/user/info/totp", middleware1FA(handlers.UserTOTPInfoGET)) r.GET("/api/user/info/totp", middleware1FA(handlers.UserTOTPInfoGET))

View File

@ -15,6 +15,7 @@ import {
RegisterWebauthnRoute, RegisterWebauthnRoute,
ResetPasswordStep1Route, ResetPasswordStep1Route,
ResetPasswordStep2Route, ResetPasswordStep2Route,
SettingsRoute,
} from "@constants/Routes"; } from "@constants/Routes";
import NotificationsContext from "@hooks/NotificationsContext"; import NotificationsContext from "@hooks/NotificationsContext";
import { Notification } from "@models/Notifications"; import { Notification } from "@models/Notifications";
@ -35,6 +36,7 @@ import LoginPortal from "@views/LoginPortal/LoginPortal";
import SignOut from "@views/LoginPortal/SignOut/SignOut"; import SignOut from "@views/LoginPortal/SignOut/SignOut";
import ResetPasswordStep1 from "@views/ResetPassword/ResetPasswordStep1"; import ResetPasswordStep1 from "@views/ResetPassword/ResetPasswordStep1";
import ResetPasswordStep2 from "@views/ResetPassword/ResetPasswordStep2"; import ResetPasswordStep2 from "@views/ResetPassword/ResetPasswordStep2";
import SettingsView from "@views/Settings/SettingsView";
import "@fortawesome/fontawesome-svg-core/styles.css"; import "@fortawesome/fontawesome-svg-core/styles.css";
@ -93,6 +95,7 @@ const App: React.FC<Props> = (props: Props) => {
<Route path={RegisterOneTimePasswordRoute} element={<RegisterOneTimePassword />} /> <Route path={RegisterOneTimePasswordRoute} element={<RegisterOneTimePassword />} />
<Route path={LogoutRoute} element={<SignOut />} /> <Route path={LogoutRoute} element={<SignOut />} />
<Route path={ConsentRoute} element={<ConsentView />} /> <Route path={ConsentRoute} element={<ConsentView />} />
<Route path={SettingsRoute} element={<SettingsView />} />
<Route <Route
path={`${IndexRoute}*`} path={`${IndexRoute}*`}
element={ element={

View File

@ -12,3 +12,4 @@ export const ResetPasswordStep2Route: string = "/reset-password/step2";
export const RegisterWebauthnRoute: string = "/webauthn/register"; export const RegisterWebauthnRoute: string = "/webauthn/register";
export const RegisterOneTimePasswordRoute: string = "/one-time-password/register"; export const RegisterOneTimePasswordRoute: string = "/one-time-password/register";
export const LogoutRoute: string = "/logout"; export const LogoutRoute: string = "/logout";
export const SettingsRoute: string = "/settings";

View File

@ -1,12 +1,15 @@
import React, { ReactNode, useEffect } from "react"; import React, { ReactNode, useEffect } from "react";
import { Container, Grid, Link, Theme } from "@mui/material"; import SettingsIcon from "@mui/icons-material/Settings";
import { AppBar, Box, Container, Grid, IconButton, Link, Theme, Toolbar, Typography } from "@mui/material";
import { grey } from "@mui/material/colors"; import { grey } from "@mui/material/colors";
import makeStyles from "@mui/styles/makeStyles"; import makeStyles from "@mui/styles/makeStyles";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { ReactComponent as UserSvg } from "@assets/images/user.svg"; import { ReactComponent as UserSvg } from "@assets/images/user.svg";
import TypographyWithTooltip from "@components/TypographyWithTootip"; import TypographyWithTooltip from "@components/TypographyWithTootip";
import { SettingsRoute } from "@root/constants/Routes";
import { getLogoOverride } from "@utils/Configuration"; import { getLogoOverride } from "@utils/Configuration";
export interface Props { export interface Props {
@ -17,11 +20,13 @@ export interface Props {
subtitle?: string; subtitle?: string;
subtitleTooltip?: string; subtitleTooltip?: string;
showBrand?: boolean; showBrand?: boolean;
showSettings?: boolean;
} }
const url = "https://www.authelia.com"; const url = "https://www.authelia.com";
const LoginLayout = function (props: Props) { const LoginLayout = function (props: Props) {
const navigate = useNavigate();
const styles = useStyles(); const styles = useStyles();
const logo = getLogoOverride() ? ( const logo = getLogoOverride() ? (
<img src="./static/media/logo.png" alt="Logo" className={styles.icon} /> <img src="./static/media/logo.png" alt="Logo" className={styles.icon} />
@ -32,8 +37,40 @@ const LoginLayout = function (props: Props) {
useEffect(() => { useEffect(() => {
document.title = `${translate("Login")} - Authelia`; document.title = `${translate("Login")} - Authelia`;
}, [translate]); }, [translate]);
const handleSettingsClick = () => {
navigate({
pathname: SettingsRoute,
});
};
return ( return (
<Grid id={props.id} className={styles.root} container spacing={0} alignItems="center" justifyContent="center"> <Box>
<AppBar position="static" color="transparent" elevation={0}>
<Toolbar variant="dense">
<Typography style={{ flexGrow: 1 }} />
{props.showSettings ? (
<IconButton
size="large"
edge="start"
color="inherit"
aria-label="menu"
sx={{ mr: 2 }}
onClick={handleSettingsClick}
>
<SettingsIcon />
</IconButton>
) : null}
</Toolbar>
</AppBar>
<Grid
id={props.id}
className={styles.root}
container
spacing={0}
alignItems="center"
justifyContent="center"
>
<Container maxWidth="xs" className={styles.rootContainer}> <Container maxWidth="xs" className={styles.rootContainer}>
<Grid container> <Grid container>
<Grid item xs={12}> <Grid item xs={12}>
@ -41,7 +78,11 @@ const LoginLayout = function (props: Props) {
</Grid> </Grid>
{props.title ? ( {props.title ? (
<Grid item xs={12}> <Grid item xs={12}>
<TypographyWithTooltip variant={"h5"} value={props.title} tooltip={props.titleTooltip} /> <TypographyWithTooltip
variant={"h5"}
value={props.title}
tooltip={props.titleTooltip}
/>
</Grid> </Grid>
) : null} ) : null}
{props.subtitle ? ( {props.subtitle ? (
@ -66,6 +107,7 @@ const LoginLayout = function (props: Props) {
</Grid> </Grid>
</Container> </Container>
</Grid> </Grid>
</Box>
); );
}; };

View File

@ -127,3 +127,19 @@ export interface AssertionPublicKeyCredentialResultJSON {
credential?: PublicKeyCredentialJSON; credential?: PublicKeyCredentialJSON;
result: AssertionResult; result: AssertionResult;
} }
export interface WebauthnDevice {
id: string;
created_at: Date;
last_used_at?: Date;
rpid: string;
username: string;
description: string;
kid: string;
public_key: Uint8Array;
attestation_type: string;
transport: string;
aaguid: string;
sign_count: number;
clone_warning: boolean;
}

View File

@ -36,6 +36,8 @@ export const UserInfoPath = basePath + "/api/user/info";
export const UserInfo2FAMethodPath = basePath + "/api/user/info/2fa_method"; export const UserInfo2FAMethodPath = basePath + "/api/user/info/2fa_method";
export const UserInfoTOTPConfigurationPath = basePath + "/api/user/info/totp"; export const UserInfoTOTPConfigurationPath = basePath + "/api/user/info/totp";
export const WebauthnDevicesPath = basePath + "/api/webauthn/devices";
export const ConfigurationPath = basePath + "/api/configuration"; export const ConfigurationPath = basePath + "/api/configuration";
export const PasswordPolicyConfigurationPath = basePath + "/api/configuration/password-policy"; export const PasswordPolicyConfigurationPath = basePath + "/api/configuration/password-policy";

View File

@ -0,0 +1,9 @@
import { WebauthnDevice } from "@root/models/Webauthn";
import { WebauthnDevicesPath } from "./Api";
import { Get } from "./Client";
// getWebauthnDevices returns the list of webauthn devices for the authenticated user.
export async function getWebauthnDevices(): Promise<WebauthnDevice[]> {
return Get<WebauthnDevice[]>(WebauthnDevicesPath);
}

View File

@ -85,7 +85,12 @@ const SecondFactorForm = function (props: Props) {
}; };
return ( return (
<LoginLayout id="second-factor-stage" title={`${translate("Hi")} ${props.userInfo.display_name}`} showBrand> <LoginLayout
id="second-factor-stage"
title={`${translate("Hi")} ${props.userInfo.display_name}`}
showBrand
showSettings
>
{props.configuration.available_methods.size > 1 ? ( {props.configuration.available_methods.size > 1 ? (
<MethodSelectionDialog <MethodSelectionDialog
open={methodSelectionOpen} open={methodSelectionOpen}

View File

@ -0,0 +1,42 @@
import { Button, Dialog, DialogActions, DialogContent, DialogContentText, DialogProps, DialogTitle, TextField } from "@mui/material";
import React from "react";
interface Props extends DialogProps {};
export default function AddSecurityKeyDialog(props: Props) {
const handleAddClick = () => {
if (props.onClose) {
props.onClose({}, "backdropClick");
}
}
const handleCancelClick = () => {
if (props.onClose) {
props.onClose({}, "backdropClick");
}
}
return (
<Dialog {...props}>
<DialogTitle>Add new Security Key</DialogTitle>
<DialogContent>
<DialogContentText>
Provide the details for the new security key.
</DialogContentText>
<TextField
autoFocus
margin="dense"
id="description"
label="Description"
type="text"
fullWidth
variant="standard"
/>
</DialogContent>
<DialogActions>
<Button onClick={handleCancelClick}>Cancel</Button>
<Button onClick={handleAddClick}>Add</Button>
</DialogActions>
</Dialog>
);
}

View File

@ -0,0 +1,121 @@
import React, { useEffect, useState } from "react";
import { AppBar, Box, Button, Drawer, Grid, IconButton, List, ListItem, ListItemButton, ListItemIcon, ListItemText, Paper, Stack, Switch, Table, TableBody, TableCell, TableHead, TableRow, Toolbar, Tooltip, Typography } from "@mui/material";
import SystemSecurityUpdateGoodIcon from '@mui/icons-material/SystemSecurityUpdateGood';
import DeleteIcon from '@mui/icons-material/Delete';
import EditIcon from '@mui/icons-material/Edit';
import { getWebauthnDevices } from "@root/services/UserWebauthnDevices";
import { WebauthnDevice } from "@root/models/Webauthn";
import AddSecurityKeyDialog from "./AddSecurityDialog";
interface Props {}
const drawerWidth = 240;
export default function SettingsView(props: Props) {
const [webauthnDevices, setWebauthnDevices] = useState<WebauthnDevice[] | undefined>();
const [addKeyOpen, setAddKeyOpen] = useState<boolean>(false);
useEffect(() => {
(async function() {
const devices = await getWebauthnDevices();
setWebauthnDevices(devices);
})()
}, []);
const handleKeyClose = () => {
setAddKeyOpen(false);
}
const handleAddKeyButtonClick = () => {
setAddKeyOpen(true);
}
return (
<Box sx={{ display: 'flex' }}>
<AppBar position="fixed" sx={{ zIndex: (theme) => theme.zIndex.drawer + 1 }}>
<Toolbar variant="dense">
<Typography style={{ flexGrow: 1 }}>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>
<ListItem disablePadding>
<ListItemButton selected={true}>
<ListItemIcon>
<SystemSecurityUpdateGoodIcon />
</ListItemIcon>
<ListItemText primary={"Security Keys"} />
</ListItemButton>
</ListItem>
</List>
</Box>
</Drawer>
<Box component="main" sx={{ flexGrow: 1, p: 3 }}>
<Grid container spacing={2}>
<Grid item xs={12}>
<Typography>Manage your security keys</Typography>
</Grid>
<Grid item xs={12}>
<Stack spacing={1} direction="row">
<Button color="primary" variant="contained" onClick={handleAddKeyButtonClick}>Add</Button>
</Stack>
</Grid>
<Grid item xs={12}>
<Paper>
<Table>
<TableHead>
<TableRow>
<TableCell>Name</TableCell>
<TableCell>Enabled</TableCell>
<TableCell>Activation</TableCell>
<TableCell>Public Key</TableCell>
<TableCell>Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{webauthnDevices ? webauthnDevices.map((x, idx) => {
return (
<TableRow key={x.description}>
<TableCell>{x.description}</TableCell>
<TableCell><Switch defaultChecked={false} size="small" /></TableCell>
<TableCell><Typography>{(false) ? "<ADATE>" : "Not enabled"}</Typography></TableCell>
<TableCell>
<Tooltip title={x.public_key}>
<div style={{overflow: "hidden", textOverflow: "ellipsis", width: '300px'}}>
<Typography noWrap>{x.public_key}</Typography>
</div>
</Tooltip>
</TableCell>
<TableCell>
<Stack direction="row" spacing={1}>
<IconButton aria-label="edit">
<EditIcon />
</IconButton>
<IconButton aria-label="delete">
<DeleteIcon />
</IconButton>
</Stack>
</TableCell>
</TableRow>
)
}) : null}
</TableBody>
</Table>
</Paper>
</Grid>
</Grid>
</Box>
<AddSecurityKeyDialog open={addKeyOpen} onClose={handleKeyClose} />
</Box>
);
}

View File

@ -58,6 +58,6 @@ export default defineConfig(({ mode }) => {
port: 3000, port: 3000,
open: false, open: false,
}, },
plugins: [eslintPlugin({ cache: false }), htmlPlugin(), istanbulPlugin, react(), svgr(), tsconfigPaths()], plugins: [/* eslintPlugin({ cache: false }) */, htmlPlugin(), istanbulPlugin, react(), svgr(), tsconfigPaths()],
}; };
}); });