Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions packages/db-migrations/databases/fxa/patches/patch-185-186.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
SET NAMES utf8mb4 COLLATE utf8mb4_bin;

CALL assertPatchLevel('185');

CREATE PROCEDURE `forgotPasswordVerified_10` (
IN `inPasswordForgotTokenId` BINARY(32),
IN `inAccountResetTokenId` BINARY(32),
IN `inTokenData` BINARY(32),
IN `inUid` BINARY(16),
IN `inCreatedAt` BIGINT UNSIGNED,
IN `inVerificationMethod` INT
)
BEGIN
DECLARE EXIT HANDLER FOR SQLEXCEPTION
BEGIN
-- ERROR
ROLLBACK;
RESIGNAL;
END;

START TRANSACTION;

-- Pass `inVerificationMethod` to the password forgot token table
REPLACE INTO accountResetTokens(
tokenId,
tokenData,
uid,
createdAt,
verificationMethod
)
VALUES(
inAccountResetTokenId,
inTokenData,
inUid,
inCreatedAt,
inVerificationMethod
);

Copy link
Contributor

Choose a reason for hiding this comment

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

Is it worth having a comment somewhere that the only difference between this migration and the previous one is we no longer delete the token?

UPDATE accounts SET emailVerified = true WHERE uid = inUid;
UPDATE emails SET isVerified = true, verifiedAt = (UNIX_TIMESTAMP(NOW(3)) * 1000) WHERE isPrimary = true AND uid = inUid;

COMMIT;
END;

UPDATE dbMetadata SET value = '186' WHERE name = 'schema-patch-level';
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
-- SET NAMES utf8mb4 COLLATE utf8mb4_bin;

-- DROP PROCEDURE `forgotPasswordVerified_10`;

-- UPDATE dbMetadata SET value = '185' WHERE name = 'schema-patch-level';
2 changes: 1 addition & 1 deletion packages/db-migrations/databases/fxa/target-patch.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
{
"level": 185
"level": 186
}
9 changes: 9 additions & 0 deletions packages/functional-tests/pages/confirmTotpResetPassword.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */

import { BaseTokenCodePage } from './baseTokenCode';

export class ConfirmTotpResetPassword extends BaseTokenCodePage {
readonly path = '/confirm_totp_reset_password';
}
2 changes: 2 additions & 0 deletions packages/functional-tests/pages/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { AvatarPage } from './settings/avatar';
import { BaseTarget } from '../lib/targets/base';
import { ConfigPage } from './config';
import { ConfirmSignupCodePage } from './confirmSignupCode';
import { ConfirmTotpResetPassword } from './confirmTotpResetPassword';
import { ConnectAnotherDevicePage } from './connectAnotherDevice';
import { CookiesDisabledPage } from './cookiesDisabled';
import { ChangePasswordPage } from './settings/changePassword';
Expand Down Expand Up @@ -47,6 +48,7 @@ export function create(page: Page, target: BaseTarget) {
changePassword: new ChangePasswordPage(page, target),
configPage: new ConfigPage(page, target),
confirmSignupCode: new ConfirmSignupCodePage(page, target),
confirmTotpResetPassword: new ConfirmTotpResetPassword(page, target),
connectAnotherDevice: new ConnectAnotherDevicePage(page, target),
cookiesDisabled: new CookiesDisabledPage(page, target),
deleteAccount: new DeleteAccountPage(page, target),
Expand Down
4 changes: 4 additions & 0 deletions packages/functional-tests/pages/settings/totp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,10 @@ export class TotpPage extends SettingsLayout {
return this.page.getByRole('button', { name: 'Finish' });
}

get confirmBackupCodeConfirmButton() {
return this.page.getByRole('button', { name: 'Confirm' });
}

get confirmBackupCodeTextbox() {
return (
this.page
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,176 @@ test.describe('severity-1 #smoke', () => {
await expect(settings.recoveryKey.status).toHaveText('Not Set');
});

test('provide invalid recovery key then reset with totp authenticator code', async ({
page,
target,
pages: {
signin,
resetPassword,
settings,
totp,
confirmTotpResetPassword,
recoveryKey,
},
testAccountTracker,
}) => {
const credentials = await testAccountTracker.signUp();

// Sign Into Settings
await signin.goto();
await signin.fillOutEmailFirstForm(credentials.email);
await signin.fillOutPasswordForm(credentials.password);
await expect(page).toHaveURL(/settings/);

// Create Recovery Key
await settings.recoveryKey.createButton.click();
await settings.confirmMfaGuard(credentials.email);
await recoveryKey.createRecoveryKey(credentials.password, 'hint');
await expect(settings.settingsHeading).toBeVisible();
await expect(settings.recoveryKey.status).toHaveText('Enabled');

// Enable 2FA
await expect(settings.totp.status).toHaveText('Disabled');
await settings.totp.addButton.click();
await settings.confirmMfaGuard(credentials.email);
const { secret } =
await totp.setUpTwoStepAuthWithQrAndBackupCodesChoice(credentials);
await expect(settings.settingsHeading).toBeVisible();
await expect(settings.alertBar).toContainText(
'Two-step authentication has been enabled'
);
await expect(settings.totp.status).toHaveText('Enabled');

// Start Reset Password Flow
await settings.signOut();
await signin.goto();
await signin.fillOutEmailFirstForm(credentials.email);
await signin.forgotPasswordLink.click();
await resetPassword.fillOutEmailForm(credentials.email);
const code = await target.emailClient.getResetPasswordCode(
credentials.email
);
await await resetPassword.fillOutResetPasswordCodeForm(code);

// Enter Invalid Recovery Key
await expect(resetPassword.confirmRecoveryKeyHeading).toBeVisible();
await resetPassword.recoveryKeyTextbox.fill(
'12345678-12345678-12345678-12345678'
);
await resetPassword.confirmRecoveryKeyButton.click();
await expect(resetPassword.errorBanner).toBeVisible();

// Note! This is the start of edge case this test validates. When we provided
// a recovery key, we took our password forgot token and exchange it for an
// account reset token, which resulted in the passwordForgotToken becoming
// invalid. We therefore must use the account reset token for the rest of
Copy link
Contributor

Choose a reason for hiding this comment

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

I think this comment was a copy/paste from the other PR and is no longer correct here.

// the web requests in this flow.

// Click Forgot Key Link
await resetPassword.forgotKeyLink.click();

// Provide TOTP Code from Authenticator
await page.waitForURL(/confirm_totp_reset_password/);
await expect(page.getByLabel('Enter 6-digit code')).toBeVisible();
const totpCode = await getTotpCode(secret);
await confirmTotpResetPassword.fillOutCodeForm(totpCode);

// Create a New Password
await expect(resetPassword.dataLossWarning).toBeVisible();
const newPassword = testAccountTracker.generatePassword();
await resetPassword.fillOutNewPasswordForm(newPassword);
testAccountTracker.updateAccountPassword(credentials.email, newPassword);

// Observe Settings
await expect(settings.settingsHeading).toBeVisible();
await expect(settings.alertBar).toHaveText('Your password has been reset');
});

test('provide invalid recovery key then reset with totp back up code', async ({
page,
target,
pages: { signin, resetPassword, settings, totp, recoveryKey },
testAccountTracker,
}) => {
const credentials = await testAccountTracker.signUp();

// Sign Into Settings
await signin.goto();
await signin.fillOutEmailFirstForm(credentials.email);
await signin.fillOutPasswordForm(credentials.password);

// Create Recovery Key
await expect(page).toHaveURL(/settings/);
await settings.recoveryKey.createButton.click();
await settings.confirmMfaGuard(credentials.email);
await recoveryKey.createRecoveryKey(credentials.password, 'hint');

await expect(settings.settingsHeading).toBeVisible();
await expect(settings.recoveryKey.status).toHaveText('Enabled');

// Enable 2FA
await expect(settings.settingsHeading).toBeVisible();
await expect(settings.totp.status).toHaveText('Disabled');
await settings.totp.addButton.click();
await settings.confirmMfaGuard(credentials.email);
const { recoveryCodes } =
await totp.setUpTwoStepAuthWithQrAndBackupCodesChoice(credentials);

await expect(settings.settingsHeading).toBeVisible();
await expect(settings.alertBar).toContainText(
'Two-step authentication has been enabled'
);
await expect(settings.totp.status).toHaveText('Enabled');

// Start Reset Password Flow
await settings.signOut();
await signin.goto();
await signin.fillOutEmailFirstForm(credentials.email);
await signin.forgotPasswordLink.click();
await resetPassword.fillOutEmailForm(credentials.email);
const code = await target.emailClient.getResetPasswordCode(
credentials.email
);
await resetPassword.fillOutResetPasswordCodeForm(code);

// Enter Invalid Recovery Key
await expect(resetPassword.confirmRecoveryKeyHeading).toBeVisible();
await resetPassword.recoveryKeyTextbox.fill(
'12345678-12345678-12345678-12345678'
);
await resetPassword.confirmRecoveryKeyButton.click();
await expect(resetPassword.errorBanner).toBeVisible();

/// Note! This is the start of edge case this test validates. When we provided
// a recovery key, we took our password forgot token and exchange it for an
// account reset token, which resulted in the passwordForgotToken becoming
// invalid. We therefore must use the account reset token for the rest of
// the web requests in this flow.

// Click Forgot Key Link
await resetPassword.forgotKeyLink.click();

// Verify TOTP code entry page is shown
await page.waitForURL(/confirm_totp_reset_password/);
await expect(page.getByLabel('Enter 6-digit code')).toBeVisible();
await resetPassword.clickTroubleEnteringCode();

// Provide a Backup TOTP Codes
await expect(totp.confirmBackupCodeHeading).toBeVisible();
await totp.confirmBackupCodeTextbox.fill(recoveryCodes[0]);
await totp.confirmBackupCodeConfirmButton.click();

// Create a New Password
await expect(resetPassword.dataLossWarning).toBeVisible();
const newPassword = testAccountTracker.generatePassword();
await resetPassword.fillOutNewPasswordForm(newPassword);
testAccountTracker.updateAccountPassword(credentials.email, newPassword);

// Observe Settings Page
await expect(settings.settingsHeading).toBeVisible();
await expect(settings.alertBar).toHaveText('Your password has been reset');
});

test('can reset password with unverified 2FA and skip recovery key', async ({
page,
target,
Expand Down Expand Up @@ -570,4 +740,114 @@ test.describe('reset password with recovery phone', () => {

await expect(settings.settingsHeading).toBeVisible();
});

test('provide invalid recovery key then reset with recovery phone', async ({
page,
target,
pages: {
signin,
resetPassword,
settings,
totp,
recoveryKey,
recoveryPhone,
},
testAccountTracker,
}) => {
const credentials = await testAccountTracker.signUp();
const testNumber = target.smsClient.getPhoneNumber();

// Sign Into Settings
await signin.goto();
await signin.fillOutEmailFirstForm(credentials.email);
await signin.fillOutPasswordForm(credentials.password);
await expect(page).toHaveURL(/settings/);

// Create Recovery Key
await settings.recoveryKey.createButton.click();
await settings.confirmMfaGuard(credentials.email);
await recoveryKey.createRecoveryKey(credentials.password, 'hint');
await expect(settings.settingsHeading).toBeVisible();
await expect(settings.recoveryKey.status).toHaveText('Enabled');

// Enable 2FA
await expect(settings.settingsHeading).toBeVisible();
await expect(settings.totp.status).toHaveText('Disabled');
await settings.totp.addButton.click();
await settings.confirmMfaGuard(credentials.email);
await totp.setUpTwoStepAuthWithQrAndBackupCodesChoice(credentials);
await expect(settings.settingsHeading).toBeVisible();
await expect(settings.alertBar).toContainText(
'Two-step authentication has been enabled'
);
await expect(settings.totp.status).toHaveText('Enabled');

// Enable Recovery Phone
await settings.totp.addRecoveryPhoneButton.click();
await page.waitForURL(/recovery_phone\/setup/);
await expect(recoveryPhone.addHeader()).toBeVisible();
await recoveryPhone.enterPhoneNumber(testNumber);
await recoveryPhone.clickSendCode();
await expect(recoveryPhone.confirmHeader).toBeVisible();
let smsCode = await target.smsClient.getCode({ ...credentials });
await recoveryPhone.enterCode(smsCode);
await recoveryPhone.clickConfirm();
await page.waitForURL(/settings/);
await expect(settings.alertBar).toHaveText('Recovery phone added');

// Start Reset Password Flow
await settings.signOut();
await signin.goto();
await signin.fillOutEmailFirstForm(credentials.email);
await signin.forgotPasswordLink.click();
await resetPassword.fillOutEmailForm(credentials.email);
const code = await target.emailClient.getResetPasswordCode(
credentials.email
);
await resetPassword.fillOutResetPasswordCodeForm(code);
await expect(resetPassword.confirmRecoveryKeyHeading).toBeVisible();

// Enter Invalid Recovery Key
await resetPassword.recoveryKeyTextbox.fill(
'12345678-12345678-12345678-12345678'
);
await resetPassword.confirmRecoveryKeyButton.click();
await expect(resetPassword.errorBanner).toBeVisible();

// Note! This is the start of edge case this test validates. When we provided
// a recovery key, we took our password forgot token and exchange it for an
// account reset token, which resulted in the passwordForgotToken becoming
// invalid. We therefore must use the account reset token for the rest of
// the web requests in this flow.

// Click Forgot Key Link
await resetPassword.forgotKeyLink.click();

// Verify TOTP code entry page is shown
await page.waitForURL(/confirm_totp_reset_password/);
await expect(page.getByLabel('Enter 6-digit code')).toBeVisible();
await resetPassword.clickTroubleEnteringCode();

// Choose Recovery Phone Option
await page.waitForURL(/reset_password_totp_recovery_choice/);
await resetPassword.clickChoosePhone();
await resetPassword.clickContinueButton();

// Provide SMS Code
await page.waitForURL(/reset_password_recovery_phone/);

smsCode = await target.smsClient.getCode({ ...credentials });
await resetPassword.fillRecoveryPhoneCodeForm(smsCode);
await resetPassword.clickConfirmButton();

// Create a New Password
await expect(resetPassword.dataLossWarning).toBeVisible();
const newPassword = testAccountTracker.generatePassword();
await resetPassword.fillOutNewPasswordForm(newPassword);
testAccountTracker.updateAccountPassword(credentials.email, newPassword);

// Observe Settings Page
await expect(settings.settingsHeading).toBeVisible();
await expect(settings.alertBar).toHaveText('Your password has been reset');
});
});
2 changes: 1 addition & 1 deletion packages/fxa-shared/db/models/auth/base-auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ export enum Proc {
DeviceFromRefreshTokenId = 'deviceFromRefreshTokenId_1',
EmailBounces = 'fetchEmailBounces_4',
FindLargeAccounts = 'findLargeAccounts_1',
ForgotPasswordVerified = 'forgotPasswordVerified_9',
ForgotPasswordVerified = 'forgotPasswordVerified_10',
KeyFetchToken = 'keyFetchToken_1',
KeyFetchTokenWithVerificationStatus = 'keyFetchTokenWithVerificationStatus_2',
LimitSessions = 'limitSessions_3',
Expand Down
Loading