Skip to content

Commit 92d6cab

Browse files
authored
Merge pull request #18929 from mozilla/fxa-11510-v2
feat(recovery): Add reset password recovery phone views and functionality
2 parents 20473cd + a9e7924 commit 92d6cab

18 files changed

Lines changed: 862 additions & 7 deletions

File tree

packages/functional-tests/pages/resetPassword.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,4 +222,21 @@ export class ResetPasswordPage extends BaseLayout {
222222
async continueWithoutDownloadingRecoveryKey() {
223223
return this.page.getByText('Continue without downloading').click();
224224
}
225+
226+
// Password reset with recovery phone
227+
async fillRecoveryPhoneCodeForm(code: string) {
228+
await this.page.locator('input[name="code"]').fill(code);
229+
}
230+
231+
async clickConfirmButton() {
232+
await this.page.getByRole('button', { name: 'Confirm' }).click();
233+
}
234+
235+
async clickChoosePhone() {
236+
return this.page.locator('.input-radio-wrapper').first().click();
237+
}
238+
239+
async clickContinueButton() {
240+
return this.page.getByRole('button', { name: 'Continue' });
241+
}
225242
}

packages/functional-tests/tests/resetPassword/resetPassword2FA.spec.ts

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,32 @@
44

55
import { expect, test } from '../../lib/fixtures/standard';
66
import { getCode } from 'fxa-settings/src/lib/totp';
7+
import { TargetName, getFromEnvWithFallback } from '../../lib/targets';
8+
9+
// Default test number, see Twilio test credentials phone numbers:
10+
// https://www.twilio.com/docs/iam/test-credentials
11+
const TEST_NUMBER = '4159929960';
12+
13+
/**
14+
* Checks the process env for a configured twilio test phone number. Defaults
15+
* to generic magic test number if one is not provided.
16+
* @param targetName The test target name. eg local, stage, prod.
17+
* @returns
18+
*/
19+
function getPhoneNumber(targetName: TargetName) {
20+
if (targetName === 'local') {
21+
return TEST_NUMBER;
22+
}
23+
return getFromEnvWithFallback(
24+
'FUNCTIONAL_TESTS__TWILIO__TEST_NUMBER',
25+
targetName,
26+
TEST_NUMBER
27+
);
28+
}
29+
30+
function usingRealTestPhoneNumber(targetName: TargetName) {
31+
return getPhoneNumber(targetName) !== TEST_NUMBER;
32+
}
733

834
test.describe('severity-1 #smoke', () => {
935
test('can reset password with 2FA enabled', async ({
@@ -332,3 +358,130 @@ test.describe('severity-1 #smoke', () => {
332358
credentials.password = newPassword;
333359
});
334360
});
361+
362+
test.describe('reset password with recovery phone', () => {
363+
test.describe.configure({ mode: 'serial' });
364+
365+
test.beforeAll(async ({ target }) => {
366+
/**
367+
* Important! Twilio does not allow you to fetch messages when using test
368+
* credentials. Twilio also does not allow you to send messages to magic
369+
* test numbers with real credentials.
370+
*
371+
* Therefore, if a 'magic' test number is configured, then we need to
372+
* use redis to peek at codes sent out, and if a 'real' testing phone
373+
* number is being being used, then we need to check the Twilio API for
374+
* the message sent out and look at the code within.
375+
*/
376+
if (
377+
usingRealTestPhoneNumber(target.name) &&
378+
!target.smsClient.isTwilioEnabled()
379+
) {
380+
throw new Error(
381+
'Twilio must be enabled when using a real test number.'
382+
);
383+
}
384+
if (
385+
!usingRealTestPhoneNumber(target.name) &&
386+
!target.smsClient.isRedisEnabled()
387+
) {
388+
throw new Error('Redis must be enabled when using a real test number.');
389+
}
390+
});
391+
392+
test.beforeEach(async ({ pages: { configPage } }) => {
393+
// Ensure that the feature flag is enabled
394+
const config = await configPage.getConfig();
395+
test.skip(config.featureFlags.recoveryPhonePasswordReset2fa !== true);
396+
});
397+
398+
test('can reset password with 2FA enabled using recovery phone', async ({
399+
page,
400+
target,
401+
pages: { signin, resetPassword, settings, totp, recoveryPhone },
402+
testAccountTracker,
403+
}) => {
404+
const credentials = await testAccountTracker.signUp();
405+
const newPassword = testAccountTracker.generatePassword();
406+
407+
await signin.goto();
408+
await signin.fillOutEmailFirstForm(credentials.email);
409+
await signin.fillOutPasswordForm(credentials.password);
410+
411+
await expect(settings.settingsHeading).toBeVisible();
412+
await expect(settings.totp.status).toHaveText('Disabled');
413+
414+
await settings.totp.addButton.click();
415+
await totp.fillOutTotpForms();
416+
417+
await expect(settings.settingsHeading).toBeVisible();
418+
await expect(settings.alertBar).toHaveText(
419+
'Two-step authentication has been enabled'
420+
);
421+
await expect(settings.totp.status).toHaveText('Enabled');
422+
423+
await settings.totp.addRecoveryPhoneButton.click();
424+
await page.waitForURL(/recovery_phone\/setup/);
425+
426+
await expect(recoveryPhone.addHeader()).toBeVisible();
427+
428+
await recoveryPhone.enterPhoneNumber(getPhoneNumber(target.name));
429+
await recoveryPhone.clickSendCode();
430+
431+
await expect(recoveryPhone.confirmHeader).toBeVisible();
432+
433+
let smsCode = await target.smsClient.getCode(
434+
getPhoneNumber(target.name),
435+
credentials.uid
436+
);
437+
438+
await recoveryPhone.enterCode(smsCode);
439+
await recoveryPhone.clickConfirm();
440+
441+
await page.waitForURL(/settings/);
442+
await expect(settings.alertBar).toHaveText('Recovery phone added');
443+
444+
await settings.signOut();
445+
446+
await resetPassword.goto();
447+
448+
await resetPassword.fillOutEmailForm(credentials.email);
449+
450+
const code = await target.emailClient.getResetPasswordCode(
451+
credentials.email
452+
);
453+
await resetPassword.fillOutResetPasswordCodeForm(code);
454+
455+
await page.waitForURL(/confirm_totp_reset_password/);
456+
457+
await resetPassword.clickTroubleEnteringCode();
458+
459+
await page.waitForURL(/reset_password_totp_recovery_choice/);
460+
461+
await resetPassword.clickChoosePhone();
462+
await resetPassword.clickContinueButton();
463+
464+
await page.waitForURL(/reset_password_recovery_phone/);
465+
466+
smsCode = await target.smsClient.getCode(
467+
getPhoneNumber(target.name),
468+
credentials.uid
469+
);
470+
471+
await resetPassword.fillRecoveryPhoneCodeForm(smsCode);
472+
473+
await resetPassword.clickConfirmButton();
474+
475+
// Create and submit new password
476+
await resetPassword.fillOutNewPasswordForm(newPassword);
477+
478+
await expect(settings.alertBar).toHaveText('Your password has been reset');
479+
480+
await expect(settings.settingsHeading).toBeVisible();
481+
482+
// Remove TOTP before teardown
483+
await settings.disconnectTotp();
484+
// Cleanup requires setting this value to correct password
485+
credentials.password = newPassword;
486+
});
487+
})

packages/fxa-auth-server/lib/routes/recovery-phone.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -628,7 +628,11 @@ class RecoveryPhoneHandler {
628628
}
629629

630630
async confirmResetPasswordCode(request: AuthRequest) {
631-
const { uid, email } = request.auth.credentials as PasswordForgotToken;
631+
const { id, uid, email } = request.auth.credentials as unknown as {
632+
id: string;
633+
uid: string;
634+
email: string;
635+
};
632636

633637
const { code } = request.payload as unknown as {
634638
code: string;
@@ -661,6 +665,11 @@ class RecoveryPhoneHandler {
661665
}
662666

663667
if (success) {
668+
await this.db.verifyPasswordForgotTokenWithMethod(
669+
id,
670+
'totp-2fa'
671+
);
672+
664673
await this.glean.resetPassword.recoveryPhoneCodeComplete(request);
665674

666675
this.statsd.increment('account.resetPassword.recoveryPhone.success');

packages/fxa-content-server/server/lib/beta-settings.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,9 @@ const settingsConfig = {
101101
recoveryCodeSetupOnSyncSignIn: config.get(
102102
'featureFlags.recoveryCodeSetupOnSyncSignIn'
103103
),
104+
recoveryPhonePasswordReset2fa: config.get(
105+
'featureFlags.recoveryPhonePasswordReset2fa'
106+
),
104107
},
105108
nimbusPreview: config.get('nimbusPreview'),
106109
};

packages/fxa-content-server/server/lib/routes/react-app/content-server-routes.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,10 @@ const FRONTEND_ROUTES = [
5959
'reset_password_confirmed',
6060
'reset_password_verified',
6161
'reset_password_with_recovery_key_verified',
62+
'reset_password_totp_recovery_choice',
63+
'reset_password_recovery_phone',
64+
'confirm_backup_code_reset_password',
65+
'confirm_totp_reset_password',
6266
'security_events',
6367
'signin',
6468
'signin_bounced',

packages/fxa-content-server/server/lib/routes/react-app/route-definition-index.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,9 @@ function getIndexRouteDefinition(config) {
5555
const FEATURE_FLAGS_RECOVERY_CODE_SETUP_ON_SYNC_SIGN_IN = config.get(
5656
'featureFlags.recoveryCodeSetupOnSyncSignIn'
5757
);
58+
const FEATURE_FLAGS_RECOVERY_PHONE_PASSWORD_RESET_2FA = config.get(
59+
'featureFlags.recoveryPhonePasswordReset2fa'
60+
);
5861
const NIMBUS_PREVIEW = config.get('nimbusPreview');
5962
const GLEAN_ENABLED = config.get('glean.enabled');
6063
const GLEAN_APPLICATION_ID = config.get('glean.applicationId');
@@ -116,6 +119,7 @@ function getIndexRouteDefinition(config) {
116119
sendFxAStatusOnSettings: FEATURE_FLAGS_FXA_STATUS_ON_SETTINGS,
117120
recoveryCodeSetupOnSyncSignIn:
118121
FEATURE_FLAGS_RECOVERY_CODE_SETUP_ON_SYNC_SIGN_IN,
122+
recoveryPhonePasswordReset2fa: FEATURE_FLAGS_RECOVERY_PHONE_PASSWORD_RESET_2FA
119123
},
120124
nimbusPreview: NIMBUS_PREVIEW,
121125
glean: {

packages/fxa-settings/src/components/App/index.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ import { IndexContainer } from '../../pages/Index/container';
8585
import AuthorizationContainer from '../../pages/Authorization/container';
8686
import CookiesDisabled from '../../pages/CookiesDisabled';
8787
import ResetPasswordRecoveryChoiceContainer from '../../pages/ResetPassword/ResetPasswordRecoveryChoice/container';
88+
import ResetPasswordRecoveryPhoneContainer from '../../pages/ResetPassword/ResetPasswordRecoveryPhone/container';
8889

8990
const Settings = lazy(() => import('../Settings'));
9091

@@ -375,6 +376,9 @@ const AuthAndAccountSetupRoutes = ({
375376
/>
376377
<ConfirmResetPasswordContainer path="/confirm_reset_password/*" />
377378
<ResetPasswordRecoveryChoiceContainer path="/reset_password_totp_recovery_choice/*" />
379+
<ResetPasswordRecoveryPhoneContainer path="/reset_password_recovery_phone/*"
380+
{...{ integration }}
381+
/>
378382
<ConfirmTotpResetPasswordContainer path="/confirm_totp_reset_password/*" />
379383
<ConfirmBackupCodeResetPasswordContainer path="/confirm_backup_code_reset_password/*" />
380384
<CompleteResetPasswordContainer

packages/fxa-settings/src/pages/ResetPassword/ConfirmTotpResetPassword/container.test.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,12 @@ import { MOCK_EMAIL, MOCK_PASSWORD_CHANGE_TOKEN, MOCK_UID } from '../../mocks';
1212
import ConfirmTotpResetPasswordContainer from './container';
1313

1414
const mockCheckTotp = jest.fn();
15+
const mockRecoveryPhoneGetWithPasswordForgotToken = jest.fn();
1516
jest.mock('../../../models', () => ({
1617
__esModule: true,
1718
useAuthClient: () => ({
1819
checkTotpTokenCodeWithPasswordForgotToken: mockCheckTotp,
20+
recoveryPhoneGetWithPasswordForgotToken: mockRecoveryPhoneGetWithPasswordForgotToken,
1921
}),
2022
useFtlMsgResolver: () => ({
2123
getMsg: (_id: string, fallback: string) => fallback,
@@ -70,10 +72,12 @@ describe('ConfirmTotpResetPasswordContainer', () => {
7072
capturedProps = undefined;
7173
mockCheckTotp.mockReset();
7274
mockNavigate.mockReset();
75+
mockRecoveryPhoneGetWithPasswordForgotToken.mockReset();
7376
});
7477

7578
it('calls authClient then navigates on success', async () => {
7679
mockCheckTotp.mockResolvedValueOnce({ success: true });
80+
mockRecoveryPhoneGetWithPasswordForgotToken.mockResolvedValueOnce({ exists: false });
7781

7882
await renderComponent();
7983

@@ -98,6 +102,7 @@ describe('ConfirmTotpResetPasswordContainer', () => {
98102

99103
it('sets localized error message when authClient returns success: false', async () => {
100104
mockCheckTotp.mockResolvedValueOnce({ success: false });
105+
mockRecoveryPhoneGetWithPasswordForgotToken.mockResolvedValueOnce({ exists: false });
101106

102107
await renderComponent();
103108

@@ -108,12 +113,13 @@ describe('ConfirmTotpResetPasswordContainer', () => {
108113
expect(capturedProps.codeErrorMessage).toBe('Valid code required');
109114
});
110115

111-
it('forwards location.state when onTroubleWithCode is invoked', async () => {
116+
it.only('forwards location.state when onTroubleWithCode is invoked', async () => {
112117
mockCheckTotp.mockResolvedValueOnce({ success: true });
118+
mockRecoveryPhoneGetWithPasswordForgotToken.mockResolvedValueOnce({ exists: true });
113119

114120
await renderComponent();
115121

116-
capturedProps.onTroubleWithCode();
122+
await capturedProps.onTroubleWithCode();
117123

118124
expect(mockNavigate).toHaveBeenCalledWith(
119125
'/reset_password_totp_recovery_choice',

packages/fxa-settings/src/pages/ResetPassword/ConfirmTotpResetPassword/container.tsx

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -67,10 +67,15 @@ const ConfirmTotpResetPasswordContainer = (_: RouteComponentProps) => {
6767
}
6868
};
6969

70-
const onTroubleWithCode = () => {
71-
const nextRoute = config.featureFlags?.recoveryPhonePasswordReset2fa
72-
? '/reset_password_totp_recovery_choice'
73-
: '/confirm_backup_code_reset_password';
70+
const onTroubleWithCode = async () => {
71+
let nextRoute = '/confirm_backup_code_reset_password';
72+
73+
const { exists } = await authClient.recoveryPhoneGetWithPasswordForgotToken(token);
74+
75+
if (config.featureFlags?.recoveryPhonePasswordReset2fa && exists) {
76+
nextRoute = '/reset_password_totp_recovery_choice';
77+
}
78+
7479
navigateWithQuery(nextRoute, {
7580
state: {
7681
code,

packages/fxa-settings/src/pages/ResetPassword/ResetPasswordRecoveryChoice/container.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,7 @@ export const ResetPasswordRecoveryChoiceContainer = (
136136
if (!numBackupCodes || numBackupCodes === 0) {
137137
navigateWithQuery('/reset_password_recovery_phone', {
138138
state: {
139+
...locationState.state,
139140
lastFourPhoneDigits: phoneData.lastFourPhoneDigits,
140141
},
141142
// ensure back button on signin_recovery_code page skips choice page and

0 commit comments

Comments
 (0)