/* 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/. */
package org.mozilla.gecko.background.fxa;
import android.support.annotation.NonNull;
import org.json.simple.JSONArray;
import org.json.simple.JSONObject;
import org.mozilla.gecko.background.common.log.Logger;
import org.mozilla.gecko.background.fxa.FxAccountClientException.FxAccountClientMalformedResponseException;
import org.mozilla.gecko.background.fxa.FxAccountClientException.FxAccountClientRemoteException;
import org.mozilla.gecko.fxa.FxAccountConstants;
import org.mozilla.gecko.Locales;
import org.mozilla.gecko.fxa.FxAccountDevice;
import org.mozilla.gecko.sync.ExtendedJSONObject;
import org.mozilla.gecko.sync.Utils;
import org.mozilla.gecko.sync.crypto.HKDF;
import org.mozilla.gecko.sync.net.AuthHeaderProvider;
import org.mozilla.gecko.sync.net.BaseResource;
import org.mozilla.gecko.sync.net.BaseResourceDelegate;
import org.mozilla.gecko.sync.net.HawkAuthHeaderProvider;
import org.mozilla.gecko.sync.net.Resource;
import org.mozilla.gecko.sync.net.SyncResponse;
import org.mozilla.gecko.sync.net.SyncStorageResponse;
import java.io.IOException;
import java.io.UnsupportedEncodingException;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URLEncoder;
import java.security.GeneralSecurityException;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Map.Entry;
import java.util.concurrent.Executor;
import javax.crypto.Mac;
import ch.boye.httpclientandroidlib.HttpEntity;
import ch.boye.httpclientandroidlib.HttpHeaders;
import ch.boye.httpclientandroidlib.HttpResponse;
import ch.boye.httpclientandroidlib.client.ClientProtocolException;
import ch.boye.httpclientandroidlib.client.methods.HttpRequestBase;
import ch.boye.httpclientandroidlib.impl.client.DefaultHttpClient;
/**
* An HTTP client for talking to an FxAccount server.
*
*
* The delegate structure used is a little different from the rest of the code
* base. We add a RequestDelegate
layer that processes a typed
* value extracted from the body of a successful response.
*/
public class FxAccountClient20 implements FxAccountClient {
protected static final String LOG_TAG = FxAccountClient20.class.getSimpleName();
protected static final String ACCEPT_HEADER = "application/json;charset=utf-8";
public static final String JSON_KEY_EMAIL = "email";
public static final String JSON_KEY_KEYFETCHTOKEN = "keyFetchToken";
public static final String JSON_KEY_SESSIONTOKEN = "sessionToken";
public static final String JSON_KEY_UID = "uid";
public static final String JSON_KEY_VERIFIED = "verified";
public static final String JSON_KEY_ERROR = "error";
public static final String JSON_KEY_MESSAGE = "message";
public static final String JSON_KEY_INFO = "info";
public static final String JSON_KEY_CODE = "code";
public static final String JSON_KEY_ERRNO = "errno";
public static final String JSON_KEY_EXISTS = "exists";
protected static final String[] requiredErrorStringFields = { JSON_KEY_ERROR, JSON_KEY_MESSAGE, JSON_KEY_INFO };
protected static final String[] requiredErrorLongFields = { JSON_KEY_CODE, JSON_KEY_ERRNO };
/**
* The server's URI.
*
* We assume throughout that this ends with a trailing slash (and guarantee as
* much in the constructor).
*/
protected final String serverURI;
protected final Executor executor;
public FxAccountClient20(String serverURI, Executor executor) {
if (serverURI == null) {
throw new IllegalArgumentException("Must provide a server URI.");
}
if (executor == null) {
throw new IllegalArgumentException("Must provide a non-null executor.");
}
this.serverURI = serverURI.endsWith("/") ? serverURI : serverURI + "/";
if (!this.serverURI.endsWith("/")) {
throw new IllegalArgumentException("Constructed serverURI must end with a trailing slash: " + this.serverURI);
}
this.executor = executor;
}
protected BaseResource getBaseResource(String path, Map queryParameters) throws UnsupportedEncodingException, URISyntaxException {
if (queryParameters == null || queryParameters.isEmpty()) {
return getBaseResource(path);
}
final String[] array = new String[2 * queryParameters.size()];
int i = 0;
for (Entry entry : queryParameters.entrySet()) {
array[i++] = entry.getKey();
array[i++] = entry.getValue();
}
return getBaseResource(path, array);
}
/**
* Create BaseResource
, encoding query parameters carefully.
*
* This is equivalent to android.net.Uri.Builder
, which is not
* present in our JUnit 4 tests.
*
* @param path fragment.
* @param queryParameters list of key/value query parameter pairs. Must be even length!
* @return BaseResource
* @throws URISyntaxException
* @throws UnsupportedEncodingException
*/
protected BaseResource getBaseResource(String path, String... queryParameters) throws URISyntaxException, UnsupportedEncodingException {
final StringBuilder sb = new StringBuilder(serverURI);
sb.append(path);
if (queryParameters != null) {
int i = 0;
while (i < queryParameters.length) {
sb.append(i > 0 ? "&" : "?");
final String key = queryParameters[i++];
final String val = queryParameters[i++];
sb.append(URLEncoder.encode(key, "UTF-8"));
sb.append("=");
sb.append(URLEncoder.encode(val, "UTF-8"));
}
}
return new BaseResource(new URI(sb.toString()));
}
/**
* Process a typed value extracted from a successful response (in an
* endpoint-dependent way).
*/
public interface RequestDelegate {
public void handleError(Exception e);
public void handleFailure(FxAccountClientRemoteException e);
public void handleSuccess(T result);
}
/**
* Thin container for two cryptographic keys.
*/
public static class TwoKeys {
public final byte[] kA;
public final byte[] wrapkB;
public TwoKeys(byte[] kA, byte[] wrapkB) {
this.kA = kA;
this.wrapkB = wrapkB;
}
}
protected void invokeHandleError(final RequestDelegate delegate, final Exception e) {
executor.execute(new Runnable() {
@Override
public void run() {
delegate.handleError(e);
}
});
}
enum ResponseType {
JSON_ARRAY,
JSON_OBJECT
}
/**
* Translate resource callbacks into request callbacks invoked on the provided
* executor.
*
* Override handleSuccess
to parse the body of the resource
* request and call the request callback. handleSuccess
is
* invoked via the executor, so you don't need to delegate further.
*/
protected abstract class ResourceDelegate extends BaseResourceDelegate {
protected void handleSuccess(final int status, HttpResponse response, final ExtendedJSONObject body) throws Exception {
throw new UnsupportedOperationException();
}
protected void handleSuccess(final int status, HttpResponse response, final JSONArray body) throws Exception {
throw new UnsupportedOperationException();
}
protected final RequestDelegate delegate;
protected final byte[] tokenId;
protected final byte[] reqHMACKey;
protected final SkewHandler skewHandler;
protected final ResponseType responseType;
/**
* Create a delegate for an un-authenticated resource.
*/
public ResourceDelegate(final Resource resource, final RequestDelegate delegate, ResponseType responseType) {
this(resource, delegate, responseType, null, null);
}
/**
* Create a delegate for a Hawk-authenticated resource.
*
* Every Hawk request that encloses an entity (PATCH, POST, and PUT) will
* include the payload verification hash.
*/
public ResourceDelegate(final Resource resource, final RequestDelegate delegate, ResponseType responseType, final byte[] tokenId, final byte[] reqHMACKey) {
super(resource);
this.delegate = delegate;
this.reqHMACKey = reqHMACKey;
this.tokenId = tokenId;
this.skewHandler = SkewHandler.getSkewHandlerForResource(resource);
this.responseType = responseType;
}
@Override
public AuthHeaderProvider getAuthHeaderProvider() {
if (tokenId != null && reqHMACKey != null) {
// We always include the payload verification hash for FxA Hawk-authenticated requests.
final boolean includePayloadVerificationHash = true;
return new HawkAuthHeaderProvider(Utils.byte2Hex(tokenId), reqHMACKey, includePayloadVerificationHash, skewHandler.getSkewInSeconds());
}
return super.getAuthHeaderProvider();
}
@Override
public String getUserAgent() {
return FxAccountConstants.USER_AGENT;
}
@Override
public void handleHttpResponse(HttpResponse response) {
try {
final int status = validateResponse(response);
skewHandler.updateSkew(response, now());
invokeHandleSuccess(status, response);
} catch (FxAccountClientRemoteException e) {
if (!skewHandler.updateSkew(response, now())) {
// If we couldn't update skew, but we got a failure, let's try clearing the skew.
skewHandler.resetSkew();
}
invokeHandleFailure(e);
}
}
protected void invokeHandleFailure(final FxAccountClientRemoteException e) {
executor.execute(new Runnable() {
@Override
public void run() {
delegate.handleFailure(e);
}
});
}
protected void invokeHandleSuccess(final int status, final HttpResponse response) {
executor.execute(new Runnable() {
@Override
public void run() {
try {
SyncResponse syncResponse = new SyncResponse(response);
if (responseType == ResponseType.JSON_ARRAY) {
JSONArray body = syncResponse.jsonArrayBody();
ResourceDelegate.this.handleSuccess(status, response, body);
} else {
ExtendedJSONObject body = syncResponse.jsonObjectBody();
ResourceDelegate.this.handleSuccess(status, response, body);
}
} catch (Exception e) {
delegate.handleError(e);
}
}
});
}
@Override
public void handleHttpProtocolException(final ClientProtocolException e) {
invokeHandleError(delegate, e);
}
@Override
public void handleHttpIOException(IOException e) {
invokeHandleError(delegate, e);
}
@Override
public void handleTransportException(GeneralSecurityException e) {
invokeHandleError(delegate, e);
}
@Override
public void addHeaders(HttpRequestBase request, DefaultHttpClient client) {
super.addHeaders(request, client);
// The basics.
final Locale locale = Locale.getDefault();
request.addHeader(HttpHeaders.ACCEPT_LANGUAGE, Locales.getLanguageTag(locale));
request.addHeader(HttpHeaders.ACCEPT, ACCEPT_HEADER);
}
}
protected void post(BaseResource resource, final ExtendedJSONObject requestBody) {
if (requestBody == null) {
resource.post((HttpEntity) null);
} else {
resource.post(requestBody);
}
}
@SuppressWarnings("static-method")
public long now() {
return System.currentTimeMillis();
}
/**
* Intepret a response from the auth server.
*
* Throw an appropriate exception on errors; otherwise, return the response's
* status code.
*
* @return response's HTTP status code.
* @throws FxAccountClientException
*/
public static int validateResponse(HttpResponse response) throws FxAccountClientRemoteException {
final int status = response.getStatusLine().getStatusCode();
if (status == 200) {
return status;
}
int code;
int errno;
String error;
String message;
String info;
ExtendedJSONObject body;
try {
body = new SyncStorageResponse(response).jsonObjectBody();
body.throwIfFieldsMissingOrMisTyped(requiredErrorStringFields, String.class);
body.throwIfFieldsMissingOrMisTyped(requiredErrorLongFields, Long.class);
code = body.getLong(JSON_KEY_CODE).intValue();
errno = body.getLong(JSON_KEY_ERRNO).intValue();
error = body.getString(JSON_KEY_ERROR);
message = body.getString(JSON_KEY_MESSAGE);
info = body.getString(JSON_KEY_INFO);
} catch (Exception e) {
throw new FxAccountClientMalformedResponseException(response);
}
throw new FxAccountClientRemoteException(response, code, errno, error, message, info, body);
}
/**
* Don't call this directly. Use unbundleBody
instead.
*/
protected void unbundleBytes(byte[] bundleBytes, byte[] respHMACKey, byte[] respXORKey, byte[]... rest)
throws InvalidKeyException, NoSuchAlgorithmException, FxAccountClientException {
if (bundleBytes.length < 32) {
throw new IllegalArgumentException("input bundle must include HMAC");
}
int len = respXORKey.length;
if (bundleBytes.length != len + 32) {
throw new IllegalArgumentException("input bundle and XOR key with HMAC have different lengths");
}
int left = len;
for (byte[] array : rest) {
left -= array.length;
}
if (left != 0) {
throw new IllegalArgumentException("XOR key and total output arrays have different lengths");
}
byte[] ciphertext = new byte[len];
byte[] HMAC = new byte[32];
System.arraycopy(bundleBytes, 0, ciphertext, 0, len);
System.arraycopy(bundleBytes, len, HMAC, 0, 32);
Mac hmacHasher = HKDF.makeHMACHasher(respHMACKey);
byte[] computedHMAC = hmacHasher.doFinal(ciphertext);
if (!Arrays.equals(computedHMAC, HMAC)) {
throw new FxAccountClientException("Bad message HMAC");
}
int offset = 0;
for (byte[] array : rest) {
for (int i = 0; i < array.length; i++) {
array[i] = (byte) (respXORKey[offset + i] ^ ciphertext[offset + i]);
}
offset += array.length;
}
}
protected void unbundleBody(ExtendedJSONObject body, byte[] requestKey, byte[] ctxInfo, byte[]... rest) throws Exception {
int length = 0;
for (byte[] array : rest) {
length += array.length;
}
if (body == null) {
throw new FxAccountClientException("body must be non-null");
}
String bundle = body.getString("bundle");
if (bundle == null) {
throw new FxAccountClientException("bundle must be a non-null string");
}
byte[] bundleBytes = Utils.hex2Byte(bundle);
final byte[] respHMACKey = new byte[32];
final byte[] respXORKey = new byte[length];
HKDF.deriveMany(requestKey, new byte[0], ctxInfo, respHMACKey, respXORKey);
unbundleBytes(bundleBytes, respHMACKey, respXORKey, rest);
}
public void keys(byte[] keyFetchToken, final RequestDelegate delegate) {
final byte[] tokenId = new byte[32];
final byte[] reqHMACKey = new byte[32];
final byte[] requestKey = new byte[32];
try {
HKDF.deriveMany(keyFetchToken, new byte[0], FxAccountUtils.KW("keyFetchToken"), tokenId, reqHMACKey, requestKey);
} catch (Exception e) {
invokeHandleError(delegate, e);
return;
}
BaseResource resource;
try {
resource = getBaseResource("account/keys");
} catch (URISyntaxException | UnsupportedEncodingException e) {
invokeHandleError(delegate, e);
return;
}
resource.delegate = new ResourceDelegate(resource, delegate, ResponseType.JSON_OBJECT, tokenId, reqHMACKey) {
@Override
public void handleSuccess(int status, HttpResponse response, ExtendedJSONObject body) throws Exception {
byte[] kA = new byte[FxAccountUtils.CRYPTO_KEY_LENGTH_BYTES];
byte[] wrapkB = new byte[FxAccountUtils.CRYPTO_KEY_LENGTH_BYTES];
unbundleBody(body, requestKey, FxAccountUtils.KW("account/keys"), kA, wrapkB);
delegate.handleSuccess(new TwoKeys(kA, wrapkB));
}
};
resource.get();
}
/**
* Thin container for account status response.
*/
public static class AccountStatusResponse {
public final boolean exists;
public AccountStatusResponse(boolean exists) {
this.exists = exists;
}
}
/**
* Query the account status of an account given a uid.
*
* @param uid to query.
* @param delegate to invoke callbacks.
*/
public void accountStatus(String uid, final RequestDelegate delegate) {
final BaseResource resource;
try {
final Map params = new HashMap<>(1);
params.put("uid", uid);
resource = getBaseResource("account/status", params);
} catch (URISyntaxException | UnsupportedEncodingException e) {
invokeHandleError(delegate, e);
return;
}
resource.delegate = new ResourceDelegate(resource, delegate, ResponseType.JSON_OBJECT) {
@Override
public void handleSuccess(int status, HttpResponse response, ExtendedJSONObject body) throws Exception {
boolean exists = body.getBoolean(JSON_KEY_EXISTS);
delegate.handleSuccess(new AccountStatusResponse(exists));
}
};
resource.get();
}
/**
* Thin container for recovery email status response.
*/
public static class RecoveryEmailStatusResponse {
public final String email;
public final boolean verified;
public RecoveryEmailStatusResponse(String email, boolean verified) {
this.email = email;
this.verified = verified;
}
}
/**
* Query the recovery email status of an account given a valid session token.
*
* This API is a little odd: the auth server returns the email and
* verification state of the account that corresponds to the (opaque) session
* token. It might fail if the session token is unknown (or invalid, or
* revoked).
*
* @param sessionToken
* to query.
* @param delegate
* to invoke callbacks.
*/
public void recoveryEmailStatus(byte[] sessionToken, final RequestDelegate 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;
}
BaseResource resource;
try {
resource = getBaseResource("recovery_email/status");
} catch (URISyntaxException | UnsupportedEncodingException e) {
invokeHandleError(delegate, e);
return;
}
resource.delegate = new ResourceDelegate(resource, delegate, ResponseType.JSON_OBJECT, tokenId, reqHMACKey) {
@Override
public void handleSuccess(int status, HttpResponse response, ExtendedJSONObject body) throws Exception {
String[] requiredStringFields = new String[] { JSON_KEY_EMAIL };
body.throwIfFieldsMissingOrMisTyped(requiredStringFields, String.class);
String email = body.getString(JSON_KEY_EMAIL);
Boolean verified = body.getBoolean(JSON_KEY_VERIFIED);
delegate.handleSuccess(new RecoveryEmailStatusResponse(email, verified));
}
};
resource.get();
}
@SuppressWarnings("unchecked")
public void sign(final byte[] sessionToken, final ExtendedJSONObject publicKey, long durationInMilliseconds, final RequestDelegate delegate) {
final ExtendedJSONObject body = new ExtendedJSONObject();
body.put("publicKey", publicKey);
body.put("duration", durationInMilliseconds);
final byte[] tokenId = new byte[32];
final byte[] reqHMACKey = new byte[32];
try {
HKDF.deriveMany(sessionToken, new byte[0], FxAccountUtils.KW("sessionToken"), tokenId, reqHMACKey);
} catch (Exception e) {
invokeHandleError(delegate, e);
return;
}
BaseResource resource;
try {
resource = getBaseResource("certificate/sign");
} catch (URISyntaxException | UnsupportedEncodingException e) {
invokeHandleError(delegate, e);
return;
}
resource.delegate = new ResourceDelegate(resource, delegate, ResponseType.JSON_OBJECT, tokenId, reqHMACKey) {
@Override
public void handleSuccess(int status, HttpResponse response, ExtendedJSONObject body) throws Exception {
String cert = body.getString("cert");
if (cert == null) {
delegate.handleError(new FxAccountClientException("cert must be a non-null string"));
return;
}
delegate.handleSuccess(cert);
}
};
post(resource, body);
}
protected static final String[] LOGIN_RESPONSE_REQUIRED_STRING_FIELDS = new String[] { JSON_KEY_UID, JSON_KEY_SESSIONTOKEN };
protected static final String[] LOGIN_RESPONSE_REQUIRED_STRING_FIELDS_KEYS = new String[] { JSON_KEY_UID, JSON_KEY_SESSIONTOKEN, JSON_KEY_KEYFETCHTOKEN, };
protected static final String[] LOGIN_RESPONSE_REQUIRED_BOOLEAN_FIELDS = new String[] { JSON_KEY_VERIFIED };
/**
* Thin container for login response.
*
* The remoteEmail
field is the email address as normalized by the
* server, and is not necessarily the email address delivered to the
* login
or create
call.
*/
public static class LoginResponse {
public final String remoteEmail;
public final String uid;
public final byte[] sessionToken;
public final boolean verified;
public final byte[] keyFetchToken;
public LoginResponse(String remoteEmail, String uid, boolean verified, byte[] sessionToken, byte[] keyFetchToken) {
this.remoteEmail = remoteEmail;
this.uid = uid;
this.verified = verified;
this.sessionToken = sessionToken;
this.keyFetchToken = keyFetchToken;
}
}
// Public for testing only; prefer login and loginAndGetKeys (without boolean parameter).
public void login(final byte[] emailUTF8, final byte[] quickStretchedPW, final boolean getKeys,
final Map queryParameters,
final RequestDelegate delegate) {
final BaseResource resource;
final ExtendedJSONObject body;
try {
final String path = "account/login";
final Map modifiedParameters = new HashMap<>();
if (queryParameters != null) {
modifiedParameters.putAll(queryParameters);
}
if (getKeys) {
modifiedParameters.put("keys", "true");
}
resource = getBaseResource(path, modifiedParameters);
body = new FxAccount20LoginDelegate(emailUTF8, quickStretchedPW).getCreateBody();
} catch (Exception e) {
invokeHandleError(delegate, e);
return;
}
resource.delegate = new ResourceDelegate(resource, delegate, ResponseType.JSON_OBJECT) {
@Override
public void handleSuccess(int status, HttpResponse response, ExtendedJSONObject body) throws Exception {
final String[] requiredStringFields = getKeys ? LOGIN_RESPONSE_REQUIRED_STRING_FIELDS_KEYS : LOGIN_RESPONSE_REQUIRED_STRING_FIELDS;
body.throwIfFieldsMissingOrMisTyped(requiredStringFields, String.class);
final String[] requiredBooleanFields = LOGIN_RESPONSE_REQUIRED_BOOLEAN_FIELDS;
body.throwIfFieldsMissingOrMisTyped(requiredBooleanFields, Boolean.class);
String uid = body.getString(JSON_KEY_UID);
boolean verified = body.getBoolean(JSON_KEY_VERIFIED);
byte[] sessionToken = Utils.hex2Byte(body.getString(JSON_KEY_SESSIONTOKEN));
byte[] keyFetchToken = null;
if (getKeys) {
keyFetchToken = Utils.hex2Byte(body.getString(JSON_KEY_KEYFETCHTOKEN));
}
LoginResponse loginResponse = new LoginResponse(new String(emailUTF8, "UTF-8"), uid, verified, sessionToken, keyFetchToken);
delegate.handleSuccess(loginResponse);
}
};
post(resource, body);
}
public void createAccount(final byte[] emailUTF8, final byte[] quickStretchedPW,
final boolean getKeys,
final boolean preVerified,
final Map queryParameters,
final RequestDelegate delegate) {
final BaseResource resource;
final ExtendedJSONObject body;
try {
final String path = "account/create";
final Map modifiedParameters = new HashMap<>();
if (queryParameters != null) {
modifiedParameters.putAll(queryParameters);
}
if (getKeys) {
modifiedParameters.put("keys", "true");
}
resource = getBaseResource(path, modifiedParameters);
body = new FxAccount20CreateDelegate(emailUTF8, quickStretchedPW, preVerified).getCreateBody();
} catch (Exception e) {
invokeHandleError(delegate, e);
return;
}
// This is very similar to login, except verified is not required.
resource.delegate = new ResourceDelegate(resource, delegate, ResponseType.JSON_OBJECT) {
@Override
public void handleSuccess(int status, HttpResponse response, ExtendedJSONObject body) throws Exception {
final String[] requiredStringFields = getKeys ? LOGIN_RESPONSE_REQUIRED_STRING_FIELDS_KEYS : LOGIN_RESPONSE_REQUIRED_STRING_FIELDS;
body.throwIfFieldsMissingOrMisTyped(requiredStringFields, String.class);
String uid = body.getString(JSON_KEY_UID);
boolean verified = false; // In production, we're definitely not verified immediately upon creation.
Boolean tempVerified = body.getBoolean(JSON_KEY_VERIFIED);
if (tempVerified != null) {
verified = tempVerified;
}
byte[] sessionToken = Utils.hex2Byte(body.getString(JSON_KEY_SESSIONTOKEN));
byte[] keyFetchToken = null;
if (getKeys) {
keyFetchToken = Utils.hex2Byte(body.getString(JSON_KEY_KEYFETCHTOKEN));
}
LoginResponse loginResponse = new LoginResponse(new String(emailUTF8, "UTF-8"), uid, verified, sessionToken, keyFetchToken);
delegate.handleSuccess(loginResponse);
}
};
post(resource, body);
}
/**
* We want users to be able to enter their email address case-insensitively.
* We stretch the password locally using the email address as a salt, to make
* dictionary attacks more expensive. This means that a client with a
* case-differing email address is unable to produce the correct
* authorization, even though it knows the password. In this case, the server
* returns the email that the account was created with, so that the client can
* re-stretch the password locally with the correct email salt. This version
* of login
retries at most one time with a server provided email
* address.
*
* Be aware that consumers will not see the initial error response from the
* server providing an alternate email (if there is one).
*
* @param emailUTF8
* user entered email address.
* @param stretcher
* delegate to stretch and re-stretch password.
* @param getKeys
* true if a keyFetchToken
should be returned (in
* addition to the standard sessionToken
).
* @param queryParameters
* @param delegate
* to invoke callbacks.
*/
public void login(final byte[] emailUTF8, final PasswordStretcher stretcher, final boolean getKeys,
final Map queryParameters,
final RequestDelegate delegate) {
byte[] quickStretchedPW;
try {
FxAccountUtils.pii(LOG_TAG, "Trying user provided email: '" + new String(emailUTF8, "UTF-8") + "'" );
quickStretchedPW = stretcher.getQuickStretchedPW(emailUTF8);
} catch (Exception e) {
delegate.handleError(e);
return;
}
this.login(emailUTF8, quickStretchedPW, getKeys, queryParameters, new RequestDelegate() {
@Override
public void handleSuccess(LoginResponse result) {
delegate.handleSuccess(result);
}
@Override
public void handleError(Exception e) {
delegate.handleError(e);
}
@Override
public void handleFailure(FxAccountClientRemoteException e) {
String alternateEmail = e.body.getString(JSON_KEY_EMAIL);
if (!e.isBadEmailCase() || alternateEmail == null) {
delegate.handleFailure(e);
return;
};
Logger.info(LOG_TAG, "Server returned alternate email; retrying login with provided email.");
FxAccountUtils.pii(LOG_TAG, "Trying server provided email: '" + alternateEmail + "'" );
try {
// Nota bene: this is not recursive, since we call the fixed password
// signature here, which invokes a non-retrying version.
byte[] alternateEmailUTF8 = alternateEmail.getBytes("UTF-8");
byte[] alternateQuickStretchedPW = stretcher.getQuickStretchedPW(alternateEmailUTF8);
login(alternateEmailUTF8, alternateQuickStretchedPW, getKeys, queryParameters, delegate);
} catch (Exception innerException) {
delegate.handleError(innerException);
return;
}
}
});
}
/**
* Registers a device given a valid session token.
*
* @param sessionToken to query.
* @param delegate to invoke callbacks.
*/
@Override
public void registerOrUpdateDevice(byte[] sessionToken, FxAccountDevice device, RequestDelegate 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;
try {
resource = getBaseResource("account/device");
body = device.toJson();
} catch (URISyntaxException | UnsupportedEncodingException e) {
invokeHandleError(delegate, e);
return;
}
resource.delegate = new ResourceDelegate(resource, delegate, ResponseType.JSON_OBJECT, tokenId, reqHMACKey) {
@Override
public void handleSuccess(int status, HttpResponse response, ExtendedJSONObject body) {
try {
delegate.handleSuccess(FxAccountDevice.fromJson(body));
} catch (Exception e) {
delegate.handleError(e);
}
}
};
post(resource, body);
}
@Override
public void deviceList(byte[] sessionToken, RequestDelegate 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;
try {
resource = getBaseResource("account/devices");
} catch (URISyntaxException | UnsupportedEncodingException e) {
invokeHandleError(delegate, e);
return;
}
resource.delegate = new ResourceDelegate(resource, delegate, ResponseType.JSON_ARRAY, tokenId, reqHMACKey) {
@Override
public void handleSuccess(int status, HttpResponse response, JSONArray devicesJson) {
try {
FxAccountDevice[] devices = new FxAccountDevice[devicesJson.size()];
for (int i = 0; i < devices.length; i++) {
ExtendedJSONObject deviceJson = new ExtendedJSONObject((JSONObject) devicesJson.get(i));
devices[i] = FxAccountDevice.fromJson(deviceJson);
}
delegate.handleSuccess(devices);
} catch (Exception e) {
delegate.handleError(e);
}
}
};
resource.get();
}
@Override
public void notifyDevices(@NonNull byte[] sessionToken, @NonNull List deviceIds, ExtendedJSONObject payload, Long TTL, RequestDelegate 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 = createNotifyDevicesBody(deviceIds, payload, TTL);
try {
resource = getBaseResource("account/devices/notify");
} catch (URISyntaxException | UnsupportedEncodingException e) {
invokeHandleError(delegate, e);
return;
}
resource.delegate = new ResourceDelegate(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);
}
@NonNull
@SuppressWarnings("unchecked")
private ExtendedJSONObject createNotifyDevicesBody(@NonNull List deviceIds, ExtendedJSONObject payload, Long TTL) {
final ExtendedJSONObject body = new ExtendedJSONObject();
final JSONArray to = new JSONArray();
to.addAll(deviceIds);
body.put("to", to);
if (payload != null) {
body.put("payload", payload);
}
if (TTL != null) {
body.put("TTL", TTL);
}
return body;
}
}