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
5 changes: 5 additions & 0 deletions src/CONST.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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',
Expand Down
9 changes: 9 additions & 0 deletions src/languages/en.js
Original file line number Diff line number Diff line change
Expand Up @@ -567,6 +567,15 @@ export default {
copy: 'Copy',
disable: 'Disable',
},
recoveryCodeForm: {
error: {
alitoshmatov marked this conversation as resolved.
Show resolved Hide resolved
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',
},
alitoshmatov marked this conversation as resolved.
Show resolved Hide resolved
twoFactorAuthForm: {
error: {
pleaseFillTwoFactorAuth: 'Please enter your two-factor authentication code',
Expand Down
9 changes: 9 additions & 0 deletions src/languages/es.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
5 changes: 5 additions & 0 deletions src/libs/ValidationUtils.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down Expand Up @@ -478,4 +482,5 @@ export {
doesContainReservedWord,
isNumeric,
isValidAccountRoute,
isValidRecoveryCode,
};
119 changes: 95 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 @@ -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);
Expand Down Expand Up @@ -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]: ''}));

Expand Down Expand Up @@ -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;
Expand All @@ -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) {
Expand All @@ -222,33 +265,61 @@ 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 ? (
<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
/>
{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={isUsingRecoveryCode ? props.translate('recoveryCodeForm.use2fa') : props.translate('recoveryCodeForm.useRecoveryCode')}
>
<Text style={[styles.link]}>{isUsingRecoveryCode ? props.translate('recoveryCodeForm.use2fa') : props.translate('recoveryCodeForm.useRecoveryCode')}</Text>
</PressableWithFeedback>
</View>
) : (
<View style={[styles.mv3]}>
Expand Down