diff --git a/packages/functional-tests/pages/confirmTotpResetPassword.ts b/packages/functional-tests/pages/confirmTotpResetPassword.ts new file mode 100644 index 00000000000..4d1ac66cf67 --- /dev/null +++ b/packages/functional-tests/pages/confirmTotpResetPassword.ts @@ -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'; +} diff --git a/packages/functional-tests/pages/index.ts b/packages/functional-tests/pages/index.ts index bf7b186ef04..31dae58f7c4 100644 --- a/packages/functional-tests/pages/index.ts +++ b/packages/functional-tests/pages/index.ts @@ -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'; @@ -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), diff --git a/packages/functional-tests/pages/settings/totp.ts b/packages/functional-tests/pages/settings/totp.ts index d420fb415be..9185a0010f5 100644 --- a/packages/functional-tests/pages/settings/totp.ts +++ b/packages/functional-tests/pages/settings/totp.ts @@ -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 diff --git a/packages/functional-tests/tests/resetPassword/resetPassword2FA.spec.ts b/packages/functional-tests/tests/resetPassword/resetPassword2FA.spec.ts index b3c2fdab787..b95634ebc01 100644 --- a/packages/functional-tests/tests/resetPassword/resetPassword2FA.spec.ts +++ b/packages/functional-tests/tests/resetPassword/resetPassword2FA.spec.ts @@ -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 + // 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, @@ -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'); + }); }); diff --git a/packages/fxa-auth-client/lib/client.ts b/packages/fxa-auth-client/lib/client.ts index 6dbafac6c96..eb75699c9a0 100644 --- a/packages/fxa-auth-client/lib/client.ts +++ b/packages/fxa-auth-client/lib/client.ts @@ -14,7 +14,7 @@ enum ERRORS { INCORRECT_EMAIL_CASE = 120, } -enum tokenType { +export enum tokenType { sessionToken = 'sessionToken', passwordForgotToken = 'passwordForgotToken', keyFetchToken = 'keyFetchToken', @@ -22,6 +22,12 @@ enum tokenType { passwordChangeToken = 'passwordChangeToken', } +export type SessionTokenTypes = tokenType.sessionToken; + +export type ResetPasswordTokenTypes = + | tokenType.passwordForgotToken + | tokenType.accountResetToken; + export enum AUTH_PROVIDER { GOOGLE = 'google', APPLE = 'apple', @@ -864,7 +870,8 @@ export default class AuthClient { async passwordForgotVerifyCode( code: string, - passwordForgotToken: hexstring, + token: hexstring, + kind: ResetPasswordTokenTypes, options: { accountResetWithRecoveryKey?: boolean; includeRecoveryKeyPrompt?: boolean; @@ -878,8 +885,8 @@ export default class AuthClient { return this.hawkRequest( 'POST', '/password/forgot/verify_code', - passwordForgotToken, - tokenType.passwordForgotToken, + token, + kind, payload, headers ); @@ -2454,28 +2461,16 @@ export default class AuthClient { } async checkTotpTokenExists( - sessionToken: hexstring, - headers?: Headers - ): Promise<{ exists: boolean; verified: boolean }> { - return this.sessionGet('/totp/exists', sessionToken, headers); - } - - async checkTotpTokenExistsWithPasswordForgotToken( token: hexstring, + kind: SessionTokenTypes | ResetPasswordTokenTypes = tokenType.sessionToken, headers?: Headers ): Promise<{ exists: boolean; verified: boolean }> { - return this.hawkRequest( - 'GET', - '/totp/exists', - token, - tokenType.passwordForgotToken, - null, - headers - ); + return this.hawkRequest('GET', '/totp/exists', token, kind, null, headers); } - async checkTotpTokenCodeWithPasswordForgotToken( + async checkTotpTokenCode( token: hexstring, + kind: ResetPasswordTokenTypes, code: string, headers?: Headers ): Promise<{ success: boolean }> { @@ -2483,14 +2478,15 @@ export default class AuthClient { 'POST', '/totp/verify', token, - tokenType.passwordForgotToken, + kind, { code }, headers ); } - async consumeRecoveryCodeWithPasswordForgotToken( + async totpVerifyRecoveryCode( token: hexstring, + kind: ResetPasswordTokenTypes, code: string, headers?: Headers ): Promise<{ success: boolean }> { @@ -2498,7 +2494,7 @@ export default class AuthClient { 'POST', '/totp/verify/recoveryCode', token, - tokenType.passwordForgotToken, + kind, { code }, headers ); @@ -2628,27 +2624,44 @@ export default class AuthClient { return this.jwtPut('/mfa/recoveryCodes', jwt, { recoveryCodes }, headers); } + /** + * Gets whether or not recovery codes exist on the account + * @param token A valid auth token + * @param type The token's type + * @param headers + * @returns + */ async getRecoveryCodesExist( - sessionToken: hexstring, - headers?: Headers - ): Promise<{ hasBackupCodes?: boolean; count?: number }> { - return this.sessionGet('/recoveryCodes/exists', sessionToken, headers); - } - - async getRecoveryCodesExistWithPasswordForgotToken( - passwordForgotToken: hexstring, + token: hexstring, + kind: SessionTokenTypes | ResetPasswordTokenTypes = tokenType.sessionToken, headers?: Headers ): Promise<{ hasBackupCodes?: boolean; count?: number }> { return this.hawkRequest( 'GET', '/recoveryCodes/exists', - passwordForgotToken, - tokenType.passwordForgotToken, + token, + kind, null, headers ); } + async consumeTotpRecoveryCode( + token: hexstring, + kind: ResetPasswordTokenTypes, + code: string, + headers?: Headers + ): Promise<{ success: boolean }> { + return this.hawkRequest( + 'POST', + '/totp/verify/recoveryCode', + token, + kind, + { code }, + headers + ); + } + async consumeRecoveryCode( sessionToken: hexstring, code: string, @@ -2842,14 +2855,17 @@ export default class AuthClient { } async recoveryKeyExists( - sessionToken: hexstring | undefined, + token: hexstring, email: string | undefined, + kind: SessionTokenTypes | ResetPasswordTokenTypes = tokenType.sessionToken, headers?: Headers ) { - if (sessionToken) { - return this.sessionPost( + if (token) { + return this.hawkRequest( + 'POST', '/recoveryKey/exists', - sessionToken, + token, + kind, { email }, headers ); @@ -3201,18 +3217,20 @@ export default class AuthClient { /** * Sends a code to the users phone during password reset. * - * @param passwordForgotToken - * @param headers + * @param token A valid token + * @param kind The token's type + * @param headers Optional request headers */ async recoveryPhonePasswordResetSendCode( - passwordForgotToken: string, + token: string, + kind: ResetPasswordTokenTypes, headers?: Headers ) { return this.hawkRequest( 'POST', '/recovery_phone/reset_password/send_code', - passwordForgotToken, - tokenType.passwordForgotToken, + token, + kind, {}, headers ); @@ -3221,21 +3239,22 @@ export default class AuthClient { /** * Confirms the code sent to the recovery phone during a password reset. * - * - * @param passwordForgotToken + * @param token A valid auth token + * @param type The token's type * @param code The otp code sent to the user's phone * @param headers */ async recoveryPhoneResetPasswordConfirm( - passwordForgotToken: string, + token: string, + kind: ResetPasswordTokenTypes, code: string, headers?: Headers ) { return this.hawkRequest( 'POST', '/recovery_phone/reset_password/confirm', - passwordForgotToken, - tokenType.passwordForgotToken, + token, + kind, { code, }, @@ -3266,16 +3285,14 @@ export default class AuthClient { /** * Gets status of the recovery phone on the users account. - * @param sessionToken The user's current session token + * @param token A valid auth token + * @param kind The token's type * @param headers * @returns { exists:boolean, phoneNumber: string } */ - async recoveryPhoneGet(sessionToken: string, headers?: Headers) { - return this.sessionGet('/recovery_phone', sessionToken, headers); - } - - async recoveryPhoneGetWithPasswordForgotToken( - passwordForgotToken: string, + async recoveryPhoneGet( + token: string, + kind: SessionTokenTypes | ResetPasswordTokenTypes = tokenType.sessionToken, headers?: Headers ): Promise<{ exists: boolean; @@ -3285,8 +3302,8 @@ export default class AuthClient { return this.hawkRequest( 'GET', '/recovery_phone', - passwordForgotToken, - tokenType.passwordForgotToken, + token, + kind, null, headers ); @@ -3438,4 +3455,4 @@ export default class AuthClient { throw error; } } -} \ No newline at end of file +} diff --git a/packages/fxa-auth-server/lib/db.ts b/packages/fxa-auth-server/lib/db.ts index a31e3557aef..a8ff1678eac 100644 --- a/packages/fxa-auth-server/lib/db.ts +++ b/packages/fxa-auth-server/lib/db.ts @@ -1001,15 +1001,36 @@ export const createDB = ( ); } + async verifyAccountResetTokenWithMethod( + tokenId: string, + verificationMethod: VerificationMethod | number + ) { + log.trace('DB.verifyAccountResetTokenWithMethod', { + tokenId, + verificationMethod, + }); + + this.metrics?.increment('db.verify.accountResetTokensWithMethod', { + method: verificationMethodToString(verificationMethod), + }); + + await RawAccountResetToken.updateVerificationMethod( + tokenId, + verificationMethod + ); + } + async forgotPasswordVerified(passwordForgotToken: { id: string; uid: string; + email: string; verificationMethod: VerificationMethod | number; }) { - const { id, uid, verificationMethod } = passwordForgotToken; + const { id, uid, email, verificationMethod } = passwordForgotToken; log.trace('DB.forgotPasswordVerified', { uid }); const accountResetToken = await AccountResetToken.create({ uid, + email, verificationMethod, }); await RawPasswordForgotToken.verify(id, accountResetToken); diff --git a/packages/fxa-auth-server/lib/routes/password.ts b/packages/fxa-auth-server/lib/routes/password.ts index f6c36f3a768..35140e3e3db 100644 --- a/packages/fxa-auth-server/lib/routes/password.ts +++ b/packages/fxa-auth-server/lib/routes/password.ts @@ -1187,7 +1187,10 @@ module.exports = function ( options: { ...PASSWORD_DOCS.PASSWORD_FORGOT_VERIFY_CODE_POST, auth: { - strategy: 'passwordForgotToken', + strategies: [ + 'multiStrategyPasswordForgotToken', + 'multiStrategyAccountResetToken', + ], payload: 'required', }, validate: { diff --git a/packages/fxa-auth-server/lib/routes/recovery-codes.js b/packages/fxa-auth-server/lib/routes/recovery-codes.js index 9cf433af40c..b5d086e44c5 100644 --- a/packages/fxa-auth-server/lib/routes/recovery-codes.js +++ b/packages/fxa-auth-server/lib/routes/recovery-codes.js @@ -274,6 +274,7 @@ module.exports = (log, db, config, customs, mailer, glean, statsd) => { strategies: [ 'multiStrategySessionToken', 'multiStrategyPasswordForgotToken', + 'multiStrategyAccountResetToken', ], payload: 'required', }, diff --git a/packages/fxa-auth-server/lib/routes/recovery-key.js b/packages/fxa-auth-server/lib/routes/recovery-key.js index f902f74f73a..26387f4d0d1 100644 --- a/packages/fxa-auth-server/lib/routes/recovery-key.js +++ b/packages/fxa-auth-server/lib/routes/recovery-key.js @@ -368,6 +368,7 @@ module.exports = ( strategies: [ 'multiStrategySessionToken', 'multiStrategyPasswordForgotToken', + 'multiStrategyAccountResetToken', ], }, validate: { diff --git a/packages/fxa-auth-server/lib/routes/recovery-phone.ts b/packages/fxa-auth-server/lib/routes/recovery-phone.ts index 5b020d39c93..9181ad3b7d8 100644 --- a/packages/fxa-auth-server/lib/routes/recovery-phone.ts +++ b/packages/fxa-auth-server/lib/routes/recovery-phone.ts @@ -36,7 +36,6 @@ import RECOVERY_PHONE_DOCS from '../../docs/swagger/recovery-phone-api'; import { Container } from 'typedi'; import { ConfigType } from '../../config'; -import { PasswordForgotToken } from 'fxa-shared/db/models/auth'; import { OtpUtils } from './utils/otp'; import { FxaMailer } from '../senders/fxa-mailer'; import { FxaMailerFormat } from '../senders/fxa-mailer-format'; @@ -205,7 +204,7 @@ class RecoveryPhoneHandler { } async sendResetPasswordCode(request: AuthRequest) { - const { uid, email } = request.auth.credentials as PasswordForgotToken; + const { uid, email } = request.auth.credentials as AuthCredential; if (!email) { throw AppError.invalidToken(); @@ -731,11 +730,7 @@ class RecoveryPhoneHandler { } async confirmResetPasswordCode(request: AuthRequest) { - const { id, uid, email } = request.auth.credentials as unknown as { - id: string; - uid: string; - email: string; - }; + const { id, uid, email } = request.auth.credentials as AuthCredential; const { code } = request.payload as unknown as { code: string; @@ -769,7 +764,13 @@ class RecoveryPhoneHandler { } if (success) { - await this.db.verifyPasswordForgotTokenWithMethod(id, 'totp-2fa'); + // Mark auth token as verified + const { strategy } = request.auth; + if (strategy === 'multiStrategyPasswordForgotToken') { + await this.db.verifyPasswordForgotTokenWithMethod(id, 'totp-2fa'); + } else if (strategy === 'multiStrategyAccountResetToken') { + await this.db.verifyAccountResetTokenWithMethod(id, 'totp-2fa'); + } await this.glean.resetPassword.recoveryPhoneCodeComplete(request); @@ -1257,7 +1258,10 @@ export const recoveryPhoneRoutes = ( ...RECOVERY_PHONE_DOCS.RECOVERY_PHONE_RESET_PASSWORD_SEND_CODE_POST, pre: [{ method: featureEnabledCheck }], auth: { - strategy: 'passwordForgotToken', + strategies: [ + 'multiStrategyPasswordForgotToken', + 'multiStrategyAccountResetToken', + ], }, }, handler: function (request: AuthRequest) { @@ -1272,7 +1276,10 @@ export const recoveryPhoneRoutes = ( ...RECOVERY_PHONE_DOCS.RECOVERY_PHONE_RESET_PASSWORD_CONFIRM_POST, pre: [{ method: featureEnabledCheck }], auth: { - strategy: 'passwordForgotToken', + strategies: [ + 'multiStrategyPasswordForgotToken', + 'multiStrategyAccountResetToken', + ], }, }, handler: function (request: AuthRequest) { @@ -1324,6 +1331,7 @@ export const recoveryPhoneRoutes = ( strategies: [ 'multiStrategySessionToken', 'multiStrategyPasswordForgotToken', + 'multiStrategyAccountResetToken', ], }, }, diff --git a/packages/fxa-auth-server/lib/routes/totp.js b/packages/fxa-auth-server/lib/routes/totp.js index efb9caa43ca..a241b9017bd 100644 --- a/packages/fxa-auth-server/lib/routes/totp.js +++ b/packages/fxa-auth-server/lib/routes/totp.js @@ -820,6 +820,7 @@ module.exports = ( strategies: [ 'multiStrategySessionToken', 'multiStrategyPasswordForgotToken', + 'multiStrategyAccountResetToken', ], }, response: { @@ -858,7 +859,10 @@ module.exports = ( options: { ...TOTP_DOCS.TOTP_VERIFY_POST, auth: { - strategy: 'passwordForgotToken', + strategies: [ + 'multiStrategyPasswordForgotToken', + 'multiStrategyAccountResetToken', + ], payload: 'required', }, validate: { @@ -881,17 +885,17 @@ module.exports = ( log.begin('totp.verify', request); const code = request.payload.code; - const passwordForgotToken = request.auth.credentials; + const authToken = request.auth.credentials; await customs.checkAuthenticated( request, - passwordForgotToken.uid, - passwordForgotToken.email, + authToken.uid, + authToken.email, 'verifyTotpCode' ); try { - const totpRecord = await db.totpToken(passwordForgotToken.uid); + const totpRecord = await db.totpToken(authToken.uid); const sharedSecret = totpRecord.sharedSecret; // Default options for TOTP @@ -910,13 +914,21 @@ module.exports = ( if (isValidCode) { glean.resetPassword.twoFactorSuccess(request, { - uid: passwordForgotToken.uid, + uid: authToken.uid, }); - await db.verifyPasswordForgotTokenWithMethod( - passwordForgotToken.id, - 'totp-2fa' - ); + const { strategy } = request.auth; + if (strategy === 'multiStrategyPasswordForgotToken') + await db.verifyPasswordForgotTokenWithMethod( + authToken.id, + 'totp-2fa' + ); + else if (strategy === 'multiStrategyAccountResetToken') { + await db.verifyAccountResetTokenWithMethod( + authToken.id, + 'totp-2fa' + ); + } } return { @@ -938,7 +950,10 @@ module.exports = ( options: { ...TOTP_DOCS.TOTP_VERIFY_RECOVERY_CODE_POST, auth: { - strategy: 'passwordForgotToken', + strategies: [ + 'multiStrategyPasswordForgotToken', + 'multiStrategyAccountResetToken', + ], }, validate: { payload: isA.object({ @@ -961,7 +976,7 @@ module.exports = ( const code = request.payload.code; const { uid, email } = request.auth.credentials; - const passwordForgotToken = request.auth.credentials; + const authToken = request.auth.credentials; await customs.checkAuthenticated( request, @@ -1036,10 +1051,19 @@ module.exports = ( uid, }); - await db.verifyPasswordForgotTokenWithMethod( - passwordForgotToken.id, - 'recovery-code' - ); + // Mark auth token as verified + const { strategy } = request.auth; + if (strategy === 'multiStrategyPasswordForgotToken') { + await db.verifyPasswordForgotTokenWithMethod( + authToken.id, + 'recovery-code' + ); + } else if (strategy === 'multiStrategyAccountResetToken') { + await db.verifyAccountResetTokenWithMethod( + authToken.id, + 'recovery-code' + ); + } return { remaining, diff --git a/packages/fxa-auth-server/lib/server.js b/packages/fxa-auth-server/lib/server.js index f9eb3db0ce3..1d2d40e025d 100644 --- a/packages/fxa-auth-server/lib/server.js +++ b/packages/fxa-auth-server/lib/server.js @@ -436,6 +436,12 @@ async function create(log, error, config, routes, db, statsd, glean, customs) { throwOnFailure: false, }) ); + server.auth.scheme( + 'multi-strategy-fxa-hawk-accountReset-token', + hawkFxAToken.strategy(makeCredentialFn(db.accountResetToken.bind(db)), { + throwOnFailure: false, + }) + ); server.auth.strategy('sessionToken', 'fxa-hawk-session-token'); server.auth.strategy('keyFetchToken', 'fxa-hawk-keyFetch-token'); @@ -457,6 +463,10 @@ async function create(log, error, config, routes, db, statsd, glean, customs) { 'multiStrategyPasswordForgotToken', 'multi-strategy-fxa-hawk-passwordForgot-token' ); + server.auth.strategy( + 'multiStrategyAccountResetToken', + 'multi-strategy-fxa-hawk-accountReset-token' + ); server.auth.scheme(authOauth.AUTH_SCHEME, authOauth.strategy); server.auth.strategy('oauthToken', authOauth.AUTH_SCHEME, config.oauth); diff --git a/packages/fxa-auth-server/lib/tokens/account_reset_token.js b/packages/fxa-auth-server/lib/tokens/account_reset_token.js index 434cc54cba9..62e72de8d21 100644 --- a/packages/fxa-auth-server/lib/tokens/account_reset_token.js +++ b/packages/fxa-auth-server/lib/tokens/account_reset_token.js @@ -10,6 +10,7 @@ module.exports = function (log, Token, lifetime) { function AccountResetToken(keys, details) { details.lifetime = lifetime; this.verificationMethod = details.verificationMethod || null; + this.email = details.email || null; Token.call(this, keys, details); } inherits(AccountResetToken, Token); diff --git a/packages/fxa-auth-server/lib/types.ts b/packages/fxa-auth-server/lib/types.ts index 6d32a6a4c07..0201eae5ed5 100644 --- a/packages/fxa-auth-server/lib/types.ts +++ b/packages/fxa-auth-server/lib/types.ts @@ -5,7 +5,10 @@ import { AuthCredentials, Request, RequestApplicationState } from '@hapi/hapi'; import { Token } from 'typedi'; import { Logger } from 'mozlog'; import { ConfigType } from '../config'; -import { PasswordForgotToken } from 'fxa-shared/db/models/auth'; +import { + AccountResetToken, + PasswordForgotToken, +} from 'fxa-shared/db/models/auth'; /** * Auth-Server specific interfaces to use objects. @@ -47,6 +50,7 @@ export interface AuthApp extends RequestApplicationState { } export interface AuthCredential { + id: string; uid: string; email: string | null; } @@ -85,8 +89,10 @@ export interface AuthRequest extends Request { // the auth-oauth scheme credentials: | AuthCredentialsWithScope + | AuthCredential | SessionTokenAuthCredential - | PasswordForgotToken; + | PasswordForgotToken + | AccountResetToken; }; // eslint-disable-next-line no-use-before-define log: AuthLogger; diff --git a/packages/fxa-auth-server/test/local/routes/recovery-phone.js b/packages/fxa-auth-server/test/local/routes/recovery-phone.js index 50294664712..6181ce6868b 100644 --- a/packages/fxa-auth-server/test/local/routes/recovery-phone.js +++ b/packages/fxa-auth-server/test/local/routes/recovery-phone.js @@ -331,13 +331,20 @@ describe('/recovery_phone', () => { ); }); - it('requires a passwordForgotToken', () => { + it('requires a passwordForgotToken or account reset token', () => { const route = getRoute( routes, '/recovery_phone/reset_password/send_code', 'POST' ); - assert.equal(route.options.auth.strategy, 'passwordForgotToken'); + assert.include( + route.options.auth.strategies, + 'multiStrategyPasswordForgotToken' + ); + assert.include( + route.options.auth.strategies, + 'multiStrategyAccountResetToken' + ); }); }); diff --git a/packages/fxa-auth-server/test/mocks.js b/packages/fxa-auth-server/test/mocks.js index db841e67647..5e297f371ba 100644 --- a/packages/fxa-auth-server/test/mocks.js +++ b/packages/fxa-auth-server/test/mocks.js @@ -128,6 +128,7 @@ const DB_METHOD_NAMES = [ 'deleteLinkedAccount', 'accountExists', 'verifyPasswordForgotTokenWithMethod', + 'verifyAccountResetTokenWithMethod', ]; const LOG_METHOD_NAMES = [ diff --git a/packages/fxa-settings/src/components/ResetPasswordWarning/mocks.tsx b/packages/fxa-settings/src/components/ResetPasswordWarning/mocks.tsx index ee2eb2014fe..be43de875d1 100644 --- a/packages/fxa-settings/src/components/ResetPasswordWarning/mocks.tsx +++ b/packages/fxa-settings/src/components/ResetPasswordWarning/mocks.tsx @@ -2,6 +2,7 @@ * 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 { ResetPasswordTokenTypes, tokenType } from 'fxa-auth-client/browser'; import { MOCK_EMAIL, MOCK_HEXSTRING_32, MOCK_UID } from '../../pages/mocks'; export const createMockLocationState = (recoveryKeyExists?: boolean) => { @@ -9,6 +10,7 @@ export const createMockLocationState = (recoveryKeyExists?: boolean) => { code: MOCK_HEXSTRING_32, email: MOCK_EMAIL, token: MOCK_HEXSTRING_32, + kind: tokenType.passwordForgotToken as ResetPasswordTokenTypes, uid: MOCK_UID, recoveryKeyExists, }; diff --git a/packages/fxa-settings/src/models/Account.ts b/packages/fxa-settings/src/models/Account.ts index 9d64068b77f..7778b1d2aa4 100644 --- a/packages/fxa-settings/src/models/Account.ts +++ b/packages/fxa-settings/src/models/Account.ts @@ -12,6 +12,7 @@ import AuthClient, { getCredentialsV2, getKeysV2, AttachedClient as RawAttachedClient, + ResetPasswordTokenTypes, } from 'fxa-auth-client/browser'; import { MetricsContext } from '@fxa/shared/glean'; import { @@ -87,7 +88,6 @@ interface RecoveryPhoneAvailableResponse { available: boolean; } - /** Response shape for auth-client email entries */ interface RawEmail { email: string; @@ -263,7 +263,9 @@ export function getNextAvatar( // "default" account model with a set of undefined properties. But there is an // interface that calls for an isDefault impl so here it is. export const isDefault = (account: object) => - !Object.keys(DEFAULTS).some((x) => (account as Record)[x] !== undefined); + !Object.keys(DEFAULTS).some( + (x) => (account as Record)[x] !== undefined + ); export class Account implements AccountData { private readonly authClient: AuthClient; @@ -303,7 +305,12 @@ export class Account implements AccountData { linkedAccounts: [], totp: { exists: false, verified: false }, backupCodes: { hasBackupCodes: false, count: 0 }, - recoveryPhone: { exists: false, phoneNumber: null, nationalFormat: null, available: false }, + recoveryPhone: { + exists: false, + phoneNumber: null, + nationalFormat: null, + available: false, + }, subscriptions: [], securityEvents: [], } as AccountData; @@ -317,10 +324,22 @@ export class Account implements AccountData { accountCreated: accountData.accountCreated || 0, passwordCreated: accountData.passwordCreated || 0, recoveryKey: accountData.recoveryKey || { exists: false }, - primaryEmail: accountData.primaryEmail || { email: accountData.email || '', isPrimary: true, verified: accountData.verified }, + primaryEmail: accountData.primaryEmail || { + email: accountData.email || '', + isPrimary: true, + verified: accountData.verified, + }, totp: accountData.totp || { exists: false, verified: false }, - backupCodes: accountData.backupCodes || { hasBackupCodes: false, count: 0 }, - recoveryPhone: accountData.recoveryPhone || { exists: false, phoneNumber: null, nationalFormat: null, available: false }, + backupCodes: accountData.backupCodes || { + hasBackupCodes: false, + count: 0, + }, + recoveryPhone: accountData.recoveryPhone || { + exists: false, + phoneNumber: null, + nationalFormat: null, + available: false, + }, } as AccountData; } @@ -443,7 +462,10 @@ export class Account implements AccountData { }); break; case 'recovery': - const recoveryKey = await this.authClient.recoveryKeyExists(token, undefined); + const recoveryKey = await this.authClient.recoveryKeyExists( + token, + undefined + ); updateExtendedAccountState({ recoveryKey: { exists: recoveryKey.exists ?? false, @@ -454,20 +476,32 @@ export class Account implements AccountData { case 'totp': const totp = await this.authClient.checkTotpTokenExists(token); updateExtendedAccountState({ - totp: { exists: totp.exists ?? false, verified: totp.verified ?? false }, + totp: { + exists: totp.exists ?? false, + verified: totp.verified ?? false, + }, }); break; case 'backupCodes': const codes = await this.authClient.getRecoveryCodesExist(token); updateExtendedAccountState({ - backupCodes: { hasBackupCodes: codes.hasBackupCodes ?? false, count: codes.count ?? 0 }, + backupCodes: { + hasBackupCodes: codes.hasBackupCodes ?? false, + count: codes.count ?? 0, + }, }); break; case 'recoveryPhone': try { const [phoneResult, availableResult] = await Promise.all([ - this.authClient.recoveryPhoneGet(token).catch((): RecoveryPhoneGetResponse => ({ exists: false })), - this.authClient.recoveryPhoneAvailable(token).catch((): RecoveryPhoneAvailableResponse => ({ available: false })), + this.authClient + .recoveryPhoneGet(token) + .catch((): RecoveryPhoneGetResponse => ({ exists: false })), + this.authClient + .recoveryPhoneAvailable(token) + .catch( + (): RecoveryPhoneAvailableResponse => ({ available: false }) + ), ]); updateExtendedAccountState({ recoveryPhone: { @@ -479,7 +513,12 @@ export class Account implements AccountData { }); } catch { updateExtendedAccountState({ - recoveryPhone: { exists: false, phoneNumber: null, nationalFormat: null, available: false }, + recoveryPhone: { + exists: false, + phoneNumber: null, + nationalFormat: null, + available: false, + }, }); } break; @@ -505,50 +544,71 @@ export class Account implements AccountData { break; case 'account': default: - - const [accountData, clientsData, totpData, codesData, keyData, phoneData, phoneAvailable] = - await Promise.allSettled([ - this.authClient.account(token), - this.authClient.attachedClients(token), - this.authClient.checkTotpTokenExists(token), - this.authClient.getRecoveryCodesExist(token), - this.authClient.recoveryKeyExists(token, undefined), - this.authClient.recoveryPhoneGet(token), - this.authClient.recoveryPhoneAvailable(token), - ]); + const [ + accountData, + clientsData, + totpData, + codesData, + keyData, + phoneData, + phoneAvailable, + ] = await Promise.allSettled([ + this.authClient.account(token), + this.authClient.attachedClients(token), + this.authClient.checkTotpTokenExists(token), + this.authClient.getRecoveryCodesExist(token), + this.authClient.recoveryKeyExists(token, undefined), + this.authClient.recoveryPhoneGet(token), + this.authClient.recoveryPhoneAvailable(token), + ]); const updates: Partial = {}; if (accountData.status === 'fulfilled') { - updates.emails = ((accountData.value.emails || []) as RawEmail[]).map((e) => ({ + updates.emails = ( + (accountData.value.emails || []) as RawEmail[] + ).map((e) => ({ email: e.email, isPrimary: e.isPrimary, verified: e.verified, })); updates.accountCreated = accountData.value.createdAt || null; - updates.passwordCreated = accountData.value.passwordCreatedAt || null; + updates.passwordCreated = + accountData.value.passwordCreatedAt || null; } if (clientsData.status === 'fulfilled') { - updates.attachedClients = clientsData.value.map(mapAttachedClient); + updates.attachedClients = + clientsData.value.map(mapAttachedClient); } if (totpData.status === 'fulfilled') { - updates.totp = { exists: totpData.value.exists ?? false, verified: totpData.value.verified ?? false }; + updates.totp = { + exists: totpData.value.exists ?? false, + verified: totpData.value.verified ?? false, + }; } if (codesData.status === 'fulfilled') { - updates.backupCodes = { hasBackupCodes: codesData.value.hasBackupCodes ?? false, count: codesData.value.count ?? 0 }; + updates.backupCodes = { + hasBackupCodes: codesData.value.hasBackupCodes ?? false, + count: codesData.value.count ?? 0, + }; } if (keyData.status === 'fulfilled') { updates.recoveryKey = { exists: keyData.value.exists ?? false, - estimatedSyncDeviceCount: keyData.value.estimatedSyncDeviceCount, + estimatedSyncDeviceCount: + keyData.value.estimatedSyncDeviceCount, }; } - const isPhoneAvailable = phoneAvailable.status === 'fulfilled' ? (phoneAvailable.value as RecoveryPhoneAvailableResponse).available ?? false : false; + const isPhoneAvailable = + phoneAvailable.status === 'fulfilled' + ? ((phoneAvailable.value as RecoveryPhoneAvailableResponse) + .available ?? false) + : false; if (phoneData.status === 'fulfilled') { const phoneResult = phoneData.value as RecoveryPhoneGetResponse; updates.recoveryPhone = { @@ -558,7 +618,12 @@ export class Account implements AccountData { available: isPhoneAvailable, }; } else { - updates.recoveryPhone = { exists: false, phoneNumber: null, nationalFormat: null, available: isPhoneAvailable }; + updates.recoveryPhone = { + exists: false, + phoneNumber: null, + nationalFormat: null, + available: isPhoneAvailable, + }; } updateExtendedAccountState(updates); @@ -760,6 +825,7 @@ export class Account implements AccountData { */ async passwordForgotVerifyCode( token: string, + kind: ResetPasswordTokenTypes, code: string, accountResetWithRecoveryKey = false, includeRecoveryKeyPrompt = false @@ -771,7 +837,7 @@ export class Account implements AccountData { // to use it unsuccessfully, and then goes through a normal reset via the link back // to a normal reset if a user can't use their key. const { accountResetToken } = - await this.authClient.passwordForgotVerifyCode(code, token, { + await this.authClient.passwordForgotVerifyCode(code, token, kind, { accountResetWithRecoveryKey, includeRecoveryKeyPrompt, }); @@ -785,9 +851,17 @@ export class Account implements AccountData { * @param token passwordForgotToken * @param code code */ - async verifyPasswordForgotToken(token: string, code: string) { + async verifyPasswordForgotToken( + token: string, + type: ResetPasswordTokenTypes, + code: string + ) { try { - const result = await this.authClient.passwordForgotVerifyCode(code, token); + const result = await this.authClient.passwordForgotVerifyCode( + code, + token, + type + ); return { accountResetToken: result.accountResetToken }; } catch (err: unknown) { const errno = getErrno(err); @@ -812,6 +886,7 @@ export class Account implements AccountData { async completeResetPassword( v2: boolean, token: string, + kind: ResetPasswordTokenTypes, code: string, email: string, newPassword: string, @@ -834,6 +909,7 @@ export class Account implements AccountData { resetToken || (await this.passwordForgotVerifyCode( token, + kind, code, false, includeRecoveryKeyPrompt @@ -887,7 +963,6 @@ export class Account implements AccountData { const token = sessionToken(); if (!token) throw AuthUiErrors.INVALID_TOKEN; - const { access_token } = await this.withLoadingStatus( this.authClient.createOAuthToken(token, config.oauth.clientId, { scope: 'profile:write', @@ -895,7 +970,6 @@ export class Account implements AccountData { }) ); - const response = await this.withLoadingStatus( fetch(`${config.servers.profile.url}/v1/display_name`, { method: 'POST', @@ -921,7 +995,9 @@ export class Account implements AccountData { ); updateExtendedAccountState({ displayName, - avatar: { ...currentAvatar, ...newAvatar } as AccountAvatar & { isDefault?: boolean }, + avatar: { ...currentAvatar, ...newAvatar } as AccountAvatar & { + isDefault?: boolean; + }, }); const legacyLocalStorageAccount = currentAccount()!; @@ -1135,7 +1211,9 @@ export class Account implements AccountData { this.displayName ); updateExtendedAccountState({ - avatar: { ...currentAvatar, ...newAvatar } as AccountAvatar & { isDefault?: boolean }, + avatar: { ...currentAvatar, ...newAvatar } as AccountAvatar & { + isDefault?: boolean; + }, }); updateBasicAccountData({ email }); @@ -1161,7 +1239,9 @@ export class Account implements AccountData { this.displayName ); updateExtendedAccountState({ - avatar: { ...currentAvatar, ...newAvatar } as AccountAvatar & { isDefault?: boolean }, + avatar: { ...currentAvatar, ...newAvatar } as AccountAvatar & { + isDefault?: boolean; + }, }); updateBasicAccountData({ email }); @@ -1321,9 +1401,7 @@ export class Account implements AccountData { const token = sessionToken(); if (!token) throw AuthUiErrors.INVALID_TOKEN; - await this.withLoadingStatus( - this.authClient.metricsOpt(token, state) - ); + await this.withLoadingStatus(this.authClient.metricsOpt(token, state)); updateBasicAccountData({ metricsEnabled: state === 'in' }); diff --git a/packages/fxa-settings/src/pages/ResetPassword/AccountRecoveryConfirmKey/container.tsx b/packages/fxa-settings/src/pages/ResetPassword/AccountRecoveryConfirmKey/container.tsx index 93c76ed2a2e..6200fc9b846 100644 --- a/packages/fxa-settings/src/pages/ResetPassword/AccountRecoveryConfirmKey/container.tsx +++ b/packages/fxa-settings/src/pages/ResetPassword/AccountRecoveryConfirmKey/container.tsx @@ -38,6 +38,7 @@ const AccountRecoveryConfirmKeyContainer = (_: RouteComponentProps) => { recoveryKeyExists, recoveryKeyHint, token, + kind, uid, totpExists, } = (location.state as AccountRecoveryConfirmKeyLocationState) || {}; @@ -92,6 +93,7 @@ const AccountRecoveryConfirmKeyContainer = (_: RouteComponentProps) => { // TODO in FXA-9672: do not use Account model in reset password pages fetchedAccountResetToken = await account.passwordForgotVerifyCode( token, + kind, code, true ); @@ -122,6 +124,7 @@ const AccountRecoveryConfirmKeyContainer = (_: RouteComponentProps) => { setErrorMessage, setIsSubmitDisabled, token, + kind, verifyRecoveryKey, uid, totpExists, diff --git a/packages/fxa-settings/src/pages/ResetPassword/AccountRecoveryConfirmKey/index.tsx b/packages/fxa-settings/src/pages/ResetPassword/AccountRecoveryConfirmKey/index.tsx index 8530195e70d..9385c1ca046 100644 --- a/packages/fxa-settings/src/pages/ResetPassword/AccountRecoveryConfirmKey/index.tsx +++ b/packages/fxa-settings/src/pages/ResetPassword/AccountRecoveryConfirmKey/index.tsx @@ -23,6 +23,7 @@ import { RecoveryKeyImage } from '../../../components/images'; import { Constants } from '../../../lib/constants'; import Banner from '../../../components/Banner'; import { HeadingPrimary } from '../../../components/HeadingPrimary'; +import { tokenType } from 'fxa-auth-client/browser'; // TODO in FXA-7894 use sensitive data client to pass sensitive data // Depends on FXA-7400 @@ -43,6 +44,7 @@ const AccountRecoveryConfirmKey = ({ setIsSubmitDisabled, verifyRecoveryKey, token, + kind, uid, totpExists, }: AccountRecoveryConfirmKeyProps) => { @@ -220,7 +222,12 @@ const AccountRecoveryConfirmKey = ({ estimatedSyncDeviceCount, recoveryKeyExists, recoveryKeyHint, - token, + // Once we exchange the passwordForgotToken for an accountResetToken, the + // passwordForgotToken becomes invalid. Therefore, for this point forward + // we will pass the accountReset token in the token field, and this should be + // used for authentication of web requests. + token: !!accountResetToken ? accountResetToken : token, + kind: !!accountResetToken ? tokenType.accountResetToken : kind, uid, }} onClick={() => GleanMetrics.passwordReset.recoveryKeyCannotFind()} @@ -240,7 +247,12 @@ const AccountRecoveryConfirmKey = ({ estimatedSyncDeviceCount, recoveryKeyExists, recoveryKeyHint, - token, + // Once we exchange the passwordForgotToken for an accountResetToken, the + // passwordForgotToken becomes invalid. Therefore, for this point forward + // we will pass the accountReset token in the token field, and this should be + // used for authentication of web requests. + token: !!accountResetToken ? accountResetToken : token, + kind: !!accountResetToken ? tokenType.accountResetToken : kind, uid, }} onClick={() => GleanMetrics.passwordReset.recoveryKeyCannotFind()} diff --git a/packages/fxa-settings/src/pages/ResetPassword/AccountRecoveryConfirmKey/interfaces.ts b/packages/fxa-settings/src/pages/ResetPassword/AccountRecoveryConfirmKey/interfaces.ts index 9a9be521d3c..de66ecbf54a 100644 --- a/packages/fxa-settings/src/pages/ResetPassword/AccountRecoveryConfirmKey/interfaces.ts +++ b/packages/fxa-settings/src/pages/ResetPassword/AccountRecoveryConfirmKey/interfaces.ts @@ -2,6 +2,8 @@ * 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 { tokenType } from 'fxa-auth-client/browser'; + export interface AccountRecoveryConfirmKeyFormData { recoveryKey: string; } @@ -11,6 +13,7 @@ export interface AccountRecoveryConfirmKeyLocationState { email: string; estimatedSyncDeviceCount: number; token: string; + kind: tokenType.passwordForgotToken; uid: string; accountResetToken?: string; emailToHashWith?: string; diff --git a/packages/fxa-settings/src/pages/ResetPassword/AccountRecoveryConfirmKey/mocks.tsx b/packages/fxa-settings/src/pages/ResetPassword/AccountRecoveryConfirmKey/mocks.tsx index cc46ed00088..0abfa1494d2 100644 --- a/packages/fxa-settings/src/pages/ResetPassword/AccountRecoveryConfirmKey/mocks.tsx +++ b/packages/fxa-settings/src/pages/ResetPassword/AccountRecoveryConfirmKey/mocks.tsx @@ -3,7 +3,12 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import React, { useState } from 'react'; -import { MOCK_EMAIL, MOCK_HEXSTRING_32, MOCK_UID } from '../../mocks'; +import { + MOCK_EMAIL, + MOCK_HEXSTRING_32, + MOCK_PASSWORD_FORGOT_TOKEN_KIND, + MOCK_UID, +} from '../../mocks'; import AccountRecoveryConfirmKey from '.'; import { LocationProvider } from '@reach/router'; import { AccountRecoveryConfirmKeyProps } from './interfaces'; @@ -20,6 +25,7 @@ export const Subject = ({ const email = MOCK_EMAIL; const estimatedSyncDeviceCount = 2; const token = MOCK_HEXSTRING_32; + const kind = MOCK_PASSWORD_FORGOT_TOKEN_KIND; const uid = MOCK_UID; const mockVerifyRecoveryKey = success ? () => Promise.resolve() @@ -42,6 +48,7 @@ export const Subject = ({ setErrorMessage, setIsSubmitDisabled, token, + kind, uid, }} /> diff --git a/packages/fxa-settings/src/pages/ResetPassword/CompleteResetPassword/container.tsx b/packages/fxa-settings/src/pages/ResetPassword/CompleteResetPassword/container.tsx index 26da15d87d3..de3841b740d 100644 --- a/packages/fxa-settings/src/pages/ResetPassword/CompleteResetPassword/container.tsx +++ b/packages/fxa-settings/src/pages/ResetPassword/CompleteResetPassword/container.tsx @@ -33,6 +33,7 @@ import { LocationState } from '../../Signin/interfaces'; import { useFinishOAuthFlowHandler } from '../../../lib/oauth/hooks'; import OAuthDataError from '../../../components/OAuthDataError'; import { SensitiveData } from '../../../lib/sensitive-data-client'; +import { tokenType } from 'fxa-auth-client/browser'; // This component is used for both /complete_reset_password and /account_recovery_reset_password routes // for easier maintenance @@ -69,6 +70,7 @@ const CompleteResetPasswordContainer = ({ code, email, token, + kind, accountResetToken, emailToHashWith, recoveryKeyId, @@ -189,28 +191,6 @@ const CompleteResetPasswordContainer = ({ return accountResetData; }; - const resetPasswordWithoutRecoveryKey = async ( - code: string, - emailToUse: string, - newPassword: string, - token: string, - includeRecoveryKeyPrompt: boolean - ) => { - // TODO in FXA-9672: do not use Account model in reset password pages - const accountResetData: AccountResetData = - await account.completeResetPassword( - keyStretchExperiment.queryParamModel.isV2(config), - token, - code, - emailToUse, - newPassword, - undefined, - undefined, - includeRecoveryKeyPrompt - ); - return accountResetData; - }; - const notifyClientOfSignin = async (accountResetData: AccountResetData) => { // Users will not be verified if they have 2FA. If this is the case, users are // taken back to `/signin`, where they can sign in with 2FA and login to Sync. @@ -326,13 +306,19 @@ const CompleteResetPasswordContainer = ({ const reason = recoveryKeyExists ? 'with key' : 'without key'; GleanMetrics.passwordReset.createNewSubmit({ event: { reason } }); const includeRecoveryKeyPrompt = !!isSyncUser; - const accountResetData = await resetPasswordWithoutRecoveryKey( - code, - emailToUse, - newPassword, - token, - includeRecoveryKeyPrompt - ); + const accountResetData: AccountResetData = + await account.completeResetPassword( + keyStretchExperiment.queryParamModel.isV2(config), + token, + kind, + code, + emailToUse, + newPassword, + kind === tokenType.accountResetToken ? token : accountResetToken, + undefined, + includeRecoveryKeyPrompt + ); + // TODO add frontend Glean event for successful reset? await notifyClientOfSignin(accountResetData); diff --git a/packages/fxa-settings/src/pages/ResetPassword/CompleteResetPassword/interfaces.ts b/packages/fxa-settings/src/pages/ResetPassword/CompleteResetPassword/interfaces.ts index a0013e35bf4..2a650387c03 100644 --- a/packages/fxa-settings/src/pages/ResetPassword/CompleteResetPassword/interfaces.ts +++ b/packages/fxa-settings/src/pages/ResetPassword/CompleteResetPassword/interfaces.ts @@ -2,6 +2,7 @@ * 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 { ResetPasswordTokenTypes } from 'fxa-auth-client/browser'; import { Integration, OAuthIntegration } from '../../../models'; export interface CompleteResetPasswordFormData { @@ -13,6 +14,7 @@ export type CompleteResetPasswordLocationState = { code: string; email: string; token: string; + kind: ResetPasswordTokenTypes; uid: string; accountResetToken?: string; emailToHashWith?: string; diff --git a/packages/fxa-settings/src/pages/ResetPassword/ConfirmBackupCodeResetPassword/container.test.tsx b/packages/fxa-settings/src/pages/ResetPassword/ConfirmBackupCodeResetPassword/container.test.tsx index f88a954d357..ca99dd6ac7b 100644 --- a/packages/fxa-settings/src/pages/ResetPassword/ConfirmBackupCodeResetPassword/container.test.tsx +++ b/packages/fxa-settings/src/pages/ResetPassword/ConfirmBackupCodeResetPassword/container.test.tsx @@ -6,14 +6,19 @@ import React from 'react'; import { LocationProvider } from '@reach/router'; import { act } from '@testing-library/react'; import { renderWithLocalizationProvider } from 'fxa-react/lib/test-utils/localizationProvider'; -import { MOCK_EMAIL, MOCK_PASSWORD_CHANGE_TOKEN, MOCK_UID } from '../../mocks'; +import { + MOCK_EMAIL, + MOCK_PASSWORD_CHANGE_TOKEN, + MOCK_PASSWORD_CHANGE_TOKEN_KIND, + MOCK_UID, +} from '../../mocks'; import ConfirmBackupCodeResetPasswordContainer from './container'; const mockConsume = jest.fn(); jest.mock('../../../models', () => ({ __esModule: true, useAuthClient: () => ({ - consumeRecoveryCodeWithPasswordForgotToken: mockConsume, + consumeTotpRecoveryCode: mockConsume, }), useFtlMsgResolver: () => ({ getMsg: (_id: string, fallback: string) => fallback, @@ -29,6 +34,7 @@ let mockLocationState = { code: 'ignored', email: MOCK_EMAIL, token: MOCK_PASSWORD_CHANGE_TOKEN, + kind: MOCK_PASSWORD_CHANGE_TOKEN_KIND, emailToHashWith: MOCK_EMAIL, recoveryKeyExists: false, estimatedSyncDeviceCount: 2, @@ -79,12 +85,14 @@ describe('ConfirmBackupCodeResetPasswordContainer', () => { expect(mockConsume).toHaveBeenCalledWith( MOCK_PASSWORD_CHANGE_TOKEN, + MOCK_PASSWORD_CHANGE_TOKEN_KIND, 'BACKUPCODE' ); expect(mockNavigate).toHaveBeenCalledWith('/complete_reset_password', { state: expect.objectContaining({ token: MOCK_PASSWORD_CHANGE_TOKEN, + kind: MOCK_PASSWORD_CHANGE_TOKEN_KIND, email: MOCK_EMAIL, uid: MOCK_UID, }), diff --git a/packages/fxa-settings/src/pages/ResetPassword/ConfirmBackupCodeResetPassword/container.tsx b/packages/fxa-settings/src/pages/ResetPassword/ConfirmBackupCodeResetPassword/container.tsx index 3386210ce34..bb8127de212 100644 --- a/packages/fxa-settings/src/pages/ResetPassword/ConfirmBackupCodeResetPassword/container.tsx +++ b/packages/fxa-settings/src/pages/ResetPassword/ConfirmBackupCodeResetPassword/container.tsx @@ -18,6 +18,7 @@ const ConfirmBackupCodeResetPasswordContainer = (_: RouteComponentProps) => { code, email, token, + kind, emailToHashWith, recoveryKeyExists, estimatedSyncDeviceCount, @@ -38,6 +39,7 @@ const ConfirmBackupCodeResetPasswordContainer = (_: RouteComponentProps) => { estimatedSyncDeviceCount, recoveryKeyExists, token, + kind, uid, }, replace: true, @@ -47,10 +49,7 @@ const ConfirmBackupCodeResetPasswordContainer = (_: RouteComponentProps) => { const verifyBackupCode = async (backupCode: string) => { setCodeErrorMessage(''); try { - await authClient.consumeRecoveryCodeWithPasswordForgotToken( - token, - backupCode - ); + await authClient.consumeTotpRecoveryCode(token, kind, backupCode); onSuccess(); } catch (error) { setCodeErrorMessage(getLocalizedErrorMessage(ftlMsgResolver, error)); diff --git a/packages/fxa-settings/src/pages/ResetPassword/ConfirmResetPassword/container.tsx b/packages/fxa-settings/src/pages/ResetPassword/ConfirmResetPassword/container.tsx index b6caa026b43..9525e9c1a54 100644 --- a/packages/fxa-settings/src/pages/ResetPassword/ConfirmResetPassword/container.tsx +++ b/packages/fxa-settings/src/pages/ResetPassword/ConfirmResetPassword/container.tsx @@ -14,6 +14,7 @@ import { ResendStatus } from '../../../lib/types'; import { useNavigateWithQuery } from '../../../lib/hooks/useNavigateWithQuery'; import { getLocalizedErrorMessage } from '../../../lib/error-utils'; import GleanMetrics from '../../../lib/glean'; +import { ResetPasswordTokenTypes, tokenType } from 'fxa-auth-client/browser'; const ConfirmResetPasswordContainer = (_: RouteComponentProps) => { const [resendStatus, setResendStatus] = useState( @@ -41,6 +42,7 @@ const ConfirmResetPasswordContainer = (_: RouteComponentProps) => { code: string, emailToHashWith: string, token: string, + kind: ResetPasswordTokenTypes, uid: string, estimatedSyncDeviceCount?: number, recoveryKeyExists?: boolean, @@ -57,6 +59,7 @@ const ConfirmResetPasswordContainer = (_: RouteComponentProps) => { recoveryKeyExists, recoveryKeyHint, token, + kind, uid, }, replace: true, @@ -71,6 +74,7 @@ const ConfirmResetPasswordContainer = (_: RouteComponentProps) => { recoveryKeyExists, recoveryKeyHint, token, + kind, uid, totpExists, }, @@ -85,6 +89,7 @@ const ConfirmResetPasswordContainer = (_: RouteComponentProps) => { estimatedSyncDeviceCount, recoveryKeyExists, token, + kind, uid, }, replace: true, @@ -106,10 +111,6 @@ const ConfirmResetPasswordContainer = (_: RouteComponentProps) => { } }; - const checkForTotp = async (token: string) => { - return await authClient.checkTotpTokenExistsWithPasswordForgotToken(token); - }; - const clearBanners = () => { setErrorMessage(''); setResendErrorMessage(''); @@ -128,13 +129,17 @@ const ConfirmResetPasswordContainer = (_: RouteComponentProps) => { hint: recoveryKeyHint, estimatedSyncDeviceCount, } = await checkForRecoveryKey(token); - const totpStatus = await checkForTotp(token); + const totpStatus = await authClient.checkTotpTokenExists( + token, + tokenType.passwordForgotToken + ); const totpExists = totpStatus.exists && totpStatus.verified; handleNavigation( code, emailToHashWith, token, + tokenType.passwordForgotToken, uid, estimatedSyncDeviceCount, recoveryKeyExists, diff --git a/packages/fxa-settings/src/pages/ResetPassword/ConfirmTotpResetPassword/container.test.tsx b/packages/fxa-settings/src/pages/ResetPassword/ConfirmTotpResetPassword/container.test.tsx index 524f300306e..55b1eb42ea5 100644 --- a/packages/fxa-settings/src/pages/ResetPassword/ConfirmTotpResetPassword/container.test.tsx +++ b/packages/fxa-settings/src/pages/ResetPassword/ConfirmTotpResetPassword/container.test.tsx @@ -7,7 +7,12 @@ import { LocationProvider } from '@reach/router'; import { act } from '@testing-library/react'; import { renderWithLocalizationProvider } from 'fxa-react/lib/test-utils/localizationProvider'; -import { MOCK_EMAIL, MOCK_PASSWORD_CHANGE_TOKEN, MOCK_UID } from '../../mocks'; +import { + MOCK_EMAIL, + MOCK_PASSWORD_CHANGE_TOKEN, + MOCK_PASSWORD_CHANGE_TOKEN_KIND, + MOCK_UID, +} from '../../mocks'; import ConfirmTotpResetPasswordContainer from './container'; @@ -16,9 +21,8 @@ const mockRecoveryPhoneGetWithPasswordForgotToken = jest.fn(); jest.mock('../../../models', () => ({ __esModule: true, useAuthClient: () => ({ - checkTotpTokenCodeWithPasswordForgotToken: mockCheckTotp, - recoveryPhoneGetWithPasswordForgotToken: - mockRecoveryPhoneGetWithPasswordForgotToken, + checkTotpTokenCode: mockCheckTotp, + recoveryPhoneGet: mockRecoveryPhoneGetWithPasswordForgotToken, }), useFtlMsgResolver: () => ({ getMsg: (_id: string, fallback: string) => fallback, @@ -34,6 +38,7 @@ let mockLocationState = { code: 'ignored', email: MOCK_EMAIL, token: MOCK_PASSWORD_CHANGE_TOKEN, + kind: MOCK_PASSWORD_CHANGE_TOKEN_KIND, emailToHashWith: MOCK_EMAIL, recoveryKeyExists: false, estimatedSyncDeviceCount: 2, @@ -87,12 +92,14 @@ describe('ConfirmTotpResetPasswordContainer', () => { expect(mockCheckTotp).toHaveBeenCalledWith( MOCK_PASSWORD_CHANGE_TOKEN, + MOCK_PASSWORD_CHANGE_TOKEN_KIND, '123456' ); expect(mockNavigate).toHaveBeenCalledWith('/complete_reset_password', { state: expect.objectContaining({ token: MOCK_PASSWORD_CHANGE_TOKEN, + kind: MOCK_PASSWORD_CHANGE_TOKEN_KIND, email: MOCK_EMAIL, uid: MOCK_UID, }), @@ -130,6 +137,7 @@ describe('ConfirmTotpResetPasswordContainer', () => { { state: expect.objectContaining({ token: MOCK_PASSWORD_CHANGE_TOKEN, + kind: MOCK_PASSWORD_CHANGE_TOKEN_KIND, email: MOCK_EMAIL, uid: MOCK_UID, }), diff --git a/packages/fxa-settings/src/pages/ResetPassword/ConfirmTotpResetPassword/container.tsx b/packages/fxa-settings/src/pages/ResetPassword/ConfirmTotpResetPassword/container.tsx index 179a318be8e..6df0e8609a9 100644 --- a/packages/fxa-settings/src/pages/ResetPassword/ConfirmTotpResetPassword/container.tsx +++ b/packages/fxa-settings/src/pages/ResetPassword/ConfirmTotpResetPassword/container.tsx @@ -18,10 +18,12 @@ const ConfirmTotpResetPasswordContainer = (_: RouteComponentProps) => { code, email, token, + kind, emailToHashWith, recoveryKeyExists, estimatedSyncDeviceCount, uid, + accountResetToken, } = location.state as CompleteResetPasswordLocationState; const ftlMsgResolver = useFtlMsgResolver(); @@ -38,6 +40,8 @@ const ConfirmTotpResetPasswordContainer = (_: RouteComponentProps) => { estimatedSyncDeviceCount, recoveryKeyExists, token, + kind, + accountResetToken, uid, }, replace: true, @@ -47,10 +51,7 @@ const ConfirmTotpResetPasswordContainer = (_: RouteComponentProps) => { const verifyCode = async (totpCode: string) => { setCodeErrorMessage(''); try { - const result = await authClient.checkTotpTokenCodeWithPasswordForgotToken( - token, - totpCode - ); + const result = await authClient.checkTotpTokenCode(token, kind, totpCode); if (result.success) { onSuccess(); } else { @@ -77,6 +78,7 @@ const ConfirmTotpResetPasswordContainer = (_: RouteComponentProps) => { estimatedSyncDeviceCount, recoveryKeyExists, token, + kind, uid, }, replace: false, diff --git a/packages/fxa-settings/src/pages/ResetPassword/ResetPasswordRecoveryChoice/container.test.tsx b/packages/fxa-settings/src/pages/ResetPassword/ResetPasswordRecoveryChoice/container.test.tsx index 545998e20cb..433188ef93f 100644 --- a/packages/fxa-settings/src/pages/ResetPassword/ResetPasswordRecoveryChoice/container.test.tsx +++ b/packages/fxa-settings/src/pages/ResetPassword/ResetPasswordRecoveryChoice/container.test.tsx @@ -39,9 +39,8 @@ function mockModelsModule({ }), mockRecoveryPhonePasswordResetSendCode = jest.fn().mockResolvedValue(true), }) { - mockAuthClient.getRecoveryCodesExistWithPasswordForgotToken = - mockGetRecoveryCodesExist; - mockAuthClient.recoveryPhoneGetWithPasswordForgotToken = mockRecoveryPhoneGet; + mockAuthClient.getRecoveryCodesExist = mockGetRecoveryCodesExist; + mockAuthClient.recoveryPhoneGet = mockRecoveryPhoneGet; mockAuthClient.recoveryPhonePasswordResetSendCode = mockRecoveryPhonePasswordResetSendCode; (ModelsModule.useAuthClient as jest.Mock).mockImplementation( @@ -83,6 +82,7 @@ function applyDefaultMocks() { mockLoadingSpinnerModule(); mockReachRouter('reset_password_totp_recovery_choice', { token: 'tok', + kind: 'kind', }); } @@ -119,12 +119,8 @@ describe('ResetPasswordRecoveryChoice container', () => { it('fetches recovery codes and phone number successfully', async () => { render(); await waitFor(() => { - expect( - mockAuthClient.getRecoveryCodesExistWithPasswordForgotToken - ).toHaveBeenCalled(); - expect( - mockAuthClient.recoveryPhoneGetWithPasswordForgotToken - ).toHaveBeenCalled(); + expect(mockAuthClient.getRecoveryCodesExist).toHaveBeenCalled(); + expect(mockAuthClient.recoveryPhoneGet).toHaveBeenCalled(); expect(mockResetPasswordRecoveryChoice).toHaveBeenCalled(); }); }); @@ -139,7 +135,7 @@ describe('ResetPasswordRecoveryChoice container', () => { maskedPhoneNumber: 'Number ending in 1234', handlePhoneChoice: expect.any(Function), numBackupCodes: 3, - completeResetPasswordLocationState: { token: 'tok' }, + completeResetPasswordLocationState: { token: 'tok', kind: 'kind' }, }, {} ); @@ -157,12 +153,13 @@ describe('ResetPasswordRecoveryChoice container', () => { await waitFor(() => { expect( mockAuthClient.recoveryPhonePasswordResetSendCode - ).toHaveBeenCalledWith('tok'); + ).toHaveBeenCalledWith('tok', 'kind'); expect(mockNavigate).toHaveBeenCalledWith( '/reset_password_recovery_phone', { state: { token: 'tok', + kind: 'kind', lastFourPhoneDigits: '1234', numBackupCodes: 0, sendError: undefined, @@ -187,12 +184,13 @@ describe('ResetPasswordRecoveryChoice container', () => { await waitFor(() => { expect( mockAuthClient.recoveryPhonePasswordResetSendCode - ).toHaveBeenCalledWith('tok'); + ).toHaveBeenCalledWith('tok', 'kind'); expect(mockNavigate).toHaveBeenCalledWith( '/reset_password_recovery_phone', { state: { token: 'tok', + kind: 'kind', lastFourPhoneDigits: '1234', numBackupCodes: 0, sendError: AuthUiErrors.SMS_SEND_RATE_LIMIT_EXCEEDED, @@ -209,18 +207,14 @@ describe('ResetPasswordRecoveryChoice container', () => { }); render(); await waitFor(() => { - expect( - mockAuthClient.getRecoveryCodesExistWithPasswordForgotToken - ).toHaveBeenCalled(); - expect( - mockAuthClient.recoveryPhoneGetWithPasswordForgotToken - ).toHaveBeenCalled(); + expect(mockAuthClient.getRecoveryCodesExist).toHaveBeenCalled(); + expect(mockAuthClient.recoveryPhoneGet).toHaveBeenCalled(); expect(mockResetPasswordRecoveryChoice).not.toHaveBeenCalled(); expect(mockNavigate).toHaveBeenCalledWith( '/confirm_backup_code_reset_password', { replace: true, - state: { token: 'tok' }, + state: { token: 'tok', kind: 'kind' }, } ); }); @@ -236,12 +230,13 @@ describe('ResetPasswordRecoveryChoice container', () => { await waitFor(() => { expect( mockAuthClient.recoveryPhonePasswordResetSendCode - ).toHaveBeenCalledWith('tok'); + ).toHaveBeenCalledWith('tok', 'kind'); expect(mockNavigate).toHaveBeenCalledWith( '/reset_password_recovery_phone', { state: { token: 'tok', + kind: 'kind', lastFourPhoneDigits: '1234', numBackupCodes: 0, sendError: undefined, @@ -264,7 +259,7 @@ describe('ResetPasswordRecoveryChoice container', () => { '/confirm_backup_code_reset_password', { replace: true, - state: { token: 'tok' }, + state: { token: 'tok', kind: 'kind' }, } ); }); @@ -285,7 +280,7 @@ describe('ResetPasswordRecoveryChoice container', () => { '/confirm_backup_code_reset_password', { replace: true, - state: { token: 'tok' }, + state: { token: 'tok', kind: 'kind' }, } ); }); diff --git a/packages/fxa-settings/src/pages/ResetPassword/ResetPasswordRecoveryChoice/container.tsx b/packages/fxa-settings/src/pages/ResetPassword/ResetPasswordRecoveryChoice/container.tsx index 1d9ae50a733..aa9fb496c1e 100644 --- a/packages/fxa-settings/src/pages/ResetPassword/ResetPasswordRecoveryChoice/container.tsx +++ b/packages/fxa-settings/src/pages/ResetPassword/ResetPasswordRecoveryChoice/container.tsx @@ -49,10 +49,10 @@ export const ResetPasswordRecoveryChoiceContainer = ( // Fetch backup codes try { - const { count } = - await authClient.getRecoveryCodesExistWithPasswordForgotToken( - locationState.state.token - ); + const { count } = await authClient.getRecoveryCodesExist( + locationState.state.token, + locationState.state.kind + ); count && setNumBackupCodes(count); backupCodesSuccess = true; } catch (err) { @@ -69,8 +69,9 @@ export const ResetPasswordRecoveryChoiceContainer = ( // Fetch phone data try { const { phoneNumber, nationalFormat } = - await authClient.recoveryPhoneGetWithPasswordForgotToken( - locationState.state.token + await authClient.recoveryPhoneGet( + locationState.state.token, + locationState.state.kind ); if (phoneNumber) { @@ -121,7 +122,8 @@ export const ResetPasswordRecoveryChoiceContainer = ( } try { await authClient.recoveryPhonePasswordResetSendCode( - locationState.state.token + locationState.state.token, + locationState.state.kind ); return undefined; } catch (err) { diff --git a/packages/fxa-settings/src/pages/ResetPassword/ResetPasswordRecoveryChoice/index.stories.tsx b/packages/fxa-settings/src/pages/ResetPassword/ResetPasswordRecoveryChoice/index.stories.tsx index ea12739801e..5013bcc0d0c 100644 --- a/packages/fxa-settings/src/pages/ResetPassword/ResetPasswordRecoveryChoice/index.stories.tsx +++ b/packages/fxa-settings/src/pages/ResetPassword/ResetPasswordRecoveryChoice/index.stories.tsx @@ -9,9 +9,11 @@ import { withLocalization } from 'fxa-react/lib/storybooks'; import { LocationProvider } from '@reach/router'; import { AuthUiErrors } from '../../../lib/auth-errors/auth-errors'; import { MOCK_MASKED_PHONE_NUMBER_WITH_COPY } from '../../mocks'; +import { ResetPasswordTokenTypes, tokenType } from 'fxa-auth-client/browser'; const fakeState = { token: 'tok', + kind: tokenType.accountResetToken as ResetPasswordTokenTypes, code: '123098', uid: '9001', email: 'testo@example.gg', diff --git a/packages/fxa-settings/src/pages/ResetPassword/ResetPasswordRecoveryChoice/index.test.tsx b/packages/fxa-settings/src/pages/ResetPassword/ResetPasswordRecoveryChoice/index.test.tsx index 96e82d1c715..0ffca44f366 100644 --- a/packages/fxa-settings/src/pages/ResetPassword/ResetPasswordRecoveryChoice/index.test.tsx +++ b/packages/fxa-settings/src/pages/ResetPassword/ResetPasswordRecoveryChoice/index.test.tsx @@ -13,9 +13,11 @@ import ResetPasswordRecoveryChoice from '.'; import { AuthUiErrors } from '../../../lib/auth-errors/auth-errors'; import GleanMetrics from '../../../lib/glean'; import { MOCK_MASKED_PHONE_NUMBER_WITH_COPY } from '../../mocks'; +import { ResetPasswordTokenTypes, tokenType } from 'fxa-auth-client/browser'; const fakeState = { token: 'tok', + kind: tokenType.accountResetToken as ResetPasswordTokenTypes, code: '123098', uid: '9001', email: 'testo@example.gg', diff --git a/packages/fxa-settings/src/pages/ResetPassword/ResetPasswordRecoveryPhone/container.test.tsx b/packages/fxa-settings/src/pages/ResetPassword/ResetPasswordRecoveryPhone/container.test.tsx index f72f266aa8d..9e333b76243 100644 --- a/packages/fxa-settings/src/pages/ResetPassword/ResetPasswordRecoveryPhone/container.test.tsx +++ b/packages/fxa-settings/src/pages/ResetPassword/ResetPasswordRecoveryPhone/container.test.tsx @@ -7,7 +7,12 @@ import { LocationProvider } from '@reach/router'; import { act } from '@testing-library/react'; import { renderWithLocalizationProvider } from 'fxa-react/lib/test-utils/localizationProvider'; -import { MOCK_EMAIL, MOCK_PASSWORD_CHANGE_TOKEN, MOCK_UID } from '../../mocks'; +import { + MOCK_EMAIL, + MOCK_PASSWORD_CHANGE_TOKEN, + MOCK_PASSWORD_CHANGE_TOKEN_KIND, + MOCK_UID, +} from '../../mocks'; import ResetPasswordRecoveryPhoneContainer from './container'; @@ -36,6 +41,7 @@ let mockLocationState = { code: 'ignored', email: MOCK_EMAIL, token: MOCK_PASSWORD_CHANGE_TOKEN, + kind: MOCK_PASSWORD_CHANGE_TOKEN_KIND, emailToHashWith: MOCK_EMAIL, recoveryKeyExists: false, estimatedSyncDeviceCount: 2, @@ -87,12 +93,14 @@ describe('ResetPasswordRecoveryPhoneContainer', () => { expect(mockRecoveryPhoneResetPasswordConfirm).toHaveBeenCalledWith( MOCK_PASSWORD_CHANGE_TOKEN, + MOCK_PASSWORD_CHANGE_TOKEN_KIND, '123456' ); expect(mockNavigate).toHaveBeenCalledWith('/complete_reset_password', { state: expect.objectContaining({ token: MOCK_PASSWORD_CHANGE_TOKEN, + kind: MOCK_PASSWORD_CHANGE_TOKEN_KIND, email: MOCK_EMAIL, uid: MOCK_UID, }), diff --git a/packages/fxa-settings/src/pages/ResetPassword/ResetPasswordRecoveryPhone/container.tsx b/packages/fxa-settings/src/pages/ResetPassword/ResetPasswordRecoveryPhone/container.tsx index c223f9a4694..41bdfc26d1d 100644 --- a/packages/fxa-settings/src/pages/ResetPassword/ResetPasswordRecoveryPhone/container.tsx +++ b/packages/fxa-settings/src/pages/ResetPassword/ResetPasswordRecoveryPhone/container.tsx @@ -39,6 +39,7 @@ const ResetPasswordRecoveryPhoneContainer = (_: RouteComponentProps) => { try { await authClient.recoveryPhoneResetPasswordConfirm( locationState.state.token, + locationState.state.kind, otpCode ); @@ -53,7 +54,8 @@ const ResetPasswordRecoveryPhoneContainer = (_: RouteComponentProps) => { const resendCode = async () => { try { await authClient.recoveryPhonePasswordResetSendCode( - locationState.state.token + locationState.state.token, + locationState.state.kind ); return; } catch (err) { diff --git a/packages/fxa-settings/src/pages/Signin/SigninRecoveryChoice/container.tsx b/packages/fxa-settings/src/pages/Signin/SigninRecoveryChoice/container.tsx index 63f1a2395cc..cba1d2cb4cb 100644 --- a/packages/fxa-settings/src/pages/Signin/SigninRecoveryChoice/container.tsx +++ b/packages/fxa-settings/src/pages/Signin/SigninRecoveryChoice/container.tsx @@ -72,15 +72,15 @@ export const SigninRecoveryChoiceContainer = ({ await authClient.recoveryPhoneGet(signinState.sessionToken); const { maskedPhoneNumber, lastFourPhoneDigits } = formatPhoneNumber({ - phoneNumber, - nationalFormat, + phoneNumber: phoneNumber || '', + nationalFormat: nationalFormat || '', ftlMsgResolver, }); setPhoneData({ - phoneNumber, - nationalFormat, - maskedPhoneNumber, - lastFourPhoneDigits, + phoneNumber: phoneNumber || '', + nationalFormat: nationalFormat || '', + maskedPhoneNumber: maskedPhoneNumber || '', + lastFourPhoneDigits: lastFourPhoneDigits || '', }); phoneSuccess = true; } catch (err) { diff --git a/packages/fxa-settings/src/pages/mocks.tsx b/packages/fxa-settings/src/pages/mocks.tsx index 7dabc46c035..80fa2928909 100644 --- a/packages/fxa-settings/src/pages/mocks.tsx +++ b/packages/fxa-settings/src/pages/mocks.tsx @@ -9,6 +9,7 @@ import { SyncEngines, WebChannelServices } from '../lib/channels/firefox'; import { MOCK_ACCOUNT } from '../models/mocks'; import { Integration, IntegrationType } from '../models'; import PLACEHOLDER_IMAGE_URL from './cat.jpg'; +import { tokenType } from 'fxa-auth-client/browser'; export const MOCK_EMAIL = MOCK_ACCOUNT.primaryEmail.email; export const MOCK_UID = 'abcd1234abcd1234abcd1234abcd1234'; @@ -53,6 +54,7 @@ export const mockFinishOAuthFlowHandler = () => Promise.resolve(MOCK_OAUTH_FLOW_HANDLER_RESPONSE); export const MOCK_WRAP_KB = '0123456789abcdef0123456789abcdef'; export const MOCK_HEXSTRING_32 = '0123456789abcdef0123456789abcdef'; +export const MOCK_PASSWORD_FORGOT_TOKEN_KIND = tokenType.passwordForgotToken; export const MOCK_CLIENT_SALT = 'identity.mozilla.com/picl/v1/quickStretchV2:0123456789abcdef0123456789abcdef'; @@ -61,6 +63,7 @@ export const MOCK_UNWRAP_BKEY_V2 = '20000000000000000123456789abcdef'; export const MOCK_WRAP_KB_V2 = '20000000000000000123456789abcdef'; export const MOCK_AUTH_PW_V2 = 'apw234'; export const MOCK_PASSWORD_CHANGE_TOKEN = '123456789abcdef0'; +export const MOCK_PASSWORD_CHANGE_TOKEN_KIND = 'passwordForgotToken'; export const MOCK_FLOW_ID = '00ff'; export function mockLoadingSpinnerModule() { diff --git a/packages/fxa-shared/db/models/auth/account-reset-token.ts b/packages/fxa-shared/db/models/auth/account-reset-token.ts index a7b7e6fceff..390401e0519 100644 --- a/packages/fxa-shared/db/models/auth/account-reset-token.ts +++ b/packages/fxa-shared/db/models/auth/account-reset-token.ts @@ -7,6 +7,7 @@ import { VerificationMethod, verificationMethodToNumber, } from './session-token'; +import { Account } from './account'; export class AccountResetToken extends BaseAuthModel { public static tableName = 'accountResetTokens'; @@ -21,6 +22,7 @@ export class AccountResetToken extends BaseAuthModel { verificationMethod!: number; // joined fields (from accountResetToken_# stored proc) + email!: string; verifierSetAt!: number; static async delete(id: string) { @@ -31,13 +33,30 @@ export class AccountResetToken extends BaseAuthModel { } static async findByTokenId(id: string) { - const { rows } = await AccountResetToken.callProcedure( - Proc.AccountResetToken, - uuidTransformer.to(id) + const a = Account.tableName; + const t = AccountResetToken.tableName; + return ( + AccountResetToken.query() + .join(a, `${a}.uid`, '=', `${t}.uid`) + .where('tokenId', uuidTransformer.to(id)) + .select( + `${t}.uid`, + `${t}.tokenData`, + `${t}.createdAt`, + `${a}.verifierSetAt`, + `${t}.verificationMethod`, + `${a}.email` + ) + .first() || null ); - if (!rows.length) { - return null; - } - return AccountResetToken.fromDatabaseJson(rows[0]); + } + + static async updateVerificationMethod( + id: string, + method: VerificationMethod | number + ) { + await AccountResetToken.query() + .update({ verificationMethod: verificationMethodToNumber(method) }) + .where('tokenId', uuidTransformer.to(id)); } }