From 06ae05511ad1edb37f19dfdebd4cee12953172f4 Mon Sep 17 00:00:00 2001 From: Ali Toshmatov Date: Sat, 22 Jul 2023 10:09:53 +0500 Subject: [PATCH 1/5] Added recovery code feature --- src/CONST.js | 5 + src/languages/en.js | 9 ++ src/languages/es.js | 9 ++ src/libs/ValidationUtils.js | 5 + .../ValidateCodeForm/BaseValidateCodeForm.js | 117 ++++++++++++++---- 5 files changed, 121 insertions(+), 24 deletions(-) diff --git a/src/CONST.js b/src/CONST.js index 46aa9a1943e9..042728e3ddcb 100755 --- a/src/CONST.js +++ b/src/CONST.js @@ -760,6 +760,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, @@ -801,6 +804,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', diff --git a/src/languages/en.js b/src/languages/en.js index 47cc8d209735..3e01f8ceb722 100755 --- a/src/languages/en.js +++ b/src/languages/en.js @@ -567,6 +567,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', diff --git a/src/languages/es.js b/src/languages/es.js index 5eea74099e4c..7c112f5658ff 100644 --- a/src/languages/es.js +++ b/src/languages/es.js @@ -568,6 +568,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', diff --git a/src/libs/ValidationUtils.js b/src/libs/ValidationUtils.js index c120f649b401..229813c64dd6 100644 --- a/src/libs/ValidationUtils.js +++ b/src/libs/ValidationUtils.js @@ -307,6 +307,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} @@ -478,4 +482,5 @@ export { doesContainReservedWord, isNumeric, isValidAccountRoute, + isValidRecoveryCode, }; diff --git a/src/pages/signin/ValidateCodeForm/BaseValidateCodeForm.js b/src/pages/signin/ValidateCodeForm/BaseValidateCodeForm.js index 9abbf5ea4957..f211f154a8a1 100755 --- a/src/pages/signin/ValidateCodeForm/BaseValidateCodeForm.js +++ b/src/pages/signin/ValidateCodeForm/BaseValidateCodeForm.js @@ -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 */ @@ -77,6 +78,8 @@ function BaseValidateCodeForm(props) { const [validateCode, setValidateCode] = useState(props.credentials.validateCode || ''); const [twoFactorAuthCode, setTwoFactorAuthCode] = useState(''); const [timeRemaining, setTimeRemaining] = useState(30); + const [isUsingRecoveryCode, setIsUsingRecoveryCode] = useState(false); + const [recoveryCode, setRecoveryCode] = useState(''); const prevRequiresTwoFactorAuth = usePrevious(props.account.requiresTwoFactorAuth); const prevValidateCode = usePrevious(props.credentials.validateCode); @@ -148,7 +151,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]: ''})); @@ -183,6 +196,22 @@ function BaseValidateCodeForm(props) { Session.clearSignInData(); }; + /** + * Switches between 2fa and recovery code, clears inputs and errors + */ + const switchBetween2faAndRecoveryCode = () => { + setIsUsingRecoveryCode(!isUsingRecoveryCode); + + setRecoveryCode(''); + setTwoFactorAuthCode(''); + + setFormError((prevError) => ({...prevError, recoveryCode: '', twoFactorAuthCode: ''})); + + if (props.account.errors) { + Session.clearAccountMessages(); + } + }; + useEffect(() => { if (!isLoadingResendValidationForm) { return; @@ -199,13 +228,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 (!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) { @@ -222,33 +265,59 @@ function BaseValidateCodeForm(props) { } setFormError({}); + const recoveryCodeOr2faCode = 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.requiresTwoFactorAuth, props.credentials, props.preferredLocale, twoFactorAuthCode, validateCode]); + }, [props.account.requiresTwoFactorAuth, props.credentials, props.preferredLocale, twoFactorAuthCode, validateCode, isUsingRecoveryCode, recoveryCode]); return ( <> {/* At this point, if we know the account requires 2FA we already successfully authenticated */} {props.account.requiresTwoFactorAuth ? ( - onTextInput(text, 'twoFactorAuthCode')} - onFulfill={validateAndSubmitForm} - maxLength={CONST.TFA_CODE_LENGTH} - errorText={formError.twoFactorAuthCode ? props.translate(formError.twoFactorAuthCode) : ''} - hasError={hasError} - autoFocus - /> + {isUsingRecoveryCode ? ( + onTextInput(text, 'recoveryCode')} + maxLength={CONST.RECOVERY_CODE_LENGTH} + label={props.translate('recoveryCodeForm.recoveryCode')} + errorText={formError.recoveryCode ? props.translate(formError.recoveryCode) : ''} + hasError={hasError} + autoFocus + /> + ) : ( + onTextInput(text, 'twoFactorAuthCode')} + onFulfill={validateAndSubmitForm} + maxLength={CONST.TFA_CODE_LENGTH} + errorText={formError.twoFactorAuthCode ? props.translate(formError.twoFactorAuthCode) : ''} + hasError={hasError} + autoFocus + /> + )} {hasError && } + + {isUsingRecoveryCode ? props.translate('recoveryCodeForm.use2fa') : props.translate('recoveryCodeForm.useRecoveryCode')} + ) : ( From d21046da995658519255fd53e0c59dcae2b1d508 Mon Sep 17 00:00:00 2001 From: Ali Toshmatov Date: Sat, 22 Jul 2023 10:58:38 +0500 Subject: [PATCH 2/5] Added focus delay so that keyboard behaves smoothlier --- src/pages/signin/ValidateCodeForm/BaseValidateCodeForm.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/pages/signin/ValidateCodeForm/BaseValidateCodeForm.js b/src/pages/signin/ValidateCodeForm/BaseValidateCodeForm.js index f211f154a8a1..458863d1eae0 100755 --- a/src/pages/signin/ValidateCodeForm/BaseValidateCodeForm.js +++ b/src/pages/signin/ValidateCodeForm/BaseValidateCodeForm.js @@ -282,6 +282,7 @@ function BaseValidateCodeForm(props) { {isUsingRecoveryCode ? ( onTextInput(text, 'recoveryCode')} @@ -293,6 +294,7 @@ function BaseValidateCodeForm(props) { /> ) : ( Date: Mon, 28 Aug 2023 01:04:32 +0500 Subject: [PATCH 3/5] Added clearing recovery code to clearLocalSignInData --- src/pages/signin/ValidateCodeForm/BaseValidateCodeForm.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/pages/signin/ValidateCodeForm/BaseValidateCodeForm.js b/src/pages/signin/ValidateCodeForm/BaseValidateCodeForm.js index 9e22f8ab04da..f001e4e945f7 100755 --- a/src/pages/signin/ValidateCodeForm/BaseValidateCodeForm.js +++ b/src/pages/signin/ValidateCodeForm/BaseValidateCodeForm.js @@ -186,6 +186,8 @@ function BaseValidateCodeForm(props) { setTwoFactorAuthCode(''); setFormError({}); setValidateCode(''); + setIsUsingRecoveryCode(false); + setRecoveryCode(''); }; /** From 3100209d509b7005f5fb76284f30cbdb03b6b23b Mon Sep 17 00:00:00 2001 From: Ali Toshmatov Date: Sun, 10 Sep 2023 23:15:19 +0500 Subject: [PATCH 4/5] Shifted state to signInPage since it was needed to determine welcome text --- src/languages/en.ts | 1 + src/languages/es.ts | 1 + src/pages/signin/SignInPage.js | 14 ++++++++--- .../ValidateCodeForm/BaseValidateCodeForm.js | 23 +++++++++++-------- .../signin/ValidateCodeForm/index.android.js | 19 ++++++++++++--- src/pages/signin/ValidateCodeForm/index.js | 19 ++++++++++++--- 6 files changed, 59 insertions(+), 18 deletions(-) diff --git a/src/languages/en.ts b/src/languages/en.ts index 2e619dc83460..290acf1da655 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -889,6 +889,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', diff --git a/src/languages/es.ts b/src/languages/es.ts index 4084925d7781..40fd89e6622c 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -893,6 +893,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', diff --git a/src/pages/signin/SignInPage.js b/src/pages/signin/SignInPage.js index 75ee4de65a5c..c4befcd1dbf5 100644 --- a/src/pages/signin/SignInPage.js +++ b/src/pages/signin/SignInPage.js @@ -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'; @@ -99,6 +99,9 @@ function SignInPage({credentials, account, isInModal, demoInfo}) { 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(() => { @@ -127,7 +130,7 @@ function SignInPage({credentials, account, isInModal, demoInfo}) { 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 || ''); @@ -177,7 +180,12 @@ function SignInPage({credentials, account, isInModal, demoInfo}) { blurOnSubmit={account.validated === false} scrollPageToTop={signInPageLayoutRef.current && signInPageLayoutRef.current.scrollPageToTop} /> - {shouldShowValidateCodeForm && } + {shouldShowValidateCodeForm && ( + + )} {shouldShowUnlinkLoginForm && } {shouldShowEmailDeliveryFailurePage && } diff --git a/src/pages/signin/ValidateCodeForm/BaseValidateCodeForm.js b/src/pages/signin/ValidateCodeForm/BaseValidateCodeForm.js index f001e4e945f7..1499448b2609 100755 --- a/src/pages/signin/ValidateCodeForm/BaseValidateCodeForm.js +++ b/src/pages/signin/ValidateCodeForm/BaseValidateCodeForm.js @@ -61,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, }; @@ -78,7 +84,6 @@ function BaseValidateCodeForm(props) { const [validateCode, setValidateCode] = useState(props.credentials.validateCode || ''); const [twoFactorAuthCode, setTwoFactorAuthCode] = useState(''); const [timeRemaining, setTimeRemaining] = useState(30); - const [isUsingRecoveryCode, setIsUsingRecoveryCode] = useState(false); const [recoveryCode, setRecoveryCode] = useState(''); const prevRequiresTwoFactorAuth = usePrevious(props.account.requiresTwoFactorAuth); @@ -186,7 +191,7 @@ function BaseValidateCodeForm(props) { setTwoFactorAuthCode(''); setFormError({}); setValidateCode(''); - setIsUsingRecoveryCode(false); + props.setIsUsingRecoveryCode(false); setRecoveryCode(''); }; @@ -202,7 +207,7 @@ function BaseValidateCodeForm(props) { * Switches between 2fa and recovery code, clears inputs and errors */ const switchBetween2faAndRecoveryCode = () => { - setIsUsingRecoveryCode(!isUsingRecoveryCode); + props.setIsUsingRecoveryCode(!props.isUsingRecoveryCode); setRecoveryCode(''); setTwoFactorAuthCode(''); @@ -236,7 +241,7 @@ function BaseValidateCodeForm(props) { /** * User could be using either recovery code or 2fa code */ - if (!isUsingRecoveryCode) { + if (!props.isUsingRecoveryCode) { if (!twoFactorAuthCode.trim()) { setFormError({twoFactorAuthCode: 'validateCodeForm.error.pleaseFillTwoFactorAuth'}); return; @@ -270,7 +275,7 @@ function BaseValidateCodeForm(props) { } setFormError({}); - const recoveryCodeOr2faCode = isUsingRecoveryCode ? recoveryCode : twoFactorAuthCode; + const recoveryCodeOr2faCode = props.isUsingRecoveryCode ? recoveryCode : twoFactorAuthCode; const accountID = lodashGet(props.credentials, 'accountID'); if (accountID) { @@ -278,14 +283,14 @@ function BaseValidateCodeForm(props) { } else { Session.signIn(validateCode, recoveryCodeOr2faCode, props.preferredLocale); } - }, [props.account, props.credentials, props.preferredLocale, twoFactorAuthCode, validateCode, isUsingRecoveryCode, recoveryCode]); + }, [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 ? ( - {isUsingRecoveryCode ? ( + {props.isUsingRecoveryCode ? ( - {isUsingRecoveryCode ? props.translate('recoveryCodeForm.use2fa') : props.translate('recoveryCodeForm.useRecoveryCode')} + {props.isUsingRecoveryCode ? props.translate('recoveryCodeForm.use2fa') : props.translate('recoveryCodeForm.useRecoveryCode')} ) : ( diff --git a/src/pages/signin/ValidateCodeForm/index.android.js b/src/pages/signin/ValidateCodeForm/index.android.js index 7ff81357725d..1e888d24bc60 100644 --- a/src/pages/signin/ValidateCodeForm/index.android.js +++ b/src/pages/signin/ValidateCodeForm/index.android.js @@ -1,11 +1,24 @@ import React from 'react'; +import PropTypes from 'prop-types'; import BaseValidateCodeForm from './BaseValidateCodeForm'; const defaultProps = {}; -const propTypes = {}; -function ValidateCodeForm() { - return ; +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 ( + + ); } ValidateCodeForm.displayName = 'ValidateCodeForm'; diff --git a/src/pages/signin/ValidateCodeForm/index.js b/src/pages/signin/ValidateCodeForm/index.js index 6b01c7d4dec2..540b6a3e3ed6 100644 --- a/src/pages/signin/ValidateCodeForm/index.js +++ b/src/pages/signin/ValidateCodeForm/index.js @@ -1,11 +1,24 @@ import React from 'react'; +import PropTypes from 'prop-types'; import BaseValidateCodeForm from './BaseValidateCodeForm'; const defaultProps = {}; -const propTypes = {}; -function ValidateCodeForm() { - return ; +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 ( + + ); } ValidateCodeForm.displayName = 'ValidateCodeForm'; From 0e1107a5f04c6f958de784f7a3554f466d54e56e Mon Sep 17 00:00:00 2001 From: Ali Toshmatov Date: Sun, 10 Sep 2023 23:37:19 +0500 Subject: [PATCH 5/5] Added eslint rule exception --- src/pages/signin/ValidateCodeForm/BaseValidateCodeForm.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/pages/signin/ValidateCodeForm/BaseValidateCodeForm.js b/src/pages/signin/ValidateCodeForm/BaseValidateCodeForm.js index 1499448b2609..0ed91023e139 100755 --- a/src/pages/signin/ValidateCodeForm/BaseValidateCodeForm.js +++ b/src/pages/signin/ValidateCodeForm/BaseValidateCodeForm.js @@ -224,6 +224,9 @@ function BaseValidateCodeForm(props) { 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]); /**