// -*- Mode: js2; tab-width: 2; indent-tabs-mode: nil; js2-basic-offset: 2; js2-skip-preprocessor-directives: t; -*- /* 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. * * Use the WebChannel component to receive messages about account * state changes. */ this.EXPORTED_SYMBOLS = ["EnsureFxAccountsWebChannel"]; const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components; /*global Components */ Cu.import("resource://gre/modules/Accounts.jsm"); /*global Accounts */ Cu.import("resource://gre/modules/Services.jsm"); /*global Services */ Cu.import("resource://gre/modules/WebChannel.jsm"); /*global WebChannel */ Cu.import("resource://gre/modules/XPCOMUtils.jsm"); /*global XPCOMUtils */ const log = Cu.import("resource://gre/modules/AndroidLog.jsm", {}).AndroidLog.bind("FxAccounts"); const WEBCHANNEL_ID = "account_updates"; const COMMAND_LOADED = "fxaccounts:loaded"; const COMMAND_CAN_LINK_ACCOUNT = "fxaccounts:can_link_account"; const COMMAND_LOGIN = "fxaccounts:login"; const COMMAND_CHANGE_PASSWORD = "fxaccounts:change_password"; const COMMAND_DELETE_ACCOUNT = "fxaccounts:delete_account"; const COMMAND_PROFILE_CHANGE = "profile:change"; const COMMAND_SYNC_PREFERENCES = "fxaccounts:sync_preferences"; const PREF_LAST_FXA_USER = "identity.fxaccounts.lastSignedInUserHash"; XPCOMUtils.defineLazyGetter(this, "strings", () => Services.strings.createBundle("chrome://browser/locale/aboutAccounts.properties")); /*global strings */ XPCOMUtils.defineLazyModuleGetter(this, "Snackbars", "resource://gre/modules/Snackbars.jsm"); XPCOMUtils.defineLazyModuleGetter(this, "Prompt", "resource://gre/modules/Prompt.jsm"); XPCOMUtils.defineLazyModuleGetter(this, "UITelemetry", "resource://gre/modules/UITelemetry.jsm"); this.FxAccountsWebChannelHelpers = function() { }; this.FxAccountsWebChannelHelpers.prototype = { /** * 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); }, }; /** * 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, /** * 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.e(e.toString()); throw e; } }, /** * 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 object that captured the * WebChannelMessageToChrome. * @param sendingContext.eventTarget {EventTarget} * The where the message was sent. * @param sendingContext.principal {Principal} * The of the EventTarget where the message was sent. * @private * */ let listener = (webChannelId, message, sendingContext) => { if (message) { let command = message.command; let data = message.data; log.d("FxAccountsWebChannel message received, command: " + command); // Respond to the message with true or false. let respond = (data) => { let response = { command: command, messageId: message.messageId, data: data }; log.d("Sending response to command: " + command); this._channel.send(response, sendingContext); }; switch (command) { case COMMAND_LOADED: let mm = sendingContext.browser.docShell .QueryInterface(Ci.nsIInterfaceRequestor) .getInterface(Ci.nsIContentFrameMessageManager); mm.sendAsyncMessage(COMMAND_LOADED); break; case COMMAND_CAN_LINK_ACCOUNT: Accounts.getFirefoxAccount().then(account => { if (account) { // If we /have/ an Android Account, we never allow the user to // login to a different account. They need to manually delete // the first Android Account and then create a new one. if (account.email == data.email) { // In future, we should use a UID for this comparison. log.d("Relinking existing Android Account: email addresses agree."); respond({ok: true}); } else { log.w("Not relinking existing Android Account: email addresses disagree!"); let message = strings.GetStringFromName("relinkDenied.message"); let buttonLabel = strings.GetStringFromName("relinkDenied.openPrefs"); Snackbars.show(message, Snackbars.LENGTH_LONG, { action: { label: buttonLabel, callback: () => { // We have an account, so this opens Sync native preferences. Accounts.launchSetup(); }, } }); respond({ok: false}); } } else { // If we /don't have/ an Android Account, we warn if we're // connecting to a new Account. This is to minimize surprise; // we never did this when changing accounts via the native UI. let prevAcctHash = this._helpers.getPreviousAccountNameHashPref(); let shouldShowWarning = prevAcctHash && (prevAcctHash != this._helpers.sha256(data.email)); if (shouldShowWarning) { log.w("Warning about creating a new Android Account: previously linked to different email address!"); let message = strings.formatStringFromName("relinkVerify.message", [data.email], 1); new Prompt({ title: strings.GetStringFromName("relinkVerify.title"), message: message, buttons: [ // This puts Cancel on the right. strings.GetStringFromName("relinkVerify.cancel"), strings.GetStringFromName("relinkVerify.continue"), ], }).show(result => respond({ok: result && result.button == 1})); } else { log.d("Not warning about creating a new Android Account: no previously linked email address."); respond({ok: true}); } } }).catch(e => { log.e(e.toString()); respond({ok: false}); }); break; case COMMAND_LOGIN: // Either create a new Android Account or re-connect an existing // Android Account here. There's not much to be done if we don't // succeed or get an error. Accounts.getFirefoxAccount().then(account => { if (!account) { return Accounts.createFirefoxAccountFromJSON(data).then(success => { if (!success) { throw new Error("Could not create Firefox Account!"); } UITelemetry.addEvent("action.1", "content", null, "fxaccount-create"); return success; }); } else { return Accounts.updateFirefoxAccountFromJSON(data).then(success => { if (!success) { throw new Error("Could not update Firefox Account!"); } UITelemetry.addEvent("action.1", "content", null, "fxaccount-login"); return success; }); } }) .then(success => { if (!success) { throw new Error("Could not create or update Firefox Account!"); } // Remember who it is so we can show a relink warning when appropriate. this._helpers.setPreviousAccountNameHashPref(data.email); log.i("Created or updated Firefox Account."); }) .catch(e => { log.e(e.toString()); }); break; case COMMAND_CHANGE_PASSWORD: // Only update an existing Android Account. Accounts.getFirefoxAccount().then(account => { if (!account) { throw new Error("Can't change password of non-existent Firefox Account!"); } return Accounts.updateFirefoxAccountFromJSON(data); }) .then(success => { if (!success) { throw new Error("Could not change Firefox Account password!"); } UITelemetry.addEvent("action.1", "content", null, "fxaccount-changepassword"); log.i("Changed Firefox Account password."); }) .catch(e => { log.e(e.toString()); }); break; case COMMAND_DELETE_ACCOUNT: // The fxa-content-server has already confirmed the user's intent. // Bombs away. There's no recovery from failure, and not even a // real need to check an account exists (although we do, for error // messaging only). Accounts.getFirefoxAccount().then(account => { if (!account) { throw new Error("Can't delete non-existent Firefox Account!"); } return Accounts.deleteFirefoxAccount().then(success => { if (!success) { throw new Error("Could not delete Firefox Account!"); } UITelemetry.addEvent("action.1", "content", null, "fxaccount-delete"); log.i("Firefox Account deleted."); }); }).catch(e => { log.e(e.toString()); }); break; case COMMAND_PROFILE_CHANGE: // Only update an existing Android Account. Accounts.getFirefoxAccount().then(account => { if (!account) { throw new Error("Can't change profile of non-existent Firefox Account!"); } UITelemetry.addEvent("action.1", "content", null, "fxaccount-changeprofile"); return Accounts.notifyFirefoxAccountProfileChanged(); }) .catch(e => { log.e(e.toString()); }); break; case COMMAND_SYNC_PREFERENCES: UITelemetry.addEvent("action.1", "content", null, "fxaccount-syncprefs"); Accounts.showSyncPreferences() .catch(e => { log.e(e.toString()); }); break; default: log.w("Ignoring unrecognized FxAccountsWebChannel command: " + JSON.stringify(command)); break; } } }; this._channelCallback = listener; this._channel = new WebChannel(this._webChannelId, this._webChannelOrigin); this._channel.listen(listener); log.d("FxAccountsWebChannel registered: " + this._webChannelId + " with origin " + this._webChannelOrigin.prePath); } }; 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 and // allowing multiple channels would cause such notifications to be sent multiple // times. this.EnsureFxAccountsWebChannel = function() { if (!singleton) { let contentUri = Services.urlFormatter.formatURLPref("identity.fxaccounts.remote.webchannel.uri"); // The FxAccountsWebChannel listens for events and updates the Java layer. singleton = new this.FxAccountsWebChannel({ content_uri: contentUri, channel_id: WEBCHANNEL_ID, }); } };