/* 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/. */ /** * Firefox Accounts Web Channel. * * Uses the WebChannel component to receive messages * about account state changes. */ this.EXPORTED_SYMBOLS = ["EnsureFxAccountsWebChannel"]; const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components; Cu.import("resource://gre/modules/XPCOMUtils.jsm"); Cu.import("resource://gre/modules/FxAccountsCommon.js"); XPCOMUtils.defineLazyModuleGetter(this, "Services", "resource://gre/modules/Services.jsm"); XPCOMUtils.defineLazyModuleGetter(this, "WebChannel", "resource://gre/modules/WebChannel.jsm"); XPCOMUtils.defineLazyModuleGetter(this, "fxAccounts", "resource://gre/modules/FxAccounts.jsm"); XPCOMUtils.defineLazyModuleGetter(this, "FxAccountsStorageManagerCanStoreField", "resource://gre/modules/FxAccountsStorage.jsm"); XPCOMUtils.defineLazyModuleGetter(this, "Weave", "resource://services-sync/main.js"); const COMMAND_PROFILE_CHANGE = "profile:change"; const COMMAND_CAN_LINK_ACCOUNT = "fxaccounts:can_link_account"; const COMMAND_LOGIN = "fxaccounts:login"; const COMMAND_LOGOUT = "fxaccounts:logout"; const COMMAND_DELETE = "fxaccounts:delete"; const COMMAND_SYNC_PREFERENCES = "fxaccounts:sync_preferences"; const COMMAND_CHANGE_PASSWORD = "fxaccounts:change_password"; const PREF_LAST_FXA_USER = "identity.fxaccounts.lastSignedInUserHash"; const PREF_SYNC_SHOW_CUSTOMIZATION = "services.sync-setup.ui.showCustomizationDialog"; /** * A helper function that extracts the message and stack from an error object. * Returns a `{ message, stack }` tuple. `stack` will be null if the error * doesn't have a stack trace. */ function getErrorDetails(error) { let details = { message: String(error), stack: null }; // Adapted from Console.jsm. if (error.stack) { let frames = []; for (let frame = error.stack; frame; frame = frame.caller) { frames.push(String(frame).padStart(4)); } details.stack = frames.join("\n"); } return details; } /** * Create a new FxAccountsWebChannel to listen for account updates * * @param {Object} options Options * @param {Object} options * @param {String} options.content_uri * The FxA Content server uri * @param {String} options.channel_id * The ID of the WebChannel * @param {String} options.helpers * Helpers functions. Should only be passed in for testing. * @constructor */ this.FxAccountsWebChannel = function(options) { if (!options) { throw new Error("Missing configuration options"); } if (!options["content_uri"]) { throw new Error("Missing 'content_uri' option"); } this._contentUri = options.content_uri; if (!options["channel_id"]) { throw new Error("Missing 'channel_id' option"); } this._webChannelId = options.channel_id; // options.helpers is only specified by tests. this._helpers = options.helpers || new FxAccountsWebChannelHelpers(options); this._setupChannel(); }; this.FxAccountsWebChannel.prototype = { /** * WebChannel that is used to communicate with content page */ _channel: null, /** * Helpers interface that does the heavy lifting. */ _helpers: null, /** * WebChannel ID. */ _webChannelId: null, /** * WebChannel origin, used to validate origin of messages */ _webChannelOrigin: null, /** * Release all resources that are in use. */ tearDown() { this._channel.stopListening(); this._channel = null; this._channelCallback = null; }, /** * Configures and registers a new WebChannel * * @private */ _setupChannel() { // if this.contentUri is present but not a valid URI, then this will throw an error. try { this._webChannelOrigin = Services.io.newURI(this._contentUri, null, null); this._registerChannel(); } catch (e) { log.error(e); throw e; } }, _receiveMessage(message, sendingContext) { let command = message.command; let data = message.data; switch (command) { case COMMAND_PROFILE_CHANGE: Services.obs.notifyObservers(null, ON_PROFILE_CHANGE_NOTIFICATION, data.uid); break; case COMMAND_LOGIN: this._helpers.login(data).catch(error => this._sendError(error, message, sendingContext)); break; case COMMAND_LOGOUT: case COMMAND_DELETE: this._helpers.logout(data.uid).catch(error => this._sendError(error, message, sendingContext)); break; case COMMAND_CAN_LINK_ACCOUNT: let canLinkAccount = this._helpers.shouldAllowRelink(data.email); let response = { command: command, messageId: message.messageId, data: { ok: canLinkAccount } }; log.debug("FxAccountsWebChannel response", response); this._channel.send(response, sendingContext); break; case COMMAND_SYNC_PREFERENCES: this._helpers.openSyncPreferences(sendingContext.browser, data.entryPoint); break; case COMMAND_CHANGE_PASSWORD: this._helpers.changePassword(data).catch(error => this._sendError(error, message, sendingContext)); break; default: log.warn("Unrecognized FxAccountsWebChannel command", command); break; } }, _sendError(error, incomingMessage, sendingContext) { log.error("Failed to handle FxAccountsWebChannel message", error); this._channel.send({ command: incomingMessage.command, messageId: incomingMessage.messageId, data: { error: getErrorDetails(error), }, }, sendingContext); }, /** * Create a new channel with the WebChannelBroker, setup a callback listener * @private */ _registerChannel() { /** * Processes messages that are called back from the FxAccountsChannel * * @param webChannelId {String} * Command webChannelId * @param message {Object} * Command message * @param sendingContext {Object} * Message sending context. * @param sendingContext.browser {browser} * The <browser> object that captured the * WebChannelMessageToChrome. * @param sendingContext.eventTarget {EventTarget} * The <EventTarget> where the message was sent. * @param sendingContext.principal {Principal} * The <Principal> of the EventTarget where the message was sent. * @private * */ let listener = (webChannelId, message, sendingContext) => { if (message) { log.debug("FxAccountsWebChannel message received", message.command); if (logPII) { log.debug("FxAccountsWebChannel message details", message); } try { this._receiveMessage(message, sendingContext); } catch (error) { this._sendError(error, message, sendingContext); } } }; this._channelCallback = listener; this._channel = new WebChannel(this._webChannelId, this._webChannelOrigin); this._channel.listen(listener); log.debug("FxAccountsWebChannel registered: " + this._webChannelId + " with origin " + this._webChannelOrigin.prePath); } }; this.FxAccountsWebChannelHelpers = function(options) { options = options || {}; this._fxAccounts = options.fxAccounts || fxAccounts; }; this.FxAccountsWebChannelHelpers.prototype = { // If the last fxa account used for sync isn't this account, we display // a modal dialog checking they really really want to do this... // (This is sync-specific, so ideally would be in sync's identity module, // but it's a little more seamless to do here, and sync is currently the // only fxa consumer, so... shouldAllowRelink(acctName) { return !this._needRelinkWarning(acctName) || this._promptForRelink(acctName); }, /** * New users are asked in the content server whether they want to * customize which data should be synced. The user is only shown * the dialog listing the possible data types upon verification. * * Save a bit into prefs that is read on verification to see whether * to show the list of data types that can be saved. */ setShowCustomizeSyncPref(showCustomizeSyncPref) { Services.prefs.setBoolPref(PREF_SYNC_SHOW_CUSTOMIZATION, showCustomizeSyncPref); }, getShowCustomizeSyncPref() { return Services.prefs.getBoolPref(PREF_SYNC_SHOW_CUSTOMIZATION); }, /** * stores sync login info it in the fxaccounts service * * @param accountData the user's account data and credentials */ login(accountData) { if (accountData.customizeSync) { this.setShowCustomizeSyncPref(true); delete accountData.customizeSync; } if (accountData.declinedSyncEngines) { let declinedSyncEngines = accountData.declinedSyncEngines; log.debug("Received declined engines", declinedSyncEngines); Weave.Service.engineManager.setDeclined(declinedSyncEngines); declinedSyncEngines.forEach(engine => { Services.prefs.setBoolPref("services.sync.engine." + engine, false); }); // if we got declinedSyncEngines that means we do not need to show the customize screen. this.setShowCustomizeSyncPref(false); delete accountData.declinedSyncEngines; } // the user has already been shown the "can link account" // screen. No need to keep this data around. delete accountData.verifiedCanLinkAccount; // Remember who it was so we can log out next time. this.setPreviousAccountNameHashPref(accountData.email); // A sync-specific hack - we want to ensure sync has been initialized // before we set the signed-in user. let xps = Cc["@mozilla.org/weave/service;1"] .getService(Ci.nsISupports) .wrappedJSObject; return xps.whenLoaded().then(() => { return this._fxAccounts.setSignedInUser(accountData); }); }, /** * logout the fxaccounts service * * @param the uid of the account which have been logged out */ logout(uid) { return fxAccounts.getSignedInUser().then(userData => { if (userData.uid === uid) { // true argument is `localOnly`, because server-side stuff // has already been taken care of by the content server return fxAccounts.signOut(true); } }); }, changePassword(credentials) { // If |credentials| has fields that aren't handled by accounts storage, // updateUserAccountData will throw - mainly to prevent errors in code // that hard-codes field names. // However, in this case the field names aren't really in our control. // We *could* still insist the server know what fields names are valid, // but that makes life difficult for the server when Firefox adds new // features (ie, new fields) - forcing the server to track a map of // versions to supported field names doesn't buy us much. // So we just remove field names we know aren't handled. let newCredentials = { deviceId: null }; for (let name of Object.keys(credentials)) { if (name == "email" || name == "uid" || FxAccountsStorageManagerCanStoreField(name)) { newCredentials[name] = credentials[name]; } else { log.info("changePassword ignoring unsupported field", name); } } return this._fxAccounts.updateUserAccountData(newCredentials) .then(() => this._fxAccounts.updateDeviceRegistration()); }, /** * Get the hash of account name of the previously signed in account */ getPreviousAccountNameHashPref() { try { return Services.prefs.getComplexValue(PREF_LAST_FXA_USER, Ci.nsISupportsString).data; } catch (_) { return ""; } }, /** * Given an account name, set the hash of the previously signed in account * * @param acctName the account name of the user's account. */ setPreviousAccountNameHashPref(acctName) { let string = Cc["@mozilla.org/supports-string;1"] .createInstance(Ci.nsISupportsString); string.data = this.sha256(acctName); Services.prefs.setComplexValue(PREF_LAST_FXA_USER, Ci.nsISupportsString, string); }, /** * Given a string, returns the SHA265 hash in base64 */ sha256(str) { let converter = Cc["@mozilla.org/intl/scriptableunicodeconverter"] .createInstance(Ci.nsIScriptableUnicodeConverter); converter.charset = "UTF-8"; // Data is an array of bytes. let data = converter.convertToByteArray(str, {}); let hasher = Cc["@mozilla.org/security/hash;1"] .createInstance(Ci.nsICryptoHash); hasher.init(hasher.SHA256); hasher.update(data, data.length); return hasher.finish(true); }, /** * Open Sync Preferences in the current tab of the browser * * @param {Object} browser the browser in which to open preferences * @param {String} [entryPoint] entryPoint to use for logging */ openSyncPreferences(browser, entryPoint) { let uri = "about:preferences"; if (entryPoint) { uri += "?entrypoint=" + encodeURIComponent(entryPoint); } uri += "#sync"; browser.loadURI(uri); }, /** * If a user signs in using a different account, the data from the * previous account and the new account will be merged. Ask the user * if they want to continue. * * @private */ _needRelinkWarning(acctName) { let prevAcctHash = this.getPreviousAccountNameHashPref(); return prevAcctHash && prevAcctHash != this.sha256(acctName); }, /** * Show the user a warning dialog that the data from the previous account * and the new account will be merged. * * @private */ _promptForRelink(acctName) { let sb = Services.strings.createBundle("chrome://browser/locale/syncSetup.properties"); let continueLabel = sb.GetStringFromName("continue.label"); let title = sb.GetStringFromName("relinkVerify.title"); let description = sb.formatStringFromName("relinkVerify.description", [acctName], 1); let body = sb.GetStringFromName("relinkVerify.heading") + "\n\n" + description; let ps = Services.prompt; let buttonFlags = (ps.BUTTON_POS_0 * ps.BUTTON_TITLE_IS_STRING) + (ps.BUTTON_POS_1 * ps.BUTTON_TITLE_CANCEL) + ps.BUTTON_POS_1_DEFAULT; // If running in context of the browser chrome, window does not exist. var targetWindow = typeof window === 'undefined' ? null : window; let pressed = Services.prompt.confirmEx(targetWindow, title, body, buttonFlags, continueLabel, null, null, null, {}); return pressed === 0; // 0 is the "continue" button } }; var singleton; // The entry-point for this module, which ensures only one of our channels is // ever created - we require this because the WebChannel is global in scope // (eg, it uses the observer service to tell interested parties of interesting // things) and allowing multiple channels would cause such notifications to be // sent multiple times. this.EnsureFxAccountsWebChannel = function() { let contentUri = Services.urlFormatter.formatURLPref("identity.fxaccounts.remote.webchannel.uri"); if (singleton && singleton._contentUri !== contentUri) { singleton.tearDown(); singleton = null; } if (!singleton) { try { if (contentUri) { // The FxAccountsWebChannel listens for events and updates // the state machine accordingly. singleton = new this.FxAccountsWebChannel({ content_uri: contentUri, channel_id: WEBCHANNEL_ID, }); } else { log.warn("FxA WebChannel functionaly is disabled due to no URI pref."); } } catch (ex) { log.error("Failed to create FxA WebChannel", ex); } } }