From 59f2ea37d886c21f9b7944944d2cc0b5c5b7823d Mon Sep 17 00:00:00 2001 From: Matthew Penner Date: Sat, 17 Jul 2021 14:32:19 -0600 Subject: [PATCH] ui(auth): add support for using a security key --- .../Controllers/Auth/WebauthnController.php | 2 +- .../RequireTwoFactorAuthentication.php | 13 +- .../{login.ts => webauthnChallenge.ts} | 0 .../auth/LoginCheckpointContainer.tsx | 118 +++++++++++------- .../auth/LoginKeyCheckpointContainer.tsx | 107 +++++++++++++++- .../scripts/routers/AuthenticationRouter.tsx | 25 ++-- 6 files changed, 195 insertions(+), 70 deletions(-) rename resources/scripts/api/account/webauthn/{login.ts => webauthnChallenge.ts} (100%) diff --git a/app/Http/Controllers/Auth/WebauthnController.php b/app/Http/Controllers/Auth/WebauthnController.php index b39e70109..438c3b342 100644 --- a/app/Http/Controllers/Auth/WebauthnController.php +++ b/app/Http/Controllers/Auth/WebauthnController.php @@ -32,7 +32,7 @@ class WebauthnController extends AbstractLoginController * @throws \Illuminate\Validation\ValidationException * @throws \Pterodactyl\Exceptions\DisplayException */ - public function auth(Request $request): JsonResponse + public function auth(Request $request) { if ($this->hasTooManyLoginAttempts($request)) { $this->sendLockoutResponse($request); diff --git a/app/Http/Middleware/RequireTwoFactorAuthentication.php b/app/Http/Middleware/RequireTwoFactorAuthentication.php index 6691179a3..724a11eb1 100644 --- a/app/Http/Middleware/RequireTwoFactorAuthentication.php +++ b/app/Http/Middleware/RequireTwoFactorAuthentication.php @@ -14,17 +14,12 @@ class RequireTwoFactorAuthentication public const LEVEL_ADMIN = 1; public const LEVEL_ALL = 2; - /** - * @var \Prologue\Alerts\AlertsMessageBag - */ - private $alert; - /** * The route to redirect a user to to enable 2FA. - * - * @var string */ - protected $redirectRoute = '/account'; + protected string $redirectRoute = '/account'; + + private AlertsMessageBag $alert; /** * RequireTwoFactorAuthentication constructor. @@ -60,7 +55,7 @@ class RequireTwoFactorAuthentication // send them right through, nothing else needs to be checked. // // If the level is set as admin and the user is not an admin, pass them through as well. - if ($level === self::LEVEL_NONE || $user->use_totp) { + if ($level === self::LEVEL_NONE || ($user->use_totp || $user->webauthnKeys()->count() > 0)) { return $next($request); } elseif ($level === self::LEVEL_ADMIN && !$user->root_admin) { return $next($request); diff --git a/resources/scripts/api/account/webauthn/login.ts b/resources/scripts/api/account/webauthn/webauthnChallenge.ts similarity index 100% rename from resources/scripts/api/account/webauthn/login.ts rename to resources/scripts/api/account/webauthn/webauthnChallenge.ts diff --git a/resources/scripts/components/auth/LoginCheckpointContainer.tsx b/resources/scripts/components/auth/LoginCheckpointContainer.tsx index a47fa94d8..2c3b04cff 100644 --- a/resources/scripts/components/auth/LoginCheckpointContainer.tsx +++ b/resources/scripts/components/auth/LoginCheckpointContainer.tsx @@ -1,6 +1,6 @@ -import React, { useState } from 'react'; -import { StaticContext } from 'react-router'; -import { Link, RouteComponentProps } from 'react-router-dom'; +import React, { useEffect, useState } from 'react'; +import { StaticContext, useLocation } from 'react-router'; +import { Link, RouteComponentProps, useHistory } from 'react-router-dom'; import loginCheckpoint from '@/api/auth/loginCheckpoint'; import LoginFormContainer from '@/components/auth/LoginFormContainer'; import { ActionCreator } from 'easy-peasy'; @@ -23,54 +23,80 @@ type Props = OwnProps & { } const LoginCheckpointContainer = () => { + const history = useHistory(); + const location = useLocation(); + const { isSubmitting, setFieldValue } = useFormikContext(); const [ isMissingDevice, setIsMissingDevice ] = useState(false); + const switchToSecurityKey = () => { + history.replace('/auth/login/key', { ...location.state }); + }; + + useEffect(() => { + setFieldValue('code', ''); + setFieldValue('recoveryCode', ''); + setIsMissingDevice(location.state?.recovery || false); + }, [ location.state ]); + return ( -
- -
-
- -
-
- { - setFieldValue('code', ''); - setFieldValue('recoveryCode', ''); - setIsMissingDevice(s => !s); - }} - css={tw`cursor-pointer text-xs text-neutral-500 tracking-wide uppercase no-underline hover:text-neutral-700`} - > - {!isMissingDevice ? 'I\'ve Lost My Device' : 'I Have My Device'} - -
-
- - Return to Login - +
+
+ +
+
+ +
+ +
+ +
+ { + setFieldValue('code', ''); + setFieldValue('recoveryCode', ''); + setIsMissingDevice(s => !s); + }} + css={tw`cursor-pointer text-xs text-neutral-500 tracking-wide uppercase no-underline hover:text-neutral-700`} + > + {!isMissingDevice ? 'I\'ve Lost My Device' : 'I Have My Device'} + +
+
+
+ + Return to Login + +
); diff --git a/resources/scripts/components/auth/LoginKeyCheckpointContainer.tsx b/resources/scripts/components/auth/LoginKeyCheckpointContainer.tsx index 4810d8d69..968269094 100644 --- a/resources/scripts/components/auth/LoginKeyCheckpointContainer.tsx +++ b/resources/scripts/components/auth/LoginKeyCheckpointContainer.tsx @@ -1,11 +1,112 @@ -import React from 'react'; +import React, { useEffect, useState } from 'react'; import tw from 'twin.macro'; +import webauthnChallenge from '@/api/account/webauthn/webauthnChallenge'; import { DivContainer as LoginFormContainer } from '@/components/auth/LoginFormContainer'; +import useFlash from '@/plugins/useFlash'; +import { useLocation } from 'react-router'; +import { Link, useHistory } from 'react-router-dom'; +import Spinner from '@/components/elements/Spinner'; +import Button from '@/components/elements/Button'; + +interface LocationParams { + token: string; + publicKey: any; + hasTotp: boolean; +} const LoginKeyCheckpointContainer = () => { + const history = useHistory(); + const location = useLocation(); + + const { clearAndAddHttpError } = useFlash(); + + const [ challenging, setChallenging ] = useState(false); + + const switchToCode = () => { + history.replace('/auth/login/checkpoint', { ...location.state, recovery: false }); + }; + + const switchToRecovery = () => { + history.replace('/auth/login/checkpoint', { ...location.state, recovery: true }); + }; + + const doChallenge = () => { + setChallenging(true); + + webauthnChallenge(location.state.token, location.state.publicKey) + .then(response => { + if (!response.complete) { + return; + } + + // @ts-ignore + window.location = response.intended || '/'; + }) + .catch(error => { + clearAndAddHttpError({ error }); + console.error(error); + setChallenging(false); + }); + }; + + useEffect(() => { + doChallenge(); + }, []); + return ( - + +
+

Attempting challenge...

+ +
+ {challenging ? + + : + + } +
+ + +
+ + Return to Login + +
+
+
); }; -export default LoginKeyCheckpointContainer; +export default () => { + const history = useHistory(); + const location = useLocation(); + + if (!location.state?.token) { + history.replace('/auth/login'); + return null; + } + + return ; +}; diff --git a/resources/scripts/routers/AuthenticationRouter.tsx b/resources/scripts/routers/AuthenticationRouter.tsx index 49290746e..84a791b26 100644 --- a/resources/scripts/routers/AuthenticationRouter.tsx +++ b/resources/scripts/routers/AuthenticationRouter.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { Route, RouteComponentProps, Switch } from 'react-router-dom'; +import TransitionRouter from '@/TransitionRouter'; import LoginContainer from '@/components/auth/LoginContainer'; import LoginCheckpointContainer from '@/components/auth/LoginCheckpointContainer'; import LoginKeyCheckpointContainer from '@/components/auth/LoginKeyCheckpointContainer'; @@ -9,16 +10,18 @@ import { NotFound } from '@/components/elements/ScreenBlock'; export default ({ location, history, match }: RouteComponentProps) => (
- - - - - - - - - history.push('/auth/login')}/> - - + + + + + + + + + + history.push('/auth/login')}/> + + +
);