diff options
author | Paul Frazee <pfrazee@gmail.com> | 2024-04-22 19:18:13 -0700 |
---|---|---|
committer | GitHub <noreply@github.com> | 2024-04-22 19:18:13 -0700 |
commit | 710e913024bab2d1c4e4f6179b089afd8eb5ba9f (patch) | |
tree | c227ae63e4b3eb79f0ab7d936126e0eaf576b526 /src | |
parent | cbb817b5b707042afefbf8ca46a7104d62349492 (diff) | |
download | voidsky-710e913024bab2d1c4e4f6179b089afd8eb5ba9f.tar.zst |
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
Diffstat (limited to 'src')
-rw-r--r-- | src/screens/Login/LoginForm.tsx | 52 | ||||
-rw-r--r-- | src/state/modals/index.tsx | 1 | ||||
-rw-r--r-- | src/state/persisted/schema.ts | 1 | ||||
-rw-r--r-- | src/state/session/index.tsx | 16 | ||||
-rw-r--r-- | src/view/com/modals/VerifyEmail.tsx | 36 | ||||
-rw-r--r-- | src/view/screens/Settings/DisableEmail2FADialog.tsx | 195 | ||||
-rw-r--r-- | src/view/screens/Settings/Email2FAToggle.tsx | 60 | ||||
-rw-r--r-- | src/view/screens/Settings/index.tsx | 8 |
8 files changed, 350 insertions, 19 deletions
diff --git a/src/screens/Login/LoginForm.tsx b/src/screens/Login/LoginForm.tsx index 711619e85..17fc32368 100644 --- a/src/screens/Login/LoginForm.tsx +++ b/src/screens/Login/LoginForm.tsx @@ -6,7 +6,10 @@ import { TextInput, View, } from 'react-native' -import {ComAtprotoServerDescribeServer} from '@atproto/api' +import { + ComAtprotoServerCreateSession, + ComAtprotoServerDescribeServer, +} from '@atproto/api' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' @@ -23,6 +26,7 @@ import {HostingProvider} from '#/components/forms/HostingProvider' import * as TextField from '#/components/forms/TextField' import {At_Stroke2_Corner0_Rounded as At} from '#/components/icons/At' 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 {Text} from '#/components/Typography' import {FormContainer} from './FormContainer' @@ -53,8 +57,11 @@ export const LoginForm = ({ const {track} = useAnalytics() const t = useTheme() const [isProcessing, setIsProcessing] = useState<boolean>(false) + const [isAuthFactorTokenNeeded, setIsAuthFactorTokenNeeded] = + useState<boolean>(false) const [identifier, setIdentifier] = useState<string>(initialHandle) const [password, setPassword] = useState<string>('') + const [authFactorToken, setAuthFactorToken] = useState<string>('') const passwordInputRef = useRef<TextInput>(null) const {_} = useLingui() const {login} = useSessionApi() @@ -100,6 +107,7 @@ export const LoginForm = ({ service: serviceUrl, identifier: fullIdent, password, + authFactorToken: authFactorToken.trim(), }, 'LoginForm', ) @@ -107,7 +115,16 @@ export const LoginForm = ({ const errMsg = e.toString() LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut) 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', { error: errMsg, }) @@ -215,6 +232,37 @@ export const LoginForm = ({ </TextField.Root> </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} /> <View style={[a.flex_row, a.align_center, a.pt_md]}> <Button diff --git a/src/state/modals/index.tsx b/src/state/modals/index.tsx index cc0f9c8b8..0f61a9711 100644 --- a/src/state/modals/index.tsx +++ b/src/state/modals/index.tsx @@ -107,6 +107,7 @@ export interface PostLanguagesSettingsModal { export interface VerifyEmailModal { name: 'verify-email' showReminder?: boolean + onSuccess?: () => void } export interface ChangeEmailModal { diff --git a/src/state/persisted/schema.ts b/src/state/persisted/schema.ts index 1b77d138b..4076a582a 100644 --- a/src/state/persisted/schema.ts +++ b/src/state/persisted/schema.ts @@ -11,6 +11,7 @@ const accountSchema = z.object({ handle: z.string(), email: z.string().optional(), emailConfirmed: z.boolean().optional(), + emailAuthFactor: z.boolean().optional(), refreshJwt: z.string().optional(), // optional because it can expire accessJwt: z.string().optional(), // optional because it can expire deactivated: z.boolean().optional(), diff --git a/src/state/session/index.tsx b/src/state/session/index.tsx index 1d60eaf8f..ad5af130a 100644 --- a/src/state/session/index.tsx +++ b/src/state/session/index.tsx @@ -59,6 +59,7 @@ export type ApiContext = { service: string identifier: string password: string + authFactorToken?: string | undefined }, logContext: LogEvents['account:loggedIn']['logContext'], ) => Promise<void> @@ -87,7 +88,10 @@ export type ApiContext = { ) => Promise<void> updateCurrentAccount: ( account: Partial< - Pick<SessionAccount, 'handle' | 'email' | 'emailConfirmed'> + Pick< + SessionAccount, + 'handle' | 'email' | 'emailConfirmed' | 'emailAuthFactor' + > >, ) => void } @@ -298,12 +302,12 @@ export function Provider({children}: React.PropsWithChildren<{}>) { ) const login = React.useCallback<ApiContext['login']>( - async ({service, identifier, password}, logContext) => { + async ({service, identifier, password, authFactorToken}, logContext) => { logger.debug(`session: login`, {}, logger.DebugContext.session) const agent = new BskyAgent({service}) - await agent.login({identifier, password}) + await agent.login({identifier, password, authFactorToken}) if (!agent.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, email: agent.session.email, emailConfirmed: agent.session.emailConfirmed || false, + emailAuthFactor: agent.session.emailAuthFactor, refreshJwt: agent.session.refreshJwt, accessJwt: agent.session.accessJwt, deactivated: isSessionDeactivated(agent.session.accessJwt), @@ -489,6 +494,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) { handle: agent.session.handle, email: agent.session.email, emailConfirmed: agent.session.emailConfirmed || false, + emailAuthFactor: agent.session.emailAuthFactor || false, refreshJwt: agent.session.refreshJwt, accessJwt: agent.session.accessJwt, deactivated: isSessionDeactivated(agent.session.accessJwt), @@ -546,6 +552,10 @@ export function Provider({children}: React.PropsWithChildren<{}>) { account.emailConfirmed !== undefined ? account.emailConfirmed : currentAccount.emailConfirmed, + emailAuthFactor: + account.emailAuthFactor !== undefined + ? account.emailAuthFactor + : currentAccount.emailAuthFactor, } return { diff --git a/src/view/com/modals/VerifyEmail.tsx b/src/view/com/modals/VerifyEmail.tsx index d3086d383..d25a9d30b 100644 --- a/src/view/com/modals/VerifyEmail.tsx +++ b/src/view/com/modals/VerifyEmail.tsx @@ -6,23 +6,24 @@ import { StyleSheet, View, } from 'react-native' -import {Svg, Circle, Path} from 'react-native-svg' -import {ScrollView, TextInput} from './util' +import {Circle, Path, Svg} from 'react-native-svg' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' -import {Text} from '../util/text/Text' -import {Button} from '../util/forms/Button' -import {ErrorMessage} from '../util/error/ErrorMessage' -import * as Toast from '../util/Toast' -import {s, colors} from 'lib/styles' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {logger} from '#/logger' +import {useModalControls} from '#/state/modals' +import {getAgent, useSession, useSessionApi} from '#/state/session' import {usePalette} from 'lib/hooks/usePalette' -import {isWeb} from 'platform/detection' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {cleanError} from 'lib/strings/errors' -import {Trans, msg} from '@lingui/macro' -import {useLingui} from '@lingui/react' -import {useModalControls} from '#/state/modals' -import {useSession, useSessionApi, getAgent} from '#/state/session' -import {logger} from '#/logger' +import {colors, s} from 'lib/styles' +import {isWeb} from 'platform/detection' +import {ErrorMessage} from '../util/error/ErrorMessage' +import {Button} from '../util/forms/Button' +import {Text} from '../util/text/Text' +import * as Toast from '../util/Toast' +import {ScrollView, TextInput} from './util' export const snapPoints = ['90%'] @@ -32,7 +33,13 @@ enum Stages { ConfirmCode, } -export function Component({showReminder}: {showReminder?: boolean}) { +export function Component({ + showReminder, + onSuccess, +}: { + showReminder?: boolean + onSuccess?: () => void +}) { const pal = usePalette('default') const {currentAccount} = useSession() const {updateCurrentAccount} = useSessionApi() @@ -77,6 +84,7 @@ export function Component({showReminder}: {showReminder?: boolean}) { updateCurrentAccount({emailConfirmed: true}) Toast.show(_(msg`Email verified`)) closeModal() + onSuccess?.() } catch (e) { setError(cleanError(String(e))) } finally { diff --git a/src/view/screens/Settings/DisableEmail2FADialog.tsx b/src/view/screens/Settings/DisableEmail2FADialog.tsx new file mode 100644 index 000000000..4e9a810ca --- /dev/null +++ b/src/view/screens/Settings/DisableEmail2FADialog.tsx @@ -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> + ) +} diff --git a/src/view/screens/Settings/Email2FAToggle.tsx b/src/view/screens/Settings/Email2FAToggle.tsx new file mode 100644 index 000000000..93f1b2042 --- /dev/null +++ b/src/view/screens/Settings/Email2FAToggle.tsx @@ -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} + /> + </> + ) +} diff --git a/src/view/screens/Settings/index.tsx b/src/view/screens/Settings/index.tsx index bb38da676..1211aa5c5 100644 --- a/src/view/screens/Settings/index.tsx +++ b/src/view/screens/Settings/index.tsx @@ -64,6 +64,7 @@ import {ScrollView} from 'view/com/util/Views' import {useDialogControl} from '#/components/Dialog' import {BirthDateSettingsDialog} from '#/components/dialogs/BirthDateSettings' import {navigate, resetToTab} from '#/Navigation' +import {Email2FAToggle} from './Email2FAToggle' import {ExportCarDialog} from './ExportCarDialog' function SettingsAccountCard({account}: {account: SessionAccount}) { @@ -691,6 +692,13 @@ export function SettingsScreen({}: Props) { )} <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]}> <Trans>Account</Trans> </Text> <TouchableOpacity |