Cleanup key UI when logging in
This commit is contained in:
parent
9032699deb
commit
d9d9b1748f
13 changed files with 280 additions and 337 deletions
76
resources/scripts/api/account/security-keys.ts
Normal file
76
resources/scripts/api/account/security-keys.ts
Normal 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 };
|
|
@ -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}`);
|
|
||||||
};
|
|
|
@ -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));
|
|
||||||
};
|
|
|
@ -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);
|
|
||||||
};
|
|
|
@ -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 };
|
|
|
@ -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);
|
|
||||||
});
|
|
||||||
};
|
|
|
@ -1,32 +1,23 @@
|
||||||
import React, { forwardRef } from 'react';
|
import React, { forwardRef } from 'react';
|
||||||
import { Form } from 'formik';
|
import { Form } from 'formik';
|
||||||
import { breakpoint } from '@/theme';
|
|
||||||
import FlashMessageRender from '@/components/FlashMessageRender';
|
import FlashMessageRender from '@/components/FlashMessageRender';
|
||||||
import tw, { styled } from 'twin.macro';
|
import tw, { styled } from 'twin.macro';
|
||||||
|
import PterodactylLogo from '@/assets/images/pterodactyl.svg';
|
||||||
|
|
||||||
const Wrapper = styled.div`
|
const Wrapper = styled.div`
|
||||||
${breakpoint('sm')`
|
${tw`sm:w-4/5 sm:mx-auto md:p-10 lg:w-3/5 xl:w-full`}
|
||||||
${tw`w-4/5 mx-auto`}
|
|
||||||
`};
|
|
||||||
|
|
||||||
${breakpoint('md')`
|
|
||||||
${tw`p-10`}
|
|
||||||
`};
|
|
||||||
|
|
||||||
${breakpoint('lg')`
|
|
||||||
${tw`w-3/5`}
|
|
||||||
`};
|
|
||||||
|
|
||||||
${breakpoint('xl')`
|
|
||||||
${tw`w-full`}
|
|
||||||
max-width: 700px;
|
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`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`}>
|
<div css={tw`flex-none select-none mb-6 md:mb-0 self-center w-48 md:w-64 mx-auto`}>
|
||||||
<img src={'/assets/svgs/pterodactyl.svg'} css={tw`block w-48 md:w-64 mx-auto`}/>
|
{sidebar || <img src={PterodactylLogo} css={tw`block w-full`}/>}
|
||||||
</div>
|
</div>
|
||||||
<div css={tw`flex-1`}>
|
<div css={tw`flex-1`}>
|
||||||
{children}
|
{children}
|
||||||
|
@ -59,24 +50,26 @@ const Container = ({ title, children }: { title?: string, children: React.ReactN
|
||||||
|
|
||||||
type FormContainerProps = React.DetailedHTMLProps<React.FormHTMLAttributes<HTMLFormElement>, HTMLFormElement> & {
|
type FormContainerProps = React.DetailedHTMLProps<React.FormHTMLAttributes<HTMLFormElement>, HTMLFormElement> & {
|
||||||
title?: string;
|
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}>
|
<Container title={title}>
|
||||||
<Form {...props} ref={ref}>
|
<Form {...props} ref={ref}>
|
||||||
<Inner>{props.children}</Inner>
|
<InnerContainer sidebar={sidebar}>{props.children}</InnerContainer>
|
||||||
</Form>
|
</Form>
|
||||||
</Container>
|
</Container>
|
||||||
));
|
));
|
||||||
|
|
||||||
type DivContainerProps = React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement> & {
|
type DivContainerProps = React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement> & {
|
||||||
title?: string;
|
title?: string;
|
||||||
|
sidebar?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DivContainer = ({ title, ...props }: DivContainerProps) => (
|
export const DivContainer = ({ title, sidebar, ...props }: DivContainerProps) => (
|
||||||
<Container title={title}>
|
<Container title={title}>
|
||||||
<div {...props}>
|
<div {...props}>
|
||||||
<Inner>{props.children}</Inner>
|
<InnerContainer sidebar={sidebar}>{props.children}</InnerContainer>
|
||||||
</div>
|
</div>
|
||||||
</Container>
|
</Container>
|
||||||
);
|
);
|
||||||
|
|
|
@ -1,12 +1,14 @@
|
||||||
import React, { useEffect, useState } 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 useFlash from '@/plugins/useFlash';
|
||||||
import { useLocation } from 'react-router';
|
import { useLocation } from 'react-router';
|
||||||
import { Link, useHistory } from 'react-router-dom';
|
import { Link, useHistory } from 'react-router-dom';
|
||||||
import Spinner from '@/components/elements/Spinner';
|
|
||||||
import Button from '@/components/elements/Button';
|
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 {
|
interface LocationParams {
|
||||||
token: string;
|
token: string;
|
||||||
|
@ -14,99 +16,114 @@ interface LocationParams {
|
||||||
hasTotp: boolean;
|
hasTotp: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const LoginKeyCheckpointContainer = () => {
|
interface Credential extends PublicKeyCredential {
|
||||||
const history = useHistory();
|
response: AuthenticatorAssertionResponse;
|
||||||
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
|
const challenge = async (publicKey: PublicKeyCredentialRequestOptions): Promise<Credential> => {
|
||||||
window.location = response.intended || '/';
|
const publicKeyCredential = Object.assign({}, publicKey);
|
||||||
})
|
|
||||||
.catch(error => {
|
|
||||||
clearAndAddHttpError({ error });
|
|
||||||
console.error(error);
|
|
||||||
setChallenging(false);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
publicKeyCredential.challenge = bufferDecode(base64Decode(publicKey.challenge.toString()));
|
||||||
doChallenge();
|
if (publicKey.allowCredentials) {
|
||||||
}, []);
|
publicKeyCredential.allowCredentials = decodeSecurityKeyCredentials(publicKey.allowCredentials);
|
||||||
|
|
||||||
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>
|
|
||||||
}
|
}
|
||||||
</div>
|
|
||||||
|
|
||||||
<div css={tw`flex flex-row text-center mt-6 md:mt-auto`}>
|
const credential = await navigator.credentials.get({ publicKey: publicKeyCredential }) as Credential | null;
|
||||||
<div css={tw`mr-4`}>
|
if (!credential) return Promise.reject(new Error('No credentials provided for challenge.'));
|
||||||
<a
|
|
||||||
css={tw`text-xs text-neutral-500 tracking-wide uppercase no-underline hover:text-neutral-700 text-center cursor-pointer`}
|
return credential;
|
||||||
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 () => {
|
export default () => {
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
const location = useLocation<LocationParams>();
|
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) {
|
if (!location.state?.token) {
|
||||||
history.replace('/auth/login');
|
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,
|
||||||
|
<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>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
|
@ -1,72 +1,17 @@
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
import { Form, Formik, FormikHelpers } from 'formik';
|
|
||||||
import tw from 'twin.macro';
|
import tw from 'twin.macro';
|
||||||
import { object, string } from 'yup';
|
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { faFingerprint, faTrashAlt } from '@fortawesome/free-solid-svg-icons';
|
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 FlashMessageRender from '@/components/FlashMessageRender';
|
||||||
import Button from '@/components/elements/Button';
|
|
||||||
import ContentBox from '@/components/elements/ContentBox';
|
import ContentBox from '@/components/elements/ContentBox';
|
||||||
import Field from '@/components/elements/Field';
|
|
||||||
import GreyRowBox from '@/components/elements/GreyRowBox';
|
import GreyRowBox from '@/components/elements/GreyRowBox';
|
||||||
import PageContentBlock from '@/components/elements/PageContentBlock';
|
import PageContentBlock from '@/components/elements/PageContentBlock';
|
||||||
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
|
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
|
||||||
import useFlash, { useFlashKey } from '@/plugins/useFlash';
|
import { useFlashKey } from '@/plugins/useFlash';
|
||||||
import ConfirmationModal from '@/components/elements/ConfirmationModal';
|
import ConfirmationModal from '@/components/elements/ConfirmationModal';
|
||||||
import { useSecurityKeys } from '@/api/account/webauthn/security-keys';
|
import { useSecurityKeys, deleteSecurityKey } from '@/api/account/security-keys';
|
||||||
|
import AddSecurityKeyForm from '@/components/dashboard/security/AddSecurityKeyForm';
|
||||||
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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default () => {
|
export default () => {
|
||||||
const { clearFlashes, clearAndAddHttpError } = useFlashKey('security_keys');
|
const { clearFlashes, clearAndAddHttpError } = useFlashKey('security_keys');
|
||||||
|
@ -83,7 +28,7 @@ export default () => {
|
||||||
|
|
||||||
if (!uuid) return;
|
if (!uuid) return;
|
||||||
|
|
||||||
deleteWebauthnKey(uuid).catch(error => {
|
deleteSecurityKey(uuid).catch(error => {
|
||||||
clearAndAddHttpError(error);
|
clearAndAddHttpError(error);
|
||||||
mutate();
|
mutate();
|
||||||
});
|
});
|
|
@ -82,8 +82,8 @@ const ButtonStyle = styled.button<Props>`
|
||||||
|
|
||||||
type ComponentProps = Omit<JSX.IntrinsicElements['button'], 'ref' | keyof Props> & Props;
|
type ComponentProps = Omit<JSX.IntrinsicElements['button'], 'ref' | keyof Props> & Props;
|
||||||
|
|
||||||
const Button: React.FC<ComponentProps> = ({ children, isLoading, disabled, ...props }) => (
|
const Button: React.FC<ComponentProps> = ({ children, isLoading, disabled, className, ...props }) => (
|
||||||
<ButtonStyle {...props} isLoading={isLoading} disabled={isLoading || disabled}>
|
<ButtonStyle {...props} isLoading={isLoading} disabled={isLoading || disabled} className={className}>
|
||||||
{isLoading &&
|
{isLoading &&
|
||||||
<div css={tw`flex absolute justify-center items-center w-full h-full left-0 top-0`}>
|
<div css={tw`flex absolute justify-center items-center w-full h-full left-0 top-0`}>
|
||||||
<Spinner size={'small'}/>
|
<Spinner size={'small'}/>
|
||||||
|
|
|
@ -67,3 +67,26 @@ export function hashToPath (hash: string): string {
|
||||||
export function formatIp (ip: string): string {
|
export function formatIp (ip: string): string {
|
||||||
return /([a-f0-9:]+:+)+[a-f0-9]+/.test(ip) ? `[${ip}]` : ip;
|
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,
|
||||||
|
}));
|
||||||
|
|
|
@ -5,7 +5,7 @@ import NavigationBar from '@/components/NavigationBar';
|
||||||
import DashboardContainer from '@/components/dashboard/DashboardContainer';
|
import DashboardContainer from '@/components/dashboard/DashboardContainer';
|
||||||
import AccountOverviewContainer from '@/components/dashboard/AccountOverviewContainer';
|
import AccountOverviewContainer from '@/components/dashboard/AccountOverviewContainer';
|
||||||
import AccountApiContainer from '@/components/dashboard/AccountApiContainer';
|
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 SSHKeyContainer from '@/components/dashboard/SSHKeyContainer';
|
||||||
import { NotFound } from '@/components/elements/ScreenBlock';
|
import { NotFound } from '@/components/elements/ScreenBlock';
|
||||||
import SubNavigation from '@/components/elements/SubNavigation';
|
import SubNavigation from '@/components/elements/SubNavigation';
|
||||||
|
|
Loading…
Reference in a new issue