From 3c21770c25da74953042b6eb7542d5ba983593b6 Mon Sep 17 00:00:00 2001 From: Matthew Penner Date: Sat, 17 Jul 2021 13:20:03 -0600 Subject: [PATCH] ui(account): add security key management --- .../{getWebauthn.ts => getWebauthnKeys.ts} | 8 +- .../api/account/webauthn/registerKey.ts | 6 +- .../dashboard/AccountOverviewContainer.tsx | 2 +- .../dashboard/SecurityKeyContainer.tsx | 127 ++++++++++++++++++ resources/scripts/routers/DashboardRouter.tsx | 9 +- routes/api-client.php | 5 + routes/auth.php | 1 + 7 files changed, 148 insertions(+), 10 deletions(-) rename resources/scripts/api/account/webauthn/{getWebauthn.ts => getWebauthnKeys.ts} (70%) create mode 100644 resources/scripts/components/dashboard/SecurityKeyContainer.tsx diff --git a/resources/scripts/api/account/webauthn/getWebauthn.ts b/resources/scripts/api/account/webauthn/getWebauthnKeys.ts similarity index 70% rename from resources/scripts/api/account/webauthn/getWebauthn.ts rename to resources/scripts/api/account/webauthn/getWebauthnKeys.ts index b487c04f5..e13085533 100644 --- a/resources/scripts/api/account/webauthn/getWebauthn.ts +++ b/resources/scripts/api/account/webauthn/getWebauthnKeys.ts @@ -1,23 +1,23 @@ import http from '@/api/http'; -export interface Key { +export interface WebauthnKey { id: number; name: string; createdAt: Date; lastUsedAt: Date; } -export const rawDataToKey = (data: any): Key => ({ +export const rawDataToWebauthnKey = (data: any): WebauthnKey => ({ id: data.id, name: data.name, createdAt: new Date(data.created_at), lastUsedAt: new Date(data.last_used_at) || new Date(), }); -export default (): Promise => { +export default (): Promise => { return new Promise((resolve, reject) => { http.get('/api/client/account/webauthn') - .then(({ data }) => resolve((data.data || []).map((d: any) => rawDataToKey(d.attributes)))) + .then(({ data }) => resolve((data.data || []).map((d: any) => rawDataToWebauthnKey(d.attributes)))) .catch(reject); }); }; diff --git a/resources/scripts/api/account/webauthn/registerKey.ts b/resources/scripts/api/account/webauthn/registerKey.ts index 878b78d85..3de54a611 100644 --- a/resources/scripts/api/account/webauthn/registerKey.ts +++ b/resources/scripts/api/account/webauthn/registerKey.ts @@ -1,5 +1,5 @@ import http from '@/api/http'; -import { Key, rawDataToKey } from '@/api/account/webauthn/getWebauthn'; +import { rawDataToWebauthnKey, WebauthnKey } from '@/api/account/webauthn/getWebauthnKeys'; export const base64Decode = (input: string): string => { input = input.replace(/-/g, '+').replace(/_/g, '/'); @@ -32,7 +32,7 @@ export const decodeCredentials = (credentials: PublicKeyCredentialDescriptor[]) }); }; -export default (name: string): Promise => { +export default (name: string): Promise => { return new Promise((resolve, reject) => { http.get('/api/client/account/webauthn/register').then((res) => { const publicKey = res.data.public_key; @@ -67,7 +67,7 @@ export default (name: string): Promise => { clientDataJSON: bufferEncode(response.clientDataJSON), }, }), - }).then(({ data }) => resolve(rawDataToKey(data.attributes))).catch(reject); + }).then(({ data }) => resolve(rawDataToWebauthnKey(data.attributes))).catch(reject); }).catch(reject); }); }; diff --git a/resources/scripts/components/dashboard/AccountOverviewContainer.tsx b/resources/scripts/components/dashboard/AccountOverviewContainer.tsx index 1baf6b253..9f9611956 100644 --- a/resources/scripts/components/dashboard/AccountOverviewContainer.tsx +++ b/resources/scripts/components/dashboard/AccountOverviewContainer.tsx @@ -27,7 +27,7 @@ const Container = styled.div` `; export default () => { - const { state } = useLocation(); + const { state } = useLocation<{ twoFactorRedirect?: boolean } | undefined>(); return ( diff --git a/resources/scripts/components/dashboard/SecurityKeyContainer.tsx b/resources/scripts/components/dashboard/SecurityKeyContainer.tsx new file mode 100644 index 000000000..78a0ccbc2 --- /dev/null +++ b/resources/scripts/components/dashboard/SecurityKeyContainer.tsx @@ -0,0 +1,127 @@ +import React, { useEffect, useState } from 'react'; +import { Form, Formik, FormikHelpers } from 'formik'; +import tw from 'twin.macro'; +import { object, string } from 'yup'; +import getWebauthnKeys, { WebauthnKey } from '@/api/account/webauthn/getWebauthnKeys'; +import registerKey from '@/api/account/webauthn/registerKey'; +import FlashMessageRender from '@/components/FlashMessageRender'; +import Button from '@/components/elements/Button'; +import ContentBox from '@/components/elements/ContentBox'; +import Field from '@/components/elements/Field'; +import GreyRowBox from '@/components/elements/GreyRowBox'; +import PageContentBlock from '@/components/elements/PageContentBlock'; +import SpinnerOverlay from '@/components/elements/SpinnerOverlay'; +import useFlash from '@/plugins/useFlash'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faKey, faTrashAlt } from '@fortawesome/free-solid-svg-icons'; +import { format } from 'date-fns'; + +interface Values { + name: string; +} + +const AddSecurityKeyForm = ({ onKeyAdded }: { onKeyAdded: (key: WebauthnKey) => void }) => { + const { clearFlashes, clearAndAddHttpError } = useFlash(); + + const submit = ({ name }: Values, { setSubmitting, resetForm }: FormikHelpers) => { + clearFlashes('security_keys'); + + registerKey(name) + .then(key => { + resetForm(); + setSubmitting(false); + onKeyAdded(key); + }) + .catch(error => { + console.error(error); + clearAndAddHttpError({ key: 'security_keys', error }); + setSubmitting(false); + }); + }; + + return ( + + {({ isSubmitting }) => ( +
+ + +
+ +
+ + )} +
+ ); +}; + +export default () => { + const { clearFlashes, clearAndAddHttpError } = useFlash(); + + const [ keys, setKeys ] = useState([]); + const [ loading, setLoading ] = useState(true); + + useEffect(() => { + clearFlashes('security_keys'); + + getWebauthnKeys() + .then(keys => setKeys(keys)) + .then(() => setLoading(false)) + .catch(error => { + console.error(error); + clearAndAddHttpError({ key: 'security_keys', error }); + }); + }, []); + + return ( + + +
+ + + {keys.length === 0 ? + !loading ? +

+ No security keys have been configured for this account. +

+ : null + : + keys.map((key, index) => ( + 0 && tw`mt-2` ]}> + +
+

{key.name}

+

+ Last used:  + {key.lastUsedAt ? format(key.lastUsedAt, 'MMM do, yyyy HH:mm') : 'Never'} +

+
+ +
+ )) + } +
+ + + setKeys(s => ([ ...s!, key ]))}/> + +
+
+ ); +}; diff --git a/resources/scripts/routers/DashboardRouter.tsx b/resources/scripts/routers/DashboardRouter.tsx index 513cc3fa8..8fd3c2293 100644 --- a/resources/scripts/routers/DashboardRouter.tsx +++ b/resources/scripts/routers/DashboardRouter.tsx @@ -1,11 +1,12 @@ import React from 'react'; import { NavLink, Route, RouteComponentProps, Switch } from 'react-router-dom'; -import AccountOverviewContainer from '@/components/dashboard/AccountOverviewContainer'; +import TransitionRouter from '@/TransitionRouter'; import NavigationBar from '@/components/NavigationBar'; import DashboardContainer from '@/components/dashboard/DashboardContainer'; +import AccountOverviewContainer from '@/components/dashboard/AccountOverviewContainer'; import AccountApiContainer from '@/components/dashboard/AccountApiContainer'; +import SecurityKeyContainer from '@/components/dashboard/SecurityKeyContainer'; import { NotFound } from '@/components/elements/ScreenBlock'; -import TransitionRouter from '@/TransitionRouter'; import SubNavigation from '@/components/elements/SubNavigation'; export default ({ location }: RouteComponentProps) => ( @@ -16,6 +17,7 @@ export default ({ location }: RouteComponentProps) => (
Settings API Credentials + Security Keys
} @@ -30,6 +32,9 @@ export default ({ location }: RouteComponentProps) => ( + + + diff --git a/routes/api-client.php b/routes/api-client.php index ec4988bc8..a17559c32 100644 --- a/routes/api-client.php +++ b/routes/api-client.php @@ -28,6 +28,11 @@ Route::group(['prefix' => '/account'], function () { Route::get('/api-keys', 'ApiKeyController@index'); Route::post('/api-keys', 'ApiKeyController@store'); Route::delete('/api-keys/{identifier}', 'ApiKeyController@delete'); + + Route::get('/webauthn', 'WebauthnController@index')->withoutMiddleware(RequireTwoFactorAuthentication::class); + Route::get('/webauthn/register', 'WebauthnController@register')->withoutMiddleware(RequireTwoFactorAuthentication::class); + Route::post('/webauthn/register', 'WebauthnController@create')->withoutMiddleware(RequireTwoFactorAuthentication::class); + Route::delete('/webauthn/{id}', 'WebauthnController@deleteKey')->withoutMiddleware(RequireTwoFactorAuthentication::class); }); /* diff --git a/routes/auth.php b/routes/auth.php index d16089ef8..e7fe3773c 100644 --- a/routes/auth.php +++ b/routes/auth.php @@ -20,6 +20,7 @@ Route::group(['middleware' => 'guest'], function () { // Login endpoints. Route::post('/login', 'LoginController@login')->middleware('recaptcha'); Route::post('/login/checkpoint', 'LoginCheckpointController')->name('auth.login-checkpoint'); + Route::post('/login/checkpoint/key', 'WebauthnController@auth'); // Forgot password route. A post to this endpoint will trigger an // email to be sent containing a reset token.