Bug 1254643 - Delete FxA device when Fennec Firefox Account is removed. r?grisha draft
authorEdouard Oger <eoger@fastmail.com>
Mon, 13 Mar 2017 14:35:41 -0400
changeset 500145 e5dbac882e28b1b1da3fde20b55ebcfe85d03676
parent 500108 3945f2297b997d59662bdb8ac20e092363eea532
child 549548 b2f11875ee07106e7fac50c01fc66a1a6dc25c25
push id49624
push userbmo:eoger@fastmail.com
push dateThu, 16 Mar 2017 18:13:57 +0000
reviewersgrisha
bugs1254643
milestone55.0a1
Bug 1254643 - Delete FxA device when Fennec Firefox Account is removed. r?grisha MozReview-Commit-ID: H4lJlXGYIBg
mobile/android/services/src/main/java/org/mozilla/gecko/background/fxa/FxAccountClient.java
mobile/android/services/src/main/java/org/mozilla/gecko/background/fxa/FxAccountClient20.java
mobile/android/services/src/main/java/org/mozilla/gecko/fxa/FxAccountConstants.java
mobile/android/services/src/main/java/org/mozilla/gecko/fxa/authenticator/AndroidFxAccount.java
mobile/android/services/src/main/java/org/mozilla/gecko/fxa/receivers/FxAccountDeletedService.java
mobile/android/tests/background/junit4/src/org/mozilla/gecko/fxa/login/MockFxAccountClient.java
--- a/mobile/android/services/src/main/java/org/mozilla/gecko/background/fxa/FxAccountClient.java
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/background/fxa/FxAccountClient.java
@@ -14,11 +14,12 @@ import org.mozilla.gecko.sync.ExtendedJS
 import java.util.List;
 
 public interface FxAccountClient {
   public void accountStatus(String uid, RequestDelegate<AccountStatusResponse> requestDelegate);
   public void recoveryEmailStatus(byte[] sessionToken, RequestDelegate<RecoveryEmailStatusResponse> requestDelegate);
   public void keys(byte[] keyFetchToken, RequestDelegate<TwoKeys> requestDelegate);
   public void sign(byte[] sessionToken, ExtendedJSONObject publicKey, long certificateDurationInMilliseconds, RequestDelegate<String> requestDelegate);
   public void registerOrUpdateDevice(byte[] sessionToken, FxAccountDevice device, RequestDelegate<FxAccountDevice> requestDelegate);
+  public void destroyDevice(byte[] sessionToken, String deviceId, RequestDelegate<ExtendedJSONObject> requestDelegate);
   public void deviceList(byte[] sessionToken, RequestDelegate<FxAccountDevice[]> requestDelegate);
   public void notifyDevices(byte[] sessionToken, List<String> deviceIds, ExtendedJSONObject payload, Long TTL, RequestDelegate<ExtendedJSONObject> requestDelegate);
 }
--- a/mobile/android/services/src/main/java/org/mozilla/gecko/background/fxa/FxAccountClient20.java
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/background/fxa/FxAccountClient20.java
@@ -818,16 +818,52 @@ public class FxAccountClient20 implement
         }
       }
     };
 
     post(resource, body);
   }
 
   @Override
+  public void destroyDevice(byte[] sessionToken, String deviceId, RequestDelegate<ExtendedJSONObject> delegate) {
+    final byte[] tokenId = new byte[32];
+    final byte[] reqHMACKey = new byte[32];
+    final byte[] requestKey = new byte[32];
+    try {
+      HKDF.deriveMany(sessionToken, new byte[0], FxAccountUtils.KW("sessionToken"), tokenId, reqHMACKey, requestKey);
+    } catch (Exception e) {
+      invokeHandleError(delegate, e);
+      return;
+    }
+
+    final BaseResource resource;
+    final ExtendedJSONObject body = new ExtendedJSONObject();
+    body.put("id", deviceId);
+    try {
+      resource = getBaseResource("account/device/destroy");
+    } catch (URISyntaxException | UnsupportedEncodingException e) {
+      invokeHandleError(delegate, e);
+      return;
+    }
+
+    resource.delegate = new ResourceDelegate<ExtendedJSONObject>(resource, delegate, ResponseType.JSON_OBJECT, tokenId, reqHMACKey) {
+      @Override
+      public void handleSuccess(int status, HttpResponse response, ExtendedJSONObject body) {
+        try {
+          delegate.handleSuccess(body);
+        } catch (Exception e) {
+          delegate.handleError(e);
+        }
+      }
+    };
+
+    post(resource, body);
+  }
+
+  @Override
   public void deviceList(byte[] sessionToken, RequestDelegate<FxAccountDevice[]> delegate) {
     final byte[] tokenId = new byte[32];
     final byte[] reqHMACKey = new byte[32];
     final byte[] requestKey = new byte[32];
     try {
       HKDF.deriveMany(sessionToken, new byte[0], FxAccountUtils.KW("sessionToken"), tokenId, reqHMACKey, requestKey);
     } catch (Exception e) {
       invokeHandleError(delegate, e);
--- a/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/FxAccountConstants.java
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/FxAccountConstants.java
@@ -47,16 +47,19 @@ public class FxAccountConstants {
    */
   public static final long ACCOUNT_DELETED_INTENT_VERSION = 1;
 
   public static final String ACCOUNT_DELETED_INTENT_VERSION_KEY = "account_deleted_intent_version";
   public static final String ACCOUNT_DELETED_INTENT_ACCOUNT_KEY = "account_deleted_intent_account";
   public static final String ACCOUNT_DELETED_INTENT_ACCOUNT_PROFILE = "account_deleted_intent_profile";
   public static final String ACCOUNT_OAUTH_SERVICE_ENDPOINT_KEY = "account_oauth_service_endpoint";
   public static final String ACCOUNT_DELETED_INTENT_ACCOUNT_AUTH_TOKENS = "account_deleted_intent_auth_tokens";
+  public static final String ACCOUNT_DELETED_INTENT_ACCOUNT_SESSION_TOKEN = "account_deleted_intent_session_token";
+  public static final String ACCOUNT_DELETED_INTENT_ACCOUNT_SERVER_URI = "account_deleted_intent_account_server_uri";
+  public static final String ACCOUNT_DELETED_INTENT_ACCOUNT_DEVICE_ID = "account_deleted_intent_account_device_id";
 
   /**
    * This action is broadcast when an Android Firefox Account's internal state
    * is changed.
    * <p>
    * It is protected by signing-level permission PER_ACCOUNT_TYPE_PERMISSION and
    * can be received only by Firefox versions sharing the same Android Firefox
    * Account type.
--- a/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/authenticator/AndroidFxAccount.java
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/authenticator/AndroidFxAccount.java
@@ -688,16 +688,25 @@ public class AndroidFxAccount {
         tokens.add(authToken);
       }
     }
 
     // Update intent with tokens and service URI.
     intent.putExtra(FxAccountConstants.ACCOUNT_OAUTH_SERVICE_ENDPOINT_KEY, getOAuthServerURI());
     // Deleted broadcasts are package-private, so there's no security risk include the tokens in the extras
     intent.putExtra(FxAccountConstants.ACCOUNT_DELETED_INTENT_ACCOUNT_AUTH_TOKENS, tokens.toArray(new String[tokens.size()]));
+
+    try {
+      intent.putExtra(FxAccountConstants.ACCOUNT_DELETED_INTENT_ACCOUNT_SESSION_TOKEN, getSessionToken());
+    } catch (InvalidFxAState e) {
+      Logger.warn(LOG_TAG, "Could not get a session token, ignoring.", e);
+    }
+    intent.putExtra(FxAccountConstants.ACCOUNT_DELETED_INTENT_ACCOUNT_SERVER_URI, getAccountServerURI());
+    intent.putExtra(FxAccountConstants.ACCOUNT_DELETED_INTENT_ACCOUNT_DEVICE_ID, getDeviceId());
+
     return intent;
   }
 
   /**
    * Create an intent announcing that the profile JSON attached to this Firefox Account has been updated.
    * <p>
    * It is not guaranteed that the profile JSON has changed.
    *
--- a/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/receivers/FxAccountDeletedService.java
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/receivers/FxAccountDeletedService.java
@@ -2,29 +2,35 @@
  * 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/. */
 
 package org.mozilla.gecko.fxa.receivers;
 
 import android.app.IntentService;
 import android.content.Context;
 import android.content.Intent;
+import android.text.TextUtils;
 
 import org.mozilla.gecko.background.common.log.Logger;
+import org.mozilla.gecko.background.fxa.FxAccountClient20;
+import org.mozilla.gecko.background.fxa.FxAccountClientException;
 import org.mozilla.gecko.background.fxa.oauth.FxAccountAbstractClient;
 import org.mozilla.gecko.background.fxa.oauth.FxAccountAbstractClientException.FxAccountAbstractClientRemoteException;
 import org.mozilla.gecko.background.fxa.oauth.FxAccountOAuthClient10;
 import org.mozilla.gecko.fxa.FxAccountConstants;
 import org.mozilla.gecko.fxa.authenticator.AndroidFxAccount;
 import org.mozilla.gecko.fxa.sync.FxAccountNotificationManager;
 import org.mozilla.gecko.fxa.sync.FxAccountSyncAdapter;
+import org.mozilla.gecko.sync.ExtendedJSONObject;
 import org.mozilla.gecko.sync.repositories.android.ClientsDatabase;
 import org.mozilla.gecko.sync.repositories.android.FennecTabsRepository;
 
 import java.util.concurrent.Executor;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
 
 /**
  * A background service to clean up after a Firefox Account is deleted.
  * <p>
  * Note that we specifically handle deleting the pickle file using a Service and a
  * BroadcastReceiver, rather than a background thread, to allow channels sharing a Firefox account
  * to delete their respective pickle files (since, if one remains, the account will be restored
  * when that channel is used).
@@ -64,24 +70,25 @@ public class FxAccountDeletedService ext
     final String accountName = intent.getStringExtra(
         FxAccountConstants.ACCOUNT_DELETED_INTENT_ACCOUNT_KEY);
     if (accountName == null) {
       Logger.warn(LOG_TAG, "Intent malformed: no account name given. Not cleaning up after " +
           "deleted Account.");
       return;
     }
 
+    // Delete current device the from FxA devices list.
+    deleteFxADevice(intent);
 
     // Fire up gecko and unsubscribe push
     final Intent geckoIntent = new Intent();
     geckoIntent.setAction("create-services");
     geckoIntent.setClassName(context, "org.mozilla.gecko.GeckoService");
     geckoIntent.putExtra("category", "android-push-service");
     geckoIntent.putExtra("data", "android-fxa-unsubscribe");
-    final AndroidFxAccount fxAccount = AndroidFxAccount.fromContext(context);
     geckoIntent.putExtra("org.mozilla.gecko.intent.PROFILE_NAME",
             intent.getStringExtra(FxAccountConstants.ACCOUNT_DELETED_INTENT_ACCOUNT_PROFILE));
     context.startService(geckoIntent);
 
     // Delete client database and non-local tabs.
     Logger.info(LOG_TAG, "Deleting the entire Fennec clients database and non-local tabs");
     FennecTabsRepository.deleteNonLocalClientsAndTabs(context);
 
@@ -146,9 +153,43 @@ public class FxAccountDeletedService ext
         } catch (Exception e) {
           Logger.error(LOG_TAG, "Exception during cached OAuth token deletion; ignoring.", e);
         }
       }
     } else {
       Logger.error(LOG_TAG, "Cached OAuth server URI is null or cached OAuth tokens are null; ignoring.");
     }
   }
+
+  // Remove our current device from the FxA device list.
+  private void deleteFxADevice(Intent intent) {
+    final byte[] sessionToken = intent.getByteArrayExtra(FxAccountConstants.ACCOUNT_DELETED_INTENT_ACCOUNT_SESSION_TOKEN);
+    if (sessionToken == null) {
+      Logger.warn(LOG_TAG, "Empty session token, skipping FxA device destruction.");
+      return;
+    }
+    final String deviceId = intent.getStringExtra(FxAccountConstants.ACCOUNT_DELETED_INTENT_ACCOUNT_DEVICE_ID);
+    if (TextUtils.isEmpty(deviceId)) {
+      Logger.warn(LOG_TAG, "Empty FxA device ID, skipping FxA device destruction.");
+      return;
+    }
+
+    ExecutorService executor = Executors.newSingleThreadExecutor(); // Not called often, it's okay to spawn another thread
+    final String accountServerURI = intent.getStringExtra(FxAccountConstants.ACCOUNT_DELETED_INTENT_ACCOUNT_SERVER_URI);
+    final FxAccountClient20 fxAccountClient = new FxAccountClient20(accountServerURI, executor);
+    fxAccountClient.destroyDevice(sessionToken, deviceId, new FxAccountClient20.RequestDelegate<ExtendedJSONObject>() {
+      @Override
+      public void handleError(Exception e) {
+        Logger.error(LOG_TAG, "Error while trying to delete the FxA device; ignoring.", e);
+      }
+
+      @Override
+      public void handleFailure(FxAccountClientException.FxAccountClientRemoteException e) {
+        Logger.error(LOG_TAG, "Exception while trying to delete the FxA device; ignoring.", e);
+      }
+
+      @Override
+      public void handleSuccess(ExtendedJSONObject result) {
+        Logger.info(LOG_TAG, "Successfully deleted the FxA device.");
+      }
+    });
+  }
 }
--- a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/fxa/login/MockFxAccountClient.java
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/fxa/login/MockFxAccountClient.java
@@ -198,16 +198,36 @@ public class MockFxAccountClient impleme
         }
       }
     } catch (Exception e) {
       requestDelegate.handleError(e);
     }
   }
 
   @Override
+  public void destroyDevice(byte[] sessionToken, String deviceId, RequestDelegate<ExtendedJSONObject> requestDelegate) {
+    String email = sessionTokens.get(Utils.byte2Hex(sessionToken));
+    User user = users.get(email);
+    if (email == null || user == null) {
+      handleFailure(requestDelegate, HttpStatus.SC_UNAUTHORIZED, FxAccountRemoteError.INVALID_AUTHENTICATION_TOKEN, "invalid sessionToken");
+      return;
+    }
+    if (!user.verified) {
+      handleFailure(requestDelegate, HttpStatus.SC_BAD_REQUEST, FxAccountRemoteError.ATTEMPT_TO_OPERATE_ON_AN_UNVERIFIED_ACCOUNT, "user is unverified");
+      return;
+    }
+    if(user.devices.containsKey(deviceId)) {
+      user.devices.remove(deviceId);
+      requestDelegate.handleSuccess(new ExtendedJSONObject());
+    } else {
+      handleFailure(requestDelegate, HttpStatus.SC_BAD_REQUEST, FxAccountRemoteError.UNKNOWN_DEVICE, "device is unknown");
+    }
+  }
+
+  @Override
   public void deviceList(byte[] sessionToken, RequestDelegate<FxAccountDevice[]> requestDelegate) {
     String email = sessionTokens.get(Utils.byte2Hex(sessionToken));
     User user = users.get(email);
     if (email == null || user == null) {
       handleFailure(requestDelegate, HttpStatus.SC_UNAUTHORIZED, FxAccountRemoteError.INVALID_AUTHENTICATION_TOKEN, "invalid sessionToken");
       return;
     }
     if (!user.verified) {