feat-dev: add U2F iframe for desktop client authentication (#2236)
This commit is contained in:
@@ -4,6 +4,7 @@ import { RefObject, useState } from 'react'
|
||||
import Button from '../Button/Button'
|
||||
import Icon from '../Icon/Icon'
|
||||
import { InputValue } from './InputValue'
|
||||
import U2FPromptIframeContainer from './U2FPromptIframeContainer'
|
||||
|
||||
type Props = {
|
||||
application: WebApplication
|
||||
@@ -17,6 +18,18 @@ const U2FPrompt = ({ application, onValueChange, prompt, buttonRef, contextData
|
||||
const [authenticatorResponse, setAuthenticatorResponse] = useState<Record<string, unknown> | null>(null)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
if (!application.isFullU2FClient) {
|
||||
return (
|
||||
<U2FPromptIframeContainer
|
||||
contextData={contextData}
|
||||
apiHost={application.getHost() || window.defaultSyncServer}
|
||||
onResponse={(response) => {
|
||||
onValueChange(response, prompt)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-w-76">
|
||||
{error && <div className="text-red-500">{error}</div>}
|
||||
@@ -27,18 +40,18 @@ const U2FPrompt = ({ application, onValueChange, prompt, buttonRef, contextData
|
||||
onClick={async () => {
|
||||
if (!contextData || contextData.username === undefined) {
|
||||
setError('No username provided')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const authenticatorResponseOrError = await application.getAuthenticatorAuthenticationResponse.execute({
|
||||
username: contextData.username,
|
||||
username: contextData.username as string,
|
||||
})
|
||||
|
||||
if (authenticatorResponseOrError.isFailed()) {
|
||||
setError(authenticatorResponseOrError.getError())
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const authenticatorResponse = authenticatorResponseOrError.getValue()
|
||||
|
||||
setAuthenticatorResponse(authenticatorResponse)
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { log, LoggingDomain } from '@/Logging'
|
||||
import { isDev } from '@/Utils'
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
type Props = {
|
||||
contextData?: Record<string, unknown>
|
||||
onResponse: (response: string) => void
|
||||
apiHost: string
|
||||
}
|
||||
|
||||
const U2F_IFRAME_ORIGIN = isDev ? 'http://localhost:3001/?route=u2f' : 'https://app.standardnotes.com/?route=u2f'
|
||||
|
||||
const U2FPromptIframeContainer = ({ contextData, onResponse, apiHost }: Props) => {
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const messageHandler = (event: MessageEvent) => {
|
||||
log(LoggingDomain.U2F, 'Native client received message', event)
|
||||
const eventDoesNotComeFromU2FIFrame = event.origin !== new URL(U2F_IFRAME_ORIGIN).origin
|
||||
if (eventDoesNotComeFromU2FIFrame) {
|
||||
log(
|
||||
LoggingDomain.U2F,
|
||||
'Not sending data to U2F iframe; origin does not match',
|
||||
event.origin,
|
||||
new URL(U2F_IFRAME_ORIGIN).origin,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (event.data.mountedAuthView) {
|
||||
if (iframeRef.current?.contentWindow) {
|
||||
log(LoggingDomain.U2F, 'Sending contextData to U2F iframe', contextData)
|
||||
iframeRef.current.contentWindow.postMessage(
|
||||
{ username: (contextData as Record<string, unknown>).username, apiHost },
|
||||
U2F_IFRAME_ORIGIN,
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (event.data.assertionResponse) {
|
||||
log(LoggingDomain.U2F, 'Received assertion response from U2F iframe', event.data.assertionResponse)
|
||||
onResponse(event.data.assertionResponse)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('message', messageHandler)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('message', messageHandler)
|
||||
}
|
||||
}, [contextData, onResponse, apiHost])
|
||||
|
||||
return (
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
src={U2F_IFRAME_ORIGIN}
|
||||
className="h-40 w-full"
|
||||
title="U2F"
|
||||
allow="publickey-credentials-get"
|
||||
id="u2f"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default U2FPromptIframeContainer
|
||||
@@ -1,5 +1,5 @@
|
||||
import { classNames } from '@standardnotes/utils'
|
||||
import { forwardRef, Fragment, Ref } from 'react'
|
||||
import { forwardRef, Fragment, KeyboardEventHandler, Ref, useCallback } from 'react'
|
||||
import { DecoratedInputProps } from './DecoratedInputProps'
|
||||
|
||||
const getClassNames = (hasLeftDecorations: boolean, hasRightDecorations: boolean, roundedFull?: boolean) => {
|
||||
@@ -31,6 +31,7 @@ const DecoratedInput = forwardRef(
|
||||
onFocus,
|
||||
onKeyDown,
|
||||
onKeyUp,
|
||||
onEnter,
|
||||
placeholder = '',
|
||||
right,
|
||||
type = 'text',
|
||||
@@ -38,6 +39,7 @@ const DecoratedInput = forwardRef(
|
||||
value,
|
||||
defaultValue,
|
||||
roundedFull,
|
||||
autofocus = false,
|
||||
}: DecoratedInputProps,
|
||||
ref: Ref<HTMLInputElement>,
|
||||
) => {
|
||||
@@ -45,6 +47,16 @@ const DecoratedInput = forwardRef(
|
||||
const hasRightDecorations = Boolean(right?.length)
|
||||
const computedClassNames = getClassNames(hasLeftDecorations, hasRightDecorations, roundedFull)
|
||||
|
||||
const handleKeyUp: KeyboardEventHandler = useCallback(
|
||||
(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
onEnter?.()
|
||||
}
|
||||
onKeyUp?.(e)
|
||||
},
|
||||
[onKeyUp, onEnter],
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
@@ -63,6 +75,7 @@ const DecoratedInput = forwardRef(
|
||||
|
||||
<input
|
||||
autoComplete={autocomplete ? 'on' : 'off'}
|
||||
autoFocus={autofocus}
|
||||
className={`${computedClassNames.input} ${disabled ? computedClassNames.disabled : ''} ${className?.input}`}
|
||||
data-lpignore={type !== 'password' ? true : false}
|
||||
disabled={disabled}
|
||||
@@ -71,7 +84,7 @@ const DecoratedInput = forwardRef(
|
||||
onChange={(e) => onChange && onChange((e.target as HTMLInputElement).value)}
|
||||
onFocus={onFocus}
|
||||
onKeyDown={onKeyDown}
|
||||
onKeyUp={onKeyUp}
|
||||
onKeyUp={handleKeyUp}
|
||||
placeholder={placeholder}
|
||||
ref={ref}
|
||||
title={title}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { FocusEventHandler, KeyboardEventHandler, ReactNode } from 'react'
|
||||
|
||||
export type DecoratedInputProps = {
|
||||
autocomplete?: boolean
|
||||
autofocus?: boolean
|
||||
spellcheck?: boolean
|
||||
className?: {
|
||||
container?: string
|
||||
@@ -17,6 +18,7 @@ export type DecoratedInputProps = {
|
||||
onFocus?: FocusEventHandler
|
||||
onKeyDown?: KeyboardEventHandler
|
||||
onKeyUp?: KeyboardEventHandler
|
||||
onEnter?: () => void
|
||||
placeholder?: string
|
||||
right?: ReactNode[]
|
||||
title?: string
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { FunctionComponent, useCallback, useState } from 'react'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { UseCaseInterface } from '@standardnotes/snjs'
|
||||
import { AddAuthenticator } from '@standardnotes/snjs'
|
||||
|
||||
import DecoratedInput from '@/Components/Input/DecoratedInput'
|
||||
import { UserProvider } from '@/Components/Preferences/Providers'
|
||||
@@ -9,7 +9,7 @@ import { MutuallyExclusiveMediaQueryBreakpoints, useMediaQuery } from '@/Hooks/u
|
||||
|
||||
type Props = {
|
||||
userProvider: UserProvider
|
||||
addAuthenticator: UseCaseInterface<void>
|
||||
addAuthenticator: AddAuthenticator
|
||||
onDeviceAddingModalToggle: (show: boolean) => void
|
||||
onDeviceAdded: () => Promise<void>
|
||||
}
|
||||
@@ -82,11 +82,22 @@ const U2FAddDeviceView: FunctionComponent<Props> = ({
|
||||
},
|
||||
]}
|
||||
>
|
||||
<div className="w-25 h-25 flex items-center justify-center bg-info">...Some Cool Device Picture Here...</div>
|
||||
<div className="flex flex-grow flex-col gap-2">
|
||||
<DecoratedInput className={{ container: 'w-92 ml-4' }} value={deviceName} onChange={handleDeviceNameChange} />
|
||||
<div className="flex px-4 py-4">
|
||||
<div className="ml-4 flex flex-grow flex-col gap-1">
|
||||
<label htmlFor="u2f-device-name" className="mb-2 text-sm font-semibold">
|
||||
Device Name
|
||||
</label>
|
||||
<DecoratedInput
|
||||
autofocus
|
||||
id="u2f-device-name"
|
||||
className={{ container: 'w-92' }}
|
||||
value={deviceName}
|
||||
onChange={handleDeviceNameChange}
|
||||
onEnter={handleAddDeviceClick}
|
||||
/>
|
||||
{errorMessage && <div className="mt-1.5 text-danger">{errorMessage}</div>}
|
||||
</div>
|
||||
</div>
|
||||
{errorMessage && <div className="text-error">{errorMessage}</div>}
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,17 +3,27 @@ import { observer } from 'mobx-react-lite'
|
||||
|
||||
import { Text } from '@/Components/Preferences/PreferencesComponents/Content'
|
||||
import { UserProvider } from '@/Components/Preferences/Providers'
|
||||
import { useApplication } from '@/Components/ApplicationProvider'
|
||||
|
||||
type Props = {
|
||||
userProvider: UserProvider
|
||||
}
|
||||
|
||||
const U2FDescription: FunctionComponent<Props> = ({ userProvider }) => {
|
||||
const application = useApplication()
|
||||
|
||||
if (userProvider.getUser() === undefined) {
|
||||
return <Text>Sign in or register for an account to configure U2F.</Text>
|
||||
}
|
||||
|
||||
return <Text>Authenticate with a U2F hardware device.</Text>
|
||||
return (
|
||||
<div>
|
||||
<Text>Authenticate with a U2F hardware device such as Yubikey.</Text>
|
||||
{!application.isFullU2FClient && (
|
||||
<Text className="italic">Please visit the web app in order to add a U2F Device.</Text>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default observer(U2FDescription)
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { FunctionComponent, useCallback } from 'react'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
|
||||
import { Text } from '@/Components/Preferences/PreferencesComponents/Content'
|
||||
import { Subtitle } from '@/Components/Preferences/PreferencesComponents/Content'
|
||||
import { WebApplication } from '@/Application/Application'
|
||||
import Button from '@/Components/Button/Button'
|
||||
import Icon from '@/Components/Icon/Icon'
|
||||
|
||||
type Props = {
|
||||
application: WebApplication
|
||||
@@ -31,24 +32,25 @@ const U2FDevicesList: FunctionComponent<Props> = ({ application, devices, onErro
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex flex-row items-center">
|
||||
<div>
|
||||
{devices.length > 0 && (
|
||||
<div className="flex flex-grow flex-col">
|
||||
<div>
|
||||
<Text>Devices:</Text>
|
||||
<>
|
||||
<Subtitle>Devices</Subtitle>
|
||||
<div className="flex flex-grow flex-col divide-y divide-border">
|
||||
{devices.map((device) => (
|
||||
<div className="flex items-center py-2" key={`device-${device.id}`}>
|
||||
<Icon type="security" />
|
||||
<div className="ml-2 mr-auto text-sm">{device.name}</div>
|
||||
<Button
|
||||
small
|
||||
key={device.id}
|
||||
label="Delete"
|
||||
onClick={async () => handleDeleteButtonOnClick(device.id)}
|
||||
></Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{devices.map((device) => (
|
||||
<div key="device-{device.id}">
|
||||
<Text>{device.name}</Text>
|
||||
<Button
|
||||
key={device.id}
|
||||
primary={true}
|
||||
label="Delete"
|
||||
onClick={async () => handleDeleteButtonOnClick(device.id)}
|
||||
></Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -10,10 +10,10 @@ type Props = {
|
||||
|
||||
const U2FTitle: FunctionComponent<Props> = ({ userProvider }) => {
|
||||
if (userProvider.getUser() === undefined) {
|
||||
return <Title>Universal 2nd Factor authentication not available</Title>
|
||||
return <Title>Universal 2nd factor authentication not available</Title>
|
||||
}
|
||||
|
||||
return <Title>Universal 2nd Factor authentication</Title>
|
||||
return <Title>Universal 2nd Factor Authentication</Title>
|
||||
}
|
||||
|
||||
export default observer(U2FTitle)
|
||||
|
||||
@@ -11,6 +11,7 @@ import U2FDescription from './U2FDescription'
|
||||
import Button from '@/Components/Button/Button'
|
||||
import U2FAddDeviceView from '../U2FAddDeviceView'
|
||||
import U2FDevicesList from './U2FDevicesList'
|
||||
import ModalOverlay from '@/Components/Modal/ModalOverlay'
|
||||
|
||||
type Props = {
|
||||
application: WebApplication
|
||||
@@ -45,34 +46,36 @@ const U2FView: FunctionComponent<Props> = ({ application, userProvider }) => {
|
||||
<>
|
||||
<PreferencesGroup>
|
||||
<PreferencesSegment>
|
||||
<div className="flex flex-row items-center">
|
||||
<div className="flex flex-grow flex-col">
|
||||
<U2FTitle userProvider={userProvider} />
|
||||
<U2FDescription userProvider={userProvider} />
|
||||
</div>
|
||||
<PreferencesSegment>
|
||||
<Button label="Add Device" primary onClick={handleAddDeviceClick} />
|
||||
</PreferencesSegment>
|
||||
<div className="flex flex-col">
|
||||
<U2FTitle userProvider={userProvider} />
|
||||
<U2FDescription userProvider={userProvider} />
|
||||
</div>
|
||||
</PreferencesSegment>
|
||||
<PreferencesSegment>
|
||||
{error && <div className="text-red-500">{error}</div>}
|
||||
<PreferencesSegment classes="mt-2">
|
||||
{error && <div className="text-danger">{error}</div>}
|
||||
<U2FDevicesList
|
||||
application={application}
|
||||
devices={devices}
|
||||
onError={setError}
|
||||
onDeviceDeleted={loadAuthenticatorDevices}
|
||||
/>
|
||||
<Button
|
||||
className="mt-1"
|
||||
disabled={!application.isFullU2FClient}
|
||||
label="Add Device"
|
||||
primary
|
||||
onClick={handleAddDeviceClick}
|
||||
/>
|
||||
</PreferencesSegment>
|
||||
</PreferencesGroup>
|
||||
{showDeviceAddingModal && (
|
||||
<ModalOverlay isOpen={showDeviceAddingModal}>
|
||||
<U2FAddDeviceView
|
||||
onDeviceAddingModalToggle={setShowDeviceAddingModal}
|
||||
onDeviceAdded={loadAuthenticatorDevices}
|
||||
userProvider={userProvider}
|
||||
addAuthenticator={application.addAuthenticator}
|
||||
/>
|
||||
)}
|
||||
</ModalOverlay>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import Button from '../Button/Button'
|
||||
import { startAuthentication } from '@simplewebauthn/browser'
|
||||
import { log, LoggingDomain } from '@/Logging'
|
||||
|
||||
/**
|
||||
* An iframe for use in the desktop and mobile application that allows them to load app.standardnotes.com to perform
|
||||
* U2F authentication. Web applications do not need this iframe, as they can perform U2F authentication directly.
|
||||
*/
|
||||
const U2FAuthIframe = () => {
|
||||
const [username, setUsername] = useState('')
|
||||
const [apiHost, setApiHost] = useState<string | null>(null)
|
||||
const [source, setSource] = useState<MessageEvent['source'] | null>(null)
|
||||
const NATIVE_CLIENT_ORIGIN = 'file://'
|
||||
|
||||
useEffect(() => {
|
||||
window.parent.postMessage(
|
||||
{
|
||||
mountedAuthView: true,
|
||||
},
|
||||
NATIVE_CLIENT_ORIGIN,
|
||||
)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const messageHandler = (event: MessageEvent) => {
|
||||
log(LoggingDomain.U2F, 'U2F iframe received message', event)
|
||||
|
||||
const eventDoesNotComeFromNativeClient = event.origin !== NATIVE_CLIENT_ORIGIN
|
||||
if (eventDoesNotComeFromNativeClient) {
|
||||
log(LoggingDomain.U2F, 'Not setting username; origin does not match', event.origin, NATIVE_CLIENT_ORIGIN)
|
||||
return
|
||||
}
|
||||
|
||||
if (event.data.username) {
|
||||
setUsername(event.data.username)
|
||||
setApiHost(event.data.apiHost)
|
||||
setSource(event.source)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('message', messageHandler)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('message', messageHandler)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const [info, setInfo] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const beginAuthentication = useCallback(async () => {
|
||||
setInfo('')
|
||||
setError('')
|
||||
|
||||
try {
|
||||
if (!username || !source) {
|
||||
throw new Error('No username provided')
|
||||
}
|
||||
|
||||
const response = await fetch(`${apiHost}/v1/authenticators/generate-authentication-options`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
username,
|
||||
}),
|
||||
})
|
||||
|
||||
const jsonResponse = await response.json()
|
||||
if (!jsonResponse.data || !jsonResponse.data.options) {
|
||||
throw new Error('No options returned from server')
|
||||
}
|
||||
|
||||
setInfo('Waiting for U2F device...')
|
||||
|
||||
const assertionResponse = await startAuthentication(jsonResponse.data.options)
|
||||
|
||||
;(source as WindowProxy).postMessage(
|
||||
{
|
||||
assertionResponse,
|
||||
},
|
||||
NATIVE_CLIENT_ORIGIN,
|
||||
)
|
||||
|
||||
setInfo('Authentication successful!')
|
||||
} catch (error) {
|
||||
if (!error) {
|
||||
return
|
||||
}
|
||||
setError(error.toString())
|
||||
console.error(error.toString())
|
||||
}
|
||||
}, [source, username, apiHost])
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-2">
|
||||
<div className="mb-2 text-center">Insert your U2F device, then press the button below to authenticate.</div>
|
||||
<Button onClick={beginAuthentication}>Authenticate</Button>
|
||||
<div className="mt-2">
|
||||
<div>{info}</div>
|
||||
<div className="text-danger">{error}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default U2FAuthIframe
|
||||
Reference in New Issue
Block a user