Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added recovery code option to 2fa #23390

Merged
merged 10 commits into from
Sep 20, 2023
Merged
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}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This caused a regression here #35947
When we paste a recovery code with spaces at the beginning it will cut out some letters from the end.

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
Loading