Bug 1249619 - Handle FxA password reset/changed push notifications. r?markh draft
authorEdouard Oger <eoger@fastmail.com>
Fri, 29 Jul 2016 20:51:16 -0700
changeset 397037 13d008d384fc2ba175951c908021f7055f560187
parent 396804 0ba72e8027cfcbcbf3426770ac264a7ade2af090
child 527360 0224464aa20ef1c1dece495bd15d4e69a3291469
push id25187
push userbmo:eoger@fastmail.com
push dateFri, 05 Aug 2016 03:39:28 +0000
reviewersmarkh
bugs1249619
milestone51.0a1
Bug 1249619 - Handle FxA password reset/changed push notifications. r?markh MozReview-Commit-ID: 4ZtUs80iPXp
browser/base/content/browser-fxaccounts.js
services/fxaccounts/FxAccounts.jsm
services/fxaccounts/FxAccountsClient.jsm
services/fxaccounts/FxAccountsCommon.js
services/fxaccounts/FxAccountsPush.js
services/fxaccounts/tests/xpcshell/test_push_service.js
services/sync/modules/browserid_identity.js
--- a/browser/base/content/browser-fxaccounts.js
+++ b/browser/base/content/browser-fxaccounts.js
@@ -16,17 +16,17 @@ var gFxAccounts = {
                           .wrappedJSObject;
   },
 
   get topics() {
     // Do all this dance to lazy-load FxAccountsCommon.
     delete this.topics;
     return this.topics = [
       "weave:service:ready",
-      "weave:service:login:error",
+      "weave:service:login:change",
       "weave:service:setup-complete",
       "weave:ui:login:error",
       "fxa-migration:state-changed",
       this.FxAccountsCommon.ONLOGIN_NOTIFICATION,
       this.FxAccountsCommon.ONLOGOUT_NOTIFICATION,
       this.FxAccountsCommon.ON_PROFILE_CHANGE_NOTIFICATION,
     ];
   },
--- a/services/fxaccounts/FxAccounts.jsm
+++ b/services/fxaccounts/FxAccounts.jsm
@@ -38,34 +38,36 @@ var publicProperties = [
   "accountStatus",
   "checkVerificationStatus",
   "getAccountsClient",
   "getAccountsSignInURI",
   "getAccountsSignUpURI",
   "getAssertion",
   "getDeviceId",
   "getKeys",
+  "getOAuthToken",
   "getSignedInUser",
-  "getOAuthToken",
   "getSignedInUserProfile",
+  "handleDeviceDisconnection",
   "invalidateCertificate",
   "loadAndPoll",
   "localtimeOffsetMsec",
   "now",
+  "promiseAccountsChangeProfileURI",
   "promiseAccountsForceSigninURI",
-  "promiseAccountsChangeProfileURI",
   "promiseAccountsManageURI",
   "removeCachedOAuthToken",
   "resendVerificationEmail",
+  "resetCredentials",
+  "sessionStatus",
   "setSignedInUser",
   "signOut",
+  "updateDeviceRegistration",
   "updateUserAccountData",
-  "updateDeviceRegistration",
-  "handleDeviceDisconnection",
-  "whenVerified"
+  "whenVerified",
 ];
 
 // An AccountState object holds all state related to one specific account.
 // Only one AccountState is ever "current" in the FxAccountsInternal object -
 // whenever a user logs out or logs in, the current AccountState is discarded,
 // making it impossible for the wrong state or state data to be accidentally
 // used.
 // In addition, it has some promise-related helpers to ensure that if an
@@ -789,16 +791,32 @@ FxAccountsInternal.prototype = {
       return this.fxAccountsClient.signOutAndDestroyDevice(sessionToken, deviceId, options);
     }
 
     log.debug("destroying session");
     return this.fxAccountsClient.signOut(sessionToken, options);
   },
 
   /**
+   * Check the status of the current session using cached credentials.
+   *
+   * @return Promise
+   *        Resolves with a boolean indicating if the session is still valid
+   */
+  sessionStatus() {
+    return this.getSignedInUser().then(data => {
+      if (!data.sessionToken) {
+        return Promise.reject(new Error(
+          "sessionStatus called without a session token"));
+      }
+      return this.fxAccountsClient.sessionStatus(data.sessionToken);
+    });
+  },
+
+  /**
    * Fetch encryption keys for the signed-in-user from the FxA API server.
    *
    * Not for user consumption.  Exists to cause the keys to be fetch.
    *
    * Returns user data so that it can be chained with other methods.
    *
    * @return Promise
    *        The promise resolves to the credentials object of the signed-in user:
@@ -1501,16 +1519,38 @@ FxAccountsInternal.prototype = {
           return this.signOut(true);
         }
         log.error(
           "The device ID to disconnect doesn't match with the local device ID.\n"
           + "Local: " + localDeviceId + ", ID to disconnect: " + deviceId);
     });
   },
 
+  /**
+   * Delete all the cached persisted credentials we store for FxA.
+   *
+   * @return Promise resolves when the user data has been persisted
+  */
+  resetCredentials() {
+    // Delete all fields except those required for the user to
+    // reauthenticate.
+    let updateData = {};
+    let clearField = field => {
+      if (!FXA_PWDMGR_REAUTH_WHITELIST.has(field)) {
+        updateData[field] = null;
+      }
+    }
+    FXA_PWDMGR_PLAINTEXT_FIELDS.forEach(clearField);
+    FXA_PWDMGR_SECURE_FIELDS.forEach(clearField);
+    FXA_PWDMGR_MEMORY_FIELDS.forEach(clearField);
+
+    let currentState = this.currentAccountState;
+    return currentState.updateUserAccountData(updateData);
+  },
+
   // If you change what we send to the FxA servers during device registration,
   // you'll have to bump the DEVICE_REGISTRATION_VERSION number to force older
   // devices to re-register when Firefox updates
   _registerOrUpdateDevice(signedInUser) {
     try {
       // Allow tests to skip device registration because:
       //   1. It makes remote requests to the auth server.
       //   2. _getDeviceName does not work from xpcshell.
@@ -1646,32 +1686,18 @@ FxAccountsInternal.prototype = {
     log.warn("recovering from invalid token error", err);
     return this.accountStatus().then(exists => {
       if (!exists) {
         // Delete all local account data. Since the account no longer
         // exists, we can skip the remote calls.
         log.info("token invalidated because the account no longer exists");
         return this.signOut(true);
       }
-
-      // Delete all fields except those required for the user to
-      // reauthenticate.
       log.info("clearing credentials to handle invalid token error");
-      let updateData = {};
-      let clearField = field => {
-        if (!FXA_PWDMGR_REAUTH_WHITELIST.has(field)) {
-          updateData[field] = null;
-        }
-      }
-      FXA_PWDMGR_PLAINTEXT_FIELDS.forEach(clearField);
-      FXA_PWDMGR_SECURE_FIELDS.forEach(clearField);
-      FXA_PWDMGR_MEMORY_FIELDS.forEach(clearField);
-
-      let currentState = this.currentAccountState;
-      return currentState.updateUserAccountData(updateData);
+      return this.resetCredentials();
     }).then(() => Promise.reject(err));
   },
 };
 
 
 // A getter for the instance to export
 XPCOMUtils.defineLazyGetter(this, "fxAccounts", function() {
   let a = new FxAccounts();
--- a/services/fxaccounts/FxAccountsClient.jsm
+++ b/services/fxaccounts/FxAccountsClient.jsm
@@ -179,16 +179,37 @@ this.FxAccountsClient.prototype = {
    *        }
    */
   signIn: function signIn(email, password, getKeys=false) {
     return this._createSession(SIGNIN, email, password, getKeys,
                                true /* retry */);
   },
 
   /**
+   * Check the status of a session given a session token
+   *
+   * @param sessionTokenHex
+   *        The session token encoded in hex
+   * @return Promise
+   *        Resolves with a boolean indicating if the session is still valid
+   */
+  sessionStatus: function (sessionTokenHex) {
+    return this._request("/session/status", "GET",
+      deriveHawkCredentials(sessionTokenHex, "sessionToken")).then(
+        () => Promise.resolve(true),
+        error => {
+          if (isInvalidTokenError(error)) {
+            return Promise.resolve(false);
+          }
+          throw error;
+        }
+      );
+  },
+
+  /**
    * Destroy the current session with the Firefox Account API server
    *
    * @param sessionTokenHex
    *        The session token encoded in hex
    * @return Promise
    */
   signOut: function (sessionTokenHex, options = {}) {
     let path = "/session/destroy";
--- a/services/fxaccounts/FxAccountsCommon.js
+++ b/services/fxaccounts/FxAccountsCommon.js
@@ -85,20 +85,23 @@ exports.POLL_SESSION       = 1000 * 60 *
 
 // Observer notifications.
 exports.ONLOGIN_NOTIFICATION = "fxaccounts:onlogin";
 exports.ONVERIFIED_NOTIFICATION = "fxaccounts:onverified";
 exports.ONLOGOUT_NOTIFICATION = "fxaccounts:onlogout";
 // Internal to services/fxaccounts only
 exports.ON_FXA_UPDATE_NOTIFICATION = "fxaccounts:update";
 exports.ON_DEVICE_DISCONNECTED_NOTIFICATION = "fxaccounts:device_disconnected";
+exports.ON_PASSWORD_CHANGED_NOTIFICATION = "fxaccounts:password_changed";
+exports.ON_PASSWORD_RESET_NOTIFICATION = "fxaccounts:password_reset";
 
 exports.FXA_PUSH_SCOPE_ACCOUNT_UPDATE = "chrome://fxa-device-update";
 
 exports.ON_PROFILE_CHANGE_NOTIFICATION = "fxaccounts:profilechange";
+exports.ON_ACCOUNT_STATE_CHANGE_NOTIFICATION = "fxaccounts:statechange";
 
 // UI Requests.
 exports.UI_REQUEST_SIGN_IN_FLOW = "signInFlow";
 exports.UI_REQUEST_REFRESH_AUTH = "refreshAuthentication";
 
 // The OAuth client ID for Firefox Desktop
 exports.FX_OAUTH_CLIENT_ID = "5882386c6d801776";
 
--- a/services/fxaccounts/FxAccountsPush.js
+++ b/services/fxaccounts/FxAccountsPush.js
@@ -5,16 +5,17 @@
 const Cc = Components.classes;
 const Ci = Components.interfaces;
 const Cu = Components.utils;
 
 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
 Cu.import("resource://gre/modules/Services.jsm");
 Cu.import("resource://services-sync/util.js");
 Cu.import("resource://gre/modules/FxAccountsCommon.js");
+Cu.import("resource://gre/modules/Task.jsm");
 
 /**
  * FxAccountsPushService manages Push notifications for Firefox Accounts in the browser
  *
  * @param [options]
  *        Object, custom options that used for testing
  * @constructor
  */
@@ -108,72 +109,100 @@ FxAccountsPushService.prototype = {
     });
   },
   /**
    * Standard observer interface to listen to push messages, changes and logout.
    *
    * @param subject
    * @param topic
    * @param data
+   * @returns {Promise}
    */
-  observe(subject, topic, data) {
+  _observe(subject, topic, data) {
     this.log.trace(`observed topic=${topic}, data=${data}, subject=${subject}`);
     switch (topic) {
       case this.pushService.pushTopic:
         if (data === FXA_PUSH_SCOPE_ACCOUNT_UPDATE) {
           let message = subject.QueryInterface(Ci.nsIPushMessage);
-          this._onPushMessage(message);
+          return this._onPushMessage(message);
         }
         break;
       case this.pushService.subscriptionChangeTopic:
         if (data === FXA_PUSH_SCOPE_ACCOUNT_UPDATE) {
-          this._onPushSubscriptionChange();
+          return this._onPushSubscriptionChange();
         }
         break;
       case ONLOGOUT_NOTIFICATION:
         // user signed out, we need to stop polling the Push Server
-        this.unsubscribe().catch(err => {
+        return this.unsubscribe().catch(err => {
           this.log.error("Error during unsubscribe", err);
         });
         break;
       default:
         break;
     }
   },
   /**
+   * Wrapper around _observe that catches errors
+   */
+  observe(subject, topic, data) {
+    Promise.resolve()
+      .then(() => this._observe(subject, topic, data))
+      .catch(err => this.log.error(err));
+  },
+  /**
    * Fired when the Push server sends a notification.
    *
    * @private
+   * @returns {Promise}
    */
   _onPushMessage(message) {
     this.log.trace("FxAccountsPushService _onPushMessage");
     if (!message.data) {
       // Use the empty signal to check the verification state of the account right away
-      this.fxAccounts.checkVerificationStatus();
-      return;
+      return this.fxAccounts.checkVerificationStatus();
     }
     let payload = message.data.json();
     switch (payload.command) {
       case ON_DEVICE_DISCONNECTED_NOTIFICATION:
-        this.fxAccounts.handleDeviceDisconnection(payload.data.id);
+        return this.fxAccounts.handleDeviceDisconnection(payload.data.id);
+        break;
+      case ON_PASSWORD_CHANGED_NOTIFICATION:
+      case ON_PASSWORD_RESET_NOTIFICATION:
+        return this._onPasswordChanged();
         break;
       default:
         this.log.warn("FxA Push command unrecognized: " + payload.command);
     }
   },
   /**
+   * Check the FxA session status after a password change/reset event.
+   * If the session is invalid, reset credentials and notify listeners of
+   * ON_ACCOUNT_STATE_CHANGE_NOTIFICATION that the account may have changed
+   *
+   * @returns {Promise}
+   * @private
+   */
+  _onPasswordChanged: Task.async(function* () {
+    if (!(yield this.fxAccounts.sessionStatus())) {
+      yield this.fxAccounts.resetCredentials();
+      Services.obs.notifyObservers(null, ON_ACCOUNT_STATE_CHANGE_NOTIFICATION, null);
+    }
+  }),
+  /**
    * Fired when the Push server drops a subscription, or the subscription identifier changes.
    *
    * https://developer.mozilla.org/en-US/docs/Mozilla/Tech/XPCOM/Reference/Interface/nsIPushService#Receiving_Push_Messages
    *
+   * @returns {Promise}
    * @private
    */
   _onPushSubscriptionChange() {
     this.log.trace("FxAccountsPushService _onPushSubscriptionChange");
-    this.fxAccounts.updateDeviceRegistration();
+    return this.fxAccounts.updateDeviceRegistration();
   },
   /**
    * Unsubscribe from the Push server
    *
    * Ref: https://developer.mozilla.org/en-US/docs/Mozilla/Tech/XPCOM/Reference/Interface/nsIPushService#unsubscribe()
    *
    * @returns {Promise}
    * @private
@@ -189,18 +218,18 @@ FxAccountsPushService.prototype = {
               this.log.debug("FxAccountsPushService unsubscribed");
             } else {
               this.log.debug("FxAccountsPushService had no subscription to unsubscribe");
             }
           } else {
             this.log.warn("FxAccountsPushService failed to unsubscribe", result);
           }
           return resolve(ok);
-        })
-    })
+        });
+    });
   },
 };
 
 // Service registration below registers with FxAccountsComponents.manifest
 const components = [FxAccountsPushService];
 this.NSGetFactory = XPCOMUtils.generateNSGetFactory(components);
 
 // The following registration below helps with testing this service.
--- a/services/fxaccounts/tests/xpcshell/test_push_service.js
+++ b/services/fxaccounts/tests/xpcshell/test_push_service.js
@@ -168,16 +168,62 @@ add_test(function observePushTopicDevice
   let pushService = new FxAccountsPushService({
     pushService: mockPushService,
     fxAccounts: customAccounts,
   });
 
   pushService.observe(msg, mockPushService.pushTopic, FXA_PUSH_SCOPE_ACCOUNT_UPDATE);
 });
 
+add_test(function observePushTopicPasswordChanged() {
+  let msg = {
+    data: {
+      json: () => ({
+        command: ON_PASSWORD_CHANGED_NOTIFICATION
+      })
+    },
+    QueryInterface: function() {
+      return this;
+    }
+  };
+
+  let pushService = new FxAccountsPushService({
+    pushService: mockPushService,
+  });
+
+  pushService._onPasswordChanged = function () {
+    run_next_test();
+  }
+
+  pushService.observe(msg, mockPushService.pushTopic, FXA_PUSH_SCOPE_ACCOUNT_UPDATE);
+});
+
+add_test(function observePushTopicPasswordReset() {
+  let msg = {
+    data: {
+      json: () => ({
+        command: ON_PASSWORD_RESET_NOTIFICATION
+      })
+    },
+    QueryInterface: function() {
+      return this;
+    }
+  };
+
+  let pushService = new FxAccountsPushService({
+    pushService: mockPushService
+  });
+
+  pushService._onPasswordChanged = function () {
+    run_next_test();
+  }
+
+  pushService.observe(msg, mockPushService.pushTopic, FXA_PUSH_SCOPE_ACCOUNT_UPDATE);
+});
+
 add_test(function observeSubscriptionChangeTopic() {
   let customAccounts = Object.assign(mockFxAccounts, {
     updateDeviceRegistration: function () {
       // subscription change means updating the device registration
       run_next_test();
     }
   });
 
--- a/services/sync/modules/browserid_identity.js
+++ b/services/sync/modules/browserid_identity.js
@@ -40,16 +40,17 @@ XPCOMUtils.defineLazyGetter(this, 'log',
 
 // FxAccountsCommon.js doesn't use a "namespace", so create one here.
 var fxAccountsCommon = {};
 Cu.import("resource://gre/modules/FxAccountsCommon.js", fxAccountsCommon);
 
 const OBSERVER_TOPICS = [
   fxAccountsCommon.ONLOGIN_NOTIFICATION,
   fxAccountsCommon.ONLOGOUT_NOTIFICATION,
+  fxAccountsCommon.ON_ACCOUNT_STATE_CHANGE_NOTIFICATION,
 ];
 
 const PREF_SYNC_SHOW_CUSTOMIZATION = "services.sync-setup.ui.showCustomizationDialog";
 
 function deriveKeyBundle(kB) {
   let out = CryptoUtils.hkdf(kB, undefined,
                              "identity.mozilla.com/picl/v1/oldsync", 2*32);
   let bundle = new BulkKeyBundle();
@@ -302,16 +303,23 @@ this.BrowserIDManager.prototype = {
       this.initializeWithCurrentIdentity(true);
       break;
 
     case fxAccountsCommon.ONLOGOUT_NOTIFICATION:
       Weave.Service.startOver();
       // startOver will cause this instance to be thrown away, so there's
       // nothing else to do.
       break;
+
+    case fxAccountsCommon.ON_ACCOUNT_STATE_CHANGE_NOTIFICATION:
+      // throw away token and fetch a new one
+      this.resetCredentials();
+      this._ensureValidToken().catch(err =>
+        this._log.error("Error while fetching a new token", err));
+      break;
     }
   },
 
   /**
    * Compute the sha256 of the message bytes.  Return bytes.
    */
   _sha256: function(message) {
     let hasher = Cc["@mozilla.org/security/hash;1"]
@@ -668,22 +676,29 @@ this.BrowserIDManager.prototype = {
 
   // Returns a promise that is resolved when we have a valid token for the
   // current user stored in this._token.  When resolved, this._token is valid.
   _ensureValidToken: function() {
     if (this.hasValidToken()) {
       this._log.debug("_ensureValidToken already has one");
       return Promise.resolve();
     }
+    const notifyStateChanged =
+      () => Services.obs.notifyObservers(null, "weave:service:login:change", null);
     // reset this._token as a safety net to reduce the possibility of us
     // repeatedly attempting to use an invalid token if _fetchTokenForUser throws.
     this._token = null;
     return this._fetchTokenForUser().then(
       token => {
         this._token = token;
+        notifyStateChanged();
+      },
+      error => {
+        notifyStateChanged();
+        throw error
       }
     );
   },
 
   getResourceAuthenticator: function () {
     return this._getAuthenticationHeader.bind(this);
   },