packages/fxa-content-server/app/scripts/models/user.js (441 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 module represents a user of the fxa-content-server site. // It persists accounts the user has logged in with and potentially // other state about the user that might be useful. // // i.e. User hasMany Accounts. import _ from 'underscore'; import Account from './account'; import AuthErrors from '../lib/auth-errors'; import Backbone from 'backbone'; import Cocktail from 'cocktail'; import Constants from '../lib/constants'; import ResumeTokenMixin from './mixins/resume-token'; import UrlMixin from './mixins/url'; import Storage from '../lib/storage'; import vat from '../lib/vat'; function isValidAccount(account) { return !!(account && account.get('email') && account.get('uid')); } var User = Backbone.Model.extend({ initialize(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._subscriptionsConfig = options.subscriptionsConfig; this._storage = options.storage || Storage.factory(); this.sentryMetrics = options.sentryMetrics; // For now, the uniqueUserId is passed in from app-start instead of // being initialized from the resume token or localStorage. this.set('uniqueUserId', options.uniqueUserId); // We cache the signed-in account instance to share across // consumers so that they don't have to refetch the account's // ephemeral data, e.g. OAuth access tokens. this._cachedSignedInAccount = null; this.window = options.window || window; this.fetch(); }, defaults: { // uniqueUserId is a stable identifier for this User on this computer. uniqueUserId: null, emailFromIndex: null, }, resumeTokenFields: ['uniqueUserId'], resumeTokenSchema: { uniqueUserId: vat.uuid(), }, // Hydrate the model. Returns a promise. fetch() { return Promise.resolve().then(() => { this.populateFromStringifiedResumeToken(this.getSearchParam('resume')); }); }, /** * Log the number of stored accounts */ logNumStoredAccounts() { const numAccounts = Object.keys(this._accounts()).length; this._metrics.logNumStoredAccounts(numAccounts); }, _accounts() { return this._storage.get('accounts') || {}; }, _getAccount(uid) { if (!uid) { return null; } else { return this._accounts()[uid] || null; } }, _setSignedInAccountUid(uid) { this._storage.set('currentAccountUid', uid); // Clear the in-memory cache if the uid has changed if ( this._cachedSignedInAccount && this._cachedSignedInAccount.get('uid') !== uid ) { this._cachedSignedInAccount = null; } }, clearSignedInAccountUid() { this._storage.remove('currentAccountUid'); this._cachedSignedInAccount = null; }, _getSignedInAccountData() { return this._getAccount(this._storage.get('currentAccountUid')); }, /** * Persists account data to localStorage. * The account will only be written if it has a uid and email * * @param {Object} accountData */ _persistAccount(accountData) { const account = this.initAccount(accountData); if (!isValidAccount(account)) { return; } const uid = account.get('uid'); const accounts = this._accounts(); accounts[uid] = account.toPersistentJSON(); this._storage.set('accounts', accounts); }, // A convenience method that initializes an account instance from // raw account data. initAccount(accountData) { if (accountData instanceof Account) { // we already have an account instance return accountData; } const account = new Account(accountData, { fxaClient: this._fxaClient, metrics: this._metrics, notifier: this._notifier, oAuthClient: this._oAuthClient, oAuthClientId: this._oAuthClientId, profileClient: this._profileClient, sentryMetrics: this.sentryMetrics, subscriptionsConfig: this._subscriptionsConfig, }); // automatically persist changes to valid accounts. this.listenTo(account, 'change', () => { if (isValidAccount(account)) { this._persistAccount(account); // An account can't very well be the signed in account // if it has no sessionToken. if (!account.has('sessionToken') && this.isSignedInAccount(account)) { this.clearSignedInAccountUid(); } } }); return account; }, isSyncAccount(account) { return this.initAccount(account).isFromSync(); }, /** * Check the session status of the currently signed in user. * * @param {Object} [account] - account to check session status. If not provided, * the currently signed in account is used. * @returns {Promise<Account>} resolves to signed in Account. * If no user is signed in, rejects with an `INVALID_TOKEN` error. */ sessionStatus(account = this.getSignedInAccount()) { return account.sessionStatus().then(() => account); }, getSignedInAccount() { if (!this._cachedSignedInAccount) { this._cachedSignedInAccount = this.initAccount( this._getSignedInAccountData() ); } return this._cachedSignedInAccount; }, /** * Check if the current account is the signed in account * * @param {Object} account * @returns {Boolean} */ isSignedInAccount(account) { const accountUid = account.get('uid'); const signedInAccountUid = this.getSignedInAccount().get('uid'); // both accounts must have a UID to be able to compare. if (!signedInAccountUid || !accountUid) { return false; } return accountUid === signedInAccountUid; }, /** * Check if another account is signed in. Not the inverse * of `isSignedInAccount` because if either account is default, * this will return `false`. * * @param {Object} account * @returns {Boolean} */ isAnotherAccountSignedIn(account) { return ( !this.getSignedInAccount().isDefault() && !this.isSignedInAccount(account) ); }, setSignedInAccountByUid(uid) { if (this._accounts()[uid]) { this._setSignedInAccountUid(uid); } }, getAccountByUid(uid) { var account = this._accounts()[uid]; return this.initAccount(account); }, getAccountByEmail(email) { // Reverse the list so newest accounts are first var uids = Object.keys(this._accounts()).reverse(); var accounts = this._accounts(); var uid = _.find(uids, function (uid) { return accounts[uid].email === email; }); return this.initAccount(accounts[uid]); }, /** * Return the account to display in the account chooser. * * Account preference order : * 1. Accounts fetched using a signinCode (only have email) * 2. First Sync accounts w/ email, sessionToken * 3. Last account that was used to sign into any service * (i.e., result of getSignedInAccount) * 4. First valid account * 5. Default (empty) account * * @returns {Account} resolves to an Account. */ getChooserAccount() { function isAuthenticatedAccount(account) { return isValidAccount(account) && account.get('sessionToken'); } // 1. Accounts fetched using a signinCode (only have email) if (this.has('signinCodeAccount')) { return this.get('signinCodeAccount'); } const allAccounts = _.map(this._accounts(), (accountData) => this.initAccount(accountData) ); // 2. First Sync account w/ email, sessionToken const authenticatedSyncAccount = _.find(allAccounts, (account) => { return this.isSyncAccount(account) && isAuthenticatedAccount(account); }); if (authenticatedSyncAccount) { return authenticatedSyncAccount; } // 3. Last account that was used to sign into any service const signedInAccount = this.getSignedInAccount(); const lastUsedAccount = isAuthenticatedAccount(signedInAccount) && signedInAccount; if (lastUsedAccount) { return lastUsedAccount; } // 4. First valid account const firstValidAccount = _.find(allAccounts, isValidAccount); if (firstValidAccount) { return firstValidAccount; } // 5. Default account return this.initAccount({}); }, removeAllAccounts() { this.clearSignedInAccountUid(); this._storage.remove('accounts'); this.unset('signinCodeAccount'); }, /** * Remove the account from storage. If account is the "signed in account", * the signed in account field will be cleared. * * @param {Object} accountData - Account model or object representing * account data. */ removeAccount(accountData) { var account = this.initAccount(accountData); if (this.isSignedInAccount(account)) { this.clearSignedInAccountUid(); } var accounts = this._accounts(); var uid = account.get('uid'); delete accounts[uid]; this._storage.set('accounts', accounts); }, /** * Delete the account from the server, notify all interested parties, * delete the account from storage. * * @param {Object} accountData * @param {String} password - the user's password * @return {Promise} - resolves when complete */ deleteAccount(accountData, password) { var account = this.initAccount(accountData); return account.destroy(password).then(() => { this.removeAccount(account); this._notifier.triggerAll(this._notifier.COMMANDS.DELETE, { uid: account.get('uid'), }); }); }, /** * Stores a new account and sets it as the current account. * * @param {Object|Account} accountData * @returns {Promise<Account>} */ setSignedInAccount(accountData) { var account = this.initAccount(accountData); account.set('lastLogin', Date.now()); return this.updateSignedInAccount(account); }, /** * Store and update the cached signed in account. * * @param {Account} account * @returns {Promise<Account>} */ updateSignedInAccount(account) { return this.setAccount(account).then((account) => { this._cachedSignedInAccount = account; this._setSignedInAccountUid(account.get('uid')); return account; }); }, // Hydrate the account then persist it setAccount(accountData) { var account = this.initAccount(accountData); return account.fetch().then(() => { this._persistAccount(account); return account; }); }, /** * Remove accounts with invalid uids. * See #4769. w/ e10s enabled, post account reset, * a phantom account with a uid of the string `undefined` * was being written to localStorage. These accounts * are garbage, get rid of them. * * @returns {Promise} */ removeAccountsWithInvalidUid() { return Promise.resolve().then(() => { const accounts = this._accounts(); // eslint-disable-next-line no-unused-vars for (const uid in accounts) { // the string `undefined` is correct here. That's the // uid being stored in localStorage. if (!uid || uid === 'undefined') { delete accounts[uid]; this._storage.set('accounts', accounts); } } }); }, /** * Sign in an account. Notifies other tabs of signin on success. * * @param {Object} account - account to sign in * @param {String} password - the user's password * @param {Object} relier - relier being signed in to * @param {Object} [options] - options * @param {String} [options.unblockCode] - unblock code * @returns {Promise} - resolves when complete */ signInAccount(account, password, relier, options) { return account.signIn(password, relier, options).then( () => { // If there's an account with the same uid in localStorage we merge // its attributes with the new account instance to retain state // used across sign-ins, such as granted permissions. var oldAccount = this.getAccountByUid(account.get('uid')); if (!oldAccount.isDefault()) { // allow new account attributes to override old ones oldAccount.set( _.omit(account.attributes, function (val) { return typeof val === 'undefined'; }) ); account = oldAccount; } this._notifyOfAccountSignIn(account); return this.setSignedInAccount(account); }, (err) => { // User tried to sign in with a cached session that is no // longer valid. Remove the account from storage and mark // the session as invalid. See #999 if (AuthErrors.is(err, 'INVALID_TOKEN')) { account.discardSessionToken(); this.removeAccount(account); } throw err; } ); }, /** * Sign up a new account * * @param {Object} account - account to sign up * @param {String} password - the user's password * @param {Object} relier - relier being signed in to * @param {Object} [options] - options * @returns {Promise} - resolves when complete */ signUpAccount(account, password, relier, options) { return account .signUp(password, relier, options) .then(() => this.setSignedInAccount(account)); }, /** * Sign out the given account clearing any info held about the account. * * @param {Object} account - account to sign out * @returns {Promise} - resolves when complete */ signOutAccount(account) { return account.signOut().then( // Remove the account, even on failure. Everything is A-OK. // See issue #616 (val) => { this.removeAccount(account); return val; }, (err) => { this.removeAccount(account); throw err; } ); }, /** * Complete signup for the account. Notifies other tabs of signin * if the account has a sessionToken and verification successfully * completes. * * @param {Object} account - account to verify * @param {String} code - verification code * @param {Object} [options] * @param {Object} [options.service] - the service issuing signup request * @returns {Promise} - resolves with the account when complete */ completeAccountSignUp(account, code, options) { // The original tab may no longer be open to notify other // windows the user is signed in. If the account has a `sessionToken`, // the user verified in the same browser. Notify any tabs that care. const notifyIfSignedIn = (account) => { if (account.has('sessionToken')) { this._notifyOfAccountSignIn(account); } }; return account.verifySignUp(code, options).then(function () { notifyIfSignedIn(account); return account; }); }, /** * Verify an account or session with `code` * * @param {*} account * @param {String} code * @param {Object} [options] * @param {Object} [options.service] - the service issuing signup request * @param {Object} [options.scopes] - the oauth scopes for request * @returns {Promise} - resolves with the account when complete */ verifyAccountSessionCode(account, code, options) { return account.verifySessionCode(code, options).then(() => { this._notifyOfAccountSignIn(account); return account; }); }, /** * Change the account password * * @param {Object} account - account to change the password for. * @param {String} oldPassword - the old password * @param {String} newPassword - the new password * @param {Object} relier - the relier used to open settings * @return {Object} promise - resolves with the updated account * when complete. */ changeAccountPassword(account, oldPassword, newPassword, relier) { return account .changePassword(oldPassword, newPassword, relier) .then(() => { return this.setSignedInAccount(account); }) .then(() => { // Notify the browser whenever the password has changed const notifier = this._notifier; const changePasswordCommand = notifier.COMMANDS.CHANGE_PASSWORD; const loginData = account.pick( Object.keys(notifier.SCHEMATA[changePasswordCommand]) ); loginData.verified = !!loginData.verified; notifier.triggerRemote(changePasswordCommand, loginData); return account; }); }, /** * Notify other tabs of account sign in * * @private * @param {Object} account */ _notifyOfAccountSignIn(account) { const notifier = this._notifier; const signedInCommand = notifier.COMMANDS.SIGNED_IN; notifier.triggerRemote( signedInCommand, account.pick(Object.keys(notifier.SCHEMATA[signedInCommand])) ); }, /** * Complete a password reset for the account. Notifies other tabs * of signin on success. * * @param {Object} account - account to sign up * @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 */ completeAccountPasswordReset( account, password, token, code, relier, emailToHashWith ) { return account .completePasswordReset(password, token, code, relier, emailToHashWith) .then(() => { this._notifyOfAccountSignIn(account); return this.setSignedInAccount(account); }); }, finishSetup(account, relier, token, email, password) { return account.finishSetup(relier, token, email, password).then(() => { this._notifyOfAccountSignIn(account); return this.setSignedInAccount(account); }); }, verifyAccountThirdParty(account, relier, code, provider) { return account.verifyAccountThirdParty(relier, code, provider).then(() => { this._notifyOfAccountSignIn(account); return this.setSignedInAccount(account); }); }, /** * Complete a password reset for the account using an account recovery key. Notifies other tabs * of signin on success. * * @param {Object} account - account to sign up * @param {String} password - the user's new password * @param {String} accountResetToken - token used to issue request * @param {String} recoveryKeyId - recoveryKeyId that maps to backup authentication code * @param {String} kB - original kB * @param {Object} relier - relier being signed in to * @param {String} emailToHashWith - hash password with this email * @returns {Promise} - resolves when complete */ completeAccountPasswordResetWithRecoveryKey( account, password, accountResetToken, recoveryKeyId, kB, relier, emailToHashWith ) { return account .resetPasswordWithRecoveryKey( accountResetToken, password, recoveryKeyId, kB, relier, emailToHashWith ) .then(() => { this._notifyOfAccountSignIn(account); return this.setSignedInAccount(account); }); }, /** * Disconnect an attached client from the given account. * When the client is the current device also sign out of Sync. * * @param {Object} account - account with the attached client * @param {Object} client - AttachedClient model to disconnect * @returns {Promise} resolves when the action completes */ destroyAccountAttachedClient(account, client) { return account.destroyAttachedClient(client).then(() => { if (client.get('isCurrentSession') && this.isSignedInAccount(account)) { this._notifier.triggerRemote(this._notifier.COMMANDS.SIGNED_OUT, { uid: account.get('uid'), }); this.removeAccount(account); } }); }, /** * Fetch and return the list of attached clients for the given account. * * @param {Object} account - account for which device list is requested * @returns {Promise} resolves when the action completes */ fetchAccountAttachedClients(account) { return account.fetchAttachedClients(); }, /** * Check whether an Account's `uid` is registered. Removes the account * from storage if account no longer exists on the server. * * @param {Object} account - account to check * @returns {Promise} resolves to `true` if an account exists, `false` otw. */ checkAccountUidExists(account) { return account.checkUidExists().then((exists) => { if (!exists) { this.removeAccount(account); } return exists; }); }, /** * Check whether an Account's `email` is registered. Removes the account * from storage if account no longer exists on the server. * * @param {Object} account - account to check * @returns {Promise} resolves to `true` if an account exists, `false` otw. */ checkAccountEmailExists(account) { return account.checkEmailExists().then((exists) => { if (!exists) { this.removeAccount(account); } return exists; }); }, /** * Check whether an Account's `email` is registered. Removes the account * from storage if account no longer exists on the server. * Additionally, retrieves third-party auth related values. * @param {Object} account - account to check * @returns {Promise<{ * exists: boolean, * hasLinkedAccount: boolean, * hasPassword: boolean * }>} */ checkAccountStatus(account) { return account.checkAccountStatus().then((result) => { if (!result.exists) { this.removeAccount(account); } return result; }); }, /** * Reject the unblockCode for the given account. This invalidates * the unblock code and logs the signin attempt as suspicious. * * @param {Object} account * @param {String} unblockCode * @returns {Promise} */ rejectAccountUnblockCode(account, unblockCode) { return account.rejectUnblockCode(unblockCode); }, /** * Should the signed in account be set to the browser's signed in account? * * @param {Object} service service being signed into. * @param {Boolean} isPairing device is trying to pair * @param {Account} browserAccount * @returns {Boolean} */ shouldSetSignedInAccountFromBrowser(service, isPairing, browserAccount) { // If service=sync or the device is trying to pair, // always use the browser's state of the world. // If trying to sign in to an OAuth RP, prefer any users that are // stored in localStorage and only use the browser's state if no // user is stored. if (!browserAccount || browserAccount.isDefault()) { return false; } if (service === Constants.SYNC_SERVICE) { return true; } if (isPairing) { return true; } if (this.getSignedInAccount().isDefault()) { return true; } return false; }, /** * Merge the browser account with any local account data. If no local * account exists, one is created. * * @param {Object} browserAccountData * @returns {Promise<Account>} */ mergeBrowserAccount(browserAccountData) { // the default (empty) account will be returned if none stored. const storedAccount = this.getAccountByUid(browserAccountData.uid); const { email, sessionToken, uid, verified } = browserAccountData; storedAccount.set({ email, sessionToken, sessionTokenContext: Constants.SESSION_TOKEN_USED_FOR_SYNC, uid, verified, }); return this.setAccount(storedAccount); }, /** * Set the signinCode account from `accountData` * * @param {Object} accountData * @returns {Promise} */ setSigninCodeAccount(accountData) { return Promise.resolve().then(() => { const account = this.initAccount(_.pick(accountData, 'email')); this.set('signinCodeAccount', account); }); }, }); Cocktail.mixin(User, ResumeTokenMixin, UrlMixin); export default User;