packages/fxa-content-server/app/scripts/views/confirm_reset_password.js (181 lines of code) (raw):
/* 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 _ from 'underscore';
import Account from '../models/account';
import AuthErrors from '../lib/auth-errors';
import BaseView from './base';
import Cocktail from 'cocktail';
import Notifier from '../lib/channels/notifier';
import PasswordResetMixin from './mixins/password-reset-mixin';
import OpenResetPasswordEmailMixin from './mixins/open-webmail-mixin';
import ResendMixin from './mixins/resend-mixin';
import ServiceMixin from './mixins/service-mixin';
import Session from '../lib/session';
import Template from 'templates/confirm_reset_password.mustache';
import { VERIFICATION_POLL_IN_MS } from '../lib/constants';
const t = (msg) => msg;
const View = BaseView.extend({
template: Template,
className: 'confirm-reset-password',
initialize(options = {}) {
this._verificationPollMS =
options.verificationPollMS || VERIFICATION_POLL_IN_MS;
},
setInitialContext(context) {
var email = this.model.get('email');
var isSignInEnabled = this.relier.get('resetPasswordConfirm');
context.set({
email: email,
encodedEmail: encodeURIComponent(email),
forceAuth: this.broker.isForceAuth(),
isSignInEnabled: isSignInEnabled,
});
},
getAccount() {
if (!this._account) {
this._account = this.user.initAccount({ email: this.model.get('email') });
}
return this._account;
},
beforeRender() {
// user cannot confirm if they have not initiated a reset password
if (!this.model.has('passwordForgotToken')) {
this.navigate('reset_password');
return;
}
// Check to see if account has an account recovery key and store in model.
// The password reset success messaging will change depending on if it does
return this.getAccount()
.checkRecoveryKeyExistsByEmail()
.then((result) => {
this.model.set('hasRecoveryKey', result.exists);
});
},
afterVisible() {
const account = this.getAccount();
return this.broker.persistVerificationData(account).then(() => {
return this._waitForConfirmation()
.then((sessionInfo) => {
this.logViewEvent('verification.success');
// The password was reset, future attempts should ask confirmation.
this.relier.set('resetPasswordConfirm', true);
// for scoped key OAuth reliers, if key tokens are missing, ask the user to login again
// and get those tokens
if (
!account.canFetchKeys() &&
this.relier.wantsKeys() &&
this.relier.isOAuth()
) {
return this._finishPasswordResetDifferentBrowser();
}
// The original window should finish the flow if the user
// completes verification in the same browser and has sessionInfo
// passed over from tab 2.
if (sessionInfo) {
return this._finishPasswordResetSameBrowser(sessionInfo);
}
return this._finishPasswordResetDifferentBrowser();
})
.catch(this.displayError.bind(this));
});
},
_waitForConfirmation() {
return new Promise((resolve, reject) => {
// If either the `login` message comes through or the `login` message
// timeout elapses after the server confirms the user is verified,
// stop waiting all together and move to the next view.
const onComplete = (response) => {
this._stopWaiting();
resolve(response);
};
const onError = (err) => {
this._stopWaiting();
reject(err);
};
/**
* A short message on password reset verification:
*
* If the user initiates a password reset from about:accounts,
* about:accounts listens for a `login` message from FxA within the
* about:accounts tab and ignores messages from other tabs (including the
* verification tab). This is unfortunate, because for password reset,
* the sessionToken, kA and kB are generated in the verification tab.
* To sign the user in and send the `login` message, all the users data
* needs to be sent from the verification tab to this tab so we can send
* it off to the browser.
*
* We hope the user verifies in this browser, but we are not assured of
* that. The only way we know if the user verified in this browser is if
* a `login` message is received.
*
* When the user attempts a password reset, we have no idea whether the
* user is going to verify in the same browser. The only way we know if
* the user verified in this browser is if a `login` message is received
* from the inter-tab channel.
*
* Because we have no idea if the user will verify in this browser,
* assume they will not. Start polling the server to see if the user has
* verified. If the server eventually reports the user has successfully
* reset their password, assume the user has completed in a different
* browser. In this case the best we can do is ask the user to sign in
* again. Once the user has entered their updated creds, we can then
* notify the browser.
*
* If a `complete_reset_password_tab_open` message is received, hooray,
* the user has opened the password reset link in this browser. At this
* point we can assume the user will complete verification in this
* browser. It's not 100% certain the user will complete, but most
* likely. Stop polling the server. The server poll is no longer needed,
* and in fact makes things more complex. Instead, wait for the `login`
* message that will arrive once the user finishes the password reset.
*
* Once the `login` message has arrived, notify the browser. BOOM.
*/
this.notifier.on(Notifier.COMPLETE_RESET_PASSWORD_TAB_OPEN, () => {
if (!this._isWaitingForLoginMessage) {
this._waitForLoginMessage().then(onComplete, onError);
this._stopWaitingForServerConfirmation();
}
});
this._waitForServerConfirmation().then(onComplete, onError);
});
},
_finishPasswordResetSameBrowser(sessionInfo) {
const account = this.user.getAccountByUid(sessionInfo.uid);
// A bug in e10s causes localStorage in about:accounts and content tabs to be isolated from
// each other. Writes to localStorage from /complete_reset_password are not able to be read
// from within about:accounts. Because of this, all account data needed to sign in must
// be passed between windows. See https://github.com/mozilla/fxa-content-server/issues/4763
// and https://bugzilla.mozilla.org/show_bug.cgi?id=666724
account.set(_.pick(sessionInfo, Account.ALLOWED_KEYS));
if (account.isDefault()) {
return Promise.reject(AuthErrors.toError('UNEXPECTED_ERROR'));
}
// The OAuth flow needs the sessionToken to finish the flow.
return this.user
.setSignedInAccount(account)
.then(() => {
this.displaySuccess(t('Password reset'));
return this.invokeBrokerMethod(
'afterResetPasswordConfirmationPoll',
account
);
})
.then(() => {
this.navigate('reset_password_confirmed');
});
},
_finishPasswordResetDifferentBrowser() {
// user verified in a different browser, make them sign in. OAuth
// users will be redirected back to the RP, Sync users will be
// taken to the Sync controlled completion page.
Session.clear();
const options = {
account: this.getAccount(),
};
if (!this.model.get('hasRecoveryKey')) {
options.success = t('Password reset successfully. Sign in to continue.');
}
this.navigate('signin', options);
},
_isWaitingForServerConfirmation: false,
_waitForServerConfirmation() {
// only check if still waiting.
this._isWaitingForServerConfirmation = true;
const account = this.getAccount();
const token = this.model.get('passwordForgotToken');
return account.isPasswordResetComplete(token).then((isComplete) => {
if (!this._isWaitingForServerConfirmation) {
// we no longer care about the response, the other tab has opened.
// drop the response on the ground and never resolve.
return new Promise(() => {});
} else if (isComplete) {
return null;
}
return new Promise((resolve) => {
this._waitForServerConfirmationTimeout = this.setTimeout(() => {
if (this._isWaitingForServerConfirmation) {
resolve(this._waitForServerConfirmation());
}
}, this._verificationPollMS);
});
});
},
_stopWaitingForServerConfirmation() {
if (this._waitForServerConfirmationTimeout) {
this.clearTimeout(this._waitForServerConfirmationTimeout);
}
this._isWaitingForServerConfirmation = false;
},
_isWaitingForLoginMessage: false,
_waitForLoginMessage() {
return new Promise((resolve) => {
this._isWaitingForLoginMessage = true;
this.notifier.on(Notifier.SIGNED_IN, resolve);
});
},
_stopListeningForInterTabMessages() {
this._isWaitingForLoginMessage = false;
this.notifier.off();
},
_stopWaiting() {
this._stopWaitingForServerConfirmation();
this._stopListeningForInterTabMessages();
},
resend() {
return this.retryResetPassword(
this.model.get('email'),
this.model.get('passwordForgotToken')
).catch((err) => {
if (AuthErrors.is(err, 'INVALID_TOKEN')) {
return this.navigate('reset_password', {
error: err,
});
}
// unexpected error, rethrow for display.
throw err;
});
},
});
Cocktail.mixin(
View,
PasswordResetMixin,
OpenResetPasswordEmailMixin,
ResendMixin(),
ServiceMixin
);
export default View;