Login checkpoint cleanup, hide prompt when leaving screen

This commit is contained in:
Dane Everitt 2022-02-13 17:27:38 -05:00
parent d9d9b1748f
commit d6cd0c6230
No known key found for this signature in database
GPG key ID: EEA66103B3D71F53
3 changed files with 81 additions and 125 deletions

View file

@ -1,12 +1,10 @@
import React, { useEffect, useState } from 'react';
import { StaticContext, useLocation } from 'react-router';
import { Link, RouteComponentProps, useHistory } from 'react-router-dom';
import { StaticContext } from 'react-router';
import { RouteComponentProps } from 'react-router-dom';
import loginCheckpoint from '@/api/auth/loginCheckpoint';
import LoginFormContainer from '@/components/auth/LoginFormContainer';
import { ActionCreator } from 'easy-peasy';
import { useFormikContext, withFormik } from 'formik';
import { Formik, FormikHelpers } from 'formik';
import useFlash from '@/plugins/useFlash';
import { FlashStore } from '@/state/flashes';
import Field from '@/components/elements/Field';
import tw from 'twin.macro';
import Button from '@/components/elements/Button';
@ -16,37 +14,49 @@ interface Values {
recoveryCode: '',
}
type OwnProps = RouteComponentProps<Record<string, string | undefined>, StaticContext, { token?: string }>
type OwnProps = RouteComponentProps<Record<string, string | undefined>, StaticContext, { token?: string, recovery?: boolean }>
type Props = OwnProps & {
clearAndAddHttpError: ActionCreator<FlashStore['clearAndAddHttpError']['payload']>;
}
export default ({ history, location }: OwnProps) => {
const { clearFlashes, clearAndAddHttpError } = useFlash();
const LoginCheckpointContainer = () => {
const history = useHistory();
const location = useLocation();
const { isSubmitting, setFieldValue } = useFormikContext<Values>();
const [ isMissingDevice, setIsMissingDevice ] = useState(false);
const switchToSecurityKey = () => {
history.replace('/auth/login/key', { ...location.state });
const onSubmit = ({ code, recoveryCode }: Values, { setSubmitting }: FormikHelpers<Values>) => {
clearFlashes();
loginCheckpoint(location.state?.token || '', code, recoveryCode)
.then(response => {
if (response.complete) {
// @ts-ignore
window.location = response.intended || '/';
}
})
.catch(error => clearAndAddHttpError({ error }))
.then(() => setSubmitting(false));
};
const [ isMissingDevice, setIsMissingDevice ] = useState(false);
useEffect(() => {
if (!location.state?.token) {
history.replace('/auth/login');
}
}, []);
useEffect(() => {
setFieldValue('code', '');
setFieldValue('recoveryCode', '');
setIsMissingDevice(location.state?.recovery || false);
}, [ location.state ]);
return (
<LoginFormContainer title={'Device Checkpoint'} css={tw`w-full flex`}>
<div css={tw`flex flex-col items-center justify-center w-full md:h-full md:pt-4`}>
<div>
<Formik initialValues={{ code: '', recoveryCode: '' }} onSubmit={onSubmit}>
{({ isSubmitting, setFieldValue }) => (
<LoginFormContainer title={'Two-Factor Authentication'} css={tw`w-full flex h-full`}>
<div css={tw`flex flex-col`}>
<div css={tw`flex-1`}>
<Field
light
name={isMissingDevice ? 'recoveryCode' : 'code'}
title={isMissingDevice ? 'Recovery Code' : 'Authentication Code'}
label={isMissingDevice ? 'Recovery Code' : 'Authentication Code'}
inputMode={isMissingDevice ? undefined : 'numeric'}
pattern={isMissingDevice ? undefined : '[0-9]*'}
autoComplete={isMissingDevice ? undefined : 'one-time-code'}
description={
isMissingDevice
? 'Enter one of the recovery codes generated when you setup 2-Factor authentication on this account in order to continue.'
@ -56,90 +66,28 @@ const LoginCheckpointContainer = () => {
autoFocus
/>
</div>
<div css={tw`mt-6 md:mt-auto`}>
<Button
size={'large'}
css={tw`mt-12 w-full block`}
type={'submit'}
disabled={isSubmitting}
isLoading={isSubmitting}
>
Continue
Login
</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={() => switchToSecurityKey()}
>
Use security key
</a>
</div>
<div css={tw`ml-4`}>
<span
<button
type={'button'}
onClick={() => {
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`}
css={tw`mt-4 p-2 w-full 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>
</button>
</div>
</LoginFormContainer>
)}
</Formik>
);
};
const EnhancedForm = withFormik<Props, Values>({
handleSubmit: ({ code, recoveryCode }, { setSubmitting, props: { clearAndAddHttpError, location } }) => {
loginCheckpoint(location.state?.token || '', code, recoveryCode)
.then(response => {
if (response.complete) {
// @ts-ignore
window.location = response.intended || '/';
return;
}
setSubmitting(false);
})
.catch(error => {
console.error(error);
setSubmitting(false);
clearAndAddHttpError({ error });
});
},
mapPropsToValues: () => ({
code: '',
recoveryCode: '',
}),
})(LoginCheckpointContainer);
export default ({ history, location, ...props }: OwnProps) => {
const { clearAndAddHttpError } = useFlash();
if (!location.state?.token) {
history.replace('/auth/login');
return null;
}
return <EnhancedForm
clearAndAddHttpError={clearAndAddHttpError}
history={history}
location={location}
{...props}
/>;
};

View file

@ -3,6 +3,7 @@ import { Form } from 'formik';
import FlashMessageRender from '@/components/FlashMessageRender';
import tw, { styled } from 'twin.macro';
import PterodactylLogo from '@/assets/images/pterodactyl.svg';
import { Link } from 'react-router-dom';
const Wrapper = styled.div`
${tw`sm:w-4/5 sm:mx-auto md:p-10 lg:w-3/5 xl:w-full`}
@ -17,7 +18,7 @@ interface InnerContentProps {
const InnerContainer = ({ children, sidebar }: InnerContentProps) => (
<div css={tw`md:flex w-full bg-white shadow-lg rounded-lg p-6 md:pl-0 mx-1`}>
<div css={tw`flex-none select-none mb-6 md:mb-0 self-center w-48 md:w-64 mx-auto`}>
{sidebar || <img src={PterodactylLogo} css={tw`block w-full`}/>}
{sidebar || <Link to={'/auth/login'}><img src={PterodactylLogo} css={tw`block w-full`}/></Link>}
</div>
<div css={tw`flex-1`}>
{children}

View file

@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useRef, useState } from 'react';
import tw from 'twin.macro';
import { DivContainer as LoginFormContainer } from '@/components/auth/LoginFormContainer';
import useFlash from '@/plugins/useFlash';
@ -20,7 +20,7 @@ interface Credential extends PublicKeyCredential {
response: AuthenticatorAssertionResponse;
}
const challenge = async (publicKey: PublicKeyCredentialRequestOptions): Promise<Credential> => {
const challenge = async (publicKey: PublicKeyCredentialRequestOptions, signal?: AbortSignal): Promise<Credential> => {
const publicKeyCredential = Object.assign({}, publicKey);
publicKeyCredential.challenge = bufferDecode(base64Decode(publicKey.challenge.toString()));
@ -28,7 +28,7 @@ const challenge = async (publicKey: PublicKeyCredentialRequestOptions): Promise<
publicKeyCredential.allowCredentials = decodeSecurityKeyCredentials(publicKey.allowCredentials);
}
const credential = await navigator.credentials.get({ publicKey: publicKeyCredential }) as Credential | null;
const credential = await navigator.credentials.get({ signal, publicKey: publicKeyCredential }) as Credential | null;
if (!credential) return Promise.reject(new Error('No credentials provided for challenge.'));
return credential;
@ -37,13 +37,14 @@ const challenge = async (publicKey: PublicKeyCredentialRequestOptions): Promise<
export default () => {
const history = useHistory();
const location = useLocation<LocationParams>();
const controller = useRef(new AbortController());
const { clearFlashes, clearAndAddHttpError } = useFlash();
const [ redirecting, setRedirecting ] = useState(false);
const triggerChallengePrompt = () => {
clearFlashes();
challenge(location.state.publicKey)
challenge(location.state.publicKey, controller.current.signal)
.then((credential) => {
setRedirecting(true);
@ -80,6 +81,12 @@ export default () => {
});
};
useEffect(() => {
return () => {
controller.current.abort();
};
});
useEffect(() => {
if (!location.state?.token) {
history.replace('/auth/login');
@ -111,14 +118,14 @@ export default () => {
</div>
<Link
css={tw`block mt-12 mb-6`}
to={{ pathname: '/auth/login/checkpoint' }}
to={{ pathname: '/auth/login/checkpoint', state: location.state }}
>
<Button size={'small'} type={'button'} css={tw`block w-full`}>
Use a Different Method
</Button>
</Link>
<Link
to={{ pathname: '/auth/login/checkpoint', state: { ...location.state, recovery: true } }}
to={{ pathname: '/auth/login/checkpoint', state: { token: location.state.token, recovery: true } }}
css={tw`text-xs text-neutral-500 tracking-wide uppercase no-underline hover:text-neutral-700 text-center cursor-pointer`}
>
{'I\'ve Lost My Device'}