summaryrefslogtreecommitdiffstats
path: root/toolkit/components/passwordmgr/LoginManagerParent.jsm
diff options
context:
space:
mode:
Diffstat (limited to 'toolkit/components/passwordmgr/LoginManagerParent.jsm')
-rw-r--r--toolkit/components/passwordmgr/LoginManagerParent.jsm511
1 files changed, 511 insertions, 0 deletions
diff --git a/toolkit/components/passwordmgr/LoginManagerParent.jsm b/toolkit/components/passwordmgr/LoginManagerParent.jsm
new file mode 100644
index 000000000..e472fb61c
--- /dev/null
+++ b/toolkit/components/passwordmgr/LoginManagerParent.jsm
@@ -0,0 +1,511 @@
+/* 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/. */
+
+"use strict";
+
+const { classes: Cc, interfaces: Ci, results: Cr, utils: Cu } = Components;
+
+Cu.importGlobalProperties(["URL"]);
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/Task.jsm");
+
+XPCOMUtils.defineLazyModuleGetter(this, "UserAutoCompleteResult",
+ "resource://gre/modules/LoginManagerContent.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "AutoCompletePopup",
+ "resource://gre/modules/AutoCompletePopup.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "DeferredTask",
+ "resource://gre/modules/DeferredTask.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "LoginHelper",
+ "resource://gre/modules/LoginHelper.jsm");
+
+XPCOMUtils.defineLazyGetter(this, "log", () => {
+ let logger = LoginHelper.createLogger("LoginManagerParent");
+ return logger.log.bind(logger);
+});
+
+this.EXPORTED_SYMBOLS = [ "LoginManagerParent" ];
+
+var LoginManagerParent = {
+ /**
+ * Reference to the default LoginRecipesParent (instead of the initialization promise) for
+ * synchronous access. This is a temporary hack and new consumers should yield on
+ * recipeParentPromise instead.
+ *
+ * @type LoginRecipesParent
+ * @deprecated
+ */
+ _recipeManager: null,
+
+ // Tracks the last time the user cancelled the master password prompt,
+ // to avoid spamming master password prompts on autocomplete searches.
+ _lastMPLoginCancelled: Math.NEGATIVE_INFINITY,
+
+ _searchAndDedupeLogins: function (formOrigin, actionOrigin) {
+ let logins;
+ try {
+ logins = LoginHelper.searchLoginsWithObject({
+ hostname: formOrigin,
+ formSubmitURL: actionOrigin,
+ schemeUpgrades: LoginHelper.schemeUpgrades,
+ });
+ } catch (e) {
+ // Record the last time the user cancelled the MP prompt
+ // to avoid spamming them with MP prompts for autocomplete.
+ if (e.result == Cr.NS_ERROR_ABORT) {
+ log("User cancelled master password prompt.");
+ this._lastMPLoginCancelled = Date.now();
+ return [];
+ }
+ throw e;
+ }
+
+ // Dedupe so the length checks below still make sense with scheme upgrades.
+ let resolveBy = [
+ "scheme",
+ "timePasswordChanged",
+ ];
+ return LoginHelper.dedupeLogins(logins, ["username"], resolveBy, formOrigin);
+ },
+
+ init: function() {
+ let mm = Cc["@mozilla.org/globalmessagemanager;1"]
+ .getService(Ci.nsIMessageListenerManager);
+ mm.addMessageListener("RemoteLogins:findLogins", this);
+ mm.addMessageListener("RemoteLogins:findRecipes", this);
+ mm.addMessageListener("RemoteLogins:onFormSubmit", this);
+ mm.addMessageListener("RemoteLogins:autoCompleteLogins", this);
+ mm.addMessageListener("RemoteLogins:removeLogin", this);
+ mm.addMessageListener("RemoteLogins:insecureLoginFormPresent", this);
+
+ XPCOMUtils.defineLazyGetter(this, "recipeParentPromise", () => {
+ const { LoginRecipesParent } = Cu.import("resource://gre/modules/LoginRecipes.jsm", {});
+ this._recipeManager = new LoginRecipesParent({
+ defaults: Services.prefs.getComplexValue("signon.recipes.path", Ci.nsISupportsString).data,
+ });
+ return this._recipeManager.initializationPromise;
+ });
+ },
+
+ receiveMessage: function (msg) {
+ let data = msg.data;
+ switch (msg.name) {
+ case "RemoteLogins:findLogins": {
+ // TODO Verify msg.target's principals against the formOrigin?
+ this.sendLoginDataToChild(data.options.showMasterPassword,
+ data.formOrigin,
+ data.actionOrigin,
+ data.requestId,
+ msg.target.messageManager);
+ break;
+ }
+
+ case "RemoteLogins:findRecipes": {
+ let formHost = (new URL(data.formOrigin)).host;
+ return this._recipeManager.getRecipesForHost(formHost);
+ }
+
+ case "RemoteLogins:onFormSubmit": {
+ // TODO Verify msg.target's principals against the formOrigin?
+ this.onFormSubmit(data.hostname,
+ data.formSubmitURL,
+ data.usernameField,
+ data.newPasswordField,
+ data.oldPasswordField,
+ msg.objects.openerTopWindow,
+ msg.target);
+ break;
+ }
+
+ case "RemoteLogins:insecureLoginFormPresent": {
+ this.setHasInsecureLoginForms(msg.target, data.hasInsecureLoginForms);
+ break;
+ }
+
+ case "RemoteLogins:autoCompleteLogins": {
+ this.doAutocompleteSearch(data, msg.target);
+ break;
+ }
+
+ case "RemoteLogins:removeLogin": {
+ let login = LoginHelper.vanillaObjectToLogin(data.login);
+ AutoCompletePopup.removeLogin(login);
+ break;
+ }
+ }
+
+ return undefined;
+ },
+
+ /**
+ * Trigger a login form fill and send relevant data (e.g. logins and recipes)
+ * to the child process (LoginManagerContent).
+ */
+ fillForm: Task.async(function* ({ browser, loginFormOrigin, login, inputElement }) {
+ let recipes = [];
+ if (loginFormOrigin) {
+ let formHost;
+ try {
+ formHost = (new URL(loginFormOrigin)).host;
+ let recipeManager = yield this.recipeParentPromise;
+ recipes = recipeManager.getRecipesForHost(formHost);
+ } catch (ex) {
+ // Some schemes e.g. chrome aren't supported by URL
+ }
+ }
+
+ // Convert the array of nsILoginInfo to vanilla JS objects since nsILoginInfo
+ // doesn't support structured cloning.
+ let jsLogins = [LoginHelper.loginToVanillaObject(login)];
+
+ let objects = inputElement ? {inputElement} : null;
+ browser.messageManager.sendAsyncMessage("RemoteLogins:fillForm", {
+ loginFormOrigin,
+ logins: jsLogins,
+ recipes,
+ }, objects);
+ }),
+
+ /**
+ * Send relevant data (e.g. logins and recipes) to the child process (LoginManagerContent).
+ */
+ sendLoginDataToChild: Task.async(function*(showMasterPassword, formOrigin, actionOrigin,
+ requestId, target) {
+ let recipes = [];
+ if (formOrigin) {
+ let formHost;
+ try {
+ formHost = (new URL(formOrigin)).host;
+ let recipeManager = yield this.recipeParentPromise;
+ recipes = recipeManager.getRecipesForHost(formHost);
+ } catch (ex) {
+ // Some schemes e.g. chrome aren't supported by URL
+ }
+ }
+
+ if (!showMasterPassword && !Services.logins.isLoggedIn) {
+ try {
+ target.sendAsyncMessage("RemoteLogins:loginsFound", {
+ requestId: requestId,
+ logins: [],
+ recipes,
+ });
+ } catch (e) {
+ log("error sending message to target", e);
+ }
+ return;
+ }
+
+ // If we're currently displaying a master password prompt, defer
+ // processing this form until the user handles the prompt.
+ if (Services.logins.uiBusy) {
+ log("deferring sendLoginDataToChild for", formOrigin);
+ let self = this;
+ let observer = {
+ QueryInterface: XPCOMUtils.generateQI([Ci.nsIObserver,
+ Ci.nsISupportsWeakReference]),
+
+ observe: function (subject, topic, data) {
+ log("Got deferred sendLoginDataToChild notification:", topic);
+ // Only run observer once.
+ Services.obs.removeObserver(this, "passwordmgr-crypto-login");
+ Services.obs.removeObserver(this, "passwordmgr-crypto-loginCanceled");
+ if (topic == "passwordmgr-crypto-loginCanceled") {
+ target.sendAsyncMessage("RemoteLogins:loginsFound", {
+ requestId: requestId,
+ logins: [],
+ recipes,
+ });
+ return;
+ }
+
+ self.sendLoginDataToChild(showMasterPassword, formOrigin, actionOrigin,
+ requestId, target);
+ },
+ };
+
+ // Possible leak: it's possible that neither of these notifications
+ // will fire, and if that happens, we'll leak the observer (and
+ // never return). We should guarantee that at least one of these
+ // will fire.
+ // See bug XXX.
+ Services.obs.addObserver(observer, "passwordmgr-crypto-login", false);
+ Services.obs.addObserver(observer, "passwordmgr-crypto-loginCanceled", false);
+ return;
+ }
+
+ let logins = this._searchAndDedupeLogins(formOrigin, actionOrigin);
+
+ log("sendLoginDataToChild:", logins.length, "deduped logins");
+ // Convert the array of nsILoginInfo to vanilla JS objects since nsILoginInfo
+ // doesn't support structured cloning.
+ var jsLogins = LoginHelper.loginsToVanillaObjects(logins);
+ target.sendAsyncMessage("RemoteLogins:loginsFound", {
+ requestId: requestId,
+ logins: jsLogins,
+ recipes,
+ });
+ }),
+
+ doAutocompleteSearch: function({ formOrigin, actionOrigin,
+ searchString, previousResult,
+ rect, requestId, isSecure, isPasswordField,
+ remote }, target) {
+ // Note: previousResult is a regular object, not an
+ // nsIAutoCompleteResult.
+
+ // Cancel if we unsuccessfully prompted for the master password too recently.
+ if (!Services.logins.isLoggedIn) {
+ let timeDiff = Date.now() - this._lastMPLoginCancelled;
+ if (timeDiff < this._repromptTimeout) {
+ log("Not searching logins for autocomplete since the master password " +
+ `prompt was last cancelled ${Math.round(timeDiff / 1000)} seconds ago.`);
+ // Send an empty array to make LoginManagerContent clear the
+ // outstanding request it has temporarily saved.
+ target.messageManager.sendAsyncMessage("RemoteLogins:loginsAutoCompleted", {
+ requestId,
+ logins: [],
+ });
+ return;
+ }
+ }
+
+ let searchStringLower = searchString.toLowerCase();
+ let logins;
+ if (previousResult &&
+ searchStringLower.startsWith(previousResult.searchString.toLowerCase())) {
+ log("Using previous autocomplete result");
+
+ // We have a list of results for a shorter search string, so just
+ // filter them further based on the new search string.
+ logins = LoginHelper.vanillaObjectsToLogins(previousResult.logins);
+ } else {
+ log("Creating new autocomplete search result.");
+
+ logins = this._searchAndDedupeLogins(formOrigin, actionOrigin);
+ }
+
+ let matchingLogins = logins.filter(function(fullMatch) {
+ let match = fullMatch.username;
+
+ // Remove results that are too short, or have different prefix.
+ // Also don't offer empty usernames as possible results except
+ // for password field.
+ if (isPasswordField) {
+ return true;
+ }
+ return match && match.toLowerCase().startsWith(searchStringLower);
+ });
+
+ // XXX In the E10S case, we're responsible for showing our own
+ // autocomplete popup here because the autocomplete protocol hasn't
+ // been e10s-ized yet. In the non-e10s case, our caller is responsible
+ // for showing the autocomplete popup (via the regular
+ // nsAutoCompleteController).
+ if (remote) {
+ let results = new UserAutoCompleteResult(searchString, matchingLogins, {isSecure});
+ AutoCompletePopup.showPopupWithResults({ browser: target.ownerDocument.defaultView, rect, results });
+ }
+
+ // Convert the array of nsILoginInfo to vanilla JS objects since nsILoginInfo
+ // doesn't support structured cloning.
+ var jsLogins = LoginHelper.loginsToVanillaObjects(matchingLogins);
+ target.messageManager.sendAsyncMessage("RemoteLogins:loginsAutoCompleted", {
+ requestId: requestId,
+ logins: jsLogins,
+ });
+ },
+
+ onFormSubmit: function(hostname, formSubmitURL,
+ usernameField, newPasswordField,
+ oldPasswordField, openerTopWindow,
+ target) {
+ function getPrompter() {
+ var prompterSvc = Cc["@mozilla.org/login-manager/prompter;1"].
+ createInstance(Ci.nsILoginManagerPrompter);
+ prompterSvc.init(target.ownerDocument.defaultView);
+ prompterSvc.browser = target;
+ prompterSvc.opener = openerTopWindow;
+ return prompterSvc;
+ }
+
+ function recordLoginUse(login) {
+ // Update the lastUsed timestamp and increment the use count.
+ let propBag = Cc["@mozilla.org/hash-property-bag;1"].
+ createInstance(Ci.nsIWritablePropertyBag);
+ propBag.setProperty("timeLastUsed", Date.now());
+ propBag.setProperty("timesUsedIncrement", 1);
+ Services.logins.modifyLogin(login, propBag);
+ }
+
+ if (!Services.logins.getLoginSavingEnabled(hostname)) {
+ log("(form submission ignored -- saving is disabled for:", hostname, ")");
+ return;
+ }
+
+ var formLogin = Cc["@mozilla.org/login-manager/loginInfo;1"].
+ createInstance(Ci.nsILoginInfo);
+ formLogin.init(hostname, formSubmitURL, null,
+ (usernameField ? usernameField.value : ""),
+ newPasswordField.value,
+ (usernameField ? usernameField.name : ""),
+ newPasswordField.name);
+
+ // Below here we have one login per hostPort + action + username with the
+ // matching scheme being preferred.
+ let logins = this._searchAndDedupeLogins(hostname, formSubmitURL);
+
+ // If we didn't find a username field, but seem to be changing a
+ // password, allow the user to select from a list of applicable
+ // logins to update the password for.
+ if (!usernameField && oldPasswordField && logins.length > 0) {
+ var prompter = getPrompter();
+
+ if (logins.length == 1) {
+ var oldLogin = logins[0];
+
+ if (oldLogin.password == formLogin.password) {
+ recordLoginUse(oldLogin);
+ log("(Not prompting to save/change since we have no username and the " +
+ "only saved password matches the new password)");
+ return;
+ }
+
+ formLogin.username = oldLogin.username;
+ formLogin.usernameField = oldLogin.usernameField;
+
+ prompter.promptToChangePassword(oldLogin, formLogin);
+ } else {
+ // Note: It's possible that that we already have the correct u+p saved
+ // but since we don't have the username, we don't know if the user is
+ // changing a second account to the new password so we ask anyways.
+
+ prompter.promptToChangePasswordWithUsernames(
+ logins, logins.length, formLogin);
+ }
+
+ return;
+ }
+
+
+ var existingLogin = null;
+ // Look for an existing login that matches the form login.
+ for (let login of logins) {
+ let same;
+
+ // If one login has a username but the other doesn't, ignore
+ // the username when comparing and only match if they have the
+ // same password. Otherwise, compare the logins and match even
+ // if the passwords differ.
+ if (!login.username && formLogin.username) {
+ var restoreMe = formLogin.username;
+ formLogin.username = "";
+ same = LoginHelper.doLoginsMatch(formLogin, login, {
+ ignorePassword: false,
+ ignoreSchemes: LoginHelper.schemeUpgrades,
+ });
+ formLogin.username = restoreMe;
+ } else if (!formLogin.username && login.username) {
+ formLogin.username = login.username;
+ same = LoginHelper.doLoginsMatch(formLogin, login, {
+ ignorePassword: false,
+ ignoreSchemes: LoginHelper.schemeUpgrades,
+ });
+ formLogin.username = ""; // we know it's always blank.
+ } else {
+ same = LoginHelper.doLoginsMatch(formLogin, login, {
+ ignorePassword: true,
+ ignoreSchemes: LoginHelper.schemeUpgrades,
+ });
+ }
+
+ if (same) {
+ existingLogin = login;
+ break;
+ }
+ }
+
+ if (existingLogin) {
+ log("Found an existing login matching this form submission");
+
+ // Change password if needed.
+ if (existingLogin.password != formLogin.password) {
+ log("...passwords differ, prompting to change.");
+ prompter = getPrompter();
+ prompter.promptToChangePassword(existingLogin, formLogin);
+ } else if (!existingLogin.username && formLogin.username) {
+ log("...empty username update, prompting to change.");
+ prompter = getPrompter();
+ prompter.promptToChangePassword(existingLogin, formLogin);
+ } else {
+ recordLoginUse(existingLogin);
+ }
+
+ return;
+ }
+
+
+ // Prompt user to save login (via dialog or notification bar)
+ prompter = getPrompter();
+ prompter.promptToSavePassword(formLogin);
+ },
+
+ /**
+ * Maps all the <browser> elements for tabs in the parent process to the
+ * current state used to display tab-specific UI.
+ *
+ * This mapping is not updated in case a web page is moved to a different
+ * chrome window by the swapDocShells method. In this case, it is possible
+ * that a UI update just requested for the login fill doorhanger and then
+ * delayed by a few hundred milliseconds will be lost. Later requests would
+ * use the new browser reference instead.
+ *
+ * Given that the case above is rare, and it would not cause any origin
+ * mismatch at the time of filling because the origin is checked later in the
+ * content process, this case is left unhandled.
+ */
+ loginFormStateByBrowser: new WeakMap(),
+
+ /**
+ * Retrieves a reference to the state object associated with the given
+ * browser. This is initialized to an empty object.
+ */
+ stateForBrowser(browser) {
+ let loginFormState = this.loginFormStateByBrowser.get(browser);
+ if (!loginFormState) {
+ loginFormState = {};
+ this.loginFormStateByBrowser.set(browser, loginFormState);
+ }
+ return loginFormState;
+ },
+
+ /**
+ * Returns true if the page currently loaded in the given browser element has
+ * insecure login forms. This state may be updated asynchronously, in which
+ * case a custom event named InsecureLoginFormsStateChange will be dispatched
+ * on the browser element.
+ */
+ hasInsecureLoginForms(browser) {
+ return !!this.stateForBrowser(browser).hasInsecureLoginForms;
+ },
+
+ /**
+ * Called to indicate whether an insecure password field is present so
+ * insecure password UI can know when to show.
+ */
+ setHasInsecureLoginForms(browser, hasInsecureLoginForms) {
+ let state = this.stateForBrowser(browser);
+
+ // Update the data to use to the latest known values. Since messages are
+ // processed in order, this will always be the latest version to use.
+ state.hasInsecureLoginForms = hasInsecureLoginForms;
+
+ // Report the insecure login form state immediately.
+ browser.dispatchEvent(new browser.ownerDocument.defaultView
+ .CustomEvent("InsecureLoginFormsStateChange"));
+ },
+};
+
+XPCOMUtils.defineLazyPreferenceGetter(LoginManagerParent, "_repromptTimeout",
+ "signon.masterPasswordReprompt.timeout_ms", 900000); // 15 Minutes