diff --git a/browser/app/profile/firefox.js b/browser/app/profile/firefox.js index 95c1befbc58a..7eec89789cc9 100644 --- a/browser/app/profile/firefox.js +++ b/browser/app/profile/firefox.js @@ -1935,6 +1935,7 @@ pref("signon.management.page.breachAlertUrl", "https://monitor.firefox.com/breach-details/"); pref("signon.management.page.showPasswordSyncNotification", true); pref("signon.passwordEditCapture.enabled", true); +pref("signon.relatedRealms.enabled", false); pref("signon.showAutoCompleteFooter", true); pref("signon.showAutoCompleteImport", "import"); pref("signon.suggestImportCount", 3); diff --git a/modules/libpref/init/all.js b/modules/libpref/init/all.js index c9122583ce9e..c984a8b0037c 100644 --- a/modules/libpref/init/all.js +++ b/modules/libpref/init/all.js @@ -3675,6 +3675,7 @@ pref("signon.userInputRequiredToCapture.enabled", true); pref("signon.debug", false); pref("signon.recipes.path", "resource://app/defaults/settings/main/password-recipes.json"); pref("signon.recipes.remoteRecipesEnabled", true); +pref("signon.relatedRealms.enabled", false); pref("signon.schemeUpgrades", true); pref("signon.includeOtherSubdomainsInLookup", true); diff --git a/toolkit/components/passwordmgr/LoginHelper.jsm b/toolkit/components/passwordmgr/LoginHelper.jsm index 452b681f8f3f..fc1689be2228 100644 --- a/toolkit/components/passwordmgr/LoginHelper.jsm +++ b/toolkit/components/passwordmgr/LoginHelper.jsm @@ -371,6 +371,8 @@ this.LoginHelper = { privateBrowsingCaptureEnabled: null, remoteRecipesEnabled: null, remoteRecipesCollection: "password-recipes", + relatedRealmsEnabled: null, + relatedRealmsCollection: "websites-with-shared-credential-backends", schemeUpgrades: null, showAutoCompleteFooter: null, showAutoCompleteImport: null, @@ -470,6 +472,9 @@ this.LoginHelper = { this.remoteRecipesEnabled = Services.prefs.getBoolPref( "signon.recipes.remoteRecipesEnabled" ); + this.relatedRealmsEnabled = Services.prefs.getBoolPref( + "signon.relatedRealms.enabled" + ); }, createLogger(aLogPrefix) { @@ -682,6 +687,8 @@ this.LoginHelper = { schemeUpgrades: false, acceptWildcardMatch: false, acceptDifferentSubdomains: false, + acceptRelatedRealms: false, + relatedRealms: [], } ) { if (aLoginOrigin == aSearchOrigin) { @@ -718,6 +725,18 @@ this.LoginHelper = { ) { return true; } + if ( + aOptions.acceptRelatedRealms && + aOptions.relatedRealms.length && + (loginURI.scheme == searchURI.scheme || + (aOptions.schemeUpgrades && schemeMatches)) + ) { + for (let relatedOrigin of aOptions.relatedRealms) { + if (Services.eTLD.hasRootDomain(loginURI.host, relatedOrigin)) { + return true; + } + } + } } if ( diff --git a/toolkit/components/passwordmgr/LoginManager.jsm b/toolkit/components/passwordmgr/LoginManager.jsm index 3e75546a7a2b..f65bd26c7e9a 100644 --- a/toolkit/components/passwordmgr/LoginManager.jsm +++ b/toolkit/components/passwordmgr/LoginManager.jsm @@ -322,7 +322,6 @@ LoginManager.prototype = { if (matchingLogin) { throw LoginHelper.createLoginAlreadyExistsError(matchingLogin.guid); } - log.debug("Adding login"); return this._storage.addLogin(login); }, diff --git a/toolkit/components/passwordmgr/LoginManagerParent.jsm b/toolkit/components/passwordmgr/LoginManagerParent.jsm index 6974cc8ea4f9..a660e421e95d 100644 --- a/toolkit/components/passwordmgr/LoginManagerParent.jsm +++ b/toolkit/components/passwordmgr/LoginManagerParent.jsm @@ -22,6 +22,13 @@ XPCOMUtils.defineLazyGetter(this, "autocompleteFeature", () => { return new ExperimentFeature("password-autocomplete"); }); +XPCOMUtils.defineLazyGetter(this, "LoginRelatedRealmsParent", () => { + const { LoginRelatedRealmsParent } = ChromeUtils.import( + "resource://gre/modules/LoginRelatedRealms.jsm" + ); + return new LoginRelatedRealmsParent(); +}); + XPCOMUtils.defineLazyGlobalGetters(this, ["URL"]); XPCOMUtils.defineLazyModuleGetters(this, { @@ -186,6 +193,7 @@ class LoginManagerParent extends JSWindowActorParent { * @param {origin?} options.httpRealm To match on. Omit this argument to match all realms. * @param {boolean} options.acceptDifferentSubdomains Include results for eTLD+1 matches * @param {boolean} options.ignoreActionAndRealm Include all form and HTTP auth logins for the site + * @param {string[]} options.relatedRealms Related realms to match against when searching */ static async searchAndDedupeLogins( formOrigin, @@ -194,6 +202,7 @@ class LoginManagerParent extends JSWindowActorParent { formActionOrigin, httpRealm, ignoreActionAndRealm, + relatedRealms, } = {} ) { let logins; @@ -209,6 +218,10 @@ class LoginManagerParent extends JSWindowActorParent { matchData.httpRealm = httpRealm; } } + if (LoginHelper.relatedRealmsEnabled) { + matchData.acceptRelatedRealms = LoginHelper.relatedRealmsEnabled; + matchData.relatedRealms = relatedRealms; + } try { logins = await Services.logins.searchLoginsAsync(matchData); } catch (e) { @@ -535,11 +548,22 @@ class LoginManagerParent extends JSWindowActorParent { origin: formOrigin, }); } else { + let relatedRealmsOrigins = []; + if (LoginHelper.relatedRealmsEnabled) { + relatedRealmsOrigins = await LoginRelatedRealmsParent.findRelatedRealms( + formOrigin + ); + } logins = await LoginManagerParent.searchAndDedupeLogins(formOrigin, { formActionOrigin: actionOrigin, ignoreActionAndRealm: true, acceptDifferentSubdomains: LoginHelper.includeOtherSubdomainsInLookup, + relatedRealms: relatedRealmsOrigins, }); + debug( + "Adding related logins on page load", + logins.map(l => l.origin) + ); } log("sendLoginDataToChild:", logins.length, "deduped logins"); @@ -606,12 +630,18 @@ class LoginManagerParent extends JSWindowActorParent { logins = LoginHelper.vanillaObjectsToLogins(previousResult.logins); } else { log("Creating new autocomplete search result."); - + let relatedRealmsOrigins = []; + if (LoginHelper.relatedRealmsEnabled) { + relatedRealmsOrigins = await LoginRelatedRealmsParent.findRelatedRealms( + formOrigin + ); + } // Autocomplete results do not need to match actionOrigin or exact origin. logins = await LoginManagerParent.searchAndDedupeLogins(formOrigin, { formActionOrigin: actionOrigin, ignoreActionAndRealm: true, acceptDifferentSubdomains: LoginHelper.includeOtherSubdomainsInLookup, + relatedRealms: relatedRealmsOrigins, }); } diff --git a/toolkit/components/passwordmgr/LoginRelatedRealms.jsm b/toolkit/components/passwordmgr/LoginRelatedRealms.jsm new file mode 100644 index 000000000000..76333e4763fe --- /dev/null +++ b/toolkit/components/passwordmgr/LoginRelatedRealms.jsm @@ -0,0 +1,118 @@ +/* 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 { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); + +XPCOMUtils.defineLazyModuleGetters(this, { + LoginHelper: "resource://gre/modules/LoginHelper.jsm", + RemoteSettings: "resource://services-settings/remote-settings.js", +}); + +XPCOMUtils.defineLazyGetter(this, "log", () => { + let logger = LoginHelper.createLogger("LoginRelatedRealms"); + return logger; +}); + +const EXPORTED_SYMBOLS = ["LoginRelatedRealmsParent"]; + +class LoginRelatedRealmsParent extends JSWindowActorParent { + /** + * @type RemoteSettingsClient + * + * @memberof LoginRelatedRealmsParent + */ + _sharedCredentialsClient = null; + /** + * @type string[][] + * + * @memberof LoginRelatedRealmsParent + */ + _relatedDomainsList = [[]]; + + /** + * Handles the Remote Settings sync event + * + * @param {Object} aEvent + * @param {Array} aEvent.current Records that are currently in the collection after the sync event + * @param {Array} aEvent.created Records that were created + * @param {Array} aEvent.updated Records that were updated + * @param {Array} aEvent.deleted Records that were deleted + * @memberof LoginRelatedRealmsParent + */ + onRemoteSettingsSync(aEvent) { + let { + data: { current }, + } = aEvent; + this._relatedDomainsList = current; + } + + async getSharedCredentialsCollection() { + if (!this._sharedCredentialsClient) { + this._sharedCredentialsClient = RemoteSettings( + LoginHelper.relatedRealmsCollection + ); + this._sharedCredentialsClient.on("sync", event => + this.onRemoteSettingsSync(event) + ); + this._relatedDomainsList = await this._sharedCredentialsClient.get(); + log.debug("Initialized related realms", this._relatedDomainsList); + } + log.debug("this._relatedDomainsList", this._relatedDomainsList); + return this._relatedDomainsList; + } + + /** + * Determine if there are any related realms of this `formOrigin` using the related realms collection + * @param {string} formOrigin A form origin + * @return {string[]} filteredRealms An array of domains related to the `formOrigin` + * @async + * @memberof LoginRelatedRealmsParent + */ + async findRelatedRealms(formOrigin) { + try { + let formOriginURI = Services.io.newURI(formOrigin); + let originDomain = formOriginURI.host; + let [ + { relatedRealms } = {}, + ] = await this.getSharedCredentialsCollection(); + if (!relatedRealms) { + return []; + } + let filterOriginIndex; + let shouldInclude = false; + let filteredRealms = relatedRealms.filter(_realms => { + for (let relatedOrigin of _realms) { + // We can't have an origin that matches multiple entries in our related realms collection + // so we exit the loop early + if (shouldInclude) { + return false; + } + if (Services.eTLD.hasRootDomain(originDomain, relatedOrigin)) { + shouldInclude = true; + break; + } + } + return shouldInclude; + }); + // * Filtered realms is a nested array due to its structure in Remote Settings + filteredRealms = filteredRealms.flat(); + + filterOriginIndex = filteredRealms.indexOf(originDomain); + // Removing the current formOrigin match if it exists in the related realms + // so that we don't return duplicates when we search for logins + if (filterOriginIndex !== -1) { + filteredRealms.splice(filterOriginIndex, 1); + } + return filteredRealms; + } catch (e) { + log.error(e); + return []; + } + } +} diff --git a/toolkit/components/passwordmgr/moz.build b/toolkit/components/passwordmgr/moz.build index 1ea21f43cbec..3409a4020ea2 100644 --- a/toolkit/components/passwordmgr/moz.build +++ b/toolkit/components/passwordmgr/moz.build @@ -41,6 +41,7 @@ EXTRA_JS_MODULES += [ "LoginManagerParent.jsm", "LoginManagerPrompter.jsm", "LoginRecipes.jsm", + "LoginRelatedRealms.jsm", "NewPasswordModel.jsm", "OSCrypto.jsm", "PasswordGenerator.jsm", diff --git a/toolkit/components/passwordmgr/storage-json.js b/toolkit/components/passwordmgr/storage-json.js index b547f82b14bc..f2bf720e0707 100644 --- a/toolkit/components/passwordmgr/storage-json.js +++ b/toolkit/components/passwordmgr/storage-json.js @@ -475,7 +475,9 @@ class LoginManagerStorage_json { // Some property names aren't field names but are special options to // affect the search. case "acceptDifferentSubdomains": - case "schemeUpgrades": { + case "schemeUpgrades": + case "acceptRelatedRealms": + case "relatedRealms": { options[prop.name] = prop.value; break; } @@ -508,6 +510,8 @@ class LoginManagerStorage_json { aOptions = { schemeUpgrades: false, acceptDifferentSubdomains: false, + acceptRelatedRealms: false, + relatedRealms: [], }, candidateLogins = this._store.data.logins ) { diff --git a/toolkit/components/passwordmgr/test/LoginTestUtils.jsm b/toolkit/components/passwordmgr/test/LoginTestUtils.jsm index 93329dff431b..bbb6d0da9ebb 100644 --- a/toolkit/components/passwordmgr/test/LoginTestUtils.jsm +++ b/toolkit/components/passwordmgr/test/LoginTestUtils.jsm @@ -9,6 +9,14 @@ const EXPORTED_SYMBOLS = ["LoginTestUtils"]; +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); + +XPCOMUtils.defineLazyModuleGetters(this, { + RemoteSettings: "resource://services-settings/remote-settings.js", +}); + const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); let { Assert: AssertCls } = ChromeUtils.import( @@ -616,3 +624,27 @@ LoginTestUtils.file = { return tmpFile; }, }; + +LoginTestUtils.remoteSettings = { + relatedRealmsCollection: "websites-with-shared-credential-backends", + async setupWebsitesWithSharedCredentials( + relatedRealms = [["other-example.com", "example.com", "example.co.uk"]] + ) { + let db = await RemoteSettings(this.relatedRealmsCollection).db; + await db.clear(); + await db.create({ + id: "some-fake-ID-abc", + relatedRealms, + }); + await db.importChanges({}, 1234567); + }, + async cleanWebsitesWithSharedCredentials() { + let db = await RemoteSettings(this.relatedRealmsCollection).db; + await db.clear(); + await db.importChanges({}, 1234); + }, + async updateTimestamp() { + let db = await RemoteSettings(this.relatedRealmsCollection).db; + await db.importChanges({}, 12345678); + }, +}; diff --git a/toolkit/components/passwordmgr/test/browser/head.js b/toolkit/components/passwordmgr/test/browser/head.js index 0a2316c173e0..b3c504a6aeb6 100644 --- a/toolkit/components/passwordmgr/test/browser/head.js +++ b/toolkit/components/passwordmgr/test/browser/head.js @@ -28,6 +28,12 @@ add_task(async function common_initialize() { ["toolkit.telemetry.ipcBatchTimeout", 0], ], }); + if (LoginHelper.relatedRealmsEnabled) { + LoginTestUtils.remoteSettings.setupWebsitesWithSharedCredentials(); + registerCleanupFunction(function() { + LoginTestUtils.remoteSettings.cleanWebsitesWithSharedCredentials(); + }); + } }); registerCleanupFunction( diff --git a/toolkit/components/passwordmgr/test/mochitest/mochitest.ini b/toolkit/components/passwordmgr/test/mochitest/mochitest.ini index 330bd1bc6e2d..079246d77c57 100644 --- a/toolkit/components/passwordmgr/test/mochitest/mochitest.ini +++ b/toolkit/components/passwordmgr/test/mochitest/mochitest.ini @@ -7,6 +7,8 @@ prefs = signon.testOnlyUserHasInteractedWithDocument=true security.insecure_field_warning.contextual.enabled=false network.auth.non-web-content-triggered-resources-http-auth-allow=true + # signon.relatedRealms.enabled pref needed until Bug 1699698 lands + signon.relatedRealms.enabled=true support-files = ../../../prompts/test/chromeScript.js @@ -38,6 +40,8 @@ skip-if = toolkit == 'android' && !is_fennec # Don't run on GeckoView # Note: new tests should use scheme = https unless they have a specific reason not to +[test_autocomplete_autofill_related_realms_no_dupes.html] +scheme = https [test_autocomplete_basic_form.html] skip-if = toolkit == 'android' || debug && (os == 'linux' || os == 'win') || os == 'linux' && tsan # android:autocomplete. Bug 1541945, Bug 1590928 scheme = https @@ -46,6 +50,8 @@ skip-if = toolkit == 'android' || os == 'linux' # android:autocomplete., linux: [test_autocomplete_basic_form_formActionOrigin.html] skip-if = toolkit == 'android' # android:autocomplete. scheme = https +[test_autocomplete_basic_form_related_realms.html] +scheme = https [test_autocomplete_basic_form_subdomain.html] skip-if = toolkit == 'android' # android:autocomplete. scheme = https diff --git a/toolkit/components/passwordmgr/test/mochitest/pwmgr_common.js b/toolkit/components/passwordmgr/test/mochitest/pwmgr_common.js index 22a7b65583ab..75a47ef72fec 100644 --- a/toolkit/components/passwordmgr/test/mochitest/pwmgr_common.js +++ b/toolkit/components/passwordmgr/test/mochitest/pwmgr_common.js @@ -667,6 +667,23 @@ function resetRecipes() { }); } +function resetWebsitesWithSharedCredential() { + info("Resetting the 'websites-with-shared-credential-backend' collection"); + return new Promise(resolve => { + PWMGR_COMMON_PARENT.addMessageListener( + "resetWebsitesWithSharedCredential", + function reset() { + PWMGR_COMMON_PARENT.removeMessageListener( + "resetWebsitesWithSharedCredential", + reset + ); + resolve(); + } + ); + PWMGR_COMMON_PARENT.sendAsyncMessage("resetWebsitesWithSharedCredential"); + }); +} + function promiseStorageChanged(expectedChangeTypes) { return new Promise((resolve, reject) => { function onStorageChanged({ topic, data }) { diff --git a/toolkit/components/passwordmgr/test/mochitest/pwmgr_common_parent.js b/toolkit/components/passwordmgr/test/mochitest/pwmgr_common_parent.js index a983950d03e6..93666911034c 100644 --- a/toolkit/components/passwordmgr/test/mochitest/pwmgr_common_parent.js +++ b/toolkit/components/passwordmgr/test/mochitest/pwmgr_common_parent.js @@ -21,6 +21,12 @@ var { LoginManagerParent } = ChromeUtils.import( const { LoginTestUtils } = ChromeUtils.import( "resource://testing-common/LoginTestUtils.jsm" ); +if (LoginHelper.relatedRealmsEnabled) { + let rsPromise = LoginTestUtils.remoteSettings.setupWebsitesWithSharedCredentials(); + async () => { + await rsPromise; + }; +} var { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); /** @@ -127,6 +133,7 @@ addMessageListener("cleanup", () => { Services.obs.removeObserver(onStorageChanged, "passwordmgr-storage-changed"); Services.obs.removeObserver(onPrompt, "passwordmgr-prompt-change"); Services.obs.removeObserver(onPrompt, "passwordmgr-prompt-save"); + Services.logins.removeAllUserFacingLogins(); }); // Begin message listeners diff --git a/toolkit/components/passwordmgr/test/mochitest/test_DOMInputPasswordAdded_fired_between_DOMContentLoaded_and_load_events.html b/toolkit/components/passwordmgr/test/mochitest/test_DOMInputPasswordAdded_fired_between_DOMContentLoaded_and_load_events.html index a49aaaf78861..31d9683a483a 100644 --- a/toolkit/components/passwordmgr/test/mochitest/test_DOMInputPasswordAdded_fired_between_DOMContentLoaded_and_load_events.html +++ b/toolkit/components/passwordmgr/test/mochitest/test_DOMInputPasswordAdded_fired_between_DOMContentLoaded_and_load_events.html @@ -25,7 +25,7 @@ let FILE_PATH = "/tests/toolkit/components/passwordmgr/test/mochitest/slow_image runInParent(function removeAll() { let {Services} = ChromeUtils.import("resource://gre/modules/Services.jsm"); Services.logins.removeAllUserFacingLogins(); -}) +}); let readyPromise = registerRunTests(); diff --git a/toolkit/components/passwordmgr/test/mochitest/test_autocomplete_autofill_related_realms_no_dupes.html b/toolkit/components/passwordmgr/test/mochitest/test_autocomplete_autofill_related_realms_no_dupes.html new file mode 100644 index 000000000000..b32ae69e34b1 --- /dev/null +++ b/toolkit/components/passwordmgr/test/mochitest/test_autocomplete_autofill_related_realms_no_dupes.html @@ -0,0 +1,101 @@ + + +
+ ++ ++ + diff --git a/toolkit/components/passwordmgr/test/mochitest/test_autocomplete_basic_form_related_realms.html b/toolkit/components/passwordmgr/test/mochitest/test_autocomplete_basic_form_related_realms.html new file mode 100644 index 000000000000..3951e9e19eed --- /dev/null +++ b/toolkit/components/passwordmgr/test/mochitest/test_autocomplete_basic_form_related_realms.html @@ -0,0 +1,131 @@ + + + + +
+ ++ + diff --git a/toolkit/components/passwordmgr/test/mochitest/test_username_focus.html b/toolkit/components/passwordmgr/test/mochitest/test_username_focus.html index 4bbfa2f1d4b2..0008087dc074 100644 --- a/toolkit/components/passwordmgr/test/mochitest/test_username_focus.html +++ b/toolkit/components/passwordmgr/test/mochitest/test_username_focus.html @@ -240,10 +240,6 @@ add_task(async function test_multiple_prefilled_focused_dynamic() { usernameField.focus(); await shownPromise; }); - -add_task(async function cleanup() { - removeFocus(); -});