diff options
Diffstat (limited to 'services/fxaccounts/FxAccountsWebChannel.jsm')
-rw-r--r-- | services/fxaccounts/FxAccountsWebChannel.jsm | 474 |
1 files changed, 474 insertions, 0 deletions
diff --git a/services/fxaccounts/FxAccountsWebChannel.jsm b/services/fxaccounts/FxAccountsWebChannel.jsm new file mode 100644 index 0000000000..810d93c651 --- /dev/null +++ b/services/fxaccounts/FxAccountsWebChannel.jsm @@ -0,0 +1,474 @@ +/* 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); + } + } +} |