Email auth factor (#3602)

* Add email 2fa toggle

* Add UI elements needed for 2fa codes in login

* Wire up to the server

* Give a better failure message for bad 2fa code

* Handle enter key in login form 2fa field

* Trim spaces

* Improve error message
zio/stable
Paul Frazee 2024-04-22 19:18:13 -07:00 committed by GitHub
parent cbb817b5b7
commit 710e913024
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 363 additions and 20 deletions

View File

@ -50,7 +50,7 @@
"open-analyzer": "EXPO_PUBLIC_OPEN_ANALYZER=1 yarn build-web" "open-analyzer": "EXPO_PUBLIC_OPEN_ANALYZER=1 yarn build-web"
}, },
"dependencies": { "dependencies": {
"@atproto/api": "^0.12.3", "@atproto/api": "^0.12.5",
"@bam.tech/react-native-image-resizer": "^3.0.4", "@bam.tech/react-native-image-resizer": "^3.0.4",
"@braintree/sanitize-url": "^6.0.2", "@braintree/sanitize-url": "^6.0.2",
"@discord/bottom-sheet": "https://github.com/bluesky-social/react-native-bottom-sheet.git#discord-fork-4.6.1", "@discord/bottom-sheet": "https://github.com/bluesky-social/react-native-bottom-sheet.git#discord-fork-4.6.1",

View File

@ -6,7 +6,10 @@ import {
TextInput, TextInput,
View, View,
} from 'react-native' } from 'react-native'
import {ComAtprotoServerDescribeServer} from '@atproto/api' import {
ComAtprotoServerCreateSession,
ComAtprotoServerDescribeServer,
} from '@atproto/api'
import {msg, Trans} from '@lingui/macro' import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
@ -23,6 +26,7 @@ import {HostingProvider} from '#/components/forms/HostingProvider'
import * as TextField from '#/components/forms/TextField' import * as TextField from '#/components/forms/TextField'
import {At_Stroke2_Corner0_Rounded as At} from '#/components/icons/At' import {At_Stroke2_Corner0_Rounded as At} from '#/components/icons/At'
import {Lock_Stroke2_Corner0_Rounded as Lock} from '#/components/icons/Lock' import {Lock_Stroke2_Corner0_Rounded as Lock} from '#/components/icons/Lock'
import {Ticket_Stroke2_Corner0_Rounded as Ticket} from '#/components/icons/Ticket'
import {Loader} from '#/components/Loader' import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {FormContainer} from './FormContainer' import {FormContainer} from './FormContainer'
@ -53,8 +57,11 @@ export const LoginForm = ({
const {track} = useAnalytics() const {track} = useAnalytics()
const t = useTheme() const t = useTheme()
const [isProcessing, setIsProcessing] = useState<boolean>(false) const [isProcessing, setIsProcessing] = useState<boolean>(false)
const [isAuthFactorTokenNeeded, setIsAuthFactorTokenNeeded] =
useState<boolean>(false)
const [identifier, setIdentifier] = useState<string>(initialHandle) const [identifier, setIdentifier] = useState<string>(initialHandle)
const [password, setPassword] = useState<string>('') const [password, setPassword] = useState<string>('')
const [authFactorToken, setAuthFactorToken] = useState<string>('')
const passwordInputRef = useRef<TextInput>(null) const passwordInputRef = useRef<TextInput>(null)
const {_} = useLingui() const {_} = useLingui()
const {login} = useSessionApi() const {login} = useSessionApi()
@ -100,6 +107,7 @@ export const LoginForm = ({
service: serviceUrl, service: serviceUrl,
identifier: fullIdent, identifier: fullIdent,
password, password,
authFactorToken: authFactorToken.trim(),
}, },
'LoginForm', 'LoginForm',
) )
@ -107,7 +115,16 @@ export const LoginForm = ({
const errMsg = e.toString() const errMsg = e.toString()
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut) LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
setIsProcessing(false) setIsProcessing(false)
if (errMsg.includes('Authentication Required')) { if (
e instanceof ComAtprotoServerCreateSession.AuthFactorTokenRequiredError
) {
setIsAuthFactorTokenNeeded(true)
} else if (errMsg.includes('Token is invalid')) {
logger.debug('Failed to login due to invalid 2fa token', {
error: errMsg,
})
setError(_(msg`Invalid 2FA confirmation code.`))
} else if (errMsg.includes('Authentication Required')) {
logger.debug('Failed to login due to invalid credentials', { logger.debug('Failed to login due to invalid credentials', {
error: errMsg, error: errMsg,
}) })
@ -215,6 +232,37 @@ export const LoginForm = ({
</TextField.Root> </TextField.Root>
</View> </View>
</View> </View>
{isAuthFactorTokenNeeded && (
<View>
<TextField.LabelText>
<Trans>2FA Confirmation</Trans>
</TextField.LabelText>
<TextField.Root>
<TextField.Icon icon={Ticket} />
<TextField.Input
testID="loginAuthFactorTokenInput"
label={_(msg`Confirmation code`)}
autoCapitalize="none"
autoFocus
autoCorrect={false}
autoComplete="off"
returnKeyType="done"
textContentType="username"
blurOnSubmit={false} // prevents flickering due to onSubmitEditing going to next field
value={authFactorToken}
onChangeText={setAuthFactorToken}
onSubmitEditing={onPressNext}
editable={!isProcessing}
accessibilityHint={_(
msg`Input the code which has been emailed to you`,
)}
/>
</TextField.Root>
<Text style={[a.text_sm, t.atoms.text_contrast_medium, a.mt_sm]}>
<Trans>Check your email for a login code and enter it here.</Trans>
</Text>
</View>
)}
<FormError error={error} /> <FormError error={error} />
<View style={[a.flex_row, a.align_center, a.pt_md]}> <View style={[a.flex_row, a.align_center, a.pt_md]}>
<Button <Button

View File

@ -107,6 +107,7 @@ export interface PostLanguagesSettingsModal {
export interface VerifyEmailModal { export interface VerifyEmailModal {
name: 'verify-email' name: 'verify-email'
showReminder?: boolean showReminder?: boolean
onSuccess?: () => void
} }
export interface ChangeEmailModal { export interface ChangeEmailModal {

View File

@ -11,6 +11,7 @@ const accountSchema = z.object({
handle: z.string(), handle: z.string(),
email: z.string().optional(), email: z.string().optional(),
emailConfirmed: z.boolean().optional(), emailConfirmed: z.boolean().optional(),
emailAuthFactor: z.boolean().optional(),
refreshJwt: z.string().optional(), // optional because it can expire refreshJwt: z.string().optional(), // optional because it can expire
accessJwt: z.string().optional(), // optional because it can expire accessJwt: z.string().optional(), // optional because it can expire
deactivated: z.boolean().optional(), deactivated: z.boolean().optional(),

View File

@ -59,6 +59,7 @@ export type ApiContext = {
service: string service: string
identifier: string identifier: string
password: string password: string
authFactorToken?: string | undefined
}, },
logContext: LogEvents['account:loggedIn']['logContext'], logContext: LogEvents['account:loggedIn']['logContext'],
) => Promise<void> ) => Promise<void>
@ -87,7 +88,10 @@ export type ApiContext = {
) => Promise<void> ) => Promise<void>
updateCurrentAccount: ( updateCurrentAccount: (
account: Partial< account: Partial<
Pick<SessionAccount, 'handle' | 'email' | 'emailConfirmed'> Pick<
SessionAccount,
'handle' | 'email' | 'emailConfirmed' | 'emailAuthFactor'
>
>, >,
) => void ) => void
} }
@ -298,12 +302,12 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
) )
const login = React.useCallback<ApiContext['login']>( const login = React.useCallback<ApiContext['login']>(
async ({service, identifier, password}, logContext) => { async ({service, identifier, password, authFactorToken}, logContext) => {
logger.debug(`session: login`, {}, logger.DebugContext.session) logger.debug(`session: login`, {}, logger.DebugContext.session)
const agent = new BskyAgent({service}) const agent = new BskyAgent({service})
await agent.login({identifier, password}) await agent.login({identifier, password, authFactorToken})
if (!agent.session) { if (!agent.session) {
throw new Error(`session: login failed to establish a session`) throw new Error(`session: login failed to establish a session`)
@ -319,6 +323,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
handle: agent.session.handle, handle: agent.session.handle,
email: agent.session.email, email: agent.session.email,
emailConfirmed: agent.session.emailConfirmed || false, emailConfirmed: agent.session.emailConfirmed || false,
emailAuthFactor: agent.session.emailAuthFactor,
refreshJwt: agent.session.refreshJwt, refreshJwt: agent.session.refreshJwt,
accessJwt: agent.session.accessJwt, accessJwt: agent.session.accessJwt,
deactivated: isSessionDeactivated(agent.session.accessJwt), deactivated: isSessionDeactivated(agent.session.accessJwt),
@ -489,6 +494,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
handle: agent.session.handle, handle: agent.session.handle,
email: agent.session.email, email: agent.session.email,
emailConfirmed: agent.session.emailConfirmed || false, emailConfirmed: agent.session.emailConfirmed || false,
emailAuthFactor: agent.session.emailAuthFactor || false,
refreshJwt: agent.session.refreshJwt, refreshJwt: agent.session.refreshJwt,
accessJwt: agent.session.accessJwt, accessJwt: agent.session.accessJwt,
deactivated: isSessionDeactivated(agent.session.accessJwt), deactivated: isSessionDeactivated(agent.session.accessJwt),
@ -546,6 +552,10 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
account.emailConfirmed !== undefined account.emailConfirmed !== undefined
? account.emailConfirmed ? account.emailConfirmed
: currentAccount.emailConfirmed, : currentAccount.emailConfirmed,
emailAuthFactor:
account.emailAuthFactor !== undefined
? account.emailAuthFactor
: currentAccount.emailAuthFactor,
} }
return { return {

View File

@ -6,23 +6,24 @@ import {
StyleSheet, StyleSheet,
View, View,
} from 'react-native' } from 'react-native'
import {Svg, Circle, Path} from 'react-native-svg' import {Circle, Path, Svg} from 'react-native-svg'
import {ScrollView, TextInput} from './util'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {Text} from '../util/text/Text' import {msg, Trans} from '@lingui/macro'
import {Button} from '../util/forms/Button' import {useLingui} from '@lingui/react'
import {ErrorMessage} from '../util/error/ErrorMessage'
import * as Toast from '../util/Toast' import {logger} from '#/logger'
import {s, colors} from 'lib/styles' import {useModalControls} from '#/state/modals'
import {getAgent, useSession, useSessionApi} from '#/state/session'
import {usePalette} from 'lib/hooks/usePalette' import {usePalette} from 'lib/hooks/usePalette'
import {isWeb} from 'platform/detection'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {cleanError} from 'lib/strings/errors' import {cleanError} from 'lib/strings/errors'
import {Trans, msg} from '@lingui/macro' import {colors, s} from 'lib/styles'
import {useLingui} from '@lingui/react' import {isWeb} from 'platform/detection'
import {useModalControls} from '#/state/modals' import {ErrorMessage} from '../util/error/ErrorMessage'
import {useSession, useSessionApi, getAgent} from '#/state/session' import {Button} from '../util/forms/Button'
import {logger} from '#/logger' import {Text} from '../util/text/Text'
import * as Toast from '../util/Toast'
import {ScrollView, TextInput} from './util'
export const snapPoints = ['90%'] export const snapPoints = ['90%']
@ -32,7 +33,13 @@ enum Stages {
ConfirmCode, ConfirmCode,
} }
export function Component({showReminder}: {showReminder?: boolean}) { export function Component({
showReminder,
onSuccess,
}: {
showReminder?: boolean
onSuccess?: () => void
}) {
const pal = usePalette('default') const pal = usePalette('default')
const {currentAccount} = useSession() const {currentAccount} = useSession()
const {updateCurrentAccount} = useSessionApi() const {updateCurrentAccount} = useSessionApi()
@ -77,6 +84,7 @@ export function Component({showReminder}: {showReminder?: boolean}) {
updateCurrentAccount({emailConfirmed: true}) updateCurrentAccount({emailConfirmed: true})
Toast.show(_(msg`Email verified`)) Toast.show(_(msg`Email verified`))
closeModal() closeModal()
onSuccess?.()
} catch (e) { } catch (e) {
setError(cleanError(String(e))) setError(cleanError(String(e)))
} finally { } finally {

View File

@ -0,0 +1,195 @@
import React, {useState} from 'react'
import {View} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {cleanError} from '#/lib/strings/errors'
import {isNative} from '#/platform/detection'
import {getAgent, useSession, useSessionApi} from '#/state/session'
import {ErrorMessage} from '#/view/com/util/error/ErrorMessage'
import * as Toast from '#/view/com/util/Toast'
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import * as TextField from '#/components/forms/TextField'
import {Lock_Stroke2_Corner0_Rounded as Lock} from '#/components/icons/Lock'
import {Loader} from '#/components/Loader'
import {P, Text} from '#/components/Typography'
enum Stages {
Email,
ConfirmCode,
}
export function DisableEmail2FADialog({
control,
}: {
control: Dialog.DialogOuterProps['control']
}) {
const {_} = useLingui()
const t = useTheme()
const {gtMobile} = useBreakpoints()
const {currentAccount} = useSession()
const {updateCurrentAccount} = useSessionApi()
const [stage, setStage] = useState<Stages>(Stages.Email)
const [confirmationCode, setConfirmationCode] = useState<string>('')
const [isProcessing, setIsProcessing] = useState<boolean>(false)
const [error, setError] = useState<string>('')
const onSendEmail = async () => {
setError('')
setIsProcessing(true)
try {
await getAgent().com.atproto.server.requestEmailUpdate()
setStage(Stages.ConfirmCode)
} catch (e) {
setError(cleanError(String(e)))
} finally {
setIsProcessing(false)
}
}
const onConfirmDisable = async () => {
setError('')
setIsProcessing(true)
try {
if (currentAccount?.email) {
await getAgent().com.atproto.server.updateEmail({
email: currentAccount!.email,
token: confirmationCode.trim(),
emailAuthFactor: false,
})
updateCurrentAccount({emailAuthFactor: false})
Toast.show(_(msg`Email 2FA disabled`))
}
control.close()
} catch (e) {
const errMsg = String(e)
if (errMsg.includes('Token is invalid')) {
setError(_(msg`Invalid 2FA confirmation code.`))
} else {
setError(cleanError(errMsg))
}
} finally {
setIsProcessing(false)
}
}
return (
<Dialog.Outer control={control}>
<Dialog.Handle />
<Dialog.ScrollableInner
accessibilityDescribedBy="dialog-description"
accessibilityLabelledBy="dialog-title">
<View style={[a.relative, a.gap_md, a.w_full]}>
<Text
nativeID="dialog-title"
style={[a.text_2xl, a.font_bold, t.atoms.text]}>
<Trans>Disable Email 2FA</Trans>
</Text>
<P
nativeID="dialog-description"
style={[a.text_sm, t.atoms.text, a.leading_snug]}>
{stage === Stages.ConfirmCode ? (
<Trans>
An email has been sent to{' '}
{currentAccount?.email || '(no email)'}. It includes a
confirmation code which you can enter below.
</Trans>
) : (
<Trans>
To disable the email 2FA method, please verify your access to
the email address.
</Trans>
)}
</P>
{error ? <ErrorMessage message={error} /> : undefined}
{stage === Stages.Email ? (
<View style={gtMobile && [a.flex_row, a.justify_end, a.gap_md]}>
<Button
testID="sendEmailButton"
variant="solid"
color="primary"
size={gtMobile ? 'small' : 'large'}
onPress={onSendEmail}
label={_(msg`Send verification email`)}
disabled={isProcessing}>
<ButtonText>
<Trans>Send verification email</Trans>
</ButtonText>
{isProcessing && <ButtonIcon icon={Loader} />}
</Button>
<Button
testID="haveCodeButton"
variant="ghost"
color="primary"
size={gtMobile ? 'small' : 'large'}
onPress={() => setStage(Stages.ConfirmCode)}
label={_(msg`I have a code`)}
disabled={isProcessing}>
<ButtonText>
<Trans>I have a code</Trans>
</ButtonText>
</Button>
</View>
) : stage === Stages.ConfirmCode ? (
<View>
<View style={[a.mb_md]}>
<TextField.LabelText>
<Trans>Confirmation code</Trans>
</TextField.LabelText>
<TextField.Root>
<TextField.Icon icon={Lock} />
<TextField.Input
testID="confirmationCode"
label={_(msg`Confirmation code`)}
autoCapitalize="none"
autoFocus
autoCorrect={false}
autoComplete="off"
value={confirmationCode}
onChangeText={setConfirmationCode}
editable={!isProcessing}
/>
</TextField.Root>
</View>
<View style={gtMobile && [a.flex_row, a.justify_end]}>
<Button
testID="resendCodeBtn"
variant="ghost"
color="primary"
size={gtMobile ? 'small' : 'large'}
onPress={onSendEmail}
label={_(msg`Resend email`)}
disabled={isProcessing}>
<ButtonText>
<Trans>Resend email</Trans>
</ButtonText>
</Button>
<Button
testID="confirmBtn"
variant="solid"
color="primary"
size={gtMobile ? 'small' : 'large'}
onPress={onConfirmDisable}
label={_(msg`Confirm`)}
disabled={isProcessing}>
<ButtonText>
<Trans>Confirm</Trans>
</ButtonText>
{isProcessing && <ButtonIcon icon={Loader} />}
</Button>
</View>
</View>
) : undefined}
{!gtMobile && isNative && <View style={{height: 40}} />}
</View>
</Dialog.ScrollableInner>
</Dialog.Outer>
)
}

View File

@ -0,0 +1,60 @@
import React from 'react'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useModalControls} from '#/state/modals'
import {getAgent, useSession, useSessionApi} from '#/state/session'
import {ToggleButton} from 'view/com/util/forms/ToggleButton'
import {useDialogControl} from '#/components/Dialog'
import {DisableEmail2FADialog} from './DisableEmail2FADialog'
export function Email2FAToggle() {
const {_} = useLingui()
const {currentAccount} = useSession()
const {updateCurrentAccount} = useSessionApi()
const {openModal} = useModalControls()
const disableDialogCtrl = useDialogControl()
const enableEmailAuthFactor = React.useCallback(async () => {
if (currentAccount?.email) {
await getAgent().com.atproto.server.updateEmail({
email: currentAccount.email,
emailAuthFactor: true,
})
updateCurrentAccount({
emailAuthFactor: true,
})
}
}, [currentAccount, updateCurrentAccount])
const onToggle = React.useCallback(() => {
if (!currentAccount) {
return
}
if (currentAccount.emailAuthFactor) {
disableDialogCtrl.open()
} else {
if (!currentAccount.emailConfirmed) {
openModal({
name: 'verify-email',
onSuccess: enableEmailAuthFactor,
})
return
}
enableEmailAuthFactor()
}
}, [currentAccount, enableEmailAuthFactor, openModal, disableDialogCtrl])
return (
<>
<DisableEmail2FADialog control={disableDialogCtrl} />
<ToggleButton
type="default-light"
label={_(msg`Require email code to log into your account`)}
labelType="lg"
isSelected={!!currentAccount?.emailAuthFactor}
onPress={onToggle}
/>
</>
)
}

View File

@ -64,6 +64,7 @@ import {ScrollView} from 'view/com/util/Views'
import {useDialogControl} from '#/components/Dialog' import {useDialogControl} from '#/components/Dialog'
import {BirthDateSettingsDialog} from '#/components/dialogs/BirthDateSettings' import {BirthDateSettingsDialog} from '#/components/dialogs/BirthDateSettings'
import {navigate, resetToTab} from '#/Navigation' import {navigate, resetToTab} from '#/Navigation'
import {Email2FAToggle} from './Email2FAToggle'
import {ExportCarDialog} from './ExportCarDialog' import {ExportCarDialog} from './ExportCarDialog'
function SettingsAccountCard({account}: {account: SessionAccount}) { function SettingsAccountCard({account}: {account: SessionAccount}) {
@ -690,6 +691,13 @@ export function SettingsScreen({}: Props) {
</View> </View>
)} )}
<View style={styles.spacer20} /> <View style={styles.spacer20} />
<Text type="xl-bold" style={[pal.text, styles.heading]}>
<Trans>Two-factor authentication</Trans>
</Text>
<View style={[pal.view, styles.toggleCard]}>
<Email2FAToggle />
</View>
<View style={styles.spacer20} />
<Text type="xl-bold" style={[pal.text, styles.heading]}> <Text type="xl-bold" style={[pal.text, styles.heading]}>
<Trans>Account</Trans> <Trans>Account</Trans>
</Text> </Text>

View File

@ -46,6 +46,18 @@
multiformats "^9.9.0" multiformats "^9.9.0"
tlds "^1.234.0" tlds "^1.234.0"
"@atproto/api@^0.12.5":
version "0.12.5"
resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.12.5.tgz#3ed70990b27c468d9663ca71306039cab663ca96"
integrity sha512-xqdl/KrAK2kW6hN8+eSmKTWHgMNaPnDAEvZzo08Xbk/5jdRzjoEPS+p7k/wQ+ZefwOHL3QUbVPO4hMfmVxzO/Q==
dependencies:
"@atproto/common-web" "^0.3.0"
"@atproto/lexicon" "^0.4.0"
"@atproto/syntax" "^0.3.0"
"@atproto/xrpc" "^0.5.0"
multiformats "^9.9.0"
tlds "^1.234.0"
"@atproto/aws@^0.2.0": "@atproto/aws@^0.2.0":
version "0.2.0" version "0.2.0"
resolved "https://registry.yarnpkg.com/@atproto/aws/-/aws-0.2.0.tgz#17f3faf744824457cabd62f87be8bf08cacf8029" resolved "https://registry.yarnpkg.com/@atproto/aws/-/aws-0.2.0.tgz#17f3faf744824457cabd62f87be8bf08cacf8029"