diff --git a/internal/handlers/handler_webauthn_devices.go b/internal/handlers/handler_webauthn_devices.go new file mode 100644 index 000000000..7e0bec222 --- /dev/null +++ b/internal/handlers/handler_webauthn_devices.go @@ -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 + } +} diff --git a/internal/model/webauthn.go b/internal/model/webauthn.go index a6fcc4084..5a1c39a0b 100644 --- a/internal/model/webauthn.go +++ b/internal/model/webauthn.go @@ -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. diff --git a/internal/server/handlers.go b/internal/server/handlers.go index 389743d39..d2808027c 100644 --- a/internal/server/handlers.go +++ b/internal/server/handlers.go @@ -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)) diff --git a/web/src/App.tsx b/web/src/App.tsx index 77ea8aff7..5c073ae2e 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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) => { } /> } /> } /> + } /> @@ -32,40 +37,77 @@ const LoginLayout = function (props: Props) { useEffect(() => { document.title = `${translate("Login")} - Authelia`; }, [translate]); + + const handleSettingsClick = () => { + navigate({ + pathname: SettingsRoute, + }); + }; + return ( - - - - - {logo} + + + + + {props.showSettings ? ( + + + + ) : null} + + + + + + + {logo} + + {props.title ? ( + + + + ) : null} + {props.subtitle ? ( + + + + ) : null} + + {props.children} + + {props.showBrand ? ( + + + {translate("Powered by")} Authelia + + + ) : null} - {props.title ? ( - - - - ) : null} - {props.subtitle ? ( - - - - ) : null} - - {props.children} - - {props.showBrand ? ( - - - {translate("Powered by")} Authelia - - - ) : null} - - - + + + ); }; diff --git a/web/src/models/Webauthn.ts b/web/src/models/Webauthn.ts index bdbfb2f6a..048bd65f0 100644 --- a/web/src/models/Webauthn.ts +++ b/web/src/models/Webauthn.ts @@ -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; +} diff --git a/web/src/services/Api.ts b/web/src/services/Api.ts index 03bb4e1ce..7e8320718 100644 --- a/web/src/services/Api.ts +++ b/web/src/services/Api.ts @@ -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"; diff --git a/web/src/services/UserWebauthnDevices.ts b/web/src/services/UserWebauthnDevices.ts new file mode 100644 index 000000000..ca0dd49a1 --- /dev/null +++ b/web/src/services/UserWebauthnDevices.ts @@ -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 { + return Get(WebauthnDevicesPath); +} \ No newline at end of file diff --git a/web/src/views/LoginPortal/SecondFactor/SecondFactorForm.tsx b/web/src/views/LoginPortal/SecondFactor/SecondFactorForm.tsx index dde55deac..4689fb223 100644 --- a/web/src/views/LoginPortal/SecondFactor/SecondFactorForm.tsx +++ b/web/src/views/LoginPortal/SecondFactor/SecondFactorForm.tsx @@ -85,7 +85,12 @@ const SecondFactorForm = function (props: Props) { }; return ( - + {props.configuration.available_methods.size > 1 ? ( { + if (props.onClose) { + props.onClose({}, "backdropClick"); + } + } + + const handleCancelClick = () => { + if (props.onClose) { + props.onClose({}, "backdropClick"); + } + } + + return ( + + Add new Security Key + + + Provide the details for the new security key. + + + + + + + + + ); +} \ No newline at end of file diff --git a/web/src/views/Settings/SettingsView.tsx b/web/src/views/Settings/SettingsView.tsx new file mode 100644 index 000000000..645a2ae73 --- /dev/null +++ b/web/src/views/Settings/SettingsView.tsx @@ -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(); + const [addKeyOpen, setAddKeyOpen] = useState(false); + + useEffect(() => { + (async function() { + const devices = await getWebauthnDevices(); + setWebauthnDevices(devices); + })() + }, []); + + const handleKeyClose = () => { + setAddKeyOpen(false); + } + + const handleAddKeyButtonClick = () => { + setAddKeyOpen(true); + } + + return ( + + theme.zIndex.drawer + 1 }}> + + Settings + + + + + + + + + + + + + + + + + + + + + Manage your security keys + + + + + + + + + + + + Name + Enabled + Activation + Public Key + Actions + + + + {webauthnDevices ? webauthnDevices.map((x, idx) => { + return ( + + {x.description} + + {(false) ? "" : "Not enabled"} + + +
+ {x.public_key} +
+
+
+ + + + + + + + + + +
+ ) + }) : null} +
+
+
+
+
+
+ +
+ ); +} diff --git a/web/vite.config.ts b/web/vite.config.ts index 659ba3e16..87fc61750 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -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()], }; });