From 710e913024bab2d1c4e4f6179b089afd8eb5ba9f Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Mon, 22 Apr 2024 19:18:13 -0700 Subject: [PATCH] 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 --- package.json | 2 +- src/screens/Login/LoginForm.tsx | 52 ++++- src/state/modals/index.tsx | 1 + src/state/persisted/schema.ts | 1 + src/state/session/index.tsx | 16 +- src/view/com/modals/VerifyEmail.tsx | 36 ++-- .../Settings/DisableEmail2FADialog.tsx | 195 ++++++++++++++++++ src/view/screens/Settings/Email2FAToggle.tsx | 60 ++++++ src/view/screens/Settings/index.tsx | 8 + yarn.lock | 12 ++ 10 files changed, 363 insertions(+), 20 deletions(-) create mode 100644 src/view/screens/Settings/DisableEmail2FADialog.tsx create mode 100644 src/view/screens/Settings/Email2FAToggle.tsx diff --git a/package.json b/package.json index da0260a7..d088c90a 100644 --- a/package.json +++ b/package.json @@ -50,7 +50,7 @@ "open-analyzer": "EXPO_PUBLIC_OPEN_ANALYZER=1 yarn build-web" }, "dependencies": { - "@atproto/api": "^0.12.3", + "@atproto/api": "^0.12.5", "@bam.tech/react-native-image-resizer": "^3.0.4", "@braintree/sanitize-url": "^6.0.2", "@discord/bottom-sheet": "https://github.com/bluesky-social/react-native-bottom-sheet.git#discord-fork-4.6.1", diff --git a/src/screens/Login/LoginForm.tsx b/src/screens/Login/LoginForm.tsx index 711619e8..17fc3236 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(false) + const [isAuthFactorTokenNeeded, setIsAuthFactorTokenNeeded] = + useState(false) const [identifier, setIdentifier] = useState(initialHandle) const [password, setPassword] = useState('') + const [authFactorToken, setAuthFactorToken] = useState('') const passwordInputRef = useRef(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 = ({ + {isAuthFactorTokenNeeded && ( + + + 2FA Confirmation + + + + + + + Check your email for a login code and enter it here. + + + )} + + + ) : stage === Stages.ConfirmCode ? ( + + + + Confirmation code + + + + + + + + + + + + ) : undefined} + + {!gtMobile && isNative && } + + + + ) +} diff --git a/src/view/screens/Settings/Email2FAToggle.tsx b/src/view/screens/Settings/Email2FAToggle.tsx new file mode 100644 index 00000000..93f1b204 --- /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 ( + <> + + + + ) +} diff --git a/src/view/screens/Settings/index.tsx b/src/view/screens/Settings/index.tsx index bb38da67..1211aa5c 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}) { @@ -690,6 +691,13 @@ export function SettingsScreen({}: Props) { )} + + Two-factor authentication + + + + + Account diff --git a/yarn.lock b/yarn.lock index f866565a..f9e8644c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -46,6 +46,18 @@ multiformats "^9.9.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": version "0.2.0" resolved "https://registry.yarnpkg.com/@atproto/aws/-/aws-0.2.0.tgz#17f3faf744824457cabd62f87be8bf08cacf8029"