Skip to content

Commit

Permalink
Merge pull request Expensify#23390 from alitoshmatov/22335/2fa-recove…
Browse files Browse the repository at this point in the history
…ry-code

Added recovery code option to 2fa
  • Loading branch information
MonilBhavsar authored Sep 20, 2023
2 parents 647027a + 36afdb5 commit 724ac0e
Show file tree
Hide file tree
Showing 8 changed files with 178 additions and 33 deletions.
5 changes: 5 additions & 0 deletions src/CONST.ts
Original file line number Diff line number Diff line change
Expand Up @@ -757,6 +757,9 @@ const CONST = {
// 6 numeric digits
VALIDATE_CODE_REGEX_STRING: /^\d{6}$/,

// 8 alphanumeric characters
RECOVERY_CODE_REGEX_STRING: /^[a-zA-Z0-9]{8}$/,

// The server has a WAF (Web Application Firewall) which will strip out HTML/XML tags using this regex pattern.
// It's copied here so that the same regex pattern can be used in form validations to be consistent with the server.
VALIDATE_FOR_HTML_TAG_REGEX: /<([^>\s]+)(?:[^>]*?)>/g,
Expand Down Expand Up @@ -806,6 +809,8 @@ const CONST = {
MAGIC_CODE_LENGTH: 6,
MAGIC_CODE_EMPTY_CHAR: ' ',

RECOVERY_CODE_LENGTH: 8,

KEYBOARD_TYPE: {
PHONE_PAD: 'phone-pad',
NUMBER_PAD: 'number-pad',
Expand Down
10 changes: 10 additions & 0 deletions src/languages/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -740,6 +740,15 @@ export default {
copy: 'Copy',
disable: 'Disable',
},
recoveryCodeForm: {
error: {
pleaseFillRecoveryCode: 'Please enter your recovery code',
incorrectRecoveryCode: 'Incorrect recovery code. Please try again.',
},
useRecoveryCode: 'Use recovery code',
recoveryCode: 'Recovery code',
use2fa: 'Use two-factor authentication code',
},
twoFactorAuthForm: {
error: {
pleaseFillTwoFactorAuth: 'Please enter your two-factor authentication code',
Expand Down Expand Up @@ -893,6 +902,7 @@ export default {
validateCodeForm: {
magicCodeNotReceived: "Didn't receive a magic code?",
enterAuthenticatorCode: 'Please enter your authenticator code',
enterRecoveryCode: 'Please enter your recovery code',
requiredWhen2FAEnabled: 'Required when 2FA is enabled',
requestNewCode: 'Request a new code in ',
requestNewCodeAfterErrorOccurred: 'Request a new code',
Expand Down
10 changes: 10 additions & 0 deletions src/languages/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -735,6 +735,15 @@ export default {
copy: 'Copiar',
disable: 'Deshabilitar',
},
recoveryCodeForm: {
error: {
pleaseFillRecoveryCode: 'Por favor, introduce tu código de recuperación',
incorrectRecoveryCode: 'Código de recuperación incorrecto. Por favor, inténtalo de nuevo',
},
useRecoveryCode: 'Usar código de recuperación',
recoveryCode: 'Código de recuperación',
use2fa: 'Usar autenticación de dos factores',
},
twoFactorAuthForm: {
error: {
pleaseFillTwoFactorAuth: 'Por favor, introduce tu código de autenticación de dos factores',
Expand Down Expand Up @@ -889,6 +898,7 @@ export default {
validateCodeForm: {
magicCodeNotReceived: '¿No recibiste un código mágico?',
enterAuthenticatorCode: 'Por favor, introduce el código de autenticador',
enterRecoveryCode: 'Por favor, introduce tu código de recuperación',
requiredWhen2FAEnabled: 'Obligatorio cuando A2F está habilitado',
requestNewCode: 'Pedir un código nuevo en ',
requestNewCodeAfterErrorOccurred: 'Solicitar un nuevo código',
Expand Down
5 changes: 5 additions & 0 deletions src/libs/ValidationUtils.js
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,10 @@ function isValidValidateCode(validateCode) {
return validateCode.match(CONST.VALIDATE_CODE_REGEX_STRING);
}

function isValidRecoveryCode(recoveryCode) {
return recoveryCode.match(CONST.RECOVERY_CODE_REGEX_STRING);
}

/**
* @param {String} code
* @returns {Boolean}
Expand Down Expand Up @@ -484,4 +488,5 @@ export {
doesContainReservedWord,
isNumeric,
isValidAccountRoute,
isValidRecoveryCode,
};
14 changes: 11 additions & 3 deletions src/pages/signin/SignInPage.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, {useEffect, useRef} from 'react';
import React, {useEffect, useRef, useState} from 'react';
import PropTypes from 'prop-types';
import _ from 'underscore';
import {withOnyx} from 'react-native-onyx';
Expand Down Expand Up @@ -87,6 +87,9 @@ function SignInPage({credentials, account, isInModal}) {
const shouldShowSmallScreen = isSmallScreenWidth || isInModal;
const safeAreaInsets = useSafeAreaInsets();
const signInPageLayoutRef = useRef();
/** This state is needed to keep track of if user is using recovery code instead of 2fa code,
* and we need it here since welcome text(`welcomeText`) also depends on it */
const [isUsingRecoveryCode, setIsUsingRecoveryCode] = useState(false);

useEffect(() => Performance.measureTTI(), []);
useEffect(() => {
Expand Down Expand Up @@ -114,7 +117,7 @@ function SignInPage({credentials, account, isInModal}) {
if (account.requiresTwoFactorAuth) {
// We will only know this after a user signs in successfully, without their 2FA code
welcomeHeader = isSmallScreenWidth ? '' : translate('welcomeText.welcomeBack');
welcomeText = translate('validateCodeForm.enterAuthenticatorCode');
welcomeText = isUsingRecoveryCode ? translate('validateCodeForm.enterRecoveryCode') : translate('validateCodeForm.enterAuthenticatorCode');
} else {
const userLogin = Str.removeSMSDomain(credentials.login || '');

Expand Down Expand Up @@ -162,7 +165,12 @@ function SignInPage({credentials, account, isInModal}) {
blurOnSubmit={account.validated === false}
scrollPageToTop={signInPageLayoutRef.current && signInPageLayoutRef.current.scrollPageToTop}
/>
{shouldShowValidateCodeForm && <ValidateCodeForm />}
{shouldShowValidateCodeForm && (
<ValidateCodeForm
isUsingRecoveryCode={isUsingRecoveryCode}
setIsUsingRecoveryCode={setIsUsingRecoveryCode}
/>
)}
{shouldShowUnlinkLoginForm && <UnlinkLoginForm />}
{shouldShowEmailDeliveryFailurePage && <EmailDeliveryFailurePage />}
</SignInPageLayout>
Expand Down
129 changes: 105 additions & 24 deletions src/pages/signin/ValidateCodeForm/BaseValidateCodeForm.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import Terms from '../Terms';
import PressableWithFeedback from '../../../components/Pressable/PressableWithFeedback';
import usePrevious from '../../../hooks/usePrevious';
import * as StyleUtils from '../../../styles/StyleUtils';
import TextInput from '../../../components/TextInput';

const propTypes = {
/* Onyx Props */
Expand Down Expand Up @@ -60,6 +61,12 @@ const propTypes = {
/** Specifies autocomplete hints for the system, so it can provide autofill */
autoComplete: PropTypes.oneOf(['sms-otp', 'one-time-code']).isRequired,

/** Determines if user is switched to using recovery code instead of 2fa code */
isUsingRecoveryCode: PropTypes.bool.isRequired,

/** Function to change `isUsingRecoveryCode` state when user toggles between 2fa code and recovery code */
setIsUsingRecoveryCode: PropTypes.func.isRequired,

...withLocalizePropTypes,
};

Expand All @@ -77,6 +84,7 @@ function BaseValidateCodeForm(props) {
const [validateCode, setValidateCode] = useState(props.credentials.validateCode || '');
const [twoFactorAuthCode, setTwoFactorAuthCode] = useState('');
const [timeRemaining, setTimeRemaining] = useState(30);
const [recoveryCode, setRecoveryCode] = useState('');

const prevRequiresTwoFactorAuth = usePrevious(props.account.requiresTwoFactorAuth);
const prevValidateCode = usePrevious(props.credentials.validateCode);
Expand Down Expand Up @@ -149,7 +157,17 @@ function BaseValidateCodeForm(props) {
* @param {String} key
*/
const onTextInput = (text, key) => {
const setInput = key === 'validateCode' ? setValidateCode : setTwoFactorAuthCode;
let setInput;
if (key === 'validateCode') {
setInput = setValidateCode;
}
if (key === 'twoFactorAuthCode') {
setInput = setTwoFactorAuthCode;
}
if (key === 'recoveryCode') {
setInput = setRecoveryCode;
}

setInput(text);
setFormError((prevError) => ({...prevError, [key]: ''}));

Expand All @@ -174,6 +192,8 @@ function BaseValidateCodeForm(props) {
setTwoFactorAuthCode('');
setFormError({});
setValidateCode('');
props.setIsUsingRecoveryCode(false);
setRecoveryCode('');
};

/**
Expand All @@ -184,11 +204,30 @@ function BaseValidateCodeForm(props) {
Session.clearSignInData();
};

/**
* Switches between 2fa and recovery code, clears inputs and errors
*/
const switchBetween2faAndRecoveryCode = () => {
props.setIsUsingRecoveryCode(!props.isUsingRecoveryCode);

setRecoveryCode('');
setTwoFactorAuthCode('');

setFormError((prevError) => ({...prevError, recoveryCode: '', twoFactorAuthCode: ''}));

if (props.account.errors) {
Session.clearAccountMessages();
}
};

useEffect(() => {
if (!isLoadingResendValidationForm) {
return;
}
clearLocalSignInData();
// `clearLocalSignInData` is not required as a dependency, and adding it
// overcomplicates things requiring clearLocalSignInData function to use useCallback
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isLoadingResendValidationForm]);

/**
Expand All @@ -203,13 +242,27 @@ function BaseValidateCodeForm(props) {
if (input2FARef.current) {
input2FARef.current.blur();
}
if (!twoFactorAuthCode.trim()) {
setFormError({twoFactorAuthCode: 'validateCodeForm.error.pleaseFillTwoFactorAuth'});
return;
}
if (!ValidationUtils.isValidTwoFactorCode(twoFactorAuthCode)) {
setFormError({twoFactorAuthCode: 'passwordForm.error.incorrect2fa'});
return;
/**
* User could be using either recovery code or 2fa code
*/
if (!props.isUsingRecoveryCode) {
if (!twoFactorAuthCode.trim()) {
setFormError({twoFactorAuthCode: 'validateCodeForm.error.pleaseFillTwoFactorAuth'});
return;
}
if (!ValidationUtils.isValidTwoFactorCode(twoFactorAuthCode)) {
setFormError({twoFactorAuthCode: 'passwordForm.error.incorrect2fa'});
return;
}
} else {
if (!recoveryCode.trim()) {
setFormError({recoveryCode: 'recoveryCodeForm.error.pleaseFillRecoveryCode'});
return;
}
if (!ValidationUtils.isValidRecoveryCode(recoveryCode)) {
setFormError({recoveryCode: 'recoveryCodeForm.error.incorrectRecoveryCode'});
return;
}
}
} else {
if (inputValidateCodeRef.current) {
Expand All @@ -226,33 +279,61 @@ function BaseValidateCodeForm(props) {
}
setFormError({});

const recoveryCodeOr2faCode = props.isUsingRecoveryCode ? recoveryCode : twoFactorAuthCode;

const accountID = lodashGet(props.credentials, 'accountID');
if (accountID) {
Session.signInWithValidateCode(accountID, validateCode, props.preferredLocale, twoFactorAuthCode);
Session.signInWithValidateCode(accountID, validateCode, props.preferredLocale, recoveryCodeOr2faCode);
} else {
Session.signIn(validateCode, twoFactorAuthCode, props.preferredLocale);
Session.signIn(validateCode, recoveryCodeOr2faCode, props.preferredLocale);
}
}, [props.account, props.credentials, props.preferredLocale, twoFactorAuthCode, validateCode]);
}, [props.account, props.credentials, props.preferredLocale, twoFactorAuthCode, validateCode, props.isUsingRecoveryCode, recoveryCode]);

return (
<>
{/* At this point, if we know the account requires 2FA we already successfully authenticated */}
{props.account.requiresTwoFactorAuth ? (
<View style={[styles.mv3]}>
<MagicCodeInput
autoComplete={props.autoComplete}
ref={input2FARef}
label={props.translate('common.twoFactorCode')}
name="twoFactorAuthCode"
value={twoFactorAuthCode}
onChangeText={(text) => onTextInput(text, 'twoFactorAuthCode')}
onFulfill={validateAndSubmitForm}
maxLength={CONST.TFA_CODE_LENGTH}
errorText={formError.twoFactorAuthCode ? props.translate(formError.twoFactorAuthCode) : ''}
hasError={hasError}
autoFocus
/>
{props.isUsingRecoveryCode ? (
<TextInput
shouldDelayFocus
accessibilityLabel={props.translate('recoveryCodeForm.recoveryCode')}
value={recoveryCode}
onChangeText={(text) => onTextInput(text, 'recoveryCode')}
maxLength={CONST.RECOVERY_CODE_LENGTH}
label={props.translate('recoveryCodeForm.recoveryCode')}
errorText={formError.recoveryCode ? props.translate(formError.recoveryCode) : ''}
hasError={hasError}
autoFocus
/>
) : (
<MagicCodeInput
shouldDelayFocus
autoComplete={props.autoComplete}
ref={input2FARef}
label={props.translate('common.twoFactorCode')}
name="twoFactorAuthCode"
value={twoFactorAuthCode}
onChangeText={(text) => onTextInput(text, 'twoFactorAuthCode')}
onFulfill={validateAndSubmitForm}
maxLength={CONST.TFA_CODE_LENGTH}
errorText={formError.twoFactorAuthCode ? props.translate(formError.twoFactorAuthCode) : ''}
hasError={hasError}
autoFocus
/>
)}
{hasError && <FormHelpMessage message={ErrorUtils.getLatestErrorMessage(props.account)} />}
<PressableWithFeedback
style={[styles.mt2]}
onPress={switchBetween2faAndRecoveryCode}
underlayColor={themeColors.componentBG}
hoverDimmingValue={1}
pressDimmingValue={0.2}
accessibilityRole={CONST.ACCESSIBILITY_ROLE.BUTTON}
accessibilityLabel={props.isUsingRecoveryCode ? props.translate('recoveryCodeForm.use2fa') : props.translate('recoveryCodeForm.useRecoveryCode')}
>
<Text style={[styles.link]}>{props.isUsingRecoveryCode ? props.translate('recoveryCodeForm.use2fa') : props.translate('recoveryCodeForm.useRecoveryCode')}</Text>
</PressableWithFeedback>
</View>
) : (
<View style={[styles.mv3]}>
Expand Down
19 changes: 16 additions & 3 deletions src/pages/signin/ValidateCodeForm/index.android.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,24 @@
import React from 'react';
import PropTypes from 'prop-types';
import BaseValidateCodeForm from './BaseValidateCodeForm';

const defaultProps = {};

const propTypes = {};
function ValidateCodeForm() {
return <BaseValidateCodeForm autoComplete="sms-otp" />;
const propTypes = {
/** Determines if user is switched to using recovery code instead of 2fa code */
isUsingRecoveryCode: PropTypes.bool.isRequired,

/** Function to change `isUsingRecoveryCode` state when user toggles between 2fa code and recovery code */
setIsUsingRecoveryCode: PropTypes.func.isRequired,
};
function ValidateCodeForm(props) {
return (
<BaseValidateCodeForm
autoComplete="sms-otp"
/* eslint-disable-next-line react/jsx-props-no-spreading */
{...props}
/>
);
}

ValidateCodeForm.displayName = 'ValidateCodeForm';
Expand Down
19 changes: 16 additions & 3 deletions src/pages/signin/ValidateCodeForm/index.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,24 @@
import React from 'react';
import PropTypes from 'prop-types';
import BaseValidateCodeForm from './BaseValidateCodeForm';

const defaultProps = {};

const propTypes = {};
function ValidateCodeForm() {
return <BaseValidateCodeForm autoComplete="one-time-code" />;
const propTypes = {
/** Determines if user is switched to using recovery code instead of 2fa code */
isUsingRecoveryCode: PropTypes.bool.isRequired,

/** Function to change `isUsingRecoveryCode` state when user toggles between 2fa code and recovery code */
setIsUsingRecoveryCode: PropTypes.func.isRequired,
};
function ValidateCodeForm(props) {
return (
<BaseValidateCodeForm
autoComplete="one-time-code"
/* eslint-disable-next-line react/jsx-props-no-spreading */
{...props}
/>
);
}

ValidateCodeForm.displayName = 'ValidateCodeForm';
Expand Down

0 comments on commit 724ac0e

Please sign in to comment.