bug 1544596 - Allow multiple connections with different users to the same CalDAV server. r=philipp
☠☠ backed out by ae1624177e2c ☠ ☠
authorJohn Bieling <john.bieling@gmx.de>
Thu, 13 Jun 2019 00:26:14 +0200
changeset 35862 18fc7a8887b9dd8cb2e92fb74597393750bfaa82
parent 35861 3e76c29bfb96ce9f785f63c4d2adb318f3664dbf
child 35863 ae1624177e2cf555a9d1f35bcae4c2e6287545bc
push id392
push userclokep@gmail.com
push dateMon, 02 Sep 2019 20:17:19 +0000
reviewersphilipp
bugs1544596
bug 1544596 - Allow multiple connections with different users to the same CalDAV server. r=philipp
calendar/base/content/dialogs/calendar-properties-dialog.js
calendar/base/content/dialogs/calendar-properties-dialog.xul
calendar/base/modules/utils/calAuthUtils.jsm
calendar/base/modules/utils/calProviderUtils.jsm
calendar/providers/caldav/calDavCalendar.js
calendar/resources/content/calendarCreation.js
calendar/resources/content/calendarCreation.xul
--- a/calendar/base/content/dialogs/calendar-properties-dialog.js
+++ b/calendar/base/content/dialogs/calendar-properties-dialog.js
@@ -23,16 +23,23 @@ function onLoad() {
     gCalendar = window.arguments[0].calendar;
     let calColor = gCalendar.getProperty("color");
 
     document.getElementById("calendar-name").value = gCalendar.name;
     document.getElementById("calendar-color").value = calColor || "#A8C2E1";
     document.getElementById("calendar-uri").value = gCalendar.uri.spec;
     document.getElementById("read-only").checked = gCalendar.readOnly;
 
+    if (gCalendar.getProperty("capabilities.username.supported") === true) {
+        document.getElementById("calendar-username").value = gCalendar.getProperty("username");
+        document.getElementById("calendar-username-row").hidden = false;
+    } else {
+        document.getElementById("calendar-username-row").hidden = true;
+    }
+
     // Set up refresh interval
     initRefreshInterval();
 
     // Set up the cache field
     let cacheBox = document.getElementById("cache");
     let canCache = (gCalendar.getProperty("cache.supported") !== false);
     let alwaysCache = gCalendar.getProperty("cache.always");
     if (!canCache || alwaysCache) {
@@ -75,16 +82,21 @@ function onLoad() {
  */
 function onAcceptDialog() {
     // Save calendar name
     gCalendar.name = document.getElementById("calendar-name").value;
 
     // Save calendar color
     gCalendar.setProperty("color", document.getElementById("calendar-color").value);
 
+    // Save calendar user
+    if (gCalendar.getProperty("capabilities.username.supported") === true) {
+        gCalendar.setProperty("username", document.getElementById("calendar-username").value);
+    }
+
     // Save readonly state
     gCalendar.readOnly = document.getElementById("read-only").checked;
 
     // Save supressAlarms
     gCalendar.setProperty("suppressAlarms", !document.getElementById("fire-alarms").checked);
 
     // Save refresh interval
     if (gCalendar.canRefresh) {
--- a/calendar/base/content/dialogs/calendar-properties-dialog.xul
+++ b/calendar/base/content/dialogs/calendar-properties-dialog.xul
@@ -5,16 +5,17 @@
 
 <?xml-stylesheet href="chrome://global/skin/global.css" type="text/css"?>
 <?xml-stylesheet href="chrome://calendar-common/skin/calendar-properties-dialog.css" type="text/css"?>
 
 <!DOCTYPE dialog
 [
     <!ENTITY % dtd1 SYSTEM "chrome://calendar/locale/global.dtd" > %dtd1;
     <!ENTITY % dtd2 SYSTEM "chrome://calendar/locale/calendar.dtd" > %dtd2;
+    <!ENTITY % dtd3 SYSTEM "chrome://calendar/locale/calendarCreation.dtd" > %dtd3;
 ]>
 
 <dialog
     id="calendar-properties-dialog-2"
     windowtype="Calendar:PropertiesDialog"
     title="&calendar.server.dialog.title.edit;"
     buttons="accept,cancel,extra1"
     buttonlabelextra1="&calendarproperties.unsubscribe.label;"
@@ -55,16 +56,24 @@
                disable-with-calendar="true"
                control="calendar-color"/>
           <hbox align="center">
             <html:input id="calendar-color"
                         type="color"
                         disable-with-calendar="true"/>
           </hbox>
       </row>
+      <row id="calendar-username-row"
+           align="center">
+        <label value="&locationpage.username.label;"
+               disable-with-calendar="true"
+               control="calendar-username"/>
+        <textbox id="calendar-username"
+                 disable-with-calendar="true"/>
+      </row>
       <row id="calendar-uri-row" align="center">
         <label value="&calendarproperties.location.label;"
                disable-with-calendar="true"
                control="calendar-uri"/>
         <!-- XXX Make location field readonly until Bug 315307 is fixed -->
         <textbox id="calendar-uri" readonly="true" disable-with-calendar="true"/>
       </row>
       <row id="calendar-refreshInterval-row" align="center">
--- a/calendar/base/modules/utils/calAuthUtils.jsm
+++ b/calendar/base/modules/utils/calAuthUtils.jsm
@@ -11,16 +11,121 @@ XPCOMUtils.defineLazyModuleGetter(this, 
  * Authentication tools and prompts, mostly for providers
  */
 
 // NOTE: This module should not be loaded directly, it is available when including
 // calUtils.jsm under the cal.auth namespace.
 
 this.EXPORTED_SYMBOLS = ["calauth"]; /* exported calauth */
 
+/**
+ * The userContextId of nsIHttpChannel is currently implemented as a uint32, so
+ * the ContainerMap defined below must not return Ids greater then the allowed
+ * range of a uint32.
+*/
+const MAX_CONTAINER_ID = Math.pow(2, 32)-1;
+
+/**
+ * A map that handles userContextIds and usernames and provides unique Ids for
+ * different usernames.
+ */
+class ContainerMap extends Map {
+    /**
+     * Create a container map with a given range of userContextIds.
+     *
+     * @param {Number} min        The lower range limit of userContextIds to be
+     *                            used.
+     * @param {Number} max        The upper range limit of userContextIds to be
+     *                            used.
+     * @param {?Object} iterable  Optional parameter which is passed to the
+     *                            constructor of Map. See definition of Map
+     *                            for more details.
+     */
+    constructor(min = 0, max = MAX_CONTAINER_ID, iterable) {
+        super(iterable);
+        this.order = [];
+        this.inverted = {};
+        this.min = min;
+        // The userConextId is a uint32, limit accordingly.
+        this.max = Math.max(max, MAX_CONTAINER_ID);
+        if (this.min > this.max) {
+            throw new RangeError("[ContainerMap] The provided min value " +
+              "(" + this.min + ") must not be greater than the provided " +
+              "max value (" + this.max + ")");
+        }
+    }
+
+    /**
+     * Check if the allowed userContextId range is fully used.
+     */
+    get full() {
+        return this.size > (this.max - this.min);
+    }
+
+    /**
+     * Add a new username to the map.
+     *
+     * @param {String} username - The username to be added.
+     * @return {Number} The userContextId assigned to the given username.
+     */
+    _add(username) {
+        let nextUserContextId;
+        if (this.full) {
+            let oldestUsernameEntry = this.order.shift();
+            nextUserContextId = this.get(oldestUsernameEntry);
+            this.delete(oldestUsernameEntry);
+        } else {
+            nextUserContextId = this.min + this.size;
+        }
+
+        Services.clearData.deleteDataFromOriginAttributesPattern(
+          { userContextId: nextUserContextId },
+        );
+        this.order.push(username);
+        this.set(username, nextUserContextId);
+        this.inverted[nextUserContextId] = username;
+        return nextUserContextId;
+    }
+
+    /**
+     * Look up the userContextId for the given username. Create a new one,
+     * if the username is not yet known.
+     *
+     * @param {String} username        The username for which the userContextId
+     *                                 is to be looked up.
+     * @return {Number}                The userContextId which is assigned to
+     *                                 the provided username.
+     */
+    getUserContextIdForUsername(username) {
+        if (this.has(username)) {
+            return this.get(username);
+        } else {
+            return this._add(username);
+        }
+    }
+
+    /**
+     * Look up the username for the given userContextId. Return empty string
+     * if not found.
+     *
+     * @param {Number} userContextId        The userContextId for which the
+     *                                      username is to be to looked up.
+     * @return {String}                     The username mapped to the given
+     *                                      userContextId.
+     */
+    getUsernameForUserContextId(userContextId) {
+        if (this.inverted.hasOwnProperty(userContextId)) {
+            return this.inverted[userContextId];
+        } else {
+            return "";
+        }
+    }
+}
+
+
 var calauth = {
     /**
      * Calendar Auth prompt implementation. This instance of the auth prompt should
      * be used by providers and other components that handle authentication using
      * nsIAuthPrompt2 and friends.
      *
      * This implementation guarantees there are no request loops when an invalid
      * password is stored in the login-manager.
@@ -40,31 +145,36 @@ var calauth = {
          * @property {?String} username     The found username
          * @property {?String} password     The found password
          */
 
         /**
          * Retrieve password information from the login manager
          *
          * @param {String} aPasswordRealm       The realm to retrieve password info for
+         * @param {String} aRequestedUser       The username to look up.
          * @return {PasswordInfo}               The retrieved password information
          */
-        getPasswordInfo(aPasswordRealm) {
-            let username;
+        getPasswordInfo(aPasswordRealm, aRequestedUser) {
+            // Prefill aRequestedUser, so it will be used in the prompter.
+            let username = aRequestedUser;
             let password;
             let found = false;
 
             let logins = Services.logins.findLogins(aPasswordRealm.prePath, null, aPasswordRealm.realm);
-            if (logins.length) {
-                username = logins[0].username;
-                password = logins[0].password;
-                found = true;
+            for (let login of logins) {
+                if (!aRequestedUser || aRequestedUser == login.username) {
+                    username = login.username;
+                    password = login.password;
+                    found = true;
+                    break;
+                }
             }
             if (found) {
-                let keyStr = aPasswordRealm.prePath + ":" + aPasswordRealm.realm;
+                let keyStr = aPasswordRealm.prePath + ":" + aPasswordRealm.realm + ":" + aRequestedUser;
                 let now = new Date();
                 // Remove the saved password if it was already returned less
                 // than 60 seconds ago. The reason for the timestamp check is that
                 // nsIHttpChannel can call the nsIAuthPrompt2 interface
                 // again in some situation. ie: When using Digest auth token
                 // expires.
                 if (this.mReturnedLogins[keyStr] &&
                     now.getTime() - this.mReturnedLogins[keyStr].getTime() < 60000) {
@@ -92,17 +202,18 @@ var calauth = {
             let port = aChannel.URI.port;
             if (port == -1) {
                 let handler = Services.io.getProtocolHandler(aChannel.URI.scheme)
                                          .QueryInterface(Ci.nsIProtocolHandler);
                 port = handler.defaultPort;
             }
             hostRealm.passwordRealm = aChannel.URI.host + ":" + port + " (" + aAuthInfo.realm + ")";
 
-            let pwInfo = this.getPasswordInfo(hostRealm);
+            let requestedUser = cal.auth.containerMap.getUsernameForUserContextId(aChannel.loadInfo.originAttributes.userContextId);
+            let pwInfo = this.getPasswordInfo(hostRealm, requestedUser);
             aAuthInfo.username = pwInfo.username;
             if (pwInfo && pwInfo.found) {
                 aAuthInfo.password = pwInfo.password;
                 return true;
             } else {
                 let savePasswordLabel = null;
                 if (Services.prefs.getBoolPref("signon.rememberSignons", true)) {
                     savePasswordLabel = cal.l10n.getAnyString("passwordmgr",
@@ -156,17 +267,18 @@ var calauth = {
                 },
 
                 onPromptCanceled: function() {
                     gAuthCache.retrieveAuthInfo(hostKey);
                     aCallback.onAuthCancelled(aContext, true);
                 }
             };
 
-            let hostKey = aChannel.URI.prePath + ":" + aAuthInfo.realm;
+            let requestedUser = cal.auth.containerMap.getUsernameForUserContextId(aChannel.loadInfo.originAttributes.userContextId);
+            let hostKey = aChannel.URI.prePath + ":" + aAuthInfo.realm + ":" + requestedUser;
             gAuthCache.planForAuthInfo(hostKey);
 
             let queuePrompt = function() {
                 let asyncprompter = Cc["@mozilla.org/messenger/msgAsyncPrompter;1"]
                                       .getService(Ci.nsIMsgAsyncPrompter);
                 asyncprompter.queueAsyncAuthPrompt(hostKey, false, promptlistener);
             };
 
@@ -268,21 +380,23 @@ var calauth = {
         }
 
         try {
             let logins = Services.logins.findLogins(origin, null, aRealm);
 
             let newLoginInfo = Cc["@mozilla.org/login-manager/loginInfo;1"]
                                  .createInstance(Ci.nsILoginInfo);
             newLoginInfo.init(origin, null, aRealm, aUsername, aPassword, "", "");
-            if (logins.length > 0) {
-                Services.logins.modifyLogin(logins[0], newLoginInfo);
-            } else {
-                Services.logins.addLogin(newLoginInfo);
+            for (let login of logins) {
+                if (aUsername == login.username) {
+                    Services.logins.modifyLogin(login, newLoginInfo);
+                    return;
+                }
             }
+            Services.logins.addLogin(newLoginInfo);
         } catch (exc) {
             // Only show the message if its not an abort, which can happen if
             // the user canceled the master password dialog
             cal.ASSERT(exc.result == Cr.NS_ERROR_ABORT, exc);
         }
     },
 
     /**
@@ -337,17 +451,28 @@ var calauth = {
                     Services.logins.removeLogin(loginInfo);
                     return true;
                 }
             }
         } catch (exc) {
             // If no logins are found, fall through to the return statement below.
         }
         return false;
-    }
+    },
+
+    /**
+     * A map which maps usernames to userContextIds, reserving a range
+     * of 20000 - 29999 for userContextIds to be used within lightning.
+     *
+     * @param {Number} min        The lower range limit of userContextIds to be
+     *                            used.
+     * @param {Number} max        The upper range limit of userContextIds to be
+     *                            used.
+     */
+    containerMap: new ContainerMap(20000, 29999)
 };
 
 // Cache for authentication information since onAuthInformation in the prompt
 // listener is called without further information. If the password is not
 // saved, there is no way to retrieve it. We use ref counting to avoid keeping
 // the password in memory longer than needed.
 var gAuthCache = {
     _authInfoCache: new Map(),
--- a/calendar/base/modules/utils/calProviderUtils.jsm
+++ b/calendar/base/modules/utils/calProviderUtils.jsm
@@ -31,20 +31,39 @@ var calprovider = {
      *                                                            string will be converted to an
      *                                                            input stream.
      * @param {String} aContentType                             Value for Content-Type header, if any
      * @param {nsIInterfaceRequestor} aNotificationCallbacks    Calendar using channel
      * @param {?nsIChannel} aExisting                           An existing channel to modify (optional)
      * @return {nsIChannel}                                     The prepared channel
      */
     prepHttpChannel: function(aUri, aUploadData, aContentType, aNotificationCallbacks, aExisting=null) {
+        let originAttributes = {};
+
+        // The current nsIHttpChannel implementation separates connections only
+        // by hosts, which causes issues with cookies and password caching for
+        // two or more simultaneous connections to the same host and different
+        // authenticated users. This can be solved by providing the additional
+        // userContextId, which also separates connections (a.k.a. containers).
+        // Connections for userA @ server1 and userA @ server2 can exist in the
+        // same container, as nsIHttpChannel will separate them. Connections
+        // for userA @ server1 and userB @ server1 however must be placed into
+        // different containers. It is therefore sufficient to add individual
+        // userContextIds per username.
+
+        let calendar = cal.wrapInstance(aNotificationCallbacks, Ci.calICalendar);
+        if (calendar && calendar.getProperty("capabilities.username.supported") === true) {
+            originAttributes.userContextId = cal.auth.containerMap
+                .getUserContextIdForUsername(calendar.getProperty("username"));
+        }
+
         // We cannot use a system principal here since the connection setup will fail if
         // same-site cookie protection is enabled in TB and server-side.
         let principal = aExisting ? null
-                                  : Services.scriptSecurityManager.createCodebasePrincipal(aUri, {});
+                                  : Services.scriptSecurityManager.createCodebasePrincipal(aUri, originAttributes);
         let channel = aExisting || Services.io.newChannelFromURI(aUri,
                                                                  null,
                                                                  principal,
                                                                  null,
                                                                  Ci.nsILoadInfo.SEC_ALLOW_CROSS_ORIGIN_DATA_IS_NULL,
                                                                  Ci.nsIContentPolicy.TYPE_OTHER);
         let httpchannel = channel.QueryInterface(Ci.nsIHttpChannel);
 
--- a/calendar/providers/caldav/calDavCalendar.js
+++ b/calendar/providers/caldav/calDavCalendar.js
@@ -566,16 +566,18 @@ calDavCalendar.prototype = {
                 } // else use outbound email-based iTIP (from cal.provider.BaseClass)
                 break;
             case "capabilities.tasks.supported":
                 return this.supportedItemTypes.includes("VTODO");
             case "capabilities.events.supported":
                 return this.supportedItemTypes.includes("VEVENT");
             case "capabilities.autoschedule.supported":
                 return this.hasAutoScheduling;
+            case "capabilities.username.supported":
+                return true;
         }
         return this.__proto__.__proto__.getProperty.apply(this, arguments);
     },
 
     promptOverwrite: function(aMethod, aItem, aListener, aOldItem) {
         let overwrite = cal.provider.promptOverwrite(aMethod, aItem, aListener, aOldItem);
         if (overwrite) {
             if (aMethod == CALDAV_MODIFY_ITEM) {
--- a/calendar/resources/content/calendarCreation.js
+++ b/calendar/resources/content/calendarCreation.js
@@ -108,16 +108,18 @@ function onSelectProvider(type) {
     let cache = document.getElementById("cache");
     let tempCal;
     try {
         tempCal = Cc["@mozilla.org/calendar/calendar;1?type=" + type].createInstance(Ci.calICalendar);
     } catch (e) {
         // keep tempCal undefined if the calendar can not be created
     }
 
+    document.getElementById("calendar-username-row").hidden = !(tempCal && tempCal.getProperty("capabilities.username.supported") === true);
+
     if (tempCal && tempCal.getProperty("cache.always")) {
         cache.oldValue = cache.checked;
         cache.checked = true;
         cache.disabled = true;
     } else {
         if (cache.oldValue !== undefined) {
             cache.checked = cache.oldValue;
             cache.oldValue = undefined;
@@ -207,16 +209,20 @@ function doCreateCalendar() {
 
     gCalendar.name = cal_name;
     gCalendar.setProperty("color", cal_color);
     if (!gCalendar.getProperty("cache.always")) {
         gCalendar.setProperty("cache.enabled", gCalendar.getProperty("cache.supported") === false
                                                ? false : document.getElementById("cache").checked);
     }
 
+    if (gCalendar.getProperty("capabilities.username.supported") === true) {
+        gCalendar.setProperty("username", document.getElementById("calendar-username").value);
+    }
+
     if (!document.getElementById("fire-alarms").checked) {
         gCalendar.setProperty("suppressAlarms", true);
     }
 
     cal.getCalendarManager().registerCalendar(gCalendar);
     return true;
 }
 
--- a/calendar/resources/content/calendarCreation.xul
+++ b/calendar/resources/content/calendarCreation.xul
@@ -50,16 +50,20 @@
         <row>
           <label value="&calendarproperties.format.label;" control="calendar-format"/>
           <radiogroup id="calendar-format" onselect="onSelectProvider(this.value)">
             <radio value="ics" label="&calendarproperties.webdav.label;" selected="true" />
             <radio value="caldav" label="&calendarproperties.caldav.label;"/>
             <radio id="wcap-radio" value="wcap" label="&calendarproperties.wcap.label;"/>
           </radiogroup>
         </row>
+        <row id="calendar-username-row" align="center">
+          <label value="&locationpage.username.label;" control="calendar-username"/>
+          <textbox id="calendar-username"/>
+        </row>
         <row align="center">
           <label value="&calendarproperties.location.label;" control="calendar-uri"/>
           <textbox is="search-textbox" id="calendar-uri"
                    required="true"
                    oninput="checkRequired();"/>
         </row>
         <row>
           <label/>