packages/fxa-content-server/app/scripts/models/auth_brokers/base.js (323 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/. */
/**
* A broker is a model that knows how to handle interaction with
* the outside world.
*/
import AuthErrors from '../../lib/auth-errors';
import Backbone from 'backbone';
import Cocktail from 'cocktail';
import Environment from '../../lib/environment';
import NotifierMixin from '../../lib/channels/notifier-mixin';
import NavigateBehavior from '../../views/behaviors/navigate';
import NavigateOrRedirectBehavior from '../../views/behaviors/navigate-or-redirect';
import NullBehavior from '../../views/behaviors/null';
import SameBrowserVerificationModel from '../verification/same-browser';
import UrlMixin from '../mixins/url';
import SettingsIfSignedInBehavior from '../../views/behaviors/settings';
import Vat from '../../lib/vat';
import VerificationMethods from '../../lib/verification-methods';
import VerificationReasons from '../../lib/verification-reasons';
const t = (msg) => msg;
const QUERY_PARAMETER_SCHEMA = {
automatedBrowser: Vat.boolean(),
};
const BaseAuthenticationBroker = Backbone.Model.extend({
type: 'base',
initialize(options = {}) {
this.relier = options.relier;
this.window = options.window || window;
this.environment = new Environment(this.window);
this._behaviors = new Backbone.Model(this.defaultBehaviors);
this._capabilities = new Backbone.Model(this.defaultCapabilities);
this._fxaClient = options.fxaClient;
this._metrics = options.metrics;
this._notificationChannel = options.notificationChannel;
if (this._notificationChannel) {
// optimistically set fxaStatus to `true` if the channel says it's supported.
// The request for fxaccounts:fxa_status could fail with a `no such channel`
// error if the browser is not configured to accept WebChannel messages
// from this FxA server, which often happens when testing against
// non-production servers. See #5114
const isFxaStatusSupported =
this._notificationChannel.isFxaStatusSupported() || options.fxaStatus;
this.setCapability('fxaStatus', isFxaStatusSupported);
if (isFxaStatusSupported) {
this.on('fxa_status', (response) => this.onFxaStatus(response));
}
}
},
/**
* Handle a response to the `fxa_status` message.
*
* @param {any} [response={}]
* @private
*/
onFxaStatus(response = {}) {
this.setCapability(
'supportsPairing',
response.capabilities && response.capabilities.pairing
);
},
notifications: {
'once!view-shown': 'afterLoaded',
},
/**
* The default list of behaviors. Behaviors indicate a view's next step
* once a broker's function has completed. A subclass can override one
* or more behavior.
*
* @property defaultBehaviors
*/
defaultBehaviors: {
afterChangePassword: new NullBehavior(),
afterCompletePrimaryEmail: new SettingsIfSignedInBehavior(
new NavigateBehavior('primary_email_verified'),
{
success: t('Primary email verified successfully'),
}
),
afterCompleteResetPassword: new NullBehavior(),
afterCompleteSecondaryEmail: new SettingsIfSignedInBehavior(
new NavigateBehavior('secondary_email_verified'),
{
success: t('Secondary email verified successfully'),
}
),
afterCompleteSignIn: new NavigateBehavior('signin_verified'),
afterCompleteSignInWithCode: new NavigateOrRedirectBehavior('settings'),
afterCompleteSignUp: new NavigateOrRedirectBehavior('signup_verified'),
afterDeleteAccount: new NullBehavior(),
afterForceAuth: new NavigateBehavior('signin_confirmed'),
afterResetPasswordConfirmationPoll: new NullBehavior(),
afterSignIn: new NavigateBehavior('signin_confirmed'),
afterSignInConfirmationPoll: new NavigateBehavior('signin_confirmed'),
// with React conversion, we are deprecating the confirm view in favor of 'confirm_signup_code'
afterSignUp: new NavigateBehavior('confirm'),
afterSignUpConfirmationPoll: new NavigateOrRedirectBehavior(
'signup_confirmed'
),
afterSignUpRequireTOTP: new NavigateBehavior('signin'),
beforeSignIn: new NullBehavior(),
beforeForcePasswordChange: new NavigateBehavior(
'/post_verify/password/force_password_change'
),
beforeSignUpConfirmationPoll: new NullBehavior(),
},
/**
* Set a behavior
*
* @param {String} behaviorName
* @param {Object} value
*/
setBehavior(behaviorName, value) {
this._behaviors.set(behaviorName, value);
},
/**
* Get a behavior
*
* @param {String} behaviorName
* @return {Object}
*/
getBehavior(behaviorName) {
if (!this._behaviors.has(behaviorName)) {
throw new Error('behavior not found for: ' + behaviorName);
}
return this._behaviors.get(behaviorName);
},
/**
* Initialize the broker with any necessary data.
*
* @returns {Promise}
*/
fetch() {
return Promise.resolve()
.then(() => {
const isPairing = this._isPairing();
this._isForceAuth = this._isForceAuthUrl();
this.importSearchParamsUsingSchema(QUERY_PARAMETER_SCHEMA, AuthErrors);
this.setCapability(
'showSecurityEvents',
!!this.getSearchParam('security_events')
);
if (this.hasCapability('fxaStatus')) {
return this._fetchFxaStatus({
isPairing,
});
}
})
.then(() => {
const signinCode = this.relier && this.relier.get('signinCode');
if (signinCode) {
return this._consumeSigninCode(signinCode);
}
});
},
/**
* Notify the browser that it should open pairing preferences
*
* @method openPairPreferences
* @returns {Promise} resolves when notification is sent.
*/
openPairPreferences() {
if (this.hasCapability('supportsPairing')) {
const channel = this._notificationChannel;
return channel.send(channel.COMMANDS.PAIR_PREFERENCES);
}
},
/**
* Request FXA_STATUS info from the UA.
*
* @param {Object} [statusOptions] extra options for the status message.
* @returns {Promise} resolves when complete.
* @private
*/
_fetchFxaStatus(statusOptions = {}) {
const channel = this._notificationChannel;
const isPairing = statusOptions.isPairing;
return channel
.request(channel.COMMANDS.FXA_STATUS, {
context: this.relier.get('context'),
isPairing,
service: this.relier.get('service'),
})
.then(
(response = {}) => {
// The browser will respond with a signedInUser in the following cases:
// - non-PB mode, service=*
// - PB mode, service=sync
this.set('browserSignedInAccount', response.signedInUser);
// In the future, additional data will be returned
// in the response, handle it here.
this.trigger('fxa_status', response);
},
(err) => {
// The browser is not configured to accept WebChannel messages from
// this FxA server. fxaStatus is not supported. Error has
// already been logged and can be ignored. See #5114
if (AuthErrors.is(err, 'INVALID_WEB_CHANNEL')) {
this.setCapability('fxaStatus', false);
return;
}
throw err;
}
);
},
/**
* Consume the `signinCode` for account data. If successfully consumed,
* `signinCodeAccount` will be available via this.get.
*
* @param {String} signinCode
* @returns {Promise} resolves when complete.
* @private
*/
_consumeSigninCode(signinCode) {
this._metrics._initializeFlowModel();
const { flowId, flowBeginTime } = this._metrics.getFlowEventMetadata();
return this._fxaClient
.consumeSigninCode(signinCode, flowId, flowBeginTime)
.then(
(response) => {
this.set('signinCodeAccount', response);
},
(err) => {
// log and ignore any errors. The user should still
// be able to sign in normally.
this._metrics.logError(err);
}
);
},
/**
* Called after the first view is rendered. Can be used
* to notify the RP the system is loaded.
*
* @returns {Promise}
*/
afterLoaded() {
return Promise.resolve();
},
/**
* Called before sign in. Can be used to prevent sign in.
*
* @param {Object} account
* @return {Promise}
*/
beforeSignIn(/* account */) {
return Promise.resolve(this.getBehavior('beforeSignIn'));
},
/**
* Called after sign in. Can be used to notify the RP that the user
* has signed in.
*
* @param {Object} account
* @return {Promise}
*/
afterSignIn(/* account */) {
return Promise.resolve(this.getBehavior('afterSignIn'));
},
/**
* Called after sign in confirmation poll. Can be used to notify the RP
* that the user has signed in and confirmed their email address to verify
* they want to allow the signin.
*
* @param {Object} account
* @return {Promise}
*/
afterSignInConfirmationPoll(/* account */) {
return Promise.resolve(this.getBehavior('afterSignInConfirmationPoll'));
},
/**
* Called after signin email verification, in the verification tab.
*
* @param {Object} account
* @return {Promise}
*/
afterCompleteSignIn(account) {
return this.unpersistVerificationData(account).then(() =>
this.getBehavior('afterCompleteSignIn')
);
},
afterCompleteSignInWithCode() {
return Promise.resolve(this.getBehavior('afterCompleteSignInWithCode'));
},
/**
* Called after primary email verification, in the verification tab.
*
* @param {Object} account
* @return {Promise}
*/
afterCompletePrimaryEmail(account) {
return this.unpersistVerificationData(account).then(() =>
this.getBehavior('afterCompletePrimaryEmail')
);
},
/**
* Called after secondary email verification, in the verification tab.
*
* @param {Object} account
* @return {Promise}
*/
afterCompleteSecondaryEmail(account) {
return this.unpersistVerificationData(account).then(() =>
this.getBehavior('afterCompleteSecondaryEmail')
);
},
/**
* Called after a force auth.
*
* @param {Object} account
* @return {Promise}
*/
afterForceAuth(/* account */) {
return Promise.resolve(this.getBehavior('afterForceAuth'));
},
/**
* Called before confirmation polls to persist any data that is needed
* for email verification. Useful for storing data that may be needed
* by the verification tab.
*
* @param {Object} account
* @return {Promise}
*/
persistVerificationData(account) {
return Promise.resolve().then(() => {
// verification info is persisted to localStorage so that
// the same `context` is used if the user verifies in the same browser.
// If the user verifies in a different browser, the
// default (direct access) context will be used.
var verificationInfo = createSameBrowserVerificationModel(
account,
'context'
);
verificationInfo.set({
context: this.relier.get('context'),
});
return verificationInfo.persist();
});
},
/**
* Clear persisted verification data for the account
*
* @param {Object} account
* @return {Promise}
*/
unpersistVerificationData(account) {
return Promise.resolve().then(function () {
clearSameBrowserVerificationModel(account, 'context');
});
},
/**
* Called after the user has signed up but before the screen has
* transitioned to the "confirm your email" view.
*
* @param {Object} account
* @return {Promise}
*/
afterSignUp(account) {
if (account.get('verified')) {
// If the account is already verified, go to the step after /confirm
return this.afterSignUpConfirmationPoll(account);
}
return Promise.resolve(this.getBehavior('afterSignUp'));
},
/**
* Called before signup email confirmation poll starts. Can be used
* to notify the RP that the user has successfully signed up but
* has not yet completed verification.
*
* @param {Object} account
* @return {Promise}
*/
beforeSignUpConfirmationPoll(/* account */) {
return Promise.resolve(this.getBehavior('beforeSignUpConfirmationPoll'));
},
/**
* Called after signup email confirmation poll completes. Can be used
* to notify the RP that the user has successfully signed up and
* completed verification.
*
* @param {Object} account
* @return {Promise}
*/
afterSignUpConfirmationPoll(account) {
// with signup codes, afterCompleteSignUp is not called and
// verification data must be unpersisted to avoid cross contaminating
// subsequent email verifications.
return this.unpersistVerificationData(account).then(() =>
this.getBehavior('afterSignUpConfirmationPoll')
);
},
/**
* Called after signup email verification, in the verification tab.
*
* @param {Object} account
* @return {Promise}
*/
afterCompleteSignUp(account) {
return this.unpersistVerificationData(account).then(() =>
this.getBehavior('afterCompleteSignUp')
);
},
/**
* Called after password reset email confirmation poll completes.
* Can be used to notify the RP that the user has successfully reset their
* password.
*
* @param {Object} account
* @return {Promise}
*/
afterResetPasswordConfirmationPoll(account) {
return Promise.resolve().then(() => {
if (
account.get('verificationMethod') === VerificationMethods.TOTP_2FA &&
account.get('verificationReason') === VerificationReasons.SIGN_IN
) {
return new NavigateBehavior('signin_totp_code', { account });
}
return this.getBehavior('afterResetPasswordConfirmationPoll');
});
},
/**
* Called after password reset email verification, in the verification tab.
*
* @param {Object} account
* @return {Promise}
*/
afterCompleteResetPassword(account) {
return this.unpersistVerificationData(account).then(() => {
// Users with TOTP enabled need to enter a TOTP code to complete password reset.
if (
account.get('verificationMethod') === VerificationMethods.TOTP_2FA &&
account.get('verificationReason') === VerificationReasons.SIGN_IN
) {
return new NavigateBehavior('signin_totp_code', { account });
}
return this.getBehavior('afterCompleteResetPassword');
});
},
/**
* Called after a user has changed their password.
*
* @param {Object} account
* @return {Promise}
*/
afterChangePassword(/* account */) {
return Promise.resolve(this.getBehavior('afterChangePassword'));
},
/**
* Called after a user has deleted their account.
*
* @param {Object} account
* @return {Promise}
*/
afterDeleteAccount(/* account */) {
return Promise.resolve(this.getBehavior('afterDeleteAccount'));
},
/**
* Called before the user is redirected to the force password change screen.
*
* @param {Object} account
* @return {Promise}
*/
beforeForcePasswordChange(account) {
return Promise.resolve(
this.getBehavior('beforeForcePasswordChange', { account })
);
},
/**
* Transform the signin/signup links if necessary
*
* @param {String} link
* @returns {String}
*/
transformLink(link) {
return link;
},
/**
* Check if the relier wants to force the user to auth with
* a particular email.
*
* @returns {Boolean}
*/
isForceAuth() {
return !!this._isForceAuth;
},
_isForceAuthUrl() {
var pathname = this.window.location.pathname;
return pathname === '/force_auth' || pathname === '/oauth/force_auth';
},
_isPairing() {
const pathname = this.window.location.pathname;
return pathname.indexOf('/pair') === 0;
},
/**
* Is the browser being automated? Set to true for selenium tests.
*
* @returns {Boolean}
*/
isAutomatedBrowser() {
return !!this.get('automatedBrowser');
},
/**
* The default list of capabilities. Set to a capability's value to
* a truthy value to indicate whether it's supported.
*
* @property defaultCapabilities
*/
defaultCapabilities: {
/**
* If the provided UID no longer exists on the auth server, can
* the user sign up/in with the same email address but a different
* uid?
*/
allowUidChange: false,
/**
* Does the browser handle screen transitions after
* an email verification?
*/
browserTransitionsAfterEmailVerification: false,
/**
* Should the legacy signin/signup pages be disabled?
*/
disableLegacySigninSignup: false,
/**
* Is the email-first flow supported?
*/
emailFirst: false,
/**
* should the *_complete pages show the marketing snippet?
*/
emailVerificationMarketingSnippet: true,
/**
* Should the UA be queried for FxA data?
*/
fxaStatus: false,
/**
* Should the view handle signed-in notifications from other tabs?
*/
handleSignedInNotification: true,
/**
* If the user has an existing sessionToken, can we safely re-use it
* on subsequent signin attempts rather than generating a new token each time?
*/
reuseExistingSession: false,
/**
* Is signup supported? the fx_ios_v1 broker can disable it.
*/
signup: true,
/**
* security events will be shown with `&security_events=true` in the url
*/
showSecurityEvents: false,
/*
* Is using signup codes supported?
*/
signupCode: true,
/**
* Does this environment support pairing?
*/
supportsPairing: false,
/**
* Does this environment support the Sync Optional flow?
*/
syncOptional: false,
/**
* Are token codes flow supported?
*/
tokenCode: true,
},
/**
* Check if a capability is supported. A capability is not supported
* if it's value is not a member of or falsy in `this.defaultCapabilities`.
*
* @param {String} capabilityName
* @return {Boolean}
*/
hasCapability(capabilityName) {
return (
this._capabilities.has(capabilityName) &&
!!this._capabilities.get(capabilityName)
);
},
/**
* Set a capability value.
*
* @param {String} capabilityName
* @param {Variant} capabilityValue
*/
setCapability(capabilityName, capabilityValue) {
this._capabilities.set(capabilityName, capabilityValue);
},
/**
* Remove support for a capability
*
* @param {String} capabilityName
*/
unsetCapability(capabilityName) {
this._capabilities.unset(capabilityName);
},
/**
* Get the capability value
*
* @param {String} capabilityName
* @return {Variant}
*/
getCapability(capabilityName) {
return this._capabilities.get(capabilityName);
},
});
function createSameBrowserVerificationModel(account, namespace) {
return new SameBrowserVerificationModel(
{},
{
email: account.get('email'),
namespace: namespace,
uid: account.get('uid'),
}
);
}
function clearSameBrowserVerificationModel(account, namespace) {
var verificationInfo = createSameBrowserVerificationModel(account, namespace);
verificationInfo.clear();
}
Cocktail.mixin(BaseAuthenticationBroker, NotifierMixin, UrlMixin);
export default BaseAuthenticationBroker;