Cleanup key UI when logging in

This commit is contained in:
Dane Everitt 2022-02-13 16:54:12 -05:00
parent 9032699deb
commit d9d9b1748f
No known key found for this signature in database
GPG key ID: EEA66103B3D71F53
13 changed files with 280 additions and 337 deletions

View file

@ -0,0 +1,76 @@
import useSWR, { SWRConfiguration, SWRResponse } from 'swr';
import { useStoreState } from '@/state/hooks';
import http, { FractalResponseList } from '@/api/http';
import Transformers from '@transformers';
import { SecurityKey } from '@models';
import { AxiosError } from 'axios';
import { base64Decode, bufferDecode, bufferEncode, decodeSecurityKeyCredentials } from '@/helpers';
import { LoginResponse } from '@/api/auth/login';
const useSecurityKeys = (config?: SWRConfiguration<SecurityKey[], AxiosError>): SWRResponse<SecurityKey[], AxiosError> => {
const uuid = useStoreState(state => state.user.data!.uuid);
return useSWR<SecurityKey[], AxiosError>(
[ 'account', uuid, 'security-keys' ],
async (): Promise<SecurityKey[]> => {
const { data } = await http.get('/api/client/account/security-keys');
return (data as FractalResponseList).data.map((datum) => Transformers.toSecurityKey(datum.attributes));
},
config,
);
};
const deleteSecurityKey = async (uuid: string): Promise<void> => {
await http.delete(`/api/client/account/security-keys/${uuid}`);
};
const registerCredentialForAccount = async (name: string, tokenId: string, credential: PublicKeyCredential): Promise<SecurityKey> => {
const { data } = await http.post('/api/client/account/security-keys/register', {
name,
token_id: tokenId,
registration: {
id: credential.id,
type: credential.type,
rawId: bufferEncode(credential.rawId),
response: {
attestationObject: bufferEncode((credential.response as AuthenticatorAttestationResponse).attestationObject),
clientDataJSON: bufferEncode(credential.response.clientDataJSON),
},
},
});
return Transformers.toSecurityKey(data.attributes);
};
const registerSecurityKey = async (name: string): Promise<SecurityKey> => {
const { data } = await http.get('/api/client/account/security-keys/register');
const publicKey = data.data.credentials;
publicKey.challenge = bufferDecode(base64Decode(publicKey.challenge));
publicKey.user.id = bufferDecode(publicKey.user.id);
if (publicKey.excludeCredentials) {
publicKey.excludeCredentials = decodeSecurityKeyCredentials(publicKey.excludeCredentials);
}
const credentials = await navigator.credentials.create({ publicKey });
if (!credentials || credentials.type !== 'public-key') {
throw new Error(`Unexpected type returned by navigator.credentials.create(): expected "public-key", got "${credentials?.type}"`);
}
return await registerCredentialForAccount(name, data.data.token_id, credentials);
};
// eslint-disable-next-line camelcase
const authenticateSecurityKey = async (data: { confirmation_token: string; data: string }): Promise<LoginResponse> => {
const response = await http.post('/auth/login/checkpoint/key', data);
return {
complete: response.data.complete,
intended: response.data.data?.intended || null,
};
};
export { useSecurityKeys, deleteSecurityKey, registerSecurityKey, authenticateSecurityKey };

View file

@ -1,5 +0,0 @@
import http from '@/api/http';
export default async (uuid: string): Promise<void> => {
await http.delete(`/api/client/account/security-keys/${uuid}`);
};

View file

@ -1,25 +0,0 @@
import http from '@/api/http';
export interface SecurityKey {
uuid: string;
name: string;
type: 'public-key';
publicKeyId: string;
createdAt: Date;
updatedAt: Date;
}
export const rawDataToSecurityKey = (data: any): SecurityKey => ({
uuid: data.uuid,
name: data.name,
type: data.type,
publicKeyId: data.public_key_id,
createdAt: new Date(data.created_at),
updatedAt: new Date(data.updated_at),
});
export default async (): Promise<SecurityKey[]> => {
const { data } = await http.get('/api/client/account/security-keys');
return (data.data || []).map((datum: any) => rawDataToSecurityKey(datum.attributes));
};

View file

@ -1,67 +0,0 @@
import http from '@/api/http';
import { rawDataToSecurityKey, SecurityKey } from '@/api/account/webauthn/getSecurityKeys';
export const base64Decode = (input: string): string => {
input = input.replace(/-/g, '+').replace(/_/g, '/');
const pad = input.length % 4;
if (pad) {
if (pad === 1) {
throw new Error('InvalidLengthError: Input base64url string is the wrong length to determine padding');
}
input += new Array(5 - pad).join('=');
}
return input;
};
export const bufferDecode = (value: string): ArrayBuffer => Uint8Array.from(window.atob(value), c => c.charCodeAt(0));
// @ts-ignore
export const bufferEncode = (value: ArrayBuffer): string => window.btoa(String.fromCharCode.apply(null, new Uint8Array(value)));
export const decodeCredentials = (credentials: PublicKeyCredentialDescriptor[]) => {
return credentials.map(c => {
return {
id: bufferDecode(base64Decode(c.id.toString())),
type: c.type,
transports: c.transports,
};
});
};
const registerCredentialForAccount = async (name: string, tokenId: string, credential: PublicKeyCredential): Promise<SecurityKey> => {
const { data } = await http.post('/api/client/account/security-keys/register', {
name,
token_id: tokenId,
registration: {
id: credential.id,
type: credential.type,
rawId: bufferEncode(credential.rawId),
response: {
attestationObject: bufferEncode((credential.response as AuthenticatorAttestationResponse).attestationObject),
clientDataJSON: bufferEncode(credential.response.clientDataJSON),
},
},
});
return rawDataToSecurityKey(data.attributes);
};
export default async (name: string): Promise<SecurityKey> => {
const { data } = await http.get('/api/client/account/security-keys/register');
const publicKey = data.data.credentials;
publicKey.challenge = bufferDecode(base64Decode(publicKey.challenge));
publicKey.user.id = bufferDecode(publicKey.user.id);
if (publicKey.excludeCredentials) {
publicKey.excludeCredentials = decodeCredentials(publicKey.excludeCredentials);
}
const credentials = await navigator.credentials.create({ publicKey });
if (!credentials || credentials.type !== 'public-key') {
throw new Error(`Unexpected type returned by navigator.credentials.create(): expected "public-key", got "${credentials?.type}"`);
}
return await registerCredentialForAccount(name, data.data.token_id, credentials);
};

View file

@ -1,22 +0,0 @@
import useSWR, { SWRConfiguration, SWRResponse } from 'swr';
import { useStoreState } from '@/state/hooks';
import http, { FractalResponseList } from '@/api/http';
import Transformers from '@transformers';
import { SecurityKey } from '@models';
import { AxiosError } from 'axios';
const useSecurityKeys = (config?: SWRConfiguration<SecurityKey[], AxiosError>): SWRResponse<SecurityKey[], AxiosError> => {
const uuid = useStoreState(state => state.user.data!.uuid);
return useSWR<SecurityKey[], AxiosError>(
[ 'account', uuid, 'security-keys' ],
async (): Promise<SecurityKey[]> => {
const { data } = await http.get('/api/client/account/security-keys');
return (data as FractalResponseList).data.map((datum) => Transformers.toSecurityKey(datum.attributes));
},
config,
);
};
export { useSecurityKeys };

View file

@ -1,46 +0,0 @@
import http from '@/api/http';
import { LoginResponse } from '@/api/auth/login';
import { base64Decode, bufferDecode, bufferEncode, decodeCredentials } from '@/api/account/webauthn/registerSecurityKey';
export default (token: string, publicKey: PublicKeyCredentialRequestOptions): Promise<LoginResponse> => {
return new Promise((resolve, reject) => {
const publicKeyCredential = Object.assign({}, publicKey);
publicKeyCredential.challenge = bufferDecode(base64Decode(publicKey.challenge.toString()));
if (publicKey.allowCredentials) {
publicKeyCredential.allowCredentials = decodeCredentials(publicKey.allowCredentials);
}
navigator.credentials.get({
publicKey: publicKeyCredential,
}).then((c) => {
if (c === null) {
return;
}
const credential = c as PublicKeyCredential;
const response = credential.response as AuthenticatorAssertionResponse;
const data = {
confirmation_token: token,
data: JSON.stringify({
id: credential.id,
type: credential.type,
rawId: bufferEncode(credential.rawId),
response: {
authenticatorData: bufferEncode(response.authenticatorData),
clientDataJSON: bufferEncode(response.clientDataJSON),
signature: bufferEncode(response.signature),
userHandle: response.userHandle ? bufferEncode(response.userHandle) : null,
},
}),
};
http.post('/auth/login/checkpoint/key', data).then(response => {
return resolve({
complete: response.data.complete,
intended: response.data.data?.intended || undefined,
});
}).catch(reject);
}).catch(reject);
});
};

View file

@ -1,32 +1,23 @@
import React, { forwardRef } from 'react';
import { Form } from 'formik';
import { breakpoint } from '@/theme';
import FlashMessageRender from '@/components/FlashMessageRender';
import tw, { styled } from 'twin.macro';
import PterodactylLogo from '@/assets/images/pterodactyl.svg';
const Wrapper = styled.div`
${breakpoint('sm')`
${tw`w-4/5 mx-auto`}
`};
${breakpoint('md')`
${tw`p-10`}
`};
${breakpoint('lg')`
${tw`w-3/5`}
`};
${breakpoint('xl')`
${tw`w-full`}
${tw`sm:w-4/5 sm:mx-auto md:p-10 lg:w-3/5 xl:w-full`}
max-width: 700px;
`};
`;
const Inner = ({ children }: { children: React.ReactNode }) => (
interface InnerContentProps {
children: React.ReactNode;
sidebar?: React.ReactNode;
}
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`}>
<img src={'/assets/svgs/pterodactyl.svg'} css={tw`block w-48 md:w-64 mx-auto`}/>
<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`}/>}
</div>
<div css={tw`flex-1`}>
{children}
@ -59,24 +50,26 @@ const Container = ({ title, children }: { title?: string, children: React.ReactN
type FormContainerProps = React.DetailedHTMLProps<React.FormHTMLAttributes<HTMLFormElement>, HTMLFormElement> & {
title?: string;
sidebar?: React.ReactNode;
}
const FormContainer = forwardRef<HTMLFormElement, FormContainerProps>(({ title, ...props }, ref) => (
const FormContainer = forwardRef<HTMLFormElement, FormContainerProps>(({ title, sidebar, ...props }, ref) => (
<Container title={title}>
<Form {...props} ref={ref}>
<Inner>{props.children}</Inner>
<InnerContainer sidebar={sidebar}>{props.children}</InnerContainer>
</Form>
</Container>
));
type DivContainerProps = React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement> & {
title?: string;
sidebar?: React.ReactNode;
}
export const DivContainer = ({ title, ...props }: DivContainerProps) => (
export const DivContainer = ({ title, sidebar, ...props }: DivContainerProps) => (
<Container title={title}>
<div {...props}>
<Inner>{props.children}</Inner>
<InnerContainer sidebar={sidebar}>{props.children}</InnerContainer>
</div>
</Container>
);

View file

@ -1,12 +1,14 @@
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';
import { authenticateSecurityKey } from '@/api/account/security-keys';
import { base64Decode, bufferDecode, bufferEncode, decodeSecurityKeyCredentials } from '@/helpers';
import { FingerPrintIcon } from '@heroicons/react/outline';
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
interface LocationParams {
token: string;
@ -14,99 +16,114 @@ interface LocationParams {
hasTotp: boolean;
}
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;
interface Credential extends PublicKeyCredential {
response: AuthenticatorAssertionResponse;
}
// @ts-ignore
window.location = response.intended || '/';
})
.catch(error => {
clearAndAddHttpError({ error });
console.error(error);
setChallenging(false);
});
};
const challenge = async (publicKey: PublicKeyCredentialRequestOptions): Promise<Credential> => {
const publicKeyCredential = Object.assign({}, publicKey);
useEffect(() => {
doChallenge();
}, []);
return (
<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>
publicKeyCredential.challenge = bufferDecode(base64Decode(publicKey.challenge.toString()));
if (publicKey.allowCredentials) {
publicKeyCredential.allowCredentials = decodeSecurityKeyCredentials(publicKey.allowCredentials);
}
</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>
);
const credential = await navigator.credentials.get({ publicKey: publicKeyCredential }) as Credential | null;
if (!credential) return Promise.reject(new Error('No credentials provided for challenge.'));
return credential;
};
export default () => {
const history = useHistory();
const location = useLocation<LocationParams>();
const { clearFlashes, clearAndAddHttpError } = useFlash();
const [ redirecting, setRedirecting ] = useState(false);
const triggerChallengePrompt = () => {
clearFlashes();
challenge(location.state.publicKey)
.then((credential) => {
setRedirecting(true);
return authenticateSecurityKey({
confirmation_token: location.state.token,
data: JSON.stringify({
id: credential.id,
type: credential.type,
rawId: bufferEncode(credential.rawId),
response: {
authenticatorData: bufferEncode(credential.response.authenticatorData),
clientDataJSON: bufferEncode(credential.response.clientDataJSON),
signature: bufferEncode(credential.response.signature),
userHandle: credential.response.userHandle ? bufferEncode(credential.response.userHandle) : null,
},
}),
});
})
.then(({ complete, intended }) => {
if (!complete) return;
// @ts-ignore
window.location = intended || '/';
})
.catch(error => {
setRedirecting(false);
if (error instanceof DOMException) {
// User canceled the operation.
if (error.code === 20) {
return;
}
}
clearAndAddHttpError({ error });
});
};
useEffect(() => {
if (!location.state?.token) {
history.replace('/auth/login');
return null;
} else {
triggerChallengePrompt();
}
}, []);
return <LoginKeyCheckpointContainer/>;
return (
<LoginFormContainer
title={'Two-Factor Authentication'}
css={tw`w-full flex`}
sidebar={<FingerPrintIcon css={tw`h-24 w-24 mx-auto animate-pulse`}/>}
>
<SpinnerOverlay size={'base'} visible={redirecting}/>
<div css={tw`flex flex-col md:h-full`}>
<div css={tw`flex-1`}>
<p css={tw`text-neutral-700`}>Insert your security key and touch it.</p>
<p css={tw`text-neutral-700 mt-2`}>
If your security key does not respond,&nbsp;
<a
href={'#'}
css={tw`text-primary-500 font-medium hover:underline`}
onClick={triggerChallengePrompt}
>
click here
</a>.
</p>
</div>
<Link
css={tw`block mt-12 mb-6`}
to={{ pathname: '/auth/login/checkpoint' }}
>
<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 } }}
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'}
</Link>
</div>
</LoginFormContainer>
);
};

View file

@ -0,0 +1,54 @@
import { SecurityKey } from '@models';
import { useFlashKey } from '@/plugins/useFlash';
import { Form, Formik, FormikHelpers } from 'formik';
import { registerSecurityKey } from '@/api/account/security-keys';
import { object, string } from 'yup';
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
import Field from '@/components/elements/Field';
import tw from 'twin.macro';
import Button from '@/components/elements/Button';
import React from 'react';
interface Values {
name: string;
}
export default ({ onKeyAdded }: { onKeyAdded: (key: SecurityKey) => void }) => {
const { clearAndAddHttpError } = useFlashKey('security_keys');
const submit = ({ name }: Values, { setSubmitting, resetForm }: FormikHelpers<Values>) => {
registerSecurityKey(name)
.then(key => {
resetForm();
onKeyAdded(key);
})
.catch(clearAndAddHttpError)
.then(() => setSubmitting(false));
};
return (
<Formik
onSubmit={submit}
initialValues={{ name: '' }}
validationSchema={object().shape({
name: string().required(),
})}
>
{({ isSubmitting }) => (
<Form>
<SpinnerOverlay visible={isSubmitting}/>
<Field
type={'text'}
id={'name'}
name={'name'}
label={'Name'}
description={'A descriptive name for this security key.'}
/>
<div css={tw`flex justify-end mt-6`}>
<Button>Create</Button>
</div>
</Form>
)}
</Formik>
);
};

View file

@ -1,72 +1,17 @@
import React, { useEffect, useState } from 'react';
import { format } from 'date-fns';
import { Form, Formik, FormikHelpers } from 'formik';
import tw from 'twin.macro';
import { object, string } from 'yup';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faFingerprint, faTrashAlt } from '@fortawesome/free-solid-svg-icons';
import deleteWebauthnKey from '@/api/account/webauthn/deleteSecurityKey';
import { SecurityKey } from '@/api/account/webauthn/getSecurityKeys';
import registerSecurityKey from '@/api/account/webauthn/registerSecurityKey';
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, { useFlashKey } from '@/plugins/useFlash';
import { useFlashKey } from '@/plugins/useFlash';
import ConfirmationModal from '@/components/elements/ConfirmationModal';
import { useSecurityKeys } from '@/api/account/webauthn/security-keys';
interface Values {
name: string;
}
const AddSecurityKeyForm = ({ onKeyAdded }: { onKeyAdded: (key: SecurityKey) => void }) => {
const { clearFlashes, clearAndAddHttpError } = useFlash();
const submit = ({ name }: Values, { setSubmitting, resetForm }: FormikHelpers<Values>) => {
clearFlashes('security_keys');
registerSecurityKey(name)
.then(key => {
resetForm();
onKeyAdded(key);
})
.catch(err => {
console.error(err);
clearAndAddHttpError({ key: 'security_keys', error: err });
})
.then(() => setSubmitting(false));
};
return (
<Formik
onSubmit={submit}
initialValues={{ name: '' }}
validationSchema={object().shape({
name: string().required(),
})}
>
{({ isSubmitting }) => (
<Form>
<SpinnerOverlay visible={isSubmitting}/>
<Field
type={'text'}
id={'name'}
name={'name'}
label={'Name'}
description={'A descriptive name for this security key.'}
/>
<div css={tw`flex justify-end mt-6`}>
<Button>Create</Button>
</div>
</Form>
)}
</Formik>
);
};
import { useSecurityKeys, deleteSecurityKey } from '@/api/account/security-keys';
import AddSecurityKeyForm from '@/components/dashboard/security/AddSecurityKeyForm';
export default () => {
const { clearFlashes, clearAndAddHttpError } = useFlashKey('security_keys');
@ -83,7 +28,7 @@ export default () => {
if (!uuid) return;
deleteWebauthnKey(uuid).catch(error => {
deleteSecurityKey(uuid).catch(error => {
clearAndAddHttpError(error);
mutate();
});

View file

@ -82,8 +82,8 @@ const ButtonStyle = styled.button<Props>`
type ComponentProps = Omit<JSX.IntrinsicElements['button'], 'ref' | keyof Props> & Props;
const Button: React.FC<ComponentProps> = ({ children, isLoading, disabled, ...props }) => (
<ButtonStyle {...props} isLoading={isLoading} disabled={isLoading || disabled}>
const Button: React.FC<ComponentProps> = ({ children, isLoading, disabled, className, ...props }) => (
<ButtonStyle {...props} isLoading={isLoading} disabled={isLoading || disabled} className={className}>
{isLoading &&
<div css={tw`flex absolute justify-center items-center w-full h-full left-0 top-0`}>
<Spinner size={'small'}/>

View file

@ -67,3 +67,26 @@ export function hashToPath (hash: string): string {
export function formatIp (ip: string): string {
return /([a-f0-9:]+:+)+[a-f0-9]+/.test(ip) ? `[${ip}]` : ip;
}
export const base64Decode = (input: string): string => {
input = input.replace(/-/g, '+').replace(/_/g, '/');
const pad = input.length % 4;
if (pad) {
if (pad === 1) {
throw new Error('InvalidLengthError: Input base64url string is the wrong length to determine padding');
}
input += new Array(5 - pad).join('=');
}
return input;
};
export const bufferDecode = (value: string): ArrayBuffer => Uint8Array.from(window.atob(value), c => c.charCodeAt(0));
// @ts-ignore
export const bufferEncode = (value: ArrayBuffer): string => window.btoa(String.fromCharCode.apply(null, new Uint8Array(value)));
export const decodeSecurityKeyCredentials = (credentials: PublicKeyCredentialDescriptor[]) => credentials.map(c => ({
id: bufferDecode(base64Decode(c.id.toString())),
type: c.type,
transports: c.transports,
}));

View file

@ -5,7 +5,7 @@ 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 SecurityKeyContainer from '@/components/dashboard/security/SecurityKeyContainer';
import SSHKeyContainer from '@/components/dashboard/SSHKeyContainer';
import { NotFound } from '@/components/elements/ScreenBlock';
import SubNavigation from '@/components/elements/SubNavigation';