Skip to content

Commit

Permalink
feat(react): Send fxaLogin webchannel message conditionally after pas…
Browse files Browse the repository at this point in the history
…sword reset

Because:
* The browser should be be notified when a PW reset occurs on a verified account through the Sync flow, as this logs the user in

This commit:
* Conditionally runs the fxaLogin command with required account data after a password reset with or without recovery key, when the flow is sync
* Receives other values off the account reset call needed for webchannel account data
* Temporarily checks for SyncBasic and SyncDesktop flows to run the check because we don't need to port over context in local storage logic (which would account for SyncDesktop only), since we are switching to codes soon
* Sends up `service` with the email request so we can append the param and check when link is received (causing the  SyncBasic integration)
* Removes resendResetPassword and calls resetPassword where needed since the code was duplicated

Fixes FXA-7172
  • Loading branch information
LZoog committed Jun 6, 2023
1 parent e52ab76 commit d022d06
Show file tree
Hide file tree
Showing 10 changed files with 97 additions and 139 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -64,4 +64,7 @@ export class AccountResetPayload {

@Field({ nullable: true })
public keyFetchToken?: string;

@Field({ nullable: true })
public unwrapBKey?: string;
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */

import React, { useState } from 'react';
import { useAccount } from '../../models';
import { CreateRelier, useAccount } from '../../models';
import { ResendStatus } from '../../lib/types';
import { logViewEvent } from 'fxa-settings/src/lib/metrics';
import { REACT_ENTRYPOINT } from 'fxa-settings/src/constants';
Expand All @@ -19,14 +19,15 @@ export const LinkExpiredResetPassword = ({
viewName,
}: LinkExpiredResetPasswordProps) => {
const account = useAccount();
const { service } = CreateRelier();

const [resendStatus, setResendStatus] = useState<ResendStatus>(
ResendStatus['not sent']
);

const resendResetPasswordLink = async () => {
try {
await account.resendResetPassword(email);
await account.resetPassword(email, service);
logViewEvent(viewName, 'resend', REACT_ENTRYPOINT);
setResendStatus(ResendStatus['sent']);
} catch (e) {
Expand Down
105 changes: 27 additions & 78 deletions packages/fxa-settings/src/lib/channels/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,93 +2,43 @@
* 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 { AccountData } from '../../models';
import firefox, { FxALoginRequest } from './firefox';

// const ALLOWED_LOGIN_FIELDS = [
// 'declinedSyncEngines',
// 'email',
// 'keyFetchToken',
// 'offeredSyncEngines',
// 'sessionToken',
// 'services',
// 'uid',
// 'unwrapBKey',
// 'verified',
// ];

// const REQUIRED_LOGIN_FIELDS = [
// 'email',
// 'keyFetchToken',
// 'sessionToken',
// 'uid',
// 'unwrapBKey',
// 'verified',
// ];

// Might need to go on the Integration?
// TODO: might need to go on Integration?
// let uidOfLoginNotification: hexstring = '';

// function hasRequiredLoginFields(loginData) {
// const loginFields = Object.keys(loginData);
// return (
// REQUIRED_LOGIN_FIELDS.filter((field) => !loginFields.includes(field))
// .length === 0
// );
// }

/**
* Get login data from `account` to send to the browser.
* All returned keys have a defined value.
*/
// function getLoginData(account: AccountData) {
// let loginData: Partial<AccountData> = {};
// for (const key of ALLOWED_LOGIN_FIELDS) {
// loginData[key] = account[key];
// }
// // TODO: account for multiservice when we combine reliers
// // const isMultiService = this.relier && this.relier.get('multiService');
// // if (isMultiService) {
// // loginData = this._formatForMultiServiceBrowser(loginData);
// // }
// loginData.verified = !!loginData.verified;
// // TODO: this is set in the `beforeSignIn` auth-broker method
// // loginData.verifiedCanLinkAccount = !!this._verifiedCanLinkEmail;
// return Object.fromEntries(
// Object.entries(loginData).filter(([key, value]) => value !== undefined)
// );
// }
const REQUIRED_LOGIN_FIELDS: Array<keyof FxALoginRequest> = [
'authAt',
'email',
'keyFetchToken',
'sessionToken',
'uid',
'unwrapBKey',
'verified',
];

// TODO in FXA-7172
export function notifyFirefoxOfLogin(
account: AccountData,
accountData: FxALoginRequest,
isSessionVerified: boolean
) {
// only notify the browser of the login if the user does not have
// to verify their account/session
if (!isSessionVerified) {
return;
}

/**
* Workaround for #3078. If the user signs up but does not verify
* their account, then visit `/` or `/settings`, they are
/* Only notify the browser of the login if the user 1) does not have to
* verify their account/session and 2) when all required fields are present,
* which is a workaround for #3078. If the user signs up but does not
* verify their account, then visit `/` or `/settings`, they are
* redirected to `/confirm` which attempts to notify the browser of
* login. Since `unwrapBKey` and `keyFetchToken` are not persisted to
* disk, the passed in account lacks these items. The browser can't
* do anything without this data, so don't actually send the message.
*
* Also works around #3514. With e10s enabled, localStorage in
* about:accounts and localStorage in the verification page are not
* shared. This lack of shared state causes the original tab of
* a password reset from about:accounts to not have all the
* required data. The verification tab sends a WebChannel message
* already, so no need here too.
*/
// const loginData = getLoginData(account);
// if (!hasRequiredLoginFields(loginData)) {
// return;
// }
* do anything without this data, so don't actually send the message. */

if (
!isSessionVerified ||
!accountData.verified ||
!REQUIRED_LOGIN_FIELDS.every((key) => key in accountData)
) {
return;
}

// TODO with relier/integration combination or during login tickets.
// Only send one login notification per uid to avoid race
// conditions within the browser. Two attempts to send
// a login message occur for users that verify while
Expand All @@ -98,6 +48,5 @@ export function notifyFirefoxOfLogin(
// if (loginData.uid !== uidOfLoginNotification) {
// uidOfLoginNotification = loginData.uid;

// send web channel LOGIN command with loginData
// }
firefox.fxaLogin(accountData);
}
1 change: 1 addition & 0 deletions packages/fxa-settings/src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export enum LinkStatus {
export enum MozServices {
Default = 'account settings',
FirefoxMonitor = 'Firefox Monitor',
Sync = 'sync', // temporary until we fix the sync/FF Sync discrepency
FirefoxSync = 'Firefox Sync',
MozillaVPN = 'Mozilla VPN',
Pocket = 'Pocket',
Expand Down
71 changes: 22 additions & 49 deletions packages/fxa-settings/src/models/Account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import Storage from '../lib/storage';
import random from '../lib/random';
import { AuthUiErrorNos, AuthUiErrors } from '../lib/auth-errors/auth-errors';
import { GET_SESSION_VERIFIED } from './Session';
import { MozServices } from '../lib/types';
import { CreateRelier } from './reliers';

export interface DeviceLocation {
city: string | null;
Expand Down Expand Up @@ -538,7 +540,10 @@ export class Account implements AccountData {
});
}

async resetPassword(email: string): Promise<PasswordForgotSendCodePayload> {
async resetPassword(
email: string,
service?: string
): Promise<PasswordForgotSendCodePayload> {
try {
const result = await this.apolloClient.mutate({
mutation: gql`
Expand All @@ -550,7 +555,14 @@ export class Account implements AccountData {
}
}
`,
variables: { input: { email } },
variables: {
input: {
email,
// only include the `service` option if the service is not default.
// This becomes a query param on the email link
...(service && service !== MozServices.Default && { service }),
},
},
});
return result.data.passwordForgotSendCode;
} catch (err) {
Expand Down Expand Up @@ -614,51 +626,6 @@ export class Account implements AccountData {
}
}

async resendResetPassword(
email: string
): Promise<PasswordForgotSendCodePayload> {
try {
const result = await this.apolloClient.mutate({
mutation: gql`
mutation passwordForgotSendCode(
$input: PasswordForgotSendCodeInput!
) {
passwordForgotSendCode(input: $input) {
clientMutationId
passwordForgotToken
}
}
`,
variables: { input: { email } },
});
return result.data.passwordForgotSendCode;
} catch (err) {
const graphQlError = ((err as ApolloError) || (err as ThrottledError))
.graphQLErrors[0];
const errno = graphQlError.extensions?.errno;
if (
(err as ThrottledError) &&
errno &&
AuthUiErrorNos[errno] &&
errno === AuthUiErrors.THROTTLED.errno
) {
const throttledErrorWithRetryAfter = {
...AuthUiErrorNos[errno],
retryAfter: graphQlError.extensions?.retryAfter,
retryAfterLocalized: graphQlError.extensions?.retryAfterLocalized,
};
throw throttledErrorWithRetryAfter;
} else if (
errno &&
AuthUiErrorNos[errno] &&
errno !== AuthUiErrors.THROTTLED.errno
) {
throw AuthUiErrorNos[errno];
}
throw AuthUiErrors.UNEXPECTED_ERROR;
}
}

/**
* Verify a passwordForgotToken, which returns an accountResetToken that can
* be used to perform the actual password reset.
Expand Down Expand Up @@ -721,6 +688,10 @@ export class Account implements AccountData {
clientMutationId
sessionToken
uid
authAt
keyFetchToken
verified
unwrapBKey
}
}
`,
Expand All @@ -729,12 +700,13 @@ export class Account implements AccountData {
accountResetToken,
email,
newPassword,
options: { sessionToken: true },
options: { sessionToken: true, keys: true },
},
},
});
currentAccount(getOldSettingsData(accountReset));
sessionToken(accountReset.sessionToken);
return accountReset;
} catch (err) {
const errno = (err as ApolloError).graphQLErrors[0].extensions?.errno;
if (errno && AuthUiErrorNos[errno]) {
Expand Down Expand Up @@ -1280,7 +1252,7 @@ export class Account implements AccountData {
opts.password,
opts.recoveryKeyId,
{ kB: opts.kB },
{ sessionToken: true }
{ sessionToken: true, keys: true }
);
currentAccount(currentAccount(getOldSettingsData(data)));
sessionToken(data.sessionToken);
Expand All @@ -1293,5 +1265,6 @@ export class Account implements AccountData {
},
},
});
return data;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -252,7 +252,9 @@ const AccountRecoveryResetPassword = ({
verificationInfo.emailToHashWith || verificationInfo.email,
};

await account.resetPasswordWithRecoveryKey(options);
const accountResetData = await account.resetPasswordWithRecoveryKey(
options
);
// must come after completeResetPassword since that receives the sessionToken
// required for this check
const sessionIsVerified = await account.isSessionVerifiedAuthClient();
Expand All @@ -267,8 +269,22 @@ const AccountRecoveryResetPassword = ({
logViewEvent(viewName, 'verification.success');

switch (integration.type) {
// NOTE: SyncBasic check is temporary until we implement codes
// See https://docs.google.com/document/d/1K4AD69QgfOCZwFLp7rUcMOkOTslbLCh7jjSdR9zpAkk/edit#heading=h.kkt4eylho93t
case IntegrationType.SyncDesktop:
notifyFirefoxOfLogin(account, sessionIsVerified);
case IntegrationType.SyncBasic:
notifyFirefoxOfLogin(
{
authAt: accountResetData.authAt,
email: verificationInfo.email,
keyFetchToken: accountResetData.keyFetchToken,
sessionToken: accountResetData.sessionToken,
uid: accountResetData.uid,
unwrapBKey: accountResetData.unwrapBKey,
verified: accountResetData.verified,
},
sessionIsVerified
);
break;
case IntegrationType.OAuth:
if (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,7 @@ const CompleteResetPassword = ({
// how account password hashing works previously.
const emailToUse = emailToHashWith || email;

await account.completeResetPassword(
const accountResetData = await account.completeResetPassword(
token,
code,
emailToUse,
Expand All @@ -189,8 +189,22 @@ const CompleteResetPassword = ({

let hardNavigate = false;
switch (integration.type) {
// NOTE: SyncBasic check is temporary until we implement codes
// See https://docs.google.com/document/d/1K4AD69QgfOCZwFLp7rUcMOkOTslbLCh7jjSdR9zpAkk/edit#heading=h.kkt4eylho93t
case IntegrationType.SyncDesktop:
notifyFirefoxOfLogin(account, sessionIsVerified);
case IntegrationType.SyncBasic:
notifyFirefoxOfLogin(
{
authAt: accountResetData.authAt,
email,
keyFetchToken: accountResetData.keyFetchToken,
sessionToken: accountResetData.sessionToken,
uid: accountResetData.uid,
unwrapBKey: accountResetData.unwrapBKey,
verified: accountResetData.verified,
},
sessionIsVerified
);
break;
case IntegrationType.OAuth:
if (
Expand Down Expand Up @@ -240,7 +254,7 @@ const CompleteResetPassword = ({
setErrorType(ErrorType['complete-reset']);
}
},
[account, alertSuccessAndNavigate, integration.type]
[account, alertSuccessAndNavigate, integration.type, location.search]
);

if (showLoadingSpinner) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,15 +73,15 @@ describe('ConfirmResetPassword page', () => {

it('sends a new email when clicking on resend button', async () => {
renderWithAccount(account);
account.resendResetPassword = jest.fn().mockResolvedValue('');
account.resetPassword = jest.fn().mockResolvedValue('');

const resendEmailButton = screen.getByRole('button', {
name: 'Not in inbox or spam folder? Resend',
});

fireEvent.click(resendEmailButton);

await waitFor(() => expect(account.resendResetPassword).toHaveBeenCalled());
await waitFor(() => expect(account.resetPassword).toHaveBeenCalled());
expect(logViewEvent).toHaveBeenCalledWith(
'confirm-reset-password',
'resend',
Expand Down
Loading

0 comments on commit d022d06

Please sign in to comment.