ui(auth): add support for using a security key

This commit is contained in:
Matthew Penner 2021-07-17 14:32:19 -06:00
parent 3c21770c25
commit 59f2ea37d8
6 changed files with 195 additions and 70 deletions

View file

@ -32,7 +32,7 @@ class WebauthnController extends AbstractLoginController
* @throws \Illuminate\Validation\ValidationException * @throws \Illuminate\Validation\ValidationException
* @throws \Pterodactyl\Exceptions\DisplayException * @throws \Pterodactyl\Exceptions\DisplayException
*/ */
public function auth(Request $request): JsonResponse public function auth(Request $request)
{ {
if ($this->hasTooManyLoginAttempts($request)) { if ($this->hasTooManyLoginAttempts($request)) {
$this->sendLockoutResponse($request); $this->sendLockoutResponse($request);

View file

@ -14,17 +14,12 @@ class RequireTwoFactorAuthentication
public const LEVEL_ADMIN = 1; public const LEVEL_ADMIN = 1;
public const LEVEL_ALL = 2; public const LEVEL_ALL = 2;
/**
* @var \Prologue\Alerts\AlertsMessageBag
*/
private $alert;
/** /**
* The route to redirect a user to to enable 2FA. * The route to redirect a user to to enable 2FA.
*
* @var string
*/ */
protected $redirectRoute = '/account'; protected string $redirectRoute = '/account';
private AlertsMessageBag $alert;
/** /**
* RequireTwoFactorAuthentication constructor. * RequireTwoFactorAuthentication constructor.
@ -60,7 +55,7 @@ class RequireTwoFactorAuthentication
// send them right through, nothing else needs to be checked. // 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 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); return $next($request);
} elseif ($level === self::LEVEL_ADMIN && !$user->root_admin) { } elseif ($level === self::LEVEL_ADMIN && !$user->root_admin) {
return $next($request); return $next($request);

View file

@ -1,6 +1,6 @@
import React, { useState } from 'react'; import React, { useEffect, useState } from 'react';
import { StaticContext } from 'react-router'; import { StaticContext, useLocation } from 'react-router';
import { Link, RouteComponentProps } from 'react-router-dom'; import { Link, RouteComponentProps, useHistory } from 'react-router-dom';
import loginCheckpoint from '@/api/auth/loginCheckpoint'; import loginCheckpoint from '@/api/auth/loginCheckpoint';
import LoginFormContainer from '@/components/auth/LoginFormContainer'; import LoginFormContainer from '@/components/auth/LoginFormContainer';
import { ActionCreator } from 'easy-peasy'; import { ActionCreator } from 'easy-peasy';
@ -23,54 +23,80 @@ type Props = OwnProps & {
} }
const LoginCheckpointContainer = () => { const LoginCheckpointContainer = () => {
const history = useHistory();
const location = useLocation();
const { isSubmitting, setFieldValue } = useFormikContext<Values>(); const { isSubmitting, setFieldValue } = useFormikContext<Values>();
const [ isMissingDevice, setIsMissingDevice ] = useState(false); 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 ( return (
<LoginFormContainer title={'Device Checkpoint'} css={tw`w-full flex`}> <LoginFormContainer title={'Device Checkpoint'} css={tw`w-full flex`}>
<div css={tw`mt-6`}> <div css={tw`flex flex-col items-center justify-center w-full md:h-full md:pt-4`}>
<Field <div>
light <Field
name={isMissingDevice ? 'recoveryCode' : 'code'} light
title={isMissingDevice ? 'Recovery Code' : 'Authentication Code'} name={isMissingDevice ? 'recoveryCode' : 'code'}
description={ title={isMissingDevice ? 'Recovery Code' : 'Authentication Code'}
isMissingDevice description={
? 'Enter one of the recovery codes generated when you setup 2-Factor authentication on this account in order to continue.' isMissingDevice
: 'Enter the two-factor token generated by your device.' ? 'Enter one of the recovery codes generated when you setup 2-Factor authentication on this account in order to continue.'
} : 'Enter the two-factor token generated by your device.'
type={'text'} }
autoFocus type={'text'}
/> autoFocus
</div> />
<div css={tw`mt-6`}> </div>
<Button <div css={tw`mt-6 md:mt-auto`}>
size={'xlarge'} <Button
type={'submit'} size={'large'}
disabled={isSubmitting} type={'submit'}
isLoading={isSubmitting} disabled={isSubmitting}
> isLoading={isSubmitting}
Continue >
</Button> Continue
</div> </Button>
<div css={tw`mt-6 text-center`}> </div>
<span
onClick={() => { <div css={tw`flex flex-row text-center mt-6 md:mt-auto`}>
setFieldValue('code', ''); <div css={tw`mr-4`}>
setFieldValue('recoveryCode', ''); <a
setIsMissingDevice(s => !s); css={tw`text-xs text-neutral-500 tracking-wide uppercase no-underline hover:text-neutral-700 text-center cursor-pointer`}
}} onClick={() => switchToSecurityKey()}
css={tw`cursor-pointer text-xs text-neutral-500 tracking-wide uppercase no-underline hover:text-neutral-700`} >
> Use security key
{!isMissingDevice ? 'I\'ve Lost My Device' : 'I Have My Device'} </a>
</span> </div>
</div> <div css={tw`ml-4`}>
<div css={tw`mt-6 text-center`}> <span
<Link onClick={() => {
to={'/auth/login'} setFieldValue('code', '');
css={tw`text-xs text-neutral-500 tracking-wide uppercase no-underline hover:text-neutral-700`} setFieldValue('recoveryCode', '');
> setIsMissingDevice(s => !s);
Return to Login }}
</Link> 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'}
</span>
</div>
</div>
<div css={tw`mt-6 text-center`}>
<Link
to={'/auth/login'}
css={tw`text-xs text-neutral-500 tracking-wide uppercase no-underline hover:text-neutral-700`}
>
Return to Login
</Link>
</div>
</div> </div>
</LoginFormContainer> </LoginFormContainer>
); );

View file

@ -1,11 +1,112 @@
import React from 'react'; import React, { useEffect, useState } from 'react';
import tw from 'twin.macro'; import tw from 'twin.macro';
import webauthnChallenge from '@/api/account/webauthn/webauthnChallenge';
import { DivContainer as LoginFormContainer } from '@/components/auth/LoginFormContainer'; 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 LoginKeyCheckpointContainer = () => {
const history = useHistory();
const location = useLocation<LocationParams>();
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 ( return (
<LoginFormContainer title={'Login to Continue'} css={tw`w-full flex`} /> <LoginFormContainer title={'Key Checkpoint'} css={tw`w-full flex`}>
<div css={tw`flex flex-col items-center justify-center w-full md:h-full md:pt-4`}>
<h3 css={tw`font-sans text-2xl text-center text-neutral-500 font-normal`}>Attempting challenge...</h3>
<div css={tw`mt-6 md:mt-auto`}>
{challenging ?
<Spinner size={'large'} isBlue/>
:
<Button onClick={() => doChallenge()}>
Retry
</Button>
}
</div>
<div css={tw`flex flex-row text-center mt-6 md:mt-auto`}>
<div css={tw`mr-4`}>
<a
css={tw`text-xs text-neutral-500 tracking-wide uppercase no-underline hover:text-neutral-700 text-center cursor-pointer`}
onClick={() => switchToCode()}
>
Use two-factor token
</a>
</div>
<div css={tw`ml-4`}>
<a
css={tw`text-xs text-neutral-500 tracking-wide uppercase no-underline hover:text-neutral-700 text-center cursor-pointer`}
onClick={() => switchToRecovery()}
>
I&apos;ve Lost My Device
</a>
</div>
</div>
<div css={tw`mt-6 text-center`}>
<Link
to={'/auth/login'}
css={tw`text-xs text-neutral-500 tracking-wide uppercase no-underline hover:text-neutral-700`}
>
Return to Login
</Link>
</div>
</div>
</LoginFormContainer>
); );
}; };
export default LoginKeyCheckpointContainer; export default () => {
const history = useHistory();
const location = useLocation<LocationParams>();
if (!location.state?.token) {
history.replace('/auth/login');
return null;
}
return <LoginKeyCheckpointContainer/>;
};

View file

@ -1,5 +1,6 @@
import React from 'react'; import React from 'react';
import { Route, RouteComponentProps, Switch } from 'react-router-dom'; import { Route, RouteComponentProps, Switch } from 'react-router-dom';
import TransitionRouter from '@/TransitionRouter';
import LoginContainer from '@/components/auth/LoginContainer'; import LoginContainer from '@/components/auth/LoginContainer';
import LoginCheckpointContainer from '@/components/auth/LoginCheckpointContainer'; import LoginCheckpointContainer from '@/components/auth/LoginCheckpointContainer';
import LoginKeyCheckpointContainer from '@/components/auth/LoginKeyCheckpointContainer'; import LoginKeyCheckpointContainer from '@/components/auth/LoginKeyCheckpointContainer';
@ -9,16 +10,18 @@ import { NotFound } from '@/components/elements/ScreenBlock';
export default ({ location, history, match }: RouteComponentProps) => ( export default ({ location, history, match }: RouteComponentProps) => (
<div className={'pt-8 xl:pt-32'}> <div className={'pt-8 xl:pt-32'}>
<Switch location={location}> <TransitionRouter>
<Route path={`${match.path}/login`} component={LoginContainer} exact/> <Switch location={location}>
<Route path={`${match.path}/login/checkpoint`} component={LoginCheckpointContainer}/> <Route path={`${match.path}/login`} component={LoginContainer} exact/>
<Route path={`${match.path}/login/key`} component={LoginKeyCheckpointContainer}/> <Route path={`${match.path}/login/checkpoint`} component={LoginCheckpointContainer}/>
<Route path={`${match.path}/password`} component={ForgotPasswordContainer} exact/> <Route path={`${match.path}/login/key`} component={LoginKeyCheckpointContainer}/>
<Route path={`${match.path}/password/reset/:token`} component={ResetPasswordContainer}/> <Route path={`${match.path}/password`} component={ForgotPasswordContainer} exact/>
<Route path={`${match.path}/checkpoint`}/> <Route path={`${match.path}/password/reset/:token`} component={ResetPasswordContainer}/>
<Route path={'*'}> <Route path={`${match.path}/checkpoint`}/>
<NotFound onBack={() => history.push('/auth/login')}/> <Route path={'*'}>
</Route> <NotFound onBack={() => history.push('/auth/login')}/>
</Switch> </Route>
</Switch>
</TransitionRouter>
</div> </div>
); );