fix: biometrics input on mobile webview challenge modal (#1572)

This commit is contained in:
Aman Harwara
2022-09-15 23:41:03 +05:30
committed by GitHub
parent 79518b6a5d
commit 3a15142940
4 changed files with 111 additions and 8 deletions

View File

@@ -288,6 +288,53 @@ export class MobileDeviceInterface implements DeviceInterface {
} }
} }
authenticateWithBiometrics() {
return new Promise<boolean>((resolve) => {
if (Platform.OS === 'android') {
FingerprintScanner.authenticate({
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore ts type does not exist for deviceCredentialAllowed
deviceCredentialAllowed: true,
description: 'Biometrics are required to access your notes.',
})
.then(() => {
FingerprintScanner.release()
resolve(true)
})
.catch((error) => {
FingerprintScanner.release()
if (error.name === 'DeviceLocked') {
Alert.alert('Unsuccessful', 'Authentication failed. Wait 30 seconds to try again.')
} else {
Alert.alert('Unsuccessful', 'Authentication failed. Tap to try again.')
}
resolve(false)
})
} else {
// iOS
FingerprintScanner.authenticate({
fallbackEnabled: true,
description: 'This is required to access your notes.',
})
.then(() => {
FingerprintScanner.release()
resolve(true)
})
.catch((error_1) => {
FingerprintScanner.release()
if (error_1.name !== 'SystemCancel') {
if (error_1.name !== 'UserCancel') {
Alert.alert('Unsuccessful')
} else {
Alert.alert('Unsuccessful', 'Authentication failed. Tap to try again.')
}
}
resolve(false)
})
}
})
}
getRawKeychainValue(): Promise<RawKeychainValue | null | undefined> { getRawKeychainValue(): Promise<RawKeychainValue | null | undefined> {
return Keychain.getKeys() return Keychain.getKeys()
} }

View File

@@ -9,4 +9,5 @@ export interface MobileDeviceInterface extends DeviceInterface {
setAndroidScreenshotPrivacy(enable: boolean): Promise<void> setAndroidScreenshotPrivacy(enable: boolean): Promise<void>
getMobileScreenshotPrivacyEnabled(): Promise<boolean | undefined> getMobileScreenshotPrivacyEnabled(): Promise<boolean | undefined>
setMobileScreenshotPrivacyEnabled(isEnabled: boolean): Promise<void> setMobileScreenshotPrivacyEnabled(isEnabled: boolean): Promise<void>
authenticateWithBiometrics(): Promise<boolean>
} }

View File

@@ -5,6 +5,7 @@ import {
Challenge, Challenge,
ChallengePrompt, ChallengePrompt,
ChallengeReason, ChallengeReason,
ChallengeValidation,
ChallengeValue, ChallengeValue,
removeFromArray, removeFromArray,
} from '@standardnotes/snjs' } from '@standardnotes/snjs'
@@ -17,6 +18,7 @@ import LockscreenWorkspaceSwitcher from './LockscreenWorkspaceSwitcher'
import { ApplicationGroup } from '@/Application/ApplicationGroup' import { ApplicationGroup } from '@/Application/ApplicationGroup'
import { ViewControllerManager } from '@/Controllers/ViewControllerManager' import { ViewControllerManager } from '@/Controllers/ViewControllerManager'
import { ChallengeModalValues } from './ChallengeModalValues' import { ChallengeModalValues } from './ChallengeModalValues'
import { InputValue } from './InputValue'
type Props = { type Props = {
application: WebApplication application: WebApplication
@@ -64,9 +66,11 @@ const ChallengeModal: FunctionComponent<Props> = ({
const [isProcessing, setIsProcessing] = useState(false) const [isProcessing, setIsProcessing] = useState(false)
const [, setProcessingPrompts] = useState<ChallengePrompt[]>([]) const [, setProcessingPrompts] = useState<ChallengePrompt[]>([])
const [bypassModalFocusLock, setBypassModalFocusLock] = useState(false) const [bypassModalFocusLock, setBypassModalFocusLock] = useState(false)
const shouldShowForgotPasscode = [ChallengeReason.ApplicationUnlock, ChallengeReason.Migration].includes( const shouldShowForgotPasscode = [ChallengeReason.ApplicationUnlock, ChallengeReason.Migration].includes(
challenge.reason, challenge.reason,
) )
const shouldShowWorkspaceSwitcher = challenge.reason === ChallengeReason.ApplicationUnlock const shouldShowWorkspaceSwitcher = challenge.reason === ChallengeReason.ApplicationUnlock
const submit = useCallback(() => { const submit = useCallback(() => {
@@ -106,7 +110,7 @@ const ChallengeModal: FunctionComponent<Props> = ({
}, [application, challenge, isProcessing, isSubmitting, values]) }, [application, challenge, isProcessing, isSubmitting, values])
const onValueChange = useCallback( const onValueChange = useCallback(
(value: string | number, prompt: ChallengePrompt) => { (value: InputValue['value'], prompt: ChallengePrompt) => {
const newValues = { ...values } const newValues = { ...values }
newValues[prompt.id].invalid = false newValues[prompt.id].invalid = false
newValues[prompt.id].value = value newValues[prompt.id].value = value
@@ -169,6 +173,17 @@ const ChallengeModal: FunctionComponent<Props> = ({
} }
}, [application, challenge, onDismiss]) }, [application, challenge, onDismiss])
const biometricPrompt = challenge.prompts.find((prompt) => prompt.validation === ChallengeValidation.Biometric)
const hasOnlyBiometricPrompt = challenge.prompts.length === 1 && !!biometricPrompt
const hasBiometricPromptValue = biometricPrompt && values[biometricPrompt.id].value
useEffect(() => {
const shouldAutoSubmit = hasOnlyBiometricPrompt && hasBiometricPromptValue
if (shouldAutoSubmit) {
submit()
}
}, [hasBiometricPromptValue, hasOnlyBiometricPrompt, submit])
if (!challenge.prompts) { if (!challenge.prompts) {
return null return null
} }
@@ -201,11 +216,9 @@ const ChallengeModal: FunctionComponent<Props> = ({
)} )}
<ProtectedIllustration className="mb-4 h-30 w-30" /> <ProtectedIllustration className="mb-4 h-30 w-30" />
<div className="mb-3 max-w-76 text-center text-lg font-bold">{challenge.heading}</div> <div className="mb-3 max-w-76 text-center text-lg font-bold">{challenge.heading}</div>
{challenge.subheading && ( {challenge.subheading && (
<div className="break-word mb-4 max-w-76 text-center text-sm">{challenge.subheading}</div> <div className="break-word mb-4 max-w-76 text-center text-sm">{challenge.subheading}</div>
)} )}
<form <form
className="flex min-w-76 flex-col items-center" className="flex min-w-76 flex-col items-center"
onSubmit={(e) => { onSubmit={(e) => {
@@ -215,6 +228,7 @@ const ChallengeModal: FunctionComponent<Props> = ({
> >
{challenge.prompts.map((prompt, index) => ( {challenge.prompts.map((prompt, index) => (
<ChallengeModalPrompt <ChallengeModalPrompt
application={application}
key={prompt.id} key={prompt.id}
prompt={prompt} prompt={prompt}
values={values} values={values}

View File

@@ -1,25 +1,50 @@
import { ChallengePrompt, ChallengeValidation, ProtectionSessionDurations } from '@standardnotes/snjs' import {
ChallengePrompt,
ChallengeValidation,
MobileDeviceInterface,
ProtectionSessionDurations,
} from '@standardnotes/snjs'
import { FunctionComponent, useEffect, useRef } from 'react' import { FunctionComponent, useEffect, useRef } from 'react'
import DecoratedInput from '@/Components/Input/DecoratedInput' import DecoratedInput from '@/Components/Input/DecoratedInput'
import DecoratedPasswordInput from '@/Components/Input/DecoratedPasswordInput' import DecoratedPasswordInput from '@/Components/Input/DecoratedPasswordInput'
import { ChallengeModalValues } from './ChallengeModalValues' import { ChallengeModalValues } from './ChallengeModalValues'
import Button from '../Button/Button'
import { WebApplication } from '@/Application/Application'
import { InputValue } from './InputValue'
type Props = { type Props = {
application: WebApplication
prompt: ChallengePrompt prompt: ChallengePrompt
values: ChallengeModalValues values: ChallengeModalValues
index: number index: number
onValueChange: (value: string | number, prompt: ChallengePrompt) => void onValueChange: (value: InputValue['value'], prompt: ChallengePrompt) => void
isInvalid: boolean isInvalid: boolean
} }
const ChallengeModalPrompt: FunctionComponent<Props> = ({ prompt, values, index, onValueChange, isInvalid }) => { const ChallengeModalPrompt: FunctionComponent<Props> = ({
application,
prompt,
values,
index,
onValueChange,
isInvalid,
}) => {
const inputRef = useRef<HTMLInputElement>(null) const inputRef = useRef<HTMLInputElement>(null)
const biometricsButtonRef = useRef<HTMLButtonElement>(null)
useEffect(() => { useEffect(() => {
if (index === 0) { const isNotFirstPrompt = index !== 0
if (isNotFirstPrompt) {
return
}
if (prompt.validation === ChallengeValidation.Biometric) {
biometricsButtonRef.current?.click()
} else {
inputRef.current?.focus() inputRef.current?.focus()
} }
}, [index]) }, [index, prompt.validation])
useEffect(() => { useEffect(() => {
if (isInvalid) { if (isInvalid) {
@@ -61,6 +86,22 @@ const ChallengeModalPrompt: FunctionComponent<Props> = ({ prompt, values, index,
})} })}
</div> </div>
</div> </div>
) : prompt.validation === ChallengeValidation.Biometric ? (
<div className="min-w-76">
<Button
primary
fullWidth
onClick={async () => {
const authenticated = await (
application.deviceInterface as MobileDeviceInterface
).authenticateWithBiometrics()
onValueChange(authenticated, prompt)
}}
ref={biometricsButtonRef}
>
Tap to use biometrics
</Button>
</div>
) : prompt.secureTextEntry ? ( ) : prompt.secureTextEntry ? (
<DecoratedPasswordInput <DecoratedPasswordInput
ref={inputRef} ref={inputRef}