packages/fxa-content-server/app/scripts/models/account.js (918 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/. */
// This model abstracts interaction between the user's account
// and the profile server and also handles (de)serialization.
import _ from 'underscore';
import AuthErrors from '../lib/auth-errors';
import Backbone from 'backbone';
import Cocktail from 'cocktail';
import Constants from '../lib/constants';
import OAuthToken from './oauth-token';
import ProfileErrors from '../lib/profile-errors';
import ProfileImage from './profile-image';
import ResumeTokenMixin from './mixins/resume-token';
import SignInReasons from '../lib/sign-in-reasons';
import VerificationMethods from '../lib/verification-methods';
import vat from '../lib/vat';
import { emailsMatch } from 'fxa-shared/email/helpers';
// Account attributes that can be persisted
const PERSISTENT = {
accountResetToken: undefined,
alertText: undefined,
displayName: undefined,
email: undefined,
grantedPermissions: undefined,
hadProfileImageSetBefore: undefined,
lastLogin: undefined,
// This property is set when a user has changed their primary email address and
// attempts to login. Similar to logging in with a different email capitalization
// the auth-server will return the proper email to reattempt the login. When reattempting
// to login, this needs to be passed back to the auth-server.
originalLoginEmail: undefined,
// password field intentionally omitted to avoid unintentional leaks
permissions: undefined,
profileImageId: undefined,
profileImageUrl: undefined,
profileImageUrlDefault: undefined,
providerUid: undefined,
recoveryKeyId: undefined,
sessionToken: undefined,
// Hint for future code spelunkers. sessionTokenContext is a misnomer,
// what the field is really used for is to indicate whether the
// sessionToken is shared with Sync. It will be set to `fx_desktop_v1` if
// the sessionToken is shared. Users cannot sign out of Sync shared
// sessions from within the content server, instead they must go into the
// Sync panel and disconnect there. The reason this field has not been
// renamed is because we cannot gracefully handle rollback without the
// side effect of users being able to sign out of their Sync based
// session. Data migration within the client goes one way. It's easy to
// move forward, very hard to move back.
sessionTokenContext: undefined,
uid: undefined,
metricsEnabled: undefined,
verified: undefined,
};
const DEFAULTS = _.extend(
{
accessToken: undefined,
declinedSyncEngines: undefined,
hasBounced: undefined,
hasLinkedAccount: undefined,
hasPassword: undefined,
keyFetchToken: undefined,
newsletters: undefined,
offeredSyncEngines: undefined,
// password field intentionally omitted to avoid unintentional leaks
providerUid: undefined,
unwrapBKey: undefined,
verificationMethod: undefined,
verificationReason: undefined,
totpVerified: undefined,
},
PERSISTENT
);
const ALLOWED_KEYS = Object.keys(DEFAULTS);
const ALLOWED_PERSISTENT_KEYS = Object.keys(PERSISTENT);
const DEPRECATED_KEYS = ['ecosystemAnonId', 'needsOptedInToMarketingEmail'];
const CONTENT_SERVER_OAUTH_SCOPE = 'profile profile:write clients:write';
const PERMISSIONS_TO_KEYS = {
'profile:avatar': 'profileImageUrl',
'profile:display_name': 'displayName',
'profile:email': 'email',
'profile:uid': 'uid',
};
const Account = Backbone.Model.extend(
{
defaults: DEFAULTS,
initialize(accountData, options = {}) {
this._oAuthClientId = options.oAuthClientId;
this._oAuthClient = options.oAuthClient;
this._profileClient = options.profileClient;
this._fxaClient = options.fxaClient;
this._metrics = options.metrics;
this._notifier = options.notifier;
this._sentryMetrics = options.sentryMetrics;
this._subscriptionsConfig = options.subscriptionsConfig;
// upgrade old `grantedPermissions` to the new `permissions`.
this._upgradeGrantedPermissions();
if (!this.get('sessionToken') && this.get('sessionTokenContext')) {
// We got into a bad place where some users did not have sessionTokens
// but had sessionTokenContext and were unable to sign in using the
// email-first flow. If the user is in this state, forcibly remove
// the sessionTokenContext and accessToken so that they can sign in.
// See #999
this.discardSessionToken();
}
this.on('change:sessionToken', () => {
// belts and suspenders measure to ensure calls to this.unset('sessionToken')
// also remove the accessToken and sessionTokenContext. See #999
if (!this.has('sessionToken')) {
this.discardSessionToken();
}
});
this._boundOnChange = this.onChange.bind(this);
this.on('change', this._boundOnChange);
const email = this.get('email');
if (email && this._notifier) {
this._notifier.trigger('set-email-domain', this.get('email'));
}
},
resumeTokenFields: ['email', 'newsletters'],
resumeTokenSchema: {
email: vat.email(),
newsletters: vat.newslettersArray(),
},
// Hydrate the account
fetch() {
if (!this.get('sessionToken') || this.get('verified')) {
return Promise.resolve();
}
// upgrade the credentials with verified state
return this.sessionStatus().catch((err) => {
// Ignore UNAUTHORIZED errors; we'll just fetch again when needed
// Otherwise report the error
if (!AuthErrors.is(err, 'UNAUTHORIZED') && this._sentryMetrics) {
this._sentryMetrics.captureException(
new Error(_.isEmpty(err) ? 'Something went wrong!' : err)
);
}
});
},
discardSessionToken() {
// If a sessionToken is invalid, the profile image should
// be considered invalid. This causes the default profile
// image to be displayed.
this.unset('profileImageId');
this.unset('profileImageUrl');
this.unset('accessToken');
this.unset('sessionTokenContext');
this.unset('sessionToken');
},
_fetchProfileOAuthToken() {
return this.createOAuthToken(this._oAuthClientId, {
scope: CONTENT_SERVER_OAUTH_SCOPE,
// set authorization TTL to 5 minutes.
// Docs: https://mozilla.github.io/ecosystem-platform/api#tag/OAuth-Server-API-Overview/operation/postAuthorization
// Issue: https://github.com/mozilla/fxa-content-server/issues/3982
ttl: 300,
}).then((accessToken) => {
this.set('accessToken', accessToken.get('token'));
});
},
profileClient() {
return this.fetch()
.then(() => {
// If the account is not verified fail before attempting to fetch a token
if (!this.get('verified')) {
throw AuthErrors.toError('UNVERIFIED_ACCOUNT');
} else if (this._needsAccessToken()) {
return this._fetchProfileOAuthToken();
}
})
.then(() => this._profileClient);
},
isFromSync() {
return (
this.get('sessionTokenContext') ===
Constants.SESSION_TOKEN_USED_FOR_SYNC
);
},
// returns true if all attributes within ALLOWED_KEYS are defaults
isDefault() {
return !_.find(ALLOWED_KEYS, (key) => {
return this.get(key) !== DEFAULTS[key];
});
},
// If we're verified and don't have an accessToken, we should
// go ahead and get one.
_needsAccessToken() {
return this.get('verified') && !this.get('accessToken');
},
/**
* Create an OAuth token for `clientId`
*
* @param {string} clientId
* @param {Object} [options={}]
* @param {String} [options.access_type=online] if `access_type=offline`, a refresh token
* will be issued when trading the code for an access token.
* @param {String} [options.scope] requested scopes
* @param {Number} [options.ttl] time to live, in seconds
* @returns {Promise<OAuthToken>}
*/
createOAuthToken(clientId, options = {}) {
const sessionToken = this.get('sessionToken');
if (!sessionToken) {
return Promise.reject(AuthErrors.toError('INVALID_TOKEN'));
}
return this._fxaClient
.createOAuthToken(sessionToken, clientId, options)
.then((result) => {
return new OAuthToken({
oAuthClient: this._oAuthClient,
token: result.access_token,
});
});
},
/**
* Create an OAuth code
* @param {String} clientId
* @param {String} state
* @param {Object} [options={}]
* @param {String} [options.access_type=online] if `access_type=offline`, a refresh token
* will be issued when trading the code for an access token.
* @param {String} [options.acr_values] allowed ACR values
* @param {String} [options.keys_jwe] Encrypted bundle of scoped key data to return to the RP
* @param {String} [options.redirect_uri] registered redirect URI to return to
* @param {String} [options.response_type=code] response type
* @param {String} [options.scope] requested scopes
* @param {String} [options.code_challenge_method] PKCE code challenge method
* @param {String} [options.code_challenge] PKCE code challenge
* @returns {Promise} A promise that will be fulfilled with:
* - `redirect` - redirect URI
* - `code` - authorization code
* - `state` - state token
*/
createOAuthCode(clientId, state, options) {
const sessionToken = this.get('sessionToken');
if (!sessionToken) {
return Promise.reject(AuthErrors.toError('INVALID_TOKEN'));
}
return this._fxaClient.createOAuthCode(
sessionToken,
clientId,
state,
options
);
},
/**
* Get scoped key data for the RP associated with `client_id`
*
* @param {String} clientId
* @param {String} scope
* @returns {Promise} A promise that will be fulfilled with:
* - `identifier`
* - `keyRotationSecret`
* - `keyRotationTimestamp`
*/
getOAuthScopedKeyData(clientId, scope) {
const sessionToken = this.get('sessionToken');
if (!sessionToken) {
return Promise.reject(AuthErrors.toError('INVALID_TOKEN'));
}
return this._fxaClient.getOAuthScopedKeyData(
sessionToken,
clientId,
scope
);
},
/**
* Check the status of the account's current session. Status information
* includes whether the session is verified, and if not, the reason
* it must be verified and by which method.
*
* @returns {Promise} resolves with the account's current session
* information if session is valid. Rejects with an INVALID_TOKEN error
* if session is invalid.
*
* Session information:
* {
* email: <canonicalized email>,
* verified: <boolean>
* verificationMethod: <see lib/verification-methods.js>
* verificationReason: <see lib/verification-reasons.js>
* }
*/
sessionStatus() {
return Promise.resolve()
.then(() => {
const sessionToken = this.get('sessionToken');
if (!sessionToken) {
throw AuthErrors.toError('INVALID_TOKEN');
}
return this._fxaClient.recoveryEmailStatus(sessionToken);
})
.then(
(resp) => {
// The session info may have changed since when it was last stored.
// Store the server's view of the world. This will update the model
// with the canonicalized email.
if (this.get('verificationReason')) {
resp.verificationReason = this.get('verificationReason');
}
this.set(resp);
return resp;
},
(err) => {
if (AuthErrors.is(err, 'INVALID_TOKEN')) {
// sessionToken is no longer valid, kill it.
this.discardSessionToken();
}
throw err;
}
);
},
/**
* Fetches the account profile from the GET /account/profile on the auth server
*
* @returns {Promise} resolves with the account's current session
* information if session is valid. Rejects with an INVALID_TOKEN error
* if session is invalid.
*
* Account information:
* {
* email,
* locale,
* authenticationMethods,
* authenticatorAssuranceLevel,
* profileChangedAt,
* }
*/
accountProfile() {
return Promise.resolve().then(() => {
const sessionToken = this.get('sessionToken');
if (!sessionToken) {
throw AuthErrors.toError('INVALID_TOKEN');
}
return this._fxaClient.accountProfile(sessionToken);
});
},
/**
* Fetches account details from GET /account, for use by the settings views.
* Caches its own result, because it's not intended for that endpoint to be
* called repeatedly like some of the polling methods are. If you're really,
* really sure you need to, pass the `force` option to force a clean request.
*
* @param {Object} [options]
* @param {Boolean} [options.force=false] - Ignore any cached results
*
* @returns {Promise} - Resolves to the result of `GET /account`, as defined in
* `packages/fxa-auth-server/lib/routes/account.js`.
*/
settingsData(options = {}) {
return Promise.resolve().then(() => {
if (this._settingsData && !options.force) {
return this._settingsData;
}
const sessionToken = this.get('sessionToken');
if (!sessionToken) {
throw AuthErrors.toError('INVALID_TOKEN');
}
return this._fxaClient
.account(sessionToken)
.then((result) => (this._settingsData = result));
});
},
/**
* This function simply returns the session status of the user. It differs
* from `sessionStatus` function above because it is not used to determine
* which view to take a user after the login. This function also does not
* have the restriction to be backwards compatible to legacy clients, nor
* does it update the account with the server provided information.
*
* @returns {Promise} resolves with the account's current session
* information if session is valid. Rejects with an INVALID_TOKEN error
* if session is invalid.
*
* Session information:
* {
* email: <canonicalized email>,
* verified: <boolean>
* emailVerified: <boolean>
* sessionVerified: <boolean>
* }
*/
sessionVerificationStatus() {
return Promise.resolve()
.then(() => {
const sessionToken = this.get('sessionToken');
if (!sessionToken) {
throw AuthErrors.toError('INVALID_TOKEN');
}
return this._fxaClient.sessionVerificationStatus(sessionToken);
})
.then(null, (err) => {
if (AuthErrors.is(err, 'INVALID_TOKEN')) {
// sessionToken is no longer valid, kill it.
this.discardSessionToken();
}
throw err;
});
},
isSignedIn() {
return this._fxaClient.isSignedIn(this.get('sessionToken'));
},
toJSON() {
/*
* toJSON is explicitly disabled because it fetches all attributes
* on the model, making accidental data exposure easier than it
* should be. Use the [pick](http:*underscorejs.org/#pick) method
* instead, which requires a list of attributes to get.
*
* e.g.:
* const accountData = account.pick('email', 'uid');
*/
throw new Error('toJSON is explicitly disabled, use `.pick` instead');
},
toPersistentJSON() {
return this.pick(ALLOWED_PERSISTENT_KEYS);
},
/**
* Fetches the user's security events from the GET /securityEvents on the auth server
*
* @returns {Promise} resolves with security events
*/
securityEvents() {
return Promise.resolve().then(() => {
const sessionToken = this.get('sessionToken');
if (!sessionToken) {
throw AuthErrors.toError('INVALID_TOKEN');
}
return this._fxaClient.securityEvents(sessionToken);
});
},
setProfileImage(profileImage) {
this.set({
profileImageId: profileImage.get('id'),
profileImageUrl: profileImage.get('url'),
profileImageUrlDefault: profileImage.get('default'),
});
if (this.get('profileImageUrl')) {
// This is a heuristic to let us know if the user has, at some point,
// had a custom profile image.
this.set('hadProfileImageSetBefore', true);
}
},
onChange() {
// if any data is set outside of the `fetchProfile` function,
// clear the cache and force a reload of the profile the next time.
delete this._profileFetchPromise;
},
_profileFetchPromise: null,
fetchProfile() {
// Avoid multiple views making profile requests by caching
// the profile fetch request. Only allow one for a given account,
// and then re-use the data after that. See #3053
if (this._profileFetchPromise) {
return this._profileFetchPromise;
}
// ignore change events while populating known good data.
// Unbinding the change event here ignores the `set` from
// the call to _fetchProfileOAuthToken made in `getProfile`.
this.off('change', this._boundOnChange);
this._profileFetchPromise = this.getProfile().then((result) => {
const profileImage = new ProfileImage({
default: result.avatarDefault,
url: result.avatar,
});
this.setProfileImage(profileImage);
this.set('displayName', result.displayName);
this.on('change', this._boundOnChange);
});
return this._profileFetchPromise;
},
fetchCurrentProfileImage() {
let profileImage = new ProfileImage();
return this.getAvatar()
.then((result) => {
profileImage = new ProfileImage({
default: result.avatarDefault,
id: result.id,
url: result.avatar,
});
this.setProfileImage(profileImage);
return profileImage.fetch();
})
.then(() => profileImage);
},
/**
* Sign in an existing user.
*
* @param {String} password - The user's password
* @param {Object} relier - Relier being signed in to
* @param {Object} [options]
* @param {String} [options.reason] - Reason for the sign in.
* See definitions in sign-in-reasons.js. Defaults to
* SIGN_IN_REASONS.SIGN_IN.
* @param {String} [options.resume] - Resume token to send
* in verification email if user is unverified.
* @param {String} [options.unblockCode] - Unblock code.
* @param {String} [options.originalLoginEmail] - Login used to login with originally.
* @returns {Promise} - resolves when complete
*/
signIn(password, relier, options = {}) {
const email = this.get('email');
return Promise.resolve()
.then(() => {
const sessionToken = this.get('sessionToken');
if (password) {
const signinOptions = {
metricsContext: this._metrics.getFlowEventMetadata(),
reason: options.reason || SignInReasons.SIGN_IN,
resume: options.resume,
// if the email case is incorrect, handle it locally so the model
// can be updated with the correct case.
skipCaseError: true,
unblockCode: options.unblockCode,
verificationMethod: VerificationMethods.EMAIL_OTP,
};
// `originalLoginEmail` is specified when the account's primary email has changed.
// This param lets the auth-server known that it should check that this email
// is the current primary for the account.
const originalLoginEmail = this.get('originalLoginEmail');
if (originalLoginEmail) {
signinOptions.originalLoginEmail = originalLoginEmail;
}
if (!sessionToken) {
// We need to do a completely fresh login.
return this._fxaClient.signIn(
email,
password,
relier,
signinOptions
);
} else {
// We have an existing sessionToken, try to re-authenticate it.
return this._fxaClient
.sessionReauth(
sessionToken,
email,
password,
relier,
signinOptions
)
.catch((err) => {
// The session was invalid, do a fresh login.
if (!AuthErrors.is(err, 'INVALID_TOKEN')) {
throw err;
}
this.discardSessionToken();
return this._fxaClient.signIn(
email,
password,
relier,
signinOptions
);
});
}
} else if (sessionToken) {
// We have a cached Sync session so just check that it hasn't expired.
// The result includes the latest verified state
return this._fxaClient.recoveryEmailStatus(sessionToken);
} else {
throw AuthErrors.toError('UNEXPECTED_ERROR');
}
})
.then((updatedSessionData) => {
// If a different email case or primary email was used to login,
// the session won't have correct email. Update the session to use the one
// originally used for login.
if (
options.originalLoginEmail &&
!emailsMatch(email, options.originalLoginEmail)
) {
updatedSessionData.email = options.originalLoginEmail;
}
// We don't really need this value other than in login flow, it can
// sometimes cause issues when user switches primary email
this.unset('originalLoginEmail');
this.set(updatedSessionData);
this._notifier.trigger(
'set-uid',
this.get('uid'),
this.get('metricsEnabled')
);
return updatedSessionData;
})
.catch((err) => {
// The `INCORRECT_EMAIL_CASE` can be returned if a user is attempting to login with a different
// email case than what the account was created with or if they changed their primary email address.
// In both scenarios, the content-server needs to know the original account email to hash
// the user's password with.
if (AuthErrors.is(err, 'INCORRECT_EMAIL_CASE')) {
// Save the original email that was used for login. This value will be
// sent to the auth-server so that it can correctly look the account.
this.set('originalLoginEmail', email);
// The email returned in the `INCORRECT_EMAIL_CASE` is either the canonical email
// address or if the primary email has changed, it is the email the account was first
// created with.
this.set('email', err.email);
return this.signIn(password, relier, options);
} else if (
AuthErrors.is(err, 'THROTTLED') ||
AuthErrors.is(err, 'REQUEST_BLOCKED')
) {
// On a throttled or block login request, the account model's email could be storing
// a canonical email address or email the account was created with. If this is the case
// set the account model's email to the email first used for the login request.
const originalLoginEmail = this.get('originalLoginEmail');
if (originalLoginEmail) {
this.set('email', originalLoginEmail);
}
}
throw err;
});
},
/**
* Sign up a new user.
*
* @param {String} password - The user's password
* @param {Object} relier - Relier being signed in to
* @param {Object} [options]
* @param {String} [options.resume] - Resume token to send in verification
* email if user is unverified.
* @returns {Promise} - resolves when complete
*/
signUp(password, relier, options = {}) {
return this._fxaClient
.signUp(this.get('email'), password, relier, {
metricsContext: this._metrics.getFlowEventMetadata(),
resume: options.resume,
verificationMethod: options.verificationMethod,
})
.then((updatedSessionData) => {
this.set(updatedSessionData);
});
},
/**
* Retry a sign up
*
* @param {Object} relier
* @param {Object} [options]
* @param {String} [options.resume] resume token
* @returns {Promise} - resolves when complete
*/
retrySignUp(relier, options = {}) {
return this._fxaClient.signUpResend(relier, this.get('sessionToken'), {
resume: options.resume,
});
},
/**
* Request to verify current session.
*
* @param {Object} [options]
* @param {String} [options.redirectTo] redirectTo url
* @returns {Promise} - resolves when complete
*/
requestVerifySession(options = {}) {
return this._fxaClient.sessionVerifyResend(this.get('sessionToken'), {
redirectTo: options.redirectTo,
});
},
/**
* Verify the account using the verification code
*
* @param {String} code - the verification code
* @param {Object} [options]
* @param {Object} [options.service] - the service issuing signup request
* @returns {Promise} - resolves when complete
*/
verifySignUp(code, options = {}) {
const newsletters = this.get('newsletters');
if (newsletters && newsletters.length) {
this.unset('newsletters');
options.newsletters = newsletters;
}
return this._fxaClient
.verifyCode(this.get('uid'), code, options)
.then(() => {
if (newsletters) {
this._notifier.trigger('flow.initialize');
this._notifier.trigger('flow.event', {
event: 'newsletter.subscribed',
});
}
});
},
/**
* Verify the session and account using the verification code.
*
* @param {String} code - the verification code
* @param {Object} [options]
* @param {Object} [options.service] - the service issuing signup request
* @returns {Promise} - resolves when complete
*/
verifySessionCode(code, options = {}) {
const newsletters = this.get('newsletters');
if (newsletters && newsletters.length) {
this.unset('newsletters');
options.newsletters = newsletters;
}
return this._fxaClient
.sessionVerifyCode(this.get('sessionToken'), code, options)
.then(() => {
// If the promise resolves without error, then the code was correct and the
// verified flag can be set to true. If verified is not set, the user will
// likely be directed to the signin.
this.set('verified', true);
});
},
/**
* Resend the session and account verification code.
*
* @returns {Promise} - resolves when complete
*/
verifySessionResendCode() {
return this._fxaClient.sessionResendVerifyCode(this.get('sessionToken'));
},
/**
* Check whether the account's email is registered.
*
* @returns {Promise} resolves to `true` if email is registered,
* `false` otw.
*/
checkEmailExists() {
return this._fxaClient.checkAccountExistsByEmail(this.get('email'));
},
/**
* Check if the account's email is registered and retrieve third-party auth related values.
* Sets the third-party auth values onto the model.
* @returns {Promise<{
* exists: boolean,
* hasLinkedAccount: boolean,
* hasPassword: boolean
* }>}
*/
async checkAccountStatus() {
const { hasLinkedAccount, hasPassword, exists } =
await this._fxaClient.checkAccountStatus(this.get('email'));
this.set('hasLinkedAccount', hasLinkedAccount);
this.set('hasPassword', hasPassword);
return {
hasLinkedAccount,
hasPassword,
exists,
};
},
/**
* Check whether the account's UID is registered.
*
* @returns {Promise} resolves to `true` if the uid is registered,
* `false` otw.
*/
checkUidExists() {
return this._fxaClient.checkAccountExists(this.get('uid'));
},
/**
* Sign out the current session.
*
* @returns {Promise} - resolves when complete
*/
signOut() {
this._notifier.trigger('clear-uid');
return this._fxaClient.sessionDestroy(this.get('sessionToken'));
},
/**
* Destroy the account, remove it from the server
*
* @param {String} password - The user's password
* @returns {Promise} - resolves when complete
*/
destroy(password) {
return this._fxaClient
.deleteAccount(this.get('email'), password, this.get('sessionToken'))
.then(() => {
this._notifier.trigger('clear-uid');
this.trigger('destroy', this);
});
},
/**
* convert the old `grantedPermissions` field to the new
* `permissions` field. `grantedPermissions` was only filled
* with permissions that were granted. `permissions` contains
* each permission that the user has made a choice for, as
* well as its status.
*
* @private
*/
_upgradeGrantedPermissions() {
if (this.has('grantedPermissions')) {
const grantedPermissions = this.get('grantedPermissions');
// eslint-disable-next-line no-unused-vars
for (const clientId in grantedPermissions) {
const clientPermissions = {};
grantedPermissions[clientId].forEach(function (permissionName) {
// if the permission is in grantedPermissions, it's
// status is `true`
clientPermissions[permissionName] = true;
});
this.setClientPermissions(clientId, clientPermissions);
}
this.unset('grantedPermissions');
}
},
/**
* Return the permissions the client has seen as well as their state.
*
* Example returned object:
* {
* 'profile:display_name': false,
* 'profile:email': true
* }
*
* @param {String} clientId
* @returns {Object}
*/
getClientPermissions(clientId) {
const permissions = this.get('permissions') || {};
return permissions[clientId] || {};
},
/**
* Get the value of a single permission
*
* @param {String} clientId
* @param {String} permissionName
* @returns {Boolean}
*/
getClientPermission(clientId, permissionName) {
const clientPermissions = this.getClientPermissions(clientId);
return clientPermissions[permissionName];
},
/**
* Set the permissions for a client. `permissions`
* should be an object with the following format:
* {
* 'profile:display_name': false,
* 'profile:email': true
* }
*
* @param {String} clientId
* @param {Object} clientPermissions
*/
setClientPermissions(clientId, clientPermissions) {
const allPermissions = this.get('permissions') || {};
allPermissions[clientId] = clientPermissions;
this.set('permissions', allPermissions);
},
/**
* Check whether all the passed in permissions have been
* seen previously.
*
* @param {String} clientId
* @param {String[]} permissions
* @returns {Boolean} `true` if client has seen all the permissions,
* `false` otw.
*/
hasSeenPermissions(clientId, permissions) {
const seenPermissions = Object.keys(this.getClientPermissions(clientId));
// without's signature is `array, *values)`,
// *values cannot be an array, so convert to a form without can use.
const args = [permissions].concat(seenPermissions);
const notSeen = _.without.apply(_, args);
return notSeen.length === 0;
},
/**
* Return a list of permissions that have
* corresponding account values.
*
* @param {String[]} permissionNames
* @returns {String[]}
*/
getPermissionsWithValues(permissionNames) {
return permissionNames
.map((permissionName) => {
const accountKey = PERMISSIONS_TO_KEYS[permissionName];
// filter out permissions we do not know about
if (!accountKey) {
return null;
}
// filter out permissions for which the account does not have a value
if (!this.has(accountKey)) {
return null;
}
return permissionName;
})
.filter((permissionName) => permissionName !== null);
},
/**
* Change the user's password
*
* @param {String} oldPassword
* @param {String} newPassword
* @param {Object} relier
* @returns {Promise}
*/
changePassword(oldPassword, newPassword, relier) {
// Try to sign the user in before checking whether the
// passwords are the same. If the user typed the incorrect old
// password, they should know that first.
const fxaClient = this._fxaClient;
const email = this.get('email');
return fxaClient
.checkPassword(email, oldPassword, this.get('sessionToken'))
.then(() => {
if (oldPassword === newPassword) {
throw AuthErrors.toError('PASSWORDS_MUST_BE_DIFFERENT');
}
return fxaClient.changePassword(
email,
oldPassword,
newPassword,
this.get('sessionToken'),
this.get('sessionTokenContext'),
relier
);
})
.then(this.set.bind(this));
},
/**
* Override set to only allow fields listed in ALLOWED_FIELDS
*
* @method set
*/
set: _.wrap(
Backbone.Model.prototype.set,
function (func, attribute, value, options) {
let attributes;
// Handle both `"key", value` and `{key: value}` -style arguments.
if (_.isObject(attribute)) {
attributes = attribute;
} else {
attributes = {};
attributes[attribute] = value;
}
// eslint-disable-next-line no-unused-vars
for (const key in attributes) {
// As fields are phased out and no longer needed, they may drop out
// of the set of ALLOWED_KEYS. In this case, we should no longer store
// or use the associated key; however, it may still exist in a client's
// cached state (e.g. in local storage). The following ensures that
// deprecated keys are cleaned up over time.
if (_.contains(DEPRECATED_KEYS, key)) {
delete attributes[key];
continue;
}
if (!_.contains(ALLOWED_KEYS, key)) {
throw new Error(key + ' cannot be set on an Account');
}
}
return func.call(this, attribute, value, options);
}
),
/**
* Complete a password reset
*
* @param {String} password - the user's new password
* @param {String} token - email verification token
* @param {String} code - email verification code
* @param {Object} relier - relier being signed in to.
* @param {String} emailToHashWith - use this email to hash password with.
* @returns {Promise} - resolves when complete
*/
completePasswordReset(password, token, code, relier, emailToHashWith) {
return this._fxaClient
.completePasswordReset(
this.get('email'),
password,
token,
code,
relier,
{
emailToHashWith,
metricsContext: this._metrics.getFlowEventMetadata(),
accountResetToken: this.get('accountResetToken'),
}
)
.then(this.set.bind(this));
},
finishSetup(relier, token, email, password) {
return this._fxaClient
.finishSetup(relier, token, email, password)
.then(this.set.bind(this));
},
verifyAccountThirdParty(relier, code, provider) {
return this._fxaClient
.verifyAccountThirdParty(
relier,
code,
provider,
this._metrics.getFlowEventMetadata()
)
.then(this.set.bind(this));
},
createPassword(email, password) {
return this._fxaClient.createPassword(
this.get('sessionToken'),
email,
password
);
},
/**
* Fetch the account's list of attached clients.
*
* @returns {Promise} - resolves with a list of `AttachedClient` attribute sets when complete.
*/
fetchAttachedClients() {
return this._fxaClient.attachedClients(this.get('sessionToken'));
},
/**
* Fetch the list of subscription plans on SubHub.
*
* @returns {Promise} - resolves with a list of subscription plans.
*/
fetchSubscriptionPlans() {
return this._fxaClient.getSubscriptionPlans();
},
/**
* Check to see if the account has any subscriptions.
*
* @returns {Promise} resolves to an array of zero or more subscriptions.
*/
getSubscriptions() {
return this.settingsData().then((settingsData) =>
Array.isArray(settingsData.subscriptions)
? settingsData.subscriptions
: []
);
},
/**
* Check to see if the account has any subscriptions.
*
* @returns {Promise} resolves to a bool.
*/
hasSubscriptions() {
return this.getSubscriptions().then(
(subscriptions) => subscriptions.length > 0
);
},
/**
* Fetch the account's list of active subscriptions.
*
* @returns {Promise} - resolves with a list of subscription objects.
*/
fetchActiveSubscriptions() {
return this._fetchShortLivedSubscriptionsOAuthToken().then(
(accessToken) => {
return this._fxaClient.getActiveSubscriptions(
accessToken.get('token')
);
}
);
},
/**
* Create a support ticket on Zendesk.
*
* @param {Object} [supportTicket={}]
* @param {String} [supportTicket.plan]
* @param {String} [supportTicket.topic]
* @param {String} [supportTicket.subject] Optional subject
* @param {String} [supportTicket.message]
* @returns {Promise} - resolves with:
* - `success`
* - `ticket` OR `error`
*/
createSupportTicket(supportTicket) {
return this._fetchShortLivedSubscriptionsOAuthToken().then(
(accessToken) => {
return this._fxaClient.createSupportTicket(
accessToken.get('token'),
supportTicket
);
}
);
},
/**
* Update a user newsletters subscription.
*
* @param {String[]} [newsletters]
* @returns {Promise} - resolves with empty response
*/
updateNewsletters(newsletters) {
return this._fxaClient.updateNewsletters(
this.get('sessionToken'),
newsletters
);
},
/**
* Fetch the account's device list.
*
* @returns {Promise} - resolves with an array of devices
*/
fetchDeviceList() {
return this._fxaClient.deviceList(this.get('sessionToken'));
},
/**
* Fetch an access token with subscription management scopes and a lifetime
* of 30 seconds.
*
* @returns {Promise<OAuthToken>}
*/
_fetchShortLivedSubscriptionsOAuthToken() {
return this.createOAuthToken(
this._subscriptionsConfig.managementClientId,
{
scope: this._subscriptionsConfig.managementScopes,
ttl: 30,
}
);
},
/**
* Disconnect a client from the account
*
* @param {Object} client - AttachedClient model to remove
* @returns {Promise} - resolves when complete
*/
destroyAttachedClient(client) {
const ids = client.pick(
'deviceId',
'sessionTokenId',
'clientId',
'refreshTokenId'
);
const sessionToken = this.get('sessionToken');
return this._fxaClient
.attachedClientDestroy(sessionToken, ids)
.then(() => {
// This notifies the containing collection that the client was destroyed.
client.destroy();
});
},
/**
* Initiate a password reset
*
* @param {Object} relier
* @param {Object} [options]
* @param {String} [options.resume] resume token
* @returns {Promise}
*/
resetPassword(relier, options = {}) {
return this._fxaClient.passwordReset(this.get('email'), relier, {
metricsContext: this._metrics.getFlowEventMetadata(),
resume: options.resume,
});
},
/**
* Retry a password reset
*
* @param {String} passwordForgotToken
* @param {Object} relier
* @param {Object} [options]
* @param {String} [options.resume] resume token
* @returns {Promise}
*/
retryResetPassword(passwordForgotToken, relier, options = {}) {
return this._fxaClient.passwordResetResend(
this.get('email'),
passwordForgotToken,
relier,
{
metricsContext: this._metrics.getFlowEventMetadata(),
resume: options.resume,
}
);
},
/**
* Returns `true` if `keyFetchToken` and `unwrapBKey` are set.
* @returns {boolean}
*/
canFetchKeys() {
return this.has('keyFetchToken') && this.has('unwrapBKey');
},
/**
* Fetch keys for the account. Requires account to have
* `keyFetchToken` and `unwrapBKey`
*
* @returns {Promise} that resolves with the account keys, if they
* can be generated, resolves with null otherwise.
*/
accountKeys() {
if (!this.canFetchKeys()) {
return Promise.resolve(null);
}
return this._fxaClient.accountKeys(
this.get('keyFetchToken'),
this.get('unwrapBKey')
);
},
/**
* Check whether password reset is complete for the given token
*
* @param {String} token
* @returns {Promise} resolves to a boolean, true if password reset has
* been completed for the given token, false otw.
*/
isPasswordResetComplete(token) {
return this._fxaClient.isPasswordResetComplete(token);
},
/**
* Send an unblock email.
*
* @returns {Promise} resolves when complete
*/
sendUnblockEmail() {
return this._fxaClient.sendUnblockEmail(this.get('email'), {
metricsContext: this._metrics.getFlowEventMetadata(),
});
},
/**
* Reject a login unblock code.
*
* @param {String} unblockCode
* @returns {Promise} resolves when complete
*/
rejectUnblockCode(unblockCode) {
return this._fxaClient.rejectUnblockCode(this.get('uid'), unblockCode);
},
/**
* Get emails associated with user.
*
* @returns {Promise}
*/
recoveryEmails() {
return this._fxaClient.recoveryEmails(this.get('sessionToken'));
},
/**
* Associates a new email to a user's account.
*
* @param {String} email
* @param {Object} [options={}] options
* @param {Boolean} [options.verificationMethod] method to verify the recovery email with, ex. email-otp (for codes)
* @returns {Promise}
*/
recoveryEmailCreate(email, options = {}) {
return this._fxaClient.recoveryEmailCreate(
this.get('sessionToken'),
email,
options
);
},
/**
* Deletes email from user's account.
*
* @param {String} email
*
* @returns {Promise}
*/
recoveryEmailDestroy(email) {
return this._fxaClient.recoveryEmailDestroy(
this.get('sessionToken'),
email
);
},
/**
* Verify a secondary email via a code.
*
* @param {String} email
* @param {Number} code
*
* @returns {Promise}
*/
recoveryEmailSecondaryVerifyCode(email, code) {
return this._fxaClient.recoveryEmailSecondaryVerifyCode(
this.get('sessionToken'),
email,
code
);
},
/**
* Resend secondary email verification code.
*
* @param {String} email
*
* @returns {Promise}
*/
recoveryEmailSecondaryResendCode(email) {
return this._fxaClient.recoveryEmailSecondaryResendCode(
this.get('sessionToken'),
email
);
},
/**
* Resend the verification code associated with the passed email address
*
* @param {String} email
*
* @returns {Promise}
*/
resendEmailCode(email) {
return this._fxaClient.resendEmailCode(this.get('sessionToken'), email);
},
/**
* Get emails associated with user.
*
* @returns {Promise}
*/
getEmails() {
return this._fxaClient.getEmails(this.get('sessionToken'));
},
/**
* Associates a new email to a users account.
*
* @param {String} email
*
* @returns {Promise}
*/
createEmail(email) {
return this._fxaClient.createEmail(this.get('sessionToken'), email);
},
/**
* Deletes an email from a users account.
*
* @param {String} email
*
* @returns {Promise}
*/
deleteEmail(email) {
return this._fxaClient.deleteEmail(this.get('sessionToken'), email);
},
/**
* Sets the primary email address of the user.
*
* @param {String} email
*
* @returns {Promise}
*/
setPrimaryEmail(email) {
return this._fxaClient.recoveryEmailSetPrimaryEmail(
this.get('sessionToken'),
email
);
},
/**
* Creates a new TOTP token for a user.
*
* @returns {Promise}
*/
createTotpToken() {
return this._fxaClient.createTotpToken(this.get('sessionToken'), {
metricsContext: this._metrics.getFlowEventMetadata(),
});
},
/**
* Deletes the current TOTP token for a user.
*
* @returns {Promise}
*/
deleteTotpToken() {
this.set('totpVerified', false);
return this._fxaClient.deleteTotpToken(this.get('sessionToken'));
},
/**
* Verifies a TOTP code. If code is verified, token will be marked as verified.
*
* @param {String} code
* @param {String} service
* @returns {Promise}
*/
verifyTotpCode(code, service) {
const options = {
metricsContext: this._metrics.getFlowEventMetadata(),
service: service,
};
return this._fxaClient
.verifyTotpCode(this.get('sessionToken'), code, options)
.then((result) => {
if (result.success) {
this.set('totpVerified', true);
// Make sure verified is also set. If this is not set, the user
// maybe redirected to the signin page unintentionally.
this.set('verified', true);
}
return result;
});
},
/**
* Check to see if the current user has a verified TOTP token.
*
* @returns {Promise}
*/
checkTotpTokenExists() {
return this._fxaClient.checkTotpTokenExists(this.get('sessionToken'));
},
/**
* Consume a backup authentication code.
*
* @param {String} code
* @returns {Promise}
*/
async consumeRecoveryCode(code) {
const response = await this._fxaClient.consumeRecoveryCode(
this.get('sessionToken'),
code
);
this.set('verified', true);
return response;
},
/**
* Replaces all current backup authentication codes.
*
* @returns {Promise}
*/
replaceRecoveryCodes() {
return this._fxaClient.replaceRecoveryCodes(this.get('sessionToken'));
},
/**
* Creates a new account recovery key bundle for the current user.
*
* @param {String} password The current password for the user
* @param {String} enable Enable to account recovery key
* @returns {Promise}
*/
createRecoveryBundle(password, enabled) {
return this._fxaClient.createRecoveryBundle(
this.get('email'),
password,
this.get('sessionToken'),
this.get('uid'),
enabled
);
},
/**
* Deletes the account recovery key associated with this user.
*
* @returns {Promise} resolves when complete.
*/
deleteRecoveryKey() {
return this._fxaClient.deleteRecoveryKey(this.get('sessionToken'));
},
/**
* Verify the account recovery key associated with this user.
*
* @returns {Promise} resolves when complete.
*/
verifyRecoveryKey(recoveryKeyId) {
return this._fxaClient.verifyRecoveryKey(
this.get('sessionToken'),
recoveryKeyId
);
},
/**
* This checks to see if an account recovery key exists for a user.
*
* @returns {Promise} resolves with response when complete.
*
* Response: {
* exists: <boolean>
* }
*/
checkRecoveryKeyExists() {
return this._fxaClient.recoveryKeyExists(this.get('sessionToken'));
},
/**
* This checks to see if an account recovery key exists for a given
* email.
*
* Response: {
* exists: <boolean>
* }
* @returns {Promise} resolves with response when complete.
*/
checkRecoveryKeyExistsByEmail() {
return this._fxaClient.recoveryKeyExists(undefined, this.get('email'));
},
/**
* Verify password forgot token to retrieve `accountResetToken`.
*
* @param {String} code
* @param {String} token
* @param {Object} [options={}] Options
* @param {String} [options.accountResetWithRecoveryKey] - perform account reset with account recovery key
* @returns {Promise} resolves with response when complete.
*/
passwordForgotVerifyCode(code, token, options) {
return this._fxaClient.passwordForgotVerifyCode(code, token, options);
},
/**
* Get this user's recovery bundle, which contains their `kB`.
*
* @param {String} uid - Uid of user
* @param {String} recoveryKey - Account recovery key for user
* @returns {Promise} resolves with response when complete.
*/
getRecoveryBundle(uid, recoveryKey) {
return this._fxaClient.getRecoveryBundle(
this.get('accountResetToken'),
uid,
recoveryKey
);
},
/**
* Reset an account using an account recovery key.
*
* @param {String} accountResetToken
* @param {String} password - new password
* @param {String} recoveryKeyId - recoveryKeyId that maps to account recovery key
* @param {String} kB - original kB
* @param {Object} relier - relier being signed in to.
* @param {String} emailToHashWith - has password with this email address
* @returns {Promise} resolves with response when complete.
*/
resetPasswordWithRecoveryKey(
accountResetToken,
password,
recoveryKeyId,
kB,
relier,
emailToHashWith
) {
return this._fxaClient
.resetPasswordWithRecoveryKey(
accountResetToken,
this.get('email'),
password,
recoveryKeyId,
kB,
relier,
{
emailToHashWith,
metricsContext: this._metrics.getFlowEventMetadata(),
}
)
.then(this.set.bind(this));
},
/**
* Verify the OIDC ID Token associated with an account.
*
* @param {String} idToken - the ID Token
* @param {String} clientId - the client ID, used to verify the 'aud' claim
* @param {Number} expiryGracePeriod - number of **seconds** past the
* token expiration ('exp' claim value) for which the token will be treated
* as valid.
* @returns {Promise} resolves with response when complete.
*/
verifyIdToken(idToken, clientId, expiryGracePeriod) {
return this._fxaClient.verifyIdToken(
idToken,
clientId,
expiryGracePeriod
);
},
/**
* Creates a signin code for a user. This code can be exchanged
* for a users email address.
*
* @returns {Promise} resolves with response when complete.
*/
createSigninCode() {
return this._fxaClient.createSigninCode(this.get('sessionToken'));
},
/**
* Queues up a reminder to CAD to be delivered at a later time.
*
* @returns {Promise} resolves with response when complete.
*/
createCadReminder() {
return this._fxaClient.createCadReminder(this.get('sessionToken'));
},
/**
* Sends a push notification to verify a login request.
*
* @returns {Promise} resolves with response when complete.
*/
sendPushLoginRequest() {
return this._fxaClient.sendPushLoginRequest(this.get('sessionToken'));
},
},
{
ALLOWED_KEYS: ALLOWED_KEYS,
PERMISSIONS_TO_KEYS: PERMISSIONS_TO_KEYS,
}
);
[
'getProfile',
'getAvatar',
'deleteAvatar',
'uploadAvatar',
'postDisplayName',
].forEach(function (method) {
Account.prototype[method] = function (...args) {
let profileClient;
return this.profileClient()
.then((client) => {
profileClient = client;
const accessToken = this.get('accessToken');
return profileClient[method].call(profileClient, accessToken, ...args);
})
.catch((err) => {
if (ProfileErrors.is(err, 'INVALID_TOKEN')) {
this.discardSessionToken();
} else if (ProfileErrors.is(err, 'UNAUTHORIZED')) {
// If no oauth token existed, or it has gone stale,
// get a new one and retry.
return this._fetchProfileOAuthToken()
.then(() => {
const accessToken = this.get('accessToken');
return profileClient[method].call(
profileClient,
accessToken,
...args
);
})
.catch((err) => {
if (ProfileErrors.is(err, 'UNAUTHORIZED')) {
// If fetching a new profile token failed, or using
// the new profile token failed, consider the sessionToken
// invalid, forcing the user to sign in again. See #999
this.discardSessionToken();
}
throw err;
});
}
throw err;
});
};
});
Cocktail.mixin(Account, ResumeTokenMixin);
export default Account;