ui(auth): add support for using a security key
This commit is contained in:
parent
3c21770c25
commit
59f2ea37d8
6 changed files with 195 additions and 70 deletions
|
@ -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);
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
@ -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'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/>;
|
||||||
|
};
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
Loading…
Reference in a new issue