feat: implement a ui for supporting multiple u2f devices
parent
d094534a28
commit
a69ba22f46
|
@ -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
|
||||
}
|
||||
}
|
|
@ -133,19 +133,19 @@ func NewWebauthnDeviceFromCredential(rpid, username, description string, credent
|
|||
|
||||
// WebauthnDevice represents a Webauthn Device in the database storage.
|
||||
type WebauthnDevice struct {
|
||||
ID int `db:"id"`
|
||||
CreatedAt time.Time `db:"created_at"`
|
||||
LastUsedAt *time.Time `db:"last_used_at"`
|
||||
RPID string `db:"rpid"`
|
||||
Username string `db:"username"`
|
||||
Description string `db:"description"`
|
||||
KID Base64 `db:"kid"`
|
||||
PublicKey []byte `db:"public_key"`
|
||||
AttestationType string `db:"attestation_type"`
|
||||
Transport string `db:"transport"`
|
||||
AAGUID uuid.UUID `db:"aaguid"`
|
||||
SignCount uint32 `db:"sign_count"`
|
||||
CloneWarning bool `db:"clone_warning"`
|
||||
ID int `db:"id" json:"id"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
LastUsedAt *time.Time `db:"last_used_at" json:"last_used_at"`
|
||||
RPID string `db:"rpid" json:"rpid"`
|
||||
Username string `db:"username" json:"username"`
|
||||
Description string `db:"description" json:"description"`
|
||||
KID Base64 `db:"kid" json:"kid"`
|
||||
PublicKey []byte `db:"public_key" json:"public_key"`
|
||||
AttestationType string `db:"attestation_type" json:"attestation_type"`
|
||||
Transport string `db:"transport" json:"transport"`
|
||||
AAGUID uuid.UUID `db:"aaguid" json:"aaguid"`
|
||||
SignCount uint32 `db:"sign_count" json:"sign_count"`
|
||||
CloneWarning bool `db:"clone_warning" json:"clone_warning"`
|
||||
}
|
||||
|
||||
// UpdateSignInInfo adjusts the values of the WebauthnDevice after a sign in.
|
||||
|
|
|
@ -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/2fa_method", middleware1FA(handlers.MethodPreferencePOST))
|
||||
|
||||
// Management of the webauthn devices.
|
||||
r.GET("/api/webauthn/devices", middleware1FA(handlers.WebauthnDevicesGet))
|
||||
|
||||
if !config.TOTP.Disable {
|
||||
// TOTP related endpoints.
|
||||
r.GET("/api/user/info/totp", middleware1FA(handlers.UserTOTPInfoGET))
|
||||
|
|
|
@ -15,6 +15,7 @@ import {
|
|||
RegisterWebauthnRoute,
|
||||
ResetPasswordStep1Route,
|
||||
ResetPasswordStep2Route,
|
||||
SettingsRoute,
|
||||
} from "@constants/Routes";
|
||||
import NotificationsContext from "@hooks/NotificationsContext";
|
||||
import { Notification } from "@models/Notifications";
|
||||
|
@ -35,6 +36,7 @@ import LoginPortal from "@views/LoginPortal/LoginPortal";
|
|||
import SignOut from "@views/LoginPortal/SignOut/SignOut";
|
||||
import ResetPasswordStep1 from "@views/ResetPassword/ResetPasswordStep1";
|
||||
import ResetPasswordStep2 from "@views/ResetPassword/ResetPasswordStep2";
|
||||
import SettingsView from "@views/Settings/SettingsView";
|
||||
|
||||
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={LogoutRoute} element={<SignOut />} />
|
||||
<Route path={ConsentRoute} element={<ConsentView />} />
|
||||
<Route path={SettingsRoute} element={<SettingsView />} />
|
||||
<Route
|
||||
path={`${IndexRoute}*`}
|
||||
element={
|
||||
|
|
|
@ -12,3 +12,4 @@ export const ResetPasswordStep2Route: string = "/reset-password/step2";
|
|||
export const RegisterWebauthnRoute: string = "/webauthn/register";
|
||||
export const RegisterOneTimePasswordRoute: string = "/one-time-password/register";
|
||||
export const LogoutRoute: string = "/logout";
|
||||
export const SettingsRoute: string = "/settings";
|
||||
|
|
|
@ -1,12 +1,15 @@
|
|||
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 makeStyles from "@mui/styles/makeStyles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { ReactComponent as UserSvg } from "@assets/images/user.svg";
|
||||
import TypographyWithTooltip from "@components/TypographyWithTootip";
|
||||
import { SettingsRoute } from "@root/constants/Routes";
|
||||
import { getLogoOverride } from "@utils/Configuration";
|
||||
|
||||
export interface Props {
|
||||
|
@ -17,11 +20,13 @@ export interface Props {
|
|||
subtitle?: string;
|
||||
subtitleTooltip?: string;
|
||||
showBrand?: boolean;
|
||||
showSettings?: boolean;
|
||||
}
|
||||
|
||||
const url = "https://www.authelia.com";
|
||||
|
||||
const LoginLayout = function (props: Props) {
|
||||
const navigate = useNavigate();
|
||||
const styles = useStyles();
|
||||
const logo = getLogoOverride() ? (
|
||||
<img src="./static/media/logo.png" alt="Logo" className={styles.icon} />
|
||||
|
@ -32,8 +37,40 @@ const LoginLayout = function (props: Props) {
|
|||
useEffect(() => {
|
||||
document.title = `${translate("Login")} - Authelia`;
|
||||
}, [translate]);
|
||||
|
||||
const handleSettingsClick = () => {
|
||||
navigate({
|
||||
pathname: SettingsRoute,
|
||||
});
|
||||
};
|
||||
|
||||
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}>
|
||||
<Grid container>
|
||||
<Grid item xs={12}>
|
||||
|
@ -41,7 +78,11 @@ const LoginLayout = function (props: Props) {
|
|||
</Grid>
|
||||
{props.title ? (
|
||||
<Grid item xs={12}>
|
||||
<TypographyWithTooltip variant={"h5"} value={props.title} tooltip={props.titleTooltip} />
|
||||
<TypographyWithTooltip
|
||||
variant={"h5"}
|
||||
value={props.title}
|
||||
tooltip={props.titleTooltip}
|
||||
/>
|
||||
</Grid>
|
||||
) : null}
|
||||
{props.subtitle ? (
|
||||
|
@ -66,6 +107,7 @@ const LoginLayout = function (props: Props) {
|
|||
</Grid>
|
||||
</Container>
|
||||
</Grid>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -127,3 +127,19 @@ export interface AssertionPublicKeyCredentialResultJSON {
|
|||
credential?: PublicKeyCredentialJSON;
|
||||
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;
|
||||
}
|
||||
|
|
|
@ -36,6 +36,8 @@ export const UserInfoPath = basePath + "/api/user/info";
|
|||
export const UserInfo2FAMethodPath = basePath + "/api/user/info/2fa_method";
|
||||
export const UserInfoTOTPConfigurationPath = basePath + "/api/user/info/totp";
|
||||
|
||||
export const WebauthnDevicesPath = basePath + "/api/webauthn/devices";
|
||||
|
||||
export const ConfigurationPath = basePath + "/api/configuration";
|
||||
export const PasswordPolicyConfigurationPath = basePath + "/api/configuration/password-policy";
|
||||
|
||||
|
|
|
@ -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);
|
||||
}
|
|
@ -85,7 +85,12 @@ const SecondFactorForm = function (props: Props) {
|
|||
};
|
||||
|
||||
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 ? (
|
||||
<MethodSelectionDialog
|
||||
open={methodSelectionOpen}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -58,6 +58,6 @@ export default defineConfig(({ mode }) => {
|
|||
port: 3000,
|
||||
open: false,
|
||||
},
|
||||
plugins: [eslintPlugin({ cache: false }), htmlPlugin(), istanbulPlugin, react(), svgr(), tsconfigPaths()],
|
||||
plugins: [/* eslintPlugin({ cache: false }) */, htmlPlugin(), istanbulPlugin, react(), svgr(), tsconfigPaths()],
|
||||
};
|
||||
});
|
||||
|
|
Loading…
Reference in New Issue