diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 19:33:14 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 19:33:14 +0000 |
commit | 36d22d82aa202bb199967e9512281e9a53db42c9 (patch) | |
tree | 105e8c98ddea1c1e4784a60a5a6410fa416be2de /toolkit/components/passwordmgr/LoginManagerParent.sys.mjs | |
parent | Initial commit. (diff) | |
download | firefox-esr-36d22d82aa202bb199967e9512281e9a53db42c9.tar.xz firefox-esr-36d22d82aa202bb199967e9512281e9a53db42c9.zip |
Adding upstream version 115.7.0esr.upstream/115.7.0esr
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'toolkit/components/passwordmgr/LoginManagerParent.sys.mjs')
-rw-r--r-- | toolkit/components/passwordmgr/LoginManagerParent.sys.mjs | 1524 |
1 files changed, 1524 insertions, 0 deletions
diff --git a/toolkit/components/passwordmgr/LoginManagerParent.sys.mjs b/toolkit/components/passwordmgr/LoginManagerParent.sys.mjs new file mode 100644 index 0000000000..dcf770b9d1 --- /dev/null +++ b/toolkit/components/passwordmgr/LoginManagerParent.sys.mjs @@ -0,0 +1,1524 @@ +/* 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/. */ + +import { FirefoxRelayTelemetry } from "resource://gre/modules/FirefoxRelayTelemetry.mjs"; +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const LoginInfo = new Components.Constructor( + "@mozilla.org/login-manager/loginInfo;1", + Ci.nsILoginInfo, + "init" +); + +const lazy = {}; + +XPCOMUtils.defineLazyGetter(lazy, "LoginRelatedRealmsParent", () => { + const { LoginRelatedRealmsParent } = ChromeUtils.importESModule( + "resource://gre/modules/LoginRelatedRealms.sys.mjs" + ); + return new LoginRelatedRealmsParent(); +}); + +XPCOMUtils.defineLazyGetter(lazy, "PasswordRulesManager", () => { + const { PasswordRulesManagerParent } = ChromeUtils.importESModule( + "resource://gre/modules/PasswordRulesManager.sys.mjs" + ); + return new PasswordRulesManagerParent(); +}); + +ChromeUtils.defineESModuleGetters(lazy, { + ChromeMigrationUtils: "resource:///modules/ChromeMigrationUtils.sys.mjs", + FirefoxRelay: "resource://gre/modules/FirefoxRelay.sys.mjs", + LoginHelper: "resource://gre/modules/LoginHelper.sys.mjs", + MigrationUtils: "resource:///modules/MigrationUtils.sys.mjs", + NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs", + PasswordGenerator: "resource://gre/modules/PasswordGenerator.sys.mjs", + PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", +}); + +XPCOMUtils.defineLazyServiceGetter( + lazy, + "prompterSvc", + "@mozilla.org/login-manager/prompter;1", + Ci.nsILoginManagerPrompter +); + +XPCOMUtils.defineLazyGetter(lazy, "log", () => { + let logger = lazy.LoginHelper.createLogger("LoginManagerParent"); + return logger.log.bind(logger); +}); +XPCOMUtils.defineLazyGetter(lazy, "debug", () => { + let logger = lazy.LoginHelper.createLogger("LoginManagerParent"); + return logger.debug.bind(logger); +}); + +/** + * A listener for notifications to tests. + */ +let gListenerForTests = null; + +/** + * A map of a principal's origin (including suffixes) to a generated password string and filled flag + * so that we can offer the same password later (e.g. in a confirmation field). + * + * We don't currently evict from this cache so entries should last until the end of the browser + * session. That may change later but for now a typical session would max out at a few entries. + */ +let gGeneratedPasswordsByPrincipalOrigin = new Map(); + +/** + * 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 + */ +let gRecipeManager = null; + +/** + * Tracks the last time the user cancelled the primary password prompt, + * to avoid spamming primary password prompts on autocomplete searches. + */ +let gLastMPLoginCancelled = Number.NEGATIVE_INFINITY; + +let gGeneratedPasswordObserver = { + addedObserver: false, + + observe(subject, topic, data) { + if (topic == "last-pb-context-exited") { + // The last private browsing context closed so clear all cached generated + // passwords for private window origins. + for (let principalOrigin of gGeneratedPasswordsByPrincipalOrigin.keys()) { + let principal = + Services.scriptSecurityManager.createContentPrincipalFromOrigin( + principalOrigin + ); + if (!principal.privateBrowsingId) { + // The origin isn't for a private context so leave it alone. + continue; + } + gGeneratedPasswordsByPrincipalOrigin.delete(principalOrigin); + } + return; + } + + // We cache generated passwords in gGeneratedPasswordsByPrincipalOrigin. + // When generated password used on the page, + // we store a login with generated password and without username. + // When user updates that autosaved login with username, + // we must clear cached generated password. + // This will generate a new password next time user needs it. + if (topic == "passwordmgr-storage-changed" && data == "modifyLogin") { + const originalLogin = subject.GetElementAt(0); + const updatedLogin = subject.GetElementAt(1); + + if (originalLogin && !originalLogin.username && updatedLogin?.username) { + const generatedPassword = gGeneratedPasswordsByPrincipalOrigin.get( + originalLogin.origin + ); + + if ( + originalLogin.password == generatedPassword.value && + updatedLogin.password == generatedPassword.value + ) { + gGeneratedPasswordsByPrincipalOrigin.delete(originalLogin.origin); + } + } + } + + if ( + topic == "passwordmgr-autosaved-login-merged" || + (topic == "passwordmgr-storage-changed" && data == "removeLogin") + ) { + let { origin, guid } = subject; + let generatedPW = gGeneratedPasswordsByPrincipalOrigin.get(origin); + + // in the case where an autosaved login removed or merged into an existing login, + // clear the guid associated with the generated-password cache entry + if ( + generatedPW && + (guid == generatedPW.storageGUID || + topic == "passwordmgr-autosaved-login-merged") + ) { + lazy.log( + `Removing storageGUID for generated-password cache entry on origin: ${origin}.` + ); + generatedPW.storageGUID = null; + } + } + }, +}; + +Services.ppmm.addMessageListener("PasswordManager:findRecipes", message => { + let formHost = new URL(message.data.formOrigin).host; + return gRecipeManager?.getRecipesForHost(formHost) ?? []; +}); + +/** + * Lazily create a Map of origins to array of browsers with importable logins. + * + * @param {origin} formOrigin + * @returns {Object?} containing array of migration browsers and experiment state. + */ +async function getImportableLogins(formOrigin) { + // Include the experiment state for data and UI decisions; otherwise skip + // importing if not supported or disabled. + const state = + lazy.LoginHelper.suggestImportCount > 0 && + lazy.LoginHelper.showAutoCompleteImport; + return state + ? { + browsers: await lazy.ChromeMigrationUtils.getImportableLogins( + formOrigin + ), + state, + } + : null; +} + +export class LoginManagerParent extends JSWindowActorParent { + possibleValues = { + // This is stored at the parent (i.e., frame) scope because the LoginManagerPrompter + // is shared across all frames. + // + // It is mutated to update values without forcing us to set a new doorhanger. + usernames: new Set(), + passwords: new Set(), + }; + + // This is used by tests to listen to form submission. + static setListenerForTests(listener) { + gListenerForTests = listener; + } + + // Used by tests to clean up recipes only when they were actually used. + static get _recipeManager() { + return gRecipeManager; + } + + // Some unit tests need to access this. + static getGeneratedPasswordsByPrincipalOrigin() { + return gGeneratedPasswordsByPrincipalOrigin; + } + + getRootBrowser() { + let browsingContext = null; + if (this._overrideBrowsingContextId) { + browsingContext = BrowsingContext.get(this._overrideBrowsingContextId); + } else { + browsingContext = this.browsingContext.top; + } + return browsingContext.embedderElement; + } + + /** + * @param {origin} formOrigin + * @param {object} options + * @param {origin?} options.formActionOrigin To match on. Omit this argument to match all action origins. + * @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, + { + acceptDifferentSubdomains, + formActionOrigin, + httpRealm, + ignoreActionAndRealm, + relatedRealms, + } = {} + ) { + let logins; + let matchData = { + origin: formOrigin, + schemeUpgrades: lazy.LoginHelper.schemeUpgrades, + acceptDifferentSubdomains, + }; + if (!ignoreActionAndRealm) { + if (typeof formActionOrigin != "undefined") { + matchData.formActionOrigin = formActionOrigin; + } else if (typeof httpRealm != "undefined") { + matchData.httpRealm = httpRealm; + } + } + if (lazy.LoginHelper.relatedRealmsEnabled) { + matchData.acceptRelatedRealms = lazy.LoginHelper.relatedRealmsEnabled; + matchData.relatedRealms = relatedRealms; + } + try { + logins = await Services.logins.searchLoginsAsync(matchData); + } 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) { + lazy.log("User cancelled primary password prompt."); + gLastMPLoginCancelled = Date.now(); + return []; + } + throw e; + } + + logins = lazy.LoginHelper.shadowHTTPLogins(logins); + + let resolveBy = [ + "subdomain", + "actionOrigin", + "scheme", + "timePasswordChanged", + ]; + return lazy.LoginHelper.dedupeLogins( + logins, + ["username", "password"], + resolveBy, + formOrigin, + formActionOrigin + ); + } + + async receiveMessage(msg) { + let data = msg.data; + if (data.origin || data.formOrigin) { + throw new Error( + "The child process should not send an origin to the parent process. See bug 1513003" + ); + } + let context = {}; + XPCOMUtils.defineLazyGetter(context, "origin", () => { + // We still need getLoginOrigin to remove the path for file: URIs until we fix bug 1625391. + let origin = lazy.LoginHelper.getLoginOrigin( + this.manager.documentPrincipal?.originNoSuffix + ); + if (!origin) { + throw new Error("An origin is required. Message name: " + msg.name); + } + return origin; + }); + + switch (msg.name) { + case "PasswordManager:updateDoorhangerSuggestions": { + this.#onUpdateDoorhangerSuggestions(data.possibleValues); + break; + } + + case "PasswordManager:decreaseSuggestImportCount": { + this.decreaseSuggestImportCount(data); + break; + } + + case "PasswordManager:findLogins": { + return this.sendLoginDataToChild( + context.origin, + data.actionOrigin, + data.options + ); + } + + case "PasswordManager:onFormSubmit": { + this.#onFormSubmit(context); + break; + } + + case "PasswordManager:onPasswordEditedOrGenerated": { + this.#onPasswordEditedOrGenerated(context, data); + break; + } + + case "PasswordManager:onIgnorePasswordEdit": { + this.#onIgnorePasswordEdit(); + break; + } + + case "PasswordManager:ShowDoorhanger": { + this.#onShowDoorhanger(context, data); + break; + } + + case "PasswordManager:autoCompleteLogins": { + return this.doAutocompleteSearch(context.origin, data); + } + + case "PasswordManager:removeLogin": { + this.#onRemoveLogin(data.login); + break; + } + + case "PasswordManager:OpenImportableLearnMore": { + this.#onOpenImportableLearnMore(); + break; + } + + case "PasswordManager:HandleImportable": { + await this.#onHandleImportable(data.browserId); + break; + } + + case "PasswordManager:OpenPreferences": { + this.#onOpenPreferences(data.hostname, data.entryPoint); + break; + } + + // Used by tests to detect that a form-fill has occurred. This redirects + // to the top-level browsing context. + case "PasswordManager:formProcessed": { + this.#onFormProcessed(data.formid); + break; + } + + case "PasswordManager:offerRelayIntegration": { + FirefoxRelayTelemetry.recordRelayOfferedEvent( + "clicked", + data.telemetry.flowId, + data.telemetry.scenarioName, + data.telemetry.isRelayUser + ); + return this.#offerRelayIntegration(context.origin); + } + + case "PasswordManager:generateRelayUsername": { + FirefoxRelayTelemetry.recordRelayUsernameFilledEvent( + "clicked", + data.telemetry.flowId + ); + return this.#generateRelayUsername(context.origin); + } + } + + return undefined; + } + + #onUpdateDoorhangerSuggestions(possibleValues) { + this.possibleValues.usernames = possibleValues.usernames; + this.possibleValues.passwords = possibleValues.passwords; + } + + #onFormSubmit(context) { + Services.obs.notifyObservers( + null, + "passwordmgr-form-submission-detected", + context.origin + ); + } + + #onPasswordEditedOrGenerated(context, data) { + lazy.log("#onPasswordEditedOrGenerated: Received PasswordManager."); + if (gListenerForTests) { + lazy.log("#onPasswordEditedOrGenerated: Calling gListenerForTests."); + gListenerForTests("PasswordEditedOrGenerated", {}); + } + let browser = this.getRootBrowser(); + this._onPasswordEditedOrGenerated(browser, context.origin, data); + } + + #onIgnorePasswordEdit() { + lazy.log("#onIgnorePasswordEdit: Received PasswordManager."); + if (gListenerForTests) { + lazy.log("#onIgnorePasswordEdit: Calling gListenerForTests."); + gListenerForTests("PasswordIgnoreEdit", {}); + } + } + + #onShowDoorhanger(context, data) { + const browser = this.getRootBrowser(); + const submitPromise = this.showDoorhanger(browser, context.origin, data); + if (gListenerForTests) { + submitPromise.then(() => { + gListenerForTests("ShowDoorhanger", { + origin: context.origin, + data, + }); + }); + } + } + + #onRemoveLogin(login) { + login = lazy.LoginHelper.vanillaObjectToLogin(login); + Services.logins.removeLogin(login); + } + + #onOpenImportableLearnMore() { + const window = this.getRootBrowser().ownerGlobal; + window.openTrustedLinkIn( + Services.urlFormatter.formatURLPref("app.support.baseURL") + + "password-import", + "tab", + { relatedToCurrent: true } + ); + } + + async #onHandleImportable(browserId) { + // Directly migrate passwords for a single profile. + const migrator = await lazy.MigrationUtils.getMigrator(browserId); + const profiles = await migrator.getSourceProfiles(); + if ( + profiles.length == 1 && + lazy.NimbusFeatures["password-autocomplete"].getVariable( + "directMigrateSingleProfile" + ) + ) { + const loginAdded = new Promise(resolve => { + const obs = (_subject, _topic, data) => { + if (data == "addLogin") { + Services.obs.removeObserver(obs, "passwordmgr-storage-changed"); + resolve(); + } + }; + Services.obs.addObserver(obs, "passwordmgr-storage-changed"); + }); + + await migrator.migrate( + lazy.MigrationUtils.resourceTypes.PASSWORDS, + null, + profiles[0] + ); + await loginAdded; + + // Reshow the popup with the imported password. + this.sendAsyncMessage("PasswordManager:repopulateAutocompletePopup"); + } else { + // Open the migration wizard pre-selecting the appropriate browser. + lazy.MigrationUtils.showMigrationWizard( + this.getRootBrowser().ownerGlobal, + { + entrypoint: lazy.MigrationUtils.MIGRATION_ENTRYPOINTS.PASSWORDS, + migratorKey: browserId, + } + ); + } + } + + #onOpenPreferences(hostname, entryPoint) { + const window = this.getRootBrowser().ownerGlobal; + lazy.LoginHelper.openPasswordManager(window, { + filterString: hostname, + entryPoint, + }); + } + + #onFormProcessed(formid) { + const topActor = + this.browsingContext.currentWindowGlobal.getActor("LoginManager"); + topActor.sendAsyncMessage("PasswordManager:formProcessed", { formid }); + if (gListenerForTests) { + gListenerForTests("FormProcessed", { + browsingContext: this.browsingContext, + }); + } + } + + async #offerRelayIntegration(origin) { + const browser = lazy.LoginHelper.getBrowserForPrompt(this.getRootBrowser()); + return lazy.FirefoxRelay.offerRelayIntegration(browser, origin); + } + + async #generateRelayUsername(origin) { + const browser = lazy.LoginHelper.getBrowserForPrompt(this.getRootBrowser()); + return lazy.FirefoxRelay.generateUsername(browser, origin); + } + + /** + * Update the remaining number of import suggestion impressions with debounce + * to allow multiple popups showing the "same" items to count as one. + */ + decreaseSuggestImportCount(count) { + // Delay an existing timer with a potentially larger count. + if (this._suggestImportTimer) { + this._suggestImportTimer.delay = + LoginManagerParent.SUGGEST_IMPORT_DEBOUNCE_MS; + this._suggestImportCount = Math.max(count, this._suggestImportCount); + return; + } + + this._suggestImportTimer = Cc["@mozilla.org/timer;1"].createInstance( + Ci.nsITimer + ); + this._suggestImportTimer.init( + () => { + this._suggestImportTimer = null; + Services.prefs.setIntPref( + "signon.suggestImportCount", + lazy.LoginHelper.suggestImportCount - this._suggestImportCount + ); + }, + LoginManagerParent.SUGGEST_IMPORT_DEBOUNCE_MS, + Ci.nsITimer.TYPE_ONE_SHOT + ); + this._suggestImportCount = count; + } + + async #getRecipesForHost(origin) { + let recipes; + if (origin) { + try { + const formHost = new URL(origin).host; + let recipeManager = await LoginManagerParent.recipeParentPromise; + recipes = recipeManager.getRecipesForHost(formHost); + } catch (ex) { + // Some schemes e.g. chrome aren't supported by URL + } + } + + return recipes ?? []; + } + + /** + * Trigger a login form fill and send relevant data (e.g. logins and recipes) + * to the child process (LoginManagerChild). + */ + async fillForm({ + browser, + loginFormOrigin, + login, + inputElementIdentifier, + style, + }) { + const recipes = await this.#getRecipesForHost(loginFormOrigin); + + // Convert the array of nsILoginInfo to vanilla JS objects since nsILoginInfo + // doesn't support structured cloning. + const jsLogins = [lazy.LoginHelper.loginToVanillaObject(login)]; + + const browserURI = browser.currentURI.spec; + const originMatches = + lazy.LoginHelper.getLoginOrigin(browserURI) == loginFormOrigin; + + this.sendAsyncMessage("PasswordManager:fillForm", { + inputElementIdentifier, + loginFormOrigin, + originMatches, + logins: jsLogins, + recipes, + style, + }); + } + + /** + * Send relevant data (e.g. logins and recipes) to the child process (LoginManagerChild). + */ + async sendLoginDataToChild( + formOrigin, + actionOrigin, + { guid, showPrimaryPassword } + ) { + const recipes = await this.#getRecipesForHost(formOrigin); + + if (!showPrimaryPassword && !Services.logins.isLoggedIn) { + return { logins: [], recipes }; + } + + // If we're currently displaying a primary password prompt, defer + // processing this form until the user handles the prompt. + if (Services.logins.uiBusy) { + lazy.log( + "UI is busy. Deferring sendLoginDataToChild for form: ", + formOrigin + ); + + let uiBusyPromiseResolve; + const uiBusyPromise = new Promise(resolve => { + uiBusyPromiseResolve = resolve; + }); + + const self = this; + const observer = { + QueryInterface: ChromeUtils.generateQI([ + "nsIObserver", + "nsISupportsWeakReference", + ]), + + observe(_subject, topic, _data) { + lazy.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") { + uiBusyPromiseResolve({ logins: [], recipes }); + return; + } + + const result = self.sendLoginDataToChild(formOrigin, actionOrigin, { + showPrimaryPassword, + }); + uiBusyPromiseResolve(result); + }, + }; + + // 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"); + Services.obs.addObserver(observer, "passwordmgr-crypto-loginCanceled"); + + return uiBusyPromise; + } + + // Autocomplete results do not need to match actionOrigin or exact origin. + let logins = null; + if (guid) { + logins = await Services.logins.searchLoginsAsync({ + guid, + origin: formOrigin, + }); + } else { + let relatedRealmsOrigins = []; + if (lazy.LoginHelper.relatedRealmsEnabled) { + relatedRealmsOrigins = + await lazy.LoginRelatedRealmsParent.findRelatedRealms(formOrigin); + } + logins = await LoginManagerParent.searchAndDedupeLogins(formOrigin, { + formActionOrigin: actionOrigin, + ignoreActionAndRealm: true, + acceptDifferentSubdomains: + lazy.LoginHelper.includeOtherSubdomainsInLookup, + relatedRealms: relatedRealmsOrigins, + }); + + if (lazy.LoginHelper.relatedRealmsEnabled) { + lazy.debug( + "Adding related logins on page load", + logins.map(l => l.origin) + ); + } + } + lazy.log(`Deduped ${logins.length} logins.`); + // Convert the array of nsILoginInfo to vanilla JS objects since nsILoginInfo + // doesn't support structured cloning. + let jsLogins = lazy.LoginHelper.loginsToVanillaObjects(logins); + return { + importable: await getImportableLogins(formOrigin), + logins: jsLogins, + recipes, + }; + } + + async doAutocompleteSearch( + formOrigin, + { + actionOrigin, + searchString, + previousResult, + forcePasswordGeneration, + hasBeenTypePassword, + isProbablyANewPasswordField, + scenarioName, + inputMaxLength, + } + ) { + // Note: previousResult is a regular object, not an + // nsIAutoCompleteResult. + + // Cancel if the primary password prompt is already showing or we unsuccessfully prompted for it too recently. + if (!Services.logins.isLoggedIn) { + if (Services.logins.uiBusy) { + lazy.log( + "Not searching logins for autocomplete since the primary password prompt is already showing." + ); + // Return an empty array to make LoginManagerChild clear the + // outstanding request it has temporarily saved. + return { logins: [] }; + } + + const timeDiff = Date.now() - gLastMPLoginCancelled; + if (timeDiff < LoginManagerParent._repromptTimeout) { + lazy.log( + `Not searching logins for autocomplete since the primary password prompt was last cancelled ${Math.round( + timeDiff / 1000 + )} seconds ago.` + ); + // Return an empty array to make LoginManagerChild clear the + // outstanding request it has temporarily saved. + return { logins: [] }; + } + } + + const searchStringLower = searchString.toLowerCase(); + let logins; + if ( + previousResult && + searchStringLower.startsWith(previousResult.searchString.toLowerCase()) + ) { + lazy.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 = lazy.LoginHelper.vanillaObjectsToLogins(previousResult.logins); + } else { + lazy.log("Creating new autocomplete search result."); + let relatedRealmsOrigins = []; + if (lazy.LoginHelper.relatedRealmsEnabled) { + relatedRealmsOrigins = + await lazy.LoginRelatedRealmsParent.findRelatedRealms(formOrigin); + } + // Autocomplete results do not need to match actionOrigin or exact origin. + logins = await LoginManagerParent.searchAndDedupeLogins(formOrigin, { + formActionOrigin: actionOrigin, + ignoreActionAndRealm: true, + acceptDifferentSubdomains: + lazy.LoginHelper.includeOtherSubdomainsInLookup, + relatedRealms: relatedRealmsOrigins, + }); + } + + const matchingLogins = logins.filter(fullMatch => { + // Remove results that are too short, or have different prefix. + // Also don't offer empty usernames as possible results except + // for on password fields. + if (hasBeenTypePassword) { + return true; + } + + const match = fullMatch.username; + + return match && match.toLowerCase().startsWith(searchStringLower); + }); + + let generatedPassword = null; + let willAutoSaveGeneratedPassword = false; + if ( + // If MP was cancelled above, don't try to offer pwgen or access storage again (causing a new MP prompt). + Services.logins.isLoggedIn && + (forcePasswordGeneration || + (isProbablyANewPasswordField && + Services.logins.getLoginSavingEnabled(formOrigin))) + ) { + // We either generate a new password here, or grab the previously generated password + // if we're still on the same domain when we generated the password + generatedPassword = await this.getGeneratedPassword({ inputMaxLength }); + const potentialConflictingLogins = + await Services.logins.searchLoginsAsync({ + origin: formOrigin, + formActionOrigin: actionOrigin, + httpRealm: null, + }); + willAutoSaveGeneratedPassword = !potentialConflictingLogins.find( + login => login.username == "" + ); + } + + // Convert the array of nsILoginInfo to vanilla JS objects since nsILoginInfo + // doesn't support structured cloning. + let jsLogins = lazy.LoginHelper.loginsToVanillaObjects(matchingLogins); + + return { + generatedPassword, + importable: await getImportableLogins(formOrigin), + autocompleteItems: hasBeenTypePassword + ? [] + : await lazy.FirefoxRelay.autocompleteItemsAsync({ + formOrigin, + scenarioName, + hasInput: !!searchStringLower.length, + }), + logins: jsLogins, + willAutoSaveGeneratedPassword, + }; + } + + /** + * Expose `BrowsingContext` so we can stub it in tests. + */ + static get _browsingContextGlobal() { + return BrowsingContext; + } + + // Set an override context within a test. + useBrowsingContext(browsingContextId = 0) { + this._overrideBrowsingContextId = browsingContextId; + } + + getBrowsingContextToUse() { + if (this._overrideBrowsingContextId) { + return BrowsingContext.get(this._overrideBrowsingContextId); + } + + return this.browsingContext; + } + + async getGeneratedPassword({ inputMaxLength } = {}) { + if ( + !lazy.LoginHelper.enabled || + !lazy.LoginHelper.generationAvailable || + !lazy.LoginHelper.generationEnabled + ) { + return null; + } + + let browsingContext = this.getBrowsingContextToUse(); + if (!browsingContext) { + return null; + } + let framePrincipalOrigin = + browsingContext.currentWindowGlobal.documentPrincipal.origin; + // Use the same password if we already generated one for this origin so that it doesn't change + // with each search/keystroke and the user can easily re-enter a password in a confirmation field. + let generatedPW = + gGeneratedPasswordsByPrincipalOrigin.get(framePrincipalOrigin); + if (generatedPW) { + return generatedPW.value; + } + + generatedPW = { + autocompleteShown: false, + edited: false, + filled: false, + /** + * GUID of a login that was already saved for this generated password that + * will be automatically updated with password changes. This shouldn't be + * an existing saved login for the site unless the user chose to + * merge/overwrite via a doorhanger. + */ + storageGUID: null, + }; + if (lazy.LoginHelper.improvedPasswordRulesEnabled) { + generatedPW.value = await lazy.PasswordRulesManager.generatePassword( + browsingContext.currentWindowGlobal.documentURI, + { inputMaxLength } + ); + } else { + generatedPW.value = lazy.PasswordGenerator.generatePassword({ + inputMaxLength, + }); + } + + // Add these observers when a password is assigned. + if (!gGeneratedPasswordObserver.addedObserver) { + Services.obs.addObserver( + gGeneratedPasswordObserver, + "passwordmgr-autosaved-login-merged" + ); + Services.obs.addObserver( + gGeneratedPasswordObserver, + "passwordmgr-storage-changed" + ); + Services.obs.addObserver( + gGeneratedPasswordObserver, + "last-pb-context-exited" + ); + gGeneratedPasswordObserver.addedObserver = true; + } + + gGeneratedPasswordsByPrincipalOrigin.set(framePrincipalOrigin, generatedPW); + return generatedPW.value; + } + + maybeRecordPasswordGenerationShownTelemetryEvent(autocompleteResults) { + if (!autocompleteResults.some(r => r.style == "generatedPassword")) { + return; + } + + let browsingContext = this.getBrowsingContextToUse(); + + let framePrincipalOrigin = + browsingContext.currentWindowGlobal.documentPrincipal.origin; + let generatedPW = + gGeneratedPasswordsByPrincipalOrigin.get(framePrincipalOrigin); + + // We only want to record the first time it was shown for an origin + if (generatedPW.autocompleteShown) { + return; + } + + generatedPW.autocompleteShown = true; + + Services.telemetry.recordEvent( + "pwmgr", + "autocomplete_shown", + "generatedpassword" + ); + } + + /** + * Used for stubbing by tests. + */ + _getPrompter() { + return lazy.prompterSvc; + } + + // Look for an existing login that matches the form login. + #findSameLogin(logins, formLogin) { + return logins.find(login => { + 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) { + let restoreMe = formLogin.username; + formLogin.username = ""; + same = lazy.LoginHelper.doLoginsMatch(formLogin, login, { + ignorePassword: false, + ignoreSchemes: lazy.LoginHelper.schemeUpgrades, + }); + formLogin.username = restoreMe; + } else if (!formLogin.username && login.username) { + formLogin.username = login.username; + same = lazy.LoginHelper.doLoginsMatch(formLogin, login, { + ignorePassword: false, + ignoreSchemes: lazy.LoginHelper.schemeUpgrades, + }); + formLogin.username = ""; // we know it's always blank. + } else { + same = lazy.LoginHelper.doLoginsMatch(formLogin, login, { + ignorePassword: true, + ignoreSchemes: lazy.LoginHelper.schemeUpgrades, + }); + } + + return same; + }); + } + + async showDoorhanger( + browser, + formOrigin, + { + browsingContextId, + formActionOrigin, + autoFilledLoginGuid, + usernameField, + newPasswordField, + oldPasswordField, + dismissedPrompt, + } + ) { + function recordLoginUse(login) { + Services.logins.recordPasswordUse( + login, + browser && lazy.PrivateBrowsingUtils.isBrowserPrivate(browser), + login.username ? "form_login" : "form_password", + !!autoFilledLoginGuid + ); + } + + // If password storage is disabled, bail out. + if (!lazy.LoginHelper.storageEnabled) { + return; + } + + if (!Services.logins.getLoginSavingEnabled(formOrigin)) { + lazy.log( + `Form submission ignored because saving is disabled for origin: ${formOrigin}.` + ); + return; + } + + let browsingContext = BrowsingContext.get(browsingContextId); + let framePrincipalOrigin = + browsingContext.currentWindowGlobal.documentPrincipal.origin; + + let formLogin = new LoginInfo( + formOrigin, + formActionOrigin, + null, + usernameField?.value ?? "", + newPasswordField.value, + usernameField?.name ?? "", + newPasswordField.name + ); + // we don't auto-save logins on form submit + let notifySaved = false; + + if (autoFilledLoginGuid) { + let loginsForGuid = await Services.logins.searchLoginsAsync({ + guid: autoFilledLoginGuid, + origin: formOrigin, // Ignored outside of GV. + }); + if ( + loginsForGuid.length == 1 && + loginsForGuid[0].password == formLogin.password && + (!formLogin.username || // Also cover cases where only the password is requested. + loginsForGuid[0].username == formLogin.username) + ) { + lazy.log( + "The filled login matches the form submission. Nothing to change." + ); + recordLoginUse(loginsForGuid[0]); + return; + } + } + + let existingLogin = null; + let canMatchExistingLogin = true; + // Below here we have one login per hostPort + action + username with the + // matching scheme being preferred. + const logins = await LoginManagerParent.searchAndDedupeLogins(formOrigin, { + formActionOrigin, + }); + + const generatedPW = + gGeneratedPasswordsByPrincipalOrigin.get(framePrincipalOrigin); + const autoSavedStorageGUID = generatedPW?.storageGUID ?? ""; + + // 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) { + if (logins.length == 1) { + existingLogin = logins[0]; + + if (existingLogin.password == formLogin.password) { + recordLoginUse(existingLogin); + lazy.log( + "Not prompting to save/change since we have no username and the only saved password matches the new password." + ); + return; + } + + formLogin.username = existingLogin.username; + formLogin.usernameField = existingLogin.usernameField; + } else if (!generatedPW || generatedPW.value != newPasswordField.value) { + // 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. + canMatchExistingLogin = false; + } + } + + if (canMatchExistingLogin && !existingLogin) { + existingLogin = this.#findSameLogin(logins, formLogin); + } + + const promptBrowser = lazy.LoginHelper.getBrowserForPrompt(browser); + const prompter = this._getPrompter(browser); + + if (!canMatchExistingLogin) { + prompter.promptToChangePasswordWithUsernames( + promptBrowser, + logins, + formLogin + ); + return; + } + + if (existingLogin) { + lazy.log("Found an existing login matching this form submission."); + + // Change password if needed. + if (existingLogin.password != formLogin.password) { + lazy.log("Passwords differ, prompting to change."); + prompter.promptToChangePassword( + promptBrowser, + existingLogin, + formLogin, + dismissedPrompt, + notifySaved, + autoSavedStorageGUID, + autoFilledLoginGuid, + this.possibleValues + ); + } else if (!existingLogin.username && formLogin.username) { + lazy.log("Empty username update, prompting to change."); + prompter.promptToChangePassword( + promptBrowser, + existingLogin, + formLogin, + dismissedPrompt, + notifySaved, + autoSavedStorageGUID, + autoFilledLoginGuid, + this.possibleValues + ); + } else { + recordLoginUse(existingLogin); + } + + return; + } + + // Prompt user to save login (via dialog or notification bar) + prompter.promptToSavePassword( + promptBrowser, + formLogin, + dismissedPrompt, + notifySaved, + autoFilledLoginGuid, + this.possibleValues + ); + } + + /** + * Performs validation of inputs against already-saved logins in order to determine whether and + * how these inputs can be stored. Depending on validation, will either no-op or show a 'save' + * or 'update' dialog to the user. + * + * This is called after any of the following: + * - The user edits a password + * - A generated password is filled + * - The user edits a username (when a matching password field has already been filled) + * + * @param {Element} browser + * @param {string} formOrigin + * @param {string} options.formActionOrigin + * @param {string?} options.autoFilledLoginGuid + * @param {Object} options.newPasswordField + * @param {Object?} options.usernameField + * @param {Element?} options.oldPasswordField + * @param {boolean} [options.triggeredByFillingGenerated = false] + */ + /* eslint-disable-next-line complexity */ + async _onPasswordEditedOrGenerated( + browser, + formOrigin, + { + formActionOrigin, + autoFilledLoginGuid, + newPasswordField, + usernameField = null, + oldPasswordField, + triggeredByFillingGenerated = false, + } + ) { + lazy.log( + `_onPasswordEditedOrGenerated: triggeredByFillingGenerated: ${triggeredByFillingGenerated}.` + ); + + // If password storage is disabled, bail out. + if (!lazy.LoginHelper.storageEnabled) { + return; + } + + if (!Services.logins.getLoginSavingEnabled(formOrigin)) { + // No UI should be shown to offer generation in this case but a user may + // disable saving for the site after already filling one and they may then + // edit it. + lazy.log(`Saving is disabled for origin: ${formOrigin}.`); + return; + } + + if (!newPasswordField.value) { + lazy.log("The password field is empty."); + return; + } + + if (!browser) { + lazy.log("The browser is gone."); + return; + } + + let browsingContext = this.getBrowsingContextToUse(); + if (!browsingContext) { + return; + } + + if (!triggeredByFillingGenerated && !Services.logins.isLoggedIn) { + // Don't show the dismissed doorhanger on "input" or "change" events + // when the Primary Password is locked + lazy.log( + "Edited field is not a generated password field, and Primary Password is locked." + ); + return; + } + + let framePrincipalOrigin = + browsingContext.currentWindowGlobal.documentPrincipal.origin; + + lazy.log("Got framePrincipalOrigin: ", framePrincipalOrigin); + + let formLogin = new LoginInfo( + formOrigin, + formActionOrigin, + null, + usernameField?.value ?? "", + newPasswordField.value, + usernameField?.name ?? "", + newPasswordField.name + ); + let existingLogin = null; + let canMatchExistingLogin = true; + let shouldAutoSaveLogin = triggeredByFillingGenerated; + let autoSavedLogin = null; + let notifySaved = false; + + if (autoFilledLoginGuid) { + let [matchedLogin] = await Services.logins.searchLoginsAsync({ + guid: autoFilledLoginGuid, + origin: formOrigin, // Ignored outside of GV. + }); + if ( + matchedLogin && + matchedLogin.password == formLogin.password && + (!formLogin.username || // Also cover cases where only the password is requested. + matchedLogin.username == formLogin.username) + ) { + lazy.log( + "The filled login matches the changed fields. Nothing to change." + ); + // We may want to update an existing doorhanger + existingLogin = matchedLogin; + } + } + + let generatedPW = + gGeneratedPasswordsByPrincipalOrigin.get(framePrincipalOrigin); + + // Below here we have one login per hostPort + action + username with the + // matching scheme being preferred. + let logins = await LoginManagerParent.searchAndDedupeLogins(formOrigin, { + formActionOrigin, + }); + // only used in the generated pw case where we auto-save + let formLoginWithoutUsername; + + if (triggeredByFillingGenerated && generatedPW) { + lazy.log("Got cached generatedPW."); + formLoginWithoutUsername = new LoginInfo( + formOrigin, + formActionOrigin, + null, + "", + newPasswordField.value + ); + + if (newPasswordField.value != generatedPW.value) { + // The user edited the field after generation to a non-empty value. + lazy.log("The field containing the generated password has changed."); + + // Record telemetry for the first edit + if (!generatedPW.edited) { + Services.telemetry.recordEvent( + "pwmgr", + "filled_field_edited", + "generatedpassword" + ); + lazy.log("filled_field_edited telemetry event recorded."); + generatedPW.edited = true; + } + } + + // This will throw if we can't look up the entry in the password/origin map + if (!generatedPW.filled) { + if (generatedPW.storageGUID) { + throw new Error( + "Generated password was saved in storage without being filled first" + ); + } + // record first use of this generated password + Services.telemetry.recordEvent( + "pwmgr", + "autocomplete_field", + "generatedpassword" + ); + lazy.log("autocomplete_field telemetry event recorded."); + generatedPW.filled = true; + } + + // We may have already autosaved this login + // Note that it could have been saved in a totally different tab in the session. + if (generatedPW.storageGUID) { + [autoSavedLogin] = await Services.logins.searchLoginsAsync({ + guid: generatedPW.storageGUID, + origin: formOrigin, // Ignored outside of GV. + }); + + if (autoSavedLogin) { + lazy.log("login to change is the auto-saved login."); + existingLogin = autoSavedLogin; + } + // The generated password login may have been deleted in the meantime. + // Proceed to maybe save a new login below. + } + generatedPW.value = newPasswordField.value; + + if (!existingLogin) { + lazy.log("Did not match generated-password login."); + + // Check if we already have a login saved for this site since we don't want to overwrite it in + // case the user still needs their old password to successfully complete a password change. + let matchedLogin = logins.find(login => + formLoginWithoutUsername.matches(login, true) + ); + if (matchedLogin) { + shouldAutoSaveLogin = false; + if (matchedLogin.password == formLoginWithoutUsername.password) { + // This login is already saved so show no new UI. + // We may want to update an existing doorhanger though... + lazy.log("Matching login already saved."); + existingLogin = matchedLogin; + } + lazy.log( + "_onPasswordEditedOrGenerated: Login with empty username already saved for this site." + ); + } + } + } + + // If we didn't find a username field, but seem to be changing a + // password, use the first match if there is only one + // If there's more than one we'll prompt to save with the initial formLogin + // and let the doorhanger code resolve this + if ( + !triggeredByFillingGenerated && + !existingLogin && + !usernameField && + oldPasswordField && + logins.length + ) { + if (logins.length == 1) { + existingLogin = logins[0]; + + if (existingLogin.password == formLogin.password) { + lazy.log( + "Not prompting to save/change since we have no username and the " + + "only saved password matches the new password." + ); + return; + } + + formLogin.username = existingLogin.username; + formLogin.usernameField = existingLogin.usernameField; + } else if (!generatedPW || generatedPW.value != newPasswordField.value) { + // 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. + canMatchExistingLogin = false; + } + } + + if (canMatchExistingLogin && !existingLogin) { + existingLogin = this.#findSameLogin(logins, formLogin); + if (existingLogin) { + lazy.log("Matched saved login."); + } + } + + if (shouldAutoSaveLogin) { + if ( + existingLogin && + existingLogin == autoSavedLogin && + existingLogin.password !== formLogin.password + ) { + lazy.log("Updating auto-saved login."); + + Services.logins.modifyLogin( + existingLogin, + lazy.LoginHelper.newPropertyBag({ + password: formLogin.password, + }) + ); + notifySaved = true; + // Update `existingLogin` with the new password if modifyLogin didn't + // throw so that the prompts later uses the new password. + existingLogin.password = formLogin.password; + } else if (!autoSavedLogin) { + lazy.log("Auto-saving new login with empty username."); + existingLogin = await Services.logins.addLoginAsync( + formLoginWithoutUsername + ); + // Remember the GUID where we saved the generated password so we can update + // the login if the user later edits the generated password. + generatedPW.storageGUID = existingLogin.guid; + notifySaved = true; + } + } else { + lazy.log("Not auto-saving this login."); + } + + const prompter = this._getPrompter(browser); + const promptBrowser = lazy.LoginHelper.getBrowserForPrompt(browser); + + if (existingLogin) { + // Show a change doorhanger to allow modifying an already-saved login + // e.g. to add a username or update the password. + let autoSavedStorageGUID = ""; + if ( + generatedPW && + generatedPW.value == existingLogin.password && + generatedPW.storageGUID == existingLogin.guid + ) { + autoSavedStorageGUID = generatedPW.storageGUID; + } + + // Change password if needed. + if ( + (shouldAutoSaveLogin && !formLogin.username) || + existingLogin.password != formLogin.password + ) { + lazy.log( + `promptToChangePassword with autoSavedStorageGUID: ${autoSavedStorageGUID}` + ); + prompter.promptToChangePassword( + promptBrowser, + existingLogin, + formLogin, + true, // dismissed prompt + notifySaved, + autoSavedStorageGUID, // autoSavedLoginGuid + autoFilledLoginGuid, + this.possibleValues + ); + } else if (!existingLogin.username && formLogin.username) { + lazy.log("Empty username update, prompting to change."); + prompter.promptToChangePassword( + promptBrowser, + existingLogin, + formLogin, + true, // dismissed prompt + notifySaved, + autoSavedStorageGUID, // autoSavedLoginGuid + autoFilledLoginGuid, + this.possibleValues + ); + } else { + lazy.log("No change to existing login."); + // is there a doorhanger we should update? + let popupNotifications = promptBrowser.ownerGlobal.PopupNotifications; + let notif = popupNotifications.getNotification("password", browser); + lazy.log( + `_onPasswordEditedOrGenerated: Has doorhanger? ${ + notif && notif.dismissed + }` + ); + if (notif && notif.dismissed) { + prompter.promptToChangePassword( + promptBrowser, + existingLogin, + formLogin, + true, // dismissed prompt + notifySaved, + autoSavedStorageGUID, // autoSavedLoginGuid + autoFilledLoginGuid, + this.possibleValues + ); + } + } + return; + } + lazy.log("No matching login to save/update."); + prompter.promptToSavePassword( + promptBrowser, + formLogin, + true, // dismissed prompt + notifySaved, + autoFilledLoginGuid, + this.possibleValues + ); + } + + static get recipeParentPromise() { + if (!gRecipeManager) { + const { LoginRecipesParent } = ChromeUtils.importESModule( + "resource://gre/modules/LoginRecipes.sys.mjs" + ); + gRecipeManager = new LoginRecipesParent({ + defaults: Services.prefs.getStringPref("signon.recipes.path"), + }); + } + + return gRecipeManager.initializationPromise; + } +} + +LoginManagerParent.SUGGEST_IMPORT_DEBOUNCE_MS = 10000; + +XPCOMUtils.defineLazyPreferenceGetter( + LoginManagerParent, + "_repromptTimeout", + "signon.masterPasswordReprompt.timeout_ms", + 900000 +); // 15 Minutes |