Bluesky app fork with some witchin' additions 💫

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

authored by

Paul Frazee and committed by
GitHub
710e9130 cbb817b5

+363 -20
+1 -1
package.json
··· 50 50 "open-analyzer": "EXPO_PUBLIC_OPEN_ANALYZER=1 yarn build-web" 51 51 }, 52 52 "dependencies": { 53 - "@atproto/api": "^0.12.3", 53 + "@atproto/api": "^0.12.5", 54 54 "@bam.tech/react-native-image-resizer": "^3.0.4", 55 55 "@braintree/sanitize-url": "^6.0.2", 56 56 "@discord/bottom-sheet": "https://github.com/bluesky-social/react-native-bottom-sheet.git#discord-fork-4.6.1",
+50 -2
src/screens/Login/LoginForm.tsx
··· 6 6 TextInput, 7 7 View, 8 8 } from 'react-native' 9 - import {ComAtprotoServerDescribeServer} from '@atproto/api' 9 + import { 10 + ComAtprotoServerCreateSession, 11 + ComAtprotoServerDescribeServer, 12 + } from '@atproto/api' 10 13 import {msg, Trans} from '@lingui/macro' 11 14 import {useLingui} from '@lingui/react' 12 15 ··· 23 26 import * as TextField from '#/components/forms/TextField' 24 27 import {At_Stroke2_Corner0_Rounded as At} from '#/components/icons/At' 25 28 import {Lock_Stroke2_Corner0_Rounded as Lock} from '#/components/icons/Lock' 29 + import {Ticket_Stroke2_Corner0_Rounded as Ticket} from '#/components/icons/Ticket' 26 30 import {Loader} from '#/components/Loader' 27 31 import {Text} from '#/components/Typography' 28 32 import {FormContainer} from './FormContainer' ··· 53 57 const {track} = useAnalytics() 54 58 const t = useTheme() 55 59 const [isProcessing, setIsProcessing] = useState<boolean>(false) 60 + const [isAuthFactorTokenNeeded, setIsAuthFactorTokenNeeded] = 61 + useState<boolean>(false) 56 62 const [identifier, setIdentifier] = useState<string>(initialHandle) 57 63 const [password, setPassword] = useState<string>('') 64 + const [authFactorToken, setAuthFactorToken] = useState<string>('') 58 65 const passwordInputRef = useRef<TextInput>(null) 59 66 const {_} = useLingui() 60 67 const {login} = useSessionApi() ··· 100 107 service: serviceUrl, 101 108 identifier: fullIdent, 102 109 password, 110 + authFactorToken: authFactorToken.trim(), 103 111 }, 104 112 'LoginForm', 105 113 ) ··· 107 115 const errMsg = e.toString() 108 116 LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut) 109 117 setIsProcessing(false) 110 - if (errMsg.includes('Authentication Required')) { 118 + if ( 119 + e instanceof ComAtprotoServerCreateSession.AuthFactorTokenRequiredError 120 + ) { 121 + setIsAuthFactorTokenNeeded(true) 122 + } else if (errMsg.includes('Token is invalid')) { 123 + logger.debug('Failed to login due to invalid 2fa token', { 124 + error: errMsg, 125 + }) 126 + setError(_(msg`Invalid 2FA confirmation code.`)) 127 + } else if (errMsg.includes('Authentication Required')) { 111 128 logger.debug('Failed to login due to invalid credentials', { 112 129 error: errMsg, 113 130 }) ··· 215 232 </TextField.Root> 216 233 </View> 217 234 </View> 235 + {isAuthFactorTokenNeeded && ( 236 + <View> 237 + <TextField.LabelText> 238 + <Trans>2FA Confirmation</Trans> 239 + </TextField.LabelText> 240 + <TextField.Root> 241 + <TextField.Icon icon={Ticket} /> 242 + <TextField.Input 243 + testID="loginAuthFactorTokenInput" 244 + label={_(msg`Confirmation code`)} 245 + autoCapitalize="none" 246 + autoFocus 247 + autoCorrect={false} 248 + autoComplete="off" 249 + returnKeyType="done" 250 + textContentType="username" 251 + blurOnSubmit={false} // prevents flickering due to onSubmitEditing going to next field 252 + value={authFactorToken} 253 + onChangeText={setAuthFactorToken} 254 + onSubmitEditing={onPressNext} 255 + editable={!isProcessing} 256 + accessibilityHint={_( 257 + msg`Input the code which has been emailed to you`, 258 + )} 259 + /> 260 + </TextField.Root> 261 + <Text style={[a.text_sm, t.atoms.text_contrast_medium, a.mt_sm]}> 262 + <Trans>Check your email for a login code and enter it here.</Trans> 263 + </Text> 264 + </View> 265 + )} 218 266 <FormError error={error} /> 219 267 <View style={[a.flex_row, a.align_center, a.pt_md]}> 220 268 <Button
+1
src/state/modals/index.tsx
··· 107 107 export interface VerifyEmailModal { 108 108 name: 'verify-email' 109 109 showReminder?: boolean 110 + onSuccess?: () => void 110 111 } 111 112 112 113 export interface ChangeEmailModal {
+1
src/state/persisted/schema.ts
··· 11 11 handle: z.string(), 12 12 email: z.string().optional(), 13 13 emailConfirmed: z.boolean().optional(), 14 + emailAuthFactor: z.boolean().optional(), 14 15 refreshJwt: z.string().optional(), // optional because it can expire 15 16 accessJwt: z.string().optional(), // optional because it can expire 16 17 deactivated: z.boolean().optional(),
+13 -3
src/state/session/index.tsx
··· 59 59 service: string 60 60 identifier: string 61 61 password: string 62 + authFactorToken?: string | undefined 62 63 }, 63 64 logContext: LogEvents['account:loggedIn']['logContext'], 64 65 ) => Promise<void> ··· 87 88 ) => Promise<void> 88 89 updateCurrentAccount: ( 89 90 account: Partial< 90 - Pick<SessionAccount, 'handle' | 'email' | 'emailConfirmed'> 91 + Pick< 92 + SessionAccount, 93 + 'handle' | 'email' | 'emailConfirmed' | 'emailAuthFactor' 94 + > 91 95 >, 92 96 ) => void 93 97 } ··· 298 302 ) 299 303 300 304 const login = React.useCallback<ApiContext['login']>( 301 - async ({service, identifier, password}, logContext) => { 305 + async ({service, identifier, password, authFactorToken}, logContext) => { 302 306 logger.debug(`session: login`, {}, logger.DebugContext.session) 303 307 304 308 const agent = new BskyAgent({service}) 305 309 306 - await agent.login({identifier, password}) 310 + await agent.login({identifier, password, authFactorToken}) 307 311 308 312 if (!agent.session) { 309 313 throw new Error(`session: login failed to establish a session`) ··· 319 323 handle: agent.session.handle, 320 324 email: agent.session.email, 321 325 emailConfirmed: agent.session.emailConfirmed || false, 326 + emailAuthFactor: agent.session.emailAuthFactor, 322 327 refreshJwt: agent.session.refreshJwt, 323 328 accessJwt: agent.session.accessJwt, 324 329 deactivated: isSessionDeactivated(agent.session.accessJwt), ··· 489 494 handle: agent.session.handle, 490 495 email: agent.session.email, 491 496 emailConfirmed: agent.session.emailConfirmed || false, 497 + emailAuthFactor: agent.session.emailAuthFactor || false, 492 498 refreshJwt: agent.session.refreshJwt, 493 499 accessJwt: agent.session.accessJwt, 494 500 deactivated: isSessionDeactivated(agent.session.accessJwt), ··· 546 552 account.emailConfirmed !== undefined 547 553 ? account.emailConfirmed 548 554 : currentAccount.emailConfirmed, 555 + emailAuthFactor: 556 + account.emailAuthFactor !== undefined 557 + ? account.emailAuthFactor 558 + : currentAccount.emailAuthFactor, 549 559 } 550 560 551 561 return {
+22 -14
src/view/com/modals/VerifyEmail.tsx
··· 6 6 StyleSheet, 7 7 View, 8 8 } from 'react-native' 9 - import {Svg, Circle, Path} from 'react-native-svg' 10 - import {ScrollView, TextInput} from './util' 9 + import {Circle, Path, Svg} from 'react-native-svg' 11 10 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' 12 - import {Text} from '../util/text/Text' 13 - import {Button} from '../util/forms/Button' 14 - import {ErrorMessage} from '../util/error/ErrorMessage' 15 - import * as Toast from '../util/Toast' 16 - import {s, colors} from 'lib/styles' 11 + import {msg, Trans} from '@lingui/macro' 12 + import {useLingui} from '@lingui/react' 13 + 14 + import {logger} from '#/logger' 15 + import {useModalControls} from '#/state/modals' 16 + import {getAgent, useSession, useSessionApi} from '#/state/session' 17 17 import {usePalette} from 'lib/hooks/usePalette' 18 - import {isWeb} from 'platform/detection' 19 18 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' 20 19 import {cleanError} from 'lib/strings/errors' 21 - import {Trans, msg} from '@lingui/macro' 22 - import {useLingui} from '@lingui/react' 23 - import {useModalControls} from '#/state/modals' 24 - import {useSession, useSessionApi, getAgent} from '#/state/session' 25 - import {logger} from '#/logger' 20 + import {colors, s} from 'lib/styles' 21 + import {isWeb} from 'platform/detection' 22 + import {ErrorMessage} from '../util/error/ErrorMessage' 23 + import {Button} from '../util/forms/Button' 24 + import {Text} from '../util/text/Text' 25 + import * as Toast from '../util/Toast' 26 + import {ScrollView, TextInput} from './util' 26 27 27 28 export const snapPoints = ['90%'] 28 29 ··· 32 33 ConfirmCode, 33 34 } 34 35 35 - export function Component({showReminder}: {showReminder?: boolean}) { 36 + export function Component({ 37 + showReminder, 38 + onSuccess, 39 + }: { 40 + showReminder?: boolean 41 + onSuccess?: () => void 42 + }) { 36 43 const pal = usePalette('default') 37 44 const {currentAccount} = useSession() 38 45 const {updateCurrentAccount} = useSessionApi() ··· 77 84 updateCurrentAccount({emailConfirmed: true}) 78 85 Toast.show(_(msg`Email verified`)) 79 86 closeModal() 87 + onSuccess?.() 80 88 } catch (e) { 81 89 setError(cleanError(String(e))) 82 90 } finally {
+195
src/view/screens/Settings/DisableEmail2FADialog.tsx
··· 1 + import React, {useState} from 'react' 2 + import {View} from 'react-native' 3 + import {msg, Trans} from '@lingui/macro' 4 + import {useLingui} from '@lingui/react' 5 + 6 + import {cleanError} from '#/lib/strings/errors' 7 + import {isNative} from '#/platform/detection' 8 + import {getAgent, useSession, useSessionApi} from '#/state/session' 9 + import {ErrorMessage} from '#/view/com/util/error/ErrorMessage' 10 + import * as Toast from '#/view/com/util/Toast' 11 + import {atoms as a, useBreakpoints, useTheme} from '#/alf' 12 + import {Button, ButtonIcon, ButtonText} from '#/components/Button' 13 + import * as Dialog from '#/components/Dialog' 14 + import * as TextField from '#/components/forms/TextField' 15 + import {Lock_Stroke2_Corner0_Rounded as Lock} from '#/components/icons/Lock' 16 + import {Loader} from '#/components/Loader' 17 + import {P, Text} from '#/components/Typography' 18 + 19 + enum Stages { 20 + Email, 21 + ConfirmCode, 22 + } 23 + 24 + export function DisableEmail2FADialog({ 25 + control, 26 + }: { 27 + control: Dialog.DialogOuterProps['control'] 28 + }) { 29 + const {_} = useLingui() 30 + const t = useTheme() 31 + const {gtMobile} = useBreakpoints() 32 + const {currentAccount} = useSession() 33 + const {updateCurrentAccount} = useSessionApi() 34 + 35 + const [stage, setStage] = useState<Stages>(Stages.Email) 36 + const [confirmationCode, setConfirmationCode] = useState<string>('') 37 + const [isProcessing, setIsProcessing] = useState<boolean>(false) 38 + const [error, setError] = useState<string>('') 39 + 40 + const onSendEmail = async () => { 41 + setError('') 42 + setIsProcessing(true) 43 + try { 44 + await getAgent().com.atproto.server.requestEmailUpdate() 45 + setStage(Stages.ConfirmCode) 46 + } catch (e) { 47 + setError(cleanError(String(e))) 48 + } finally { 49 + setIsProcessing(false) 50 + } 51 + } 52 + 53 + const onConfirmDisable = async () => { 54 + setError('') 55 + setIsProcessing(true) 56 + try { 57 + if (currentAccount?.email) { 58 + await getAgent().com.atproto.server.updateEmail({ 59 + email: currentAccount!.email, 60 + token: confirmationCode.trim(), 61 + emailAuthFactor: false, 62 + }) 63 + updateCurrentAccount({emailAuthFactor: false}) 64 + Toast.show(_(msg`Email 2FA disabled`)) 65 + } 66 + control.close() 67 + } catch (e) { 68 + const errMsg = String(e) 69 + if (errMsg.includes('Token is invalid')) { 70 + setError(_(msg`Invalid 2FA confirmation code.`)) 71 + } else { 72 + setError(cleanError(errMsg)) 73 + } 74 + } finally { 75 + setIsProcessing(false) 76 + } 77 + } 78 + 79 + return ( 80 + <Dialog.Outer control={control}> 81 + <Dialog.Handle /> 82 + 83 + <Dialog.ScrollableInner 84 + accessibilityDescribedBy="dialog-description" 85 + accessibilityLabelledBy="dialog-title"> 86 + <View style={[a.relative, a.gap_md, a.w_full]}> 87 + <Text 88 + nativeID="dialog-title" 89 + style={[a.text_2xl, a.font_bold, t.atoms.text]}> 90 + <Trans>Disable Email 2FA</Trans> 91 + </Text> 92 + <P 93 + nativeID="dialog-description" 94 + style={[a.text_sm, t.atoms.text, a.leading_snug]}> 95 + {stage === Stages.ConfirmCode ? ( 96 + <Trans> 97 + An email has been sent to{' '} 98 + {currentAccount?.email || '(no email)'}. It includes a 99 + confirmation code which you can enter below. 100 + </Trans> 101 + ) : ( 102 + <Trans> 103 + To disable the email 2FA method, please verify your access to 104 + the email address. 105 + </Trans> 106 + )} 107 + </P> 108 + 109 + {error ? <ErrorMessage message={error} /> : undefined} 110 + 111 + {stage === Stages.Email ? ( 112 + <View style={gtMobile && [a.flex_row, a.justify_end, a.gap_md]}> 113 + <Button 114 + testID="sendEmailButton" 115 + variant="solid" 116 + color="primary" 117 + size={gtMobile ? 'small' : 'large'} 118 + onPress={onSendEmail} 119 + label={_(msg`Send verification email`)} 120 + disabled={isProcessing}> 121 + <ButtonText> 122 + <Trans>Send verification email</Trans> 123 + </ButtonText> 124 + {isProcessing && <ButtonIcon icon={Loader} />} 125 + </Button> 126 + <Button 127 + testID="haveCodeButton" 128 + variant="ghost" 129 + color="primary" 130 + size={gtMobile ? 'small' : 'large'} 131 + onPress={() => setStage(Stages.ConfirmCode)} 132 + label={_(msg`I have a code`)} 133 + disabled={isProcessing}> 134 + <ButtonText> 135 + <Trans>I have a code</Trans> 136 + </ButtonText> 137 + </Button> 138 + </View> 139 + ) : stage === Stages.ConfirmCode ? ( 140 + <View> 141 + <View style={[a.mb_md]}> 142 + <TextField.LabelText> 143 + <Trans>Confirmation code</Trans> 144 + </TextField.LabelText> 145 + <TextField.Root> 146 + <TextField.Icon icon={Lock} /> 147 + <TextField.Input 148 + testID="confirmationCode" 149 + label={_(msg`Confirmation code`)} 150 + autoCapitalize="none" 151 + autoFocus 152 + autoCorrect={false} 153 + autoComplete="off" 154 + value={confirmationCode} 155 + onChangeText={setConfirmationCode} 156 + editable={!isProcessing} 157 + /> 158 + </TextField.Root> 159 + </View> 160 + <View style={gtMobile && [a.flex_row, a.justify_end]}> 161 + <Button 162 + testID="resendCodeBtn" 163 + variant="ghost" 164 + color="primary" 165 + size={gtMobile ? 'small' : 'large'} 166 + onPress={onSendEmail} 167 + label={_(msg`Resend email`)} 168 + disabled={isProcessing}> 169 + <ButtonText> 170 + <Trans>Resend email</Trans> 171 + </ButtonText> 172 + </Button> 173 + <Button 174 + testID="confirmBtn" 175 + variant="solid" 176 + color="primary" 177 + size={gtMobile ? 'small' : 'large'} 178 + onPress={onConfirmDisable} 179 + label={_(msg`Confirm`)} 180 + disabled={isProcessing}> 181 + <ButtonText> 182 + <Trans>Confirm</Trans> 183 + </ButtonText> 184 + {isProcessing && <ButtonIcon icon={Loader} />} 185 + </Button> 186 + </View> 187 + </View> 188 + ) : undefined} 189 + 190 + {!gtMobile && isNative && <View style={{height: 40}} />} 191 + </View> 192 + </Dialog.ScrollableInner> 193 + </Dialog.Outer> 194 + ) 195 + }
+60
src/view/screens/Settings/Email2FAToggle.tsx
··· 1 + import React from 'react' 2 + import {msg} from '@lingui/macro' 3 + import {useLingui} from '@lingui/react' 4 + 5 + import {useModalControls} from '#/state/modals' 6 + import {getAgent, useSession, useSessionApi} from '#/state/session' 7 + import {ToggleButton} from 'view/com/util/forms/ToggleButton' 8 + import {useDialogControl} from '#/components/Dialog' 9 + import {DisableEmail2FADialog} from './DisableEmail2FADialog' 10 + 11 + export function Email2FAToggle() { 12 + const {_} = useLingui() 13 + const {currentAccount} = useSession() 14 + const {updateCurrentAccount} = useSessionApi() 15 + const {openModal} = useModalControls() 16 + const disableDialogCtrl = useDialogControl() 17 + 18 + const enableEmailAuthFactor = React.useCallback(async () => { 19 + if (currentAccount?.email) { 20 + await getAgent().com.atproto.server.updateEmail({ 21 + email: currentAccount.email, 22 + emailAuthFactor: true, 23 + }) 24 + updateCurrentAccount({ 25 + emailAuthFactor: true, 26 + }) 27 + } 28 + }, [currentAccount, updateCurrentAccount]) 29 + 30 + const onToggle = React.useCallback(() => { 31 + if (!currentAccount) { 32 + return 33 + } 34 + if (currentAccount.emailAuthFactor) { 35 + disableDialogCtrl.open() 36 + } else { 37 + if (!currentAccount.emailConfirmed) { 38 + openModal({ 39 + name: 'verify-email', 40 + onSuccess: enableEmailAuthFactor, 41 + }) 42 + return 43 + } 44 + enableEmailAuthFactor() 45 + } 46 + }, [currentAccount, enableEmailAuthFactor, openModal, disableDialogCtrl]) 47 + 48 + return ( 49 + <> 50 + <DisableEmail2FADialog control={disableDialogCtrl} /> 51 + <ToggleButton 52 + type="default-light" 53 + label={_(msg`Require email code to log into your account`)} 54 + labelType="lg" 55 + isSelected={!!currentAccount?.emailAuthFactor} 56 + onPress={onToggle} 57 + /> 58 + </> 59 + ) 60 + }
+8
src/view/screens/Settings/index.tsx
··· 64 64 import {useDialogControl} from '#/components/Dialog' 65 65 import {BirthDateSettingsDialog} from '#/components/dialogs/BirthDateSettings' 66 66 import {navigate, resetToTab} from '#/Navigation' 67 + import {Email2FAToggle} from './Email2FAToggle' 67 68 import {ExportCarDialog} from './ExportCarDialog' 68 69 69 70 function SettingsAccountCard({account}: {account: SessionAccount}) { ··· 689 690 /> 690 691 </View> 691 692 )} 693 + <View style={styles.spacer20} /> 694 + <Text type="xl-bold" style={[pal.text, styles.heading]}> 695 + <Trans>Two-factor authentication</Trans> 696 + </Text> 697 + <View style={[pal.view, styles.toggleCard]}> 698 + <Email2FAToggle /> 699 + </View> 692 700 <View style={styles.spacer20} /> 693 701 <Text type="xl-bold" style={[pal.text, styles.heading]}> 694 702 <Trans>Account</Trans>
+12
yarn.lock
··· 46 46 multiformats "^9.9.0" 47 47 tlds "^1.234.0" 48 48 49 + "@atproto/api@^0.12.5": 50 + version "0.12.5" 51 + resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.12.5.tgz#3ed70990b27c468d9663ca71306039cab663ca96" 52 + integrity sha512-xqdl/KrAK2kW6hN8+eSmKTWHgMNaPnDAEvZzo08Xbk/5jdRzjoEPS+p7k/wQ+ZefwOHL3QUbVPO4hMfmVxzO/Q== 53 + dependencies: 54 + "@atproto/common-web" "^0.3.0" 55 + "@atproto/lexicon" "^0.4.0" 56 + "@atproto/syntax" "^0.3.0" 57 + "@atproto/xrpc" "^0.5.0" 58 + multiformats "^9.9.0" 59 + tlds "^1.234.0" 60 + 49 61 "@atproto/aws@^0.2.0": 50 62 version "0.2.0" 51 63 resolved "https://registry.yarnpkg.com/@atproto/aws/-/aws-0.2.0.tgz#17f3faf744824457cabd62f87be8bf08cacf8029"