diff --git a/resources/scripts/api/account/security-keys.ts b/resources/scripts/api/account/security-keys.ts new file mode 100644 index 000000000..248008789 --- /dev/null +++ b/resources/scripts/api/account/security-keys.ts @@ -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): SWRResponse => { + const uuid = useStoreState(state => state.user.data!.uuid); + + return useSWR( + [ 'account', uuid, 'security-keys' ], + async (): Promise => { + 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 => { + await http.delete(`/api/client/account/security-keys/${uuid}`); +}; + +const registerCredentialForAccount = async (name: string, tokenId: string, credential: PublicKeyCredential): Promise => { + 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 => { + 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 => { + 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 }; diff --git a/resources/scripts/api/account/webauthn/deleteSecurityKey.ts b/resources/scripts/api/account/webauthn/deleteSecurityKey.ts deleted file mode 100644 index 3a2342441..000000000 --- a/resources/scripts/api/account/webauthn/deleteSecurityKey.ts +++ /dev/null @@ -1,5 +0,0 @@ -import http from '@/api/http'; - -export default async (uuid: string): Promise => { - await http.delete(`/api/client/account/security-keys/${uuid}`); -}; diff --git a/resources/scripts/api/account/webauthn/getSecurityKeys.ts b/resources/scripts/api/account/webauthn/getSecurityKeys.ts deleted file mode 100644 index 4d6f94496..000000000 --- a/resources/scripts/api/account/webauthn/getSecurityKeys.ts +++ /dev/null @@ -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 => { - const { data } = await http.get('/api/client/account/security-keys'); - - return (data.data || []).map((datum: any) => rawDataToSecurityKey(datum.attributes)); -}; diff --git a/resources/scripts/api/account/webauthn/registerSecurityKey.ts b/resources/scripts/api/account/webauthn/registerSecurityKey.ts deleted file mode 100644 index 85c7c3a5f..000000000 --- a/resources/scripts/api/account/webauthn/registerSecurityKey.ts +++ /dev/null @@ -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 => { - 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 => { - 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); -}; diff --git a/resources/scripts/api/account/webauthn/security-keys.ts b/resources/scripts/api/account/webauthn/security-keys.ts deleted file mode 100644 index f89849664..000000000 --- a/resources/scripts/api/account/webauthn/security-keys.ts +++ /dev/null @@ -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): SWRResponse => { - const uuid = useStoreState(state => state.user.data!.uuid); - - return useSWR( - [ 'account', uuid, 'security-keys' ], - async (): Promise => { - const { data } = await http.get('/api/client/account/security-keys'); - - return (data as FractalResponseList).data.map((datum) => Transformers.toSecurityKey(datum.attributes)); - }, - config, - ); -}; - -export { useSecurityKeys }; diff --git a/resources/scripts/api/account/webauthn/webauthnChallenge.ts b/resources/scripts/api/account/webauthn/webauthnChallenge.ts deleted file mode 100644 index f5374a44c..000000000 --- a/resources/scripts/api/account/webauthn/webauthnChallenge.ts +++ /dev/null @@ -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 => { - 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); - }); -}; diff --git a/resources/scripts/components/auth/LoginFormContainer.tsx b/resources/scripts/components/auth/LoginFormContainer.tsx index 5250a5433..83ed2e7ba 100644 --- a/resources/scripts/components/auth/LoginFormContainer.tsx +++ b/resources/scripts/components/auth/LoginFormContainer.tsx @@ -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`} - max-width: 700px; - `}; + ${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) => (
-
- +
+ {sidebar || }
{children} @@ -59,24 +50,26 @@ const Container = ({ title, children }: { title?: string, children: React.ReactN type FormContainerProps = React.DetailedHTMLProps, HTMLFormElement> & { title?: string; + sidebar?: React.ReactNode; } -const FormContainer = forwardRef(({ title, ...props }, ref) => ( +const FormContainer = forwardRef(({ title, sidebar, ...props }, ref) => (
- {props.children} + {props.children}
)); type DivContainerProps = React.DetailedHTMLProps, HTMLDivElement> & { title?: string; + sidebar?: React.ReactNode; } -export const DivContainer = ({ title, ...props }: DivContainerProps) => ( +export const DivContainer = ({ title, sidebar, ...props }: DivContainerProps) => (
- {props.children} + {props.children}
); diff --git a/resources/scripts/components/auth/LoginKeyCheckpointContainer.tsx b/resources/scripts/components/auth/LoginKeyCheckpointContainer.tsx index 968269094..1b14d227d 100644 --- a/resources/scripts/components/auth/LoginKeyCheckpointContainer.tsx +++ b/resources/scripts/components/auth/LoginKeyCheckpointContainer.tsx @@ -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(); +interface Credential extends PublicKeyCredential { + response: AuthenticatorAssertionResponse; +} - const { clearAndAddHttpError } = useFlash(); +const challenge = async (publicKey: PublicKeyCredentialRequestOptions): Promise => { + const publicKeyCredential = Object.assign({}, publicKey); - const [ challenging, setChallenging ] = useState(false); + publicKeyCredential.challenge = bufferDecode(base64Decode(publicKey.challenge.toString())); + if (publicKey.allowCredentials) { + publicKeyCredential.allowCredentials = decodeSecurityKeyCredentials(publicKey.allowCredentials); + } - const switchToCode = () => { - history.replace('/auth/login/checkpoint', { ...location.state, recovery: false }); - }; + const credential = await navigator.credentials.get({ publicKey: publicKeyCredential }) as Credential | null; + if (!credential) return Promise.reject(new Error('No credentials provided for challenge.')); - 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 ( - -
-

Attempting challenge...

- -
- {challenging ? - - : - - } -
- - -
- - Return to Login - -
-
-
- ); + return credential; }; export default () => { const history = useHistory(); const location = useLocation(); + const { clearFlashes, clearAndAddHttpError } = useFlash(); + const [ redirecting, setRedirecting ] = useState(false); - if (!location.state?.token) { - history.replace('/auth/login'); - return null; - } + const triggerChallengePrompt = () => { + clearFlashes(); - return ; + 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'); + } else { + triggerChallengePrompt(); + } + }, []); + + return ( + } + > + +
+
+

Insert your security key and touch it.

+

+ If your security key does not respond,  + + click here + . +

+
+ + + + + {'I\'ve Lost My Device'} + +
+
+ ); }; diff --git a/resources/scripts/components/dashboard/security/AddSecurityKeyForm.tsx b/resources/scripts/components/dashboard/security/AddSecurityKeyForm.tsx new file mode 100644 index 000000000..01a184162 --- /dev/null +++ b/resources/scripts/components/dashboard/security/AddSecurityKeyForm.tsx @@ -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) => { + registerSecurityKey(name) + .then(key => { + resetForm(); + onKeyAdded(key); + }) + .catch(clearAndAddHttpError) + .then(() => setSubmitting(false)); + }; + + return ( + + {({ isSubmitting }) => ( +
+ + +
+ +
+ + )} +
+ ); +}; diff --git a/resources/scripts/components/dashboard/SecurityKeyContainer.tsx b/resources/scripts/components/dashboard/security/SecurityKeyContainer.tsx similarity index 66% rename from resources/scripts/components/dashboard/SecurityKeyContainer.tsx rename to resources/scripts/components/dashboard/security/SecurityKeyContainer.tsx index 6446d2a55..fd98f2284 100644 --- a/resources/scripts/components/dashboard/SecurityKeyContainer.tsx +++ b/resources/scripts/components/dashboard/security/SecurityKeyContainer.tsx @@ -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) => { - 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 ( - - {({ isSubmitting }) => ( -
- - -
- -
- - )} -
- ); -}; +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(); }); diff --git a/resources/scripts/components/elements/Button.tsx b/resources/scripts/components/elements/Button.tsx index e4804c8a8..ca98c3e42 100644 --- a/resources/scripts/components/elements/Button.tsx +++ b/resources/scripts/components/elements/Button.tsx @@ -82,8 +82,8 @@ const ButtonStyle = styled.button` type ComponentProps = Omit & Props; -const Button: React.FC = ({ children, isLoading, disabled, ...props }) => ( - +const Button: React.FC = ({ children, isLoading, disabled, className, ...props }) => ( + {isLoading &&
diff --git a/resources/scripts/helpers.ts b/resources/scripts/helpers.ts index cd57c55b6..15b8fe18b 100644 --- a/resources/scripts/helpers.ts +++ b/resources/scripts/helpers.ts @@ -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, +})); diff --git a/resources/scripts/routers/DashboardRouter.tsx b/resources/scripts/routers/DashboardRouter.tsx index 52a5c6d9b..428414310 100644 --- a/resources/scripts/routers/DashboardRouter.tsx +++ b/resources/scripts/routers/DashboardRouter.tsx @@ -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';