package org.commcare.tasks;
import android.content.Context;
import org.apache.http.HttpResponse;
import org.apache.http.client.ClientProtocolException;
import org.commcare.CommCareApp;
import org.commcare.CommCareApplication;
import org.commcare.activities.DataPullController;
import org.commcare.activities.LoginMode;
import org.commcare.android.logging.ForceCloseLogger;
import org.commcare.data.xml.TransactionParser;
import org.commcare.data.xml.TransactionParserFactory;
import org.commcare.logging.AndroidLogger;
import org.commcare.models.database.SqlStorage;
import org.commcare.android.database.app.models.UserKeyRecord;
import org.commcare.models.database.user.UserSandboxUtils;
import org.commcare.models.encryption.ByteEncrypter;
import org.commcare.models.legacy.LegacyInstallUtils;
import org.commcare.network.HttpCalloutTask;
import org.commcare.network.HttpRequestGenerator;
import org.commcare.preferences.CommCarePreferences;
import org.commcare.utils.SessionUnavailableException;
import org.commcare.views.notifications.NotificationMessageFactory;
import org.commcare.views.notifications.NotificationMessageFactory.StockMessages;
import org.commcare.xml.KeyRecordParser;
import org.javarosa.core.model.User;
import org.javarosa.core.services.Logger;
import org.javarosa.core.services.locale.Localization;
import org.kxml2.io.KXmlParser;
import java.io.IOException;
import java.util.ArrayList;
import java.util.NoSuchElementException;
import java.util.Vector;
/**
* This task is responsible for taking user credentials and attempting to
* log in a user with local data. If the credentials represent a user who
* doesn't exist, this task will attempt to fetch and create a key record
* for the user specified.
*
* This task uses three steps
* 1) Clean up user key records and figure out whether we need to look for
* new records
* 2) Fetch new records [HTTP step] (not always executed)
* 3) Process the new records and perform any necessary data migration
*
* @author ctsims
*/
public abstract class ManageKeyRecordTask<R extends DataPullController> extends HttpCalloutTask<R> {
private final String username;
private String password;
private String pin;
private final LoginMode loginMode;
private final CommCareApp app;
private String keyServerUrl;
private ArrayList<UserKeyRecord> keyRecords;
private final boolean triggerMultipleUserWarning;
private boolean userRecordExists = false;
private boolean calloutNeeded = false;
private final boolean restoreSession;
private final boolean forCustomDemoUser;
private boolean calloutSuccessRequired;
private User loggedIn = null;
public ManageKeyRecordTask(Context c, int taskId, String username, String passwordOrPin,
LoginMode loginMode, CommCareApp app,
boolean restoreSession, boolean triggerMultipleUserWarning,
boolean forCustomDemoUser) {
super(c);
this.username = username;
this.loginMode = loginMode;
if (loginMode == LoginMode.PIN) {
this.pin = passwordOrPin;
this.password = null;
} else if (loginMode == LoginMode.PASSWORD) {
this.password = passwordOrPin;
this.pin = null;
}
this.app = app;
this.restoreSession = restoreSession;
this.forCustomDemoUser = forCustomDemoUser;
if (forCustomDemoUser) {
// block remote key management if we're logging in a custom demo user
keyServerUrl = null;
} else {
keyServerUrl = CommCarePreferences.getKeyServer();
//long story
keyServerUrl = "".equals(keyServerUrl) ? null : keyServerUrl;
}
this.triggerMultipleUserWarning = triggerMultipleUserWarning;
this.taskId = taskId;
}
@Override
protected void deliverResult(R receiver, HttpCalloutOutcomes result) {
// If this task completed and we logged in.
if (result == HttpCalloutOutcomes.Success) {
if (loggedIn == null) {
//If we got here, we didn't "log in" fully. IE: We have a key record and a
//functional sandbox, but this user has never been synced, so we aren't
//really "logged in".
CommCareApplication.instance().releaseUserResourcesAndServices();
keysReadyForSync(receiver);
return;
} else {
keysLoginComplete(receiver);
return;
}
} else if (result == HttpCalloutOutcomes.NetworkFailure) {
if (calloutNeeded && userRecordExists) {
result = HttpCalloutOutcomes.NetworkFailureBadPassword;
}
}
//For any other result make sure we're logged out.
CommCareApplication.instance().releaseUserResourcesAndServices();
//TODO: Do we wanna split this up at all? Seems unlikely. We don't have, like, a ton
//more context that the receiving activity will
keysDoneOther(receiver, result);
}
@Override
protected void deliverError(R receiver, Exception e) {
Logger.log(AndroidLogger.TYPE_ERROR_WORKFLOW, "Error executing task in background: " + e.getMessage());
keysDoneOther(receiver, HttpCalloutOutcomes.UnknownError);
}
protected void keysReadyForSync(R receiver) {
// TODO: we only wanna do this on the _first_ try. Not subsequent ones (IE: On return from startDataPull)
receiver.startDataPull(forCustomDemoUser ?
DataPullController.DataPullMode.CCZ_DEMO : DataPullController.DataPullMode.NORMAL);
}
protected void keysLoginComplete(R receiver) {
if (triggerMultipleUserWarning) {
Logger.log(AndroidLogger.SOFT_ASSERT,
"Warning a user upon login that they already have another " +
"sandbox whose data will not transition over");
// We've successfully pulled down new user data. Should see if the user
// already has a sandbox and let them know that their old data doesn't transition
receiver.raiseMessage(NotificationMessageFactory.message(StockMessages.Auth_RemoteCredentialsChanged), true);
Logger.log(AndroidLogger.TYPE_USER,
"User " + username + " has logged in for the first time with a new " +
"password. They may have unsent data in their other sandbox");
}
receiver.dataPullCompleted();
}
protected void keysDoneOther(R receiver, HttpCalloutOutcomes outcome) {
switch (outcome) {
case AuthFailed:
Logger.log(AndroidLogger.TYPE_USER, "auth failed");
receiver.raiseLoginMessage(StockMessages.Auth_BadCredentials, false);
break;
case BadResponse:
Logger.log(AndroidLogger.TYPE_USER, "bad response");
receiver.raiseLoginMessage(StockMessages.Remote_BadRestore, true);
break;
case NetworkFailure:
Logger.log(AndroidLogger.TYPE_USER, "bad network");
receiver.raiseLoginMessage(StockMessages.Remote_NoNetwork, false);
break;
case NetworkFailureBadPassword:
Logger.log(AndroidLogger.TYPE_USER, "bad network");
receiver.raiseLoginMessage(StockMessages.Remote_NoNetwork_BadPass, true);
break;
case BadCertificate:
Logger.log(AndroidLogger.TYPE_USER, "bad certificate");
receiver.raiseLoginMessage(StockMessages.BadSSLCertificate, false);
break;
case UnknownError:
Logger.log(AndroidLogger.TYPE_USER, "unknown");
receiver.raiseLoginMessage(StockMessages.Restore_Unknown, true);
break;
case IncorrectPin:
Logger.log(AndroidLogger.TYPE_USER, "incorrect pin");
receiver.raiseLoginMessage(StockMessages.Auth_InvalidPin, true);
break;
default:
break;
}
}
@Override
protected HttpCalloutOutcomes doSetupTaskBeforeRequest() {
/**
* This step needs to determine three things:
* 1) Whether we are doing remote key management
* 2) Whether we should look for new key records
* 3) Whether we _need_ new key records, or can proceed without them if the fetch fails.
*/
// Clean up the existing key records and make sure we're in a consistent state
cleanupUserKeyRecords();
// Now, see whether we have a valid record for this username/password/pin combo
boolean hasRecord = false;
userRecordExists = false;
UserKeyRecord valid = null;
SqlStorage<UserKeyRecord> storage = app.getStorage(UserKeyRecord.class);
for (UserKeyRecord ukr : storage.getRecordsForValue(UserKeyRecord.META_USERNAME, username)) {
userRecordExists = true;
if (!ukr.isPasswordOrPinValid(password, pin)) {
continue;
}
// Regardless of whether it's "valid", we have a record
hasRecord = true;
// Now check whether this record is fully valid, or we need to look for an update
if (ukr.isCurrentlyValid()) {
valid = ukr;
}
}
if (!hasRecord && keyServerUrl == null) {
// If we don't have any records and we aren't doing remote key management, this is as
// far as we're going
return HttpCalloutOutcomes.Success;
}
/* Should only try to look for new records if ALL of the following are true:
* a) We're in normal password login mode (otherwise, should only be try matching to an existing record on the device)
* b) We didn't find a matching record that is valid
* c) There is a keyServerUrl to make the http callout to */
calloutNeeded = (loginMode == LoginMode.PASSWORD)
&& (!hasRecord || valid == null)
&& keyServerUrl != null;
if (calloutNeeded) {
calloutSuccessRequired = !hasRecord;
Logger.log(AndroidLogger.TYPE_USER, "Performing key record callout." + (calloutSuccessRequired ? " Success is required for login" : ""));
this.publishProgress(Localization.get("key.manage.callout"));
}
return null;
}
private void cleanupUserKeyRecords() {
UserKeyRecord currentlyValid = null;
//For all "new" entries: If there's another sandbox record (regardless of user)
//which shares the sandbox ID, we can set the status of the new record to be
//the same as the old record.
SqlStorage<UserKeyRecord> storage = app.getStorage(UserKeyRecord.class);
for (UserKeyRecord normalRecord :
storage.getRecordsForValue(UserKeyRecord.META_KEY_STATUS, UserKeyRecord.TYPE_NORMAL)) {
if (normalRecord.getUsername().equals(username) && normalRecord.isCurrentlyValid()
&& normalRecord.isPasswordOrPinValid(password, pin)) {
if (currentlyValid == null) {
currentlyValid = normalRecord;
} else {
Logger.log(AndroidLogger.TYPE_ERROR_ASSERTION, "User " + username + " has more than one currently valid key record!!");
}
}
}
for (UserKeyRecord newRecord :
storage.getRecordsForValue(UserKeyRecord.META_KEY_STATUS, UserKeyRecord.TYPE_NEW)) {
// See if we have another sandbox with this ID that is fully initialized.
if (storage.getIDsForValues(
new String[]{UserKeyRecord.META_SANDBOX_ID, UserKeyRecord.META_KEY_STATUS},
new Object[]{newRecord.getUuid(), UserKeyRecord.TYPE_NORMAL}
).size() > 0) {
Logger.log(AndroidLogger.TYPE_MAINTENANCE, "Marking new sandbox " + newRecord.getUuid() + " as initialized, since it's already in use on this device");
// If so, this sandbox _has_ to have already been initialized, and we should treat it as such.
newRecord.setType(UserKeyRecord.TYPE_NORMAL);
storage.write(newRecord);
}
}
for (UserKeyRecord recordPendingDelete :
storage.getRecordsForValue(UserKeyRecord.META_KEY_STATUS, UserKeyRecord.TYPE_PENDING_DELETE)) {
Logger.log(AndroidLogger.TYPE_MAINTENANCE, "Cleaning up sandbox which is pending removal");
// See if there are more records in this sandbox. (If so, we can just wipe this record and move on)
if (storage.getIDsForValue(UserKeyRecord.META_SANDBOX_ID, recordPendingDelete.getUuid()).size() > 2) {
Logger.log(AndroidLogger.TYPE_MAINTENANCE, "Record for sandbox " + recordPendingDelete.getUuid() + " has siblings. Removing record");
//TODO: Will this invalidate our iterator?
storage.remove(recordPendingDelete);
} else {
// Otherwise, we should see if we can read the data, and if so, wipe it as well as the record.
if (recordPendingDelete.isPasswordValid(password)) {
//TODO AMS: Changed this such that you can only wipe the record if it was a password login -- is that OK?
Logger.log(AndroidLogger.TYPE_MAINTENANCE, "Current user has access to purgable sandbox " + recordPendingDelete.getUuid() + ". Wiping that sandbox");
UserSandboxUtils.purgeSandbox(this.getContext(), app, recordPendingDelete, recordPendingDelete.unWrapKey(password));
}
//Do we do anything here if we couldn't open the sandbox?
}
}
//TODO: Specifically we should never have two sandboxes which can be opened by the same password (I think...)
}
//CTS: These will be fleshed out to comply with the server's Key Request/response protocol
@Override
protected HttpResponse doHttpRequest() throws ClientProtocolException, IOException {
HttpRequestGenerator requestor = new HttpRequestGenerator(username, password);
return requestor.makeKeyFetchRequest(keyServerUrl, null);
}
@Override
protected TransactionParserFactory getTransactionParserFactory() {
return new TransactionParserFactory() {
@Override
public TransactionParser getParser(KXmlParser parser) {
String name = parser.getName();
if ("auth_keys".equals(name)) {
return new KeyRecordParser(parser, username, password) {
@Override
public void commit(ArrayList<UserKeyRecord> parsed) throws IOException {
ManageKeyRecordTask.this.keyRecords = parsed;
}
};
} else {
return null;
}
}
};
}
@Override
protected boolean shouldMakeHttpCallout() {
return calloutNeeded;
}
@Override
protected boolean calloutSuccessRequired() {
return calloutSuccessRequired;
}
@Override
protected boolean processSuccessfulRequest() {
if (keyRecords == null || keyRecords.size() == 0) {
Logger.log(AndroidLogger.TYPE_USER, "No key records received on server request!");
return false;
}
Logger.log(AndroidLogger.TYPE_USER, "Key record request complete. Received: " + keyRecords.size() + " key records from server");
SqlStorage<UserKeyRecord> storage = app.getStorage(UserKeyRecord.class);
//We successfully received and parsed out some key records! Let's update the db
for (UserKeyRecord record : keyRecords) {
// See if we already have a key record for this sandbox and user
// (There should _definitely_ only be one if there is one)
UserKeyRecord existing;
try {
existing = storage.getRecordForValues(
new String[]{UserKeyRecord.META_SANDBOX_ID, UserKeyRecord.META_USERNAME},
new Object[]{record.getUuid(), record.getUsername()});
Logger.log(AndroidLogger.TYPE_MAINTENANCE, "Got new record for existing sandbox " + existing.getUuid() + " . Merging");
//So we have an existing record. Either we're updating our current record and we're updating the key details
//or our password has changed and we need to overwrite the existing key record. Either way, all
//we should need to do is merge the records.
UserKeyRecord ukr = UserKeyRecord.buildFrom(record, existing.getType());
ukr.setID(existing.getID());
storage.write(ukr);
} catch (NoSuchElementException nsee) {
// If there's no existing record, write this new one (we'll handle updating the status later)
storage.write(record);
}
markOldRecordsInactive(storage, record.getUsername(), record.getUuid());
}
return true;
}
/**
* While there is guaranteed to be at most 1 existing record in the same sandbox for the same
* username, there may be multiple in OTHER sandboxes. We want to mark all of those except for
* the one we just wrote as inactive
*/
private void markOldRecordsInactive(SqlStorage<UserKeyRecord> storage, String username,
String uuidOfActiveRecord) {
Vector<UserKeyRecord> allRecordsWithSameUsername = storage.getRecordsForValues(
new String[]{UserKeyRecord.META_USERNAME},
new Object[]{username});
for (UserKeyRecord r : allRecordsWithSameUsername) {
if (!r.getUuid().equals(uuidOfActiveRecord)) {
r.setInactive();
storage.write(r);
}
}
}
@Override
protected HttpCalloutTask.HttpCalloutOutcomes doPostCalloutTask(boolean calloutFailed) {
// First, check for consistency in our key records
cleanupUserKeyRecords();
UserKeyRecord current = getCurrentValidRecord();
if (current == null) {
return handleNullRecord();
}
setPasswordFromRecord(current);
if (!processUserKeyRecord(current)) {
return HttpCalloutTask.HttpCalloutOutcomes.UnknownError;
}
// Log into our local sandbox.
CommCareApplication.instance().startUserSession(current.unWrapKey(password), current, restoreSession);
setupLoggedInUser();
return HttpCalloutTask.HttpCalloutOutcomes.Success;
}
private HttpCalloutTask.HttpCalloutOutcomes handleNullRecord() {
if (loginMode == LoginMode.PIN) {
// If we are in pin mode then we did not execute the callout task; just means there
// is no existing record matching the username/pin combo
return HttpCalloutOutcomes.IncorrectPin;
} else {
return HttpCalloutOutcomes.UnknownError;
}
}
private void setPasswordFromRecord(UserKeyRecord current) {
// If we successfully found a matching record in either PIN or Primed mode, we don't yet
// have access to the un-hashed password, but are going to need it now to finish up
if (loginMode == LoginMode.PIN) {
this.password = current.getUnhashedPasswordViaPin(this.pin);
} else if (loginMode == LoginMode.PRIMED) {
this.password = current.getPrimedPassword();
}
}
private boolean processUserKeyRecord(UserKeyRecord current) {
// Now, see if we need to do anything to process our new record.
if (current.getType() != UserKeyRecord.TYPE_NORMAL) {
if (current.getType() == UserKeyRecord.TYPE_NEW) {
return processNewUserKeyRecord(current);
} else if (current.getType() == UserKeyRecord.TYPE_LEGACY_TRANSITION) {
return processLegacyUserKeyRecord(current);
}
}
return true;
}
private boolean processNewUserKeyRecord(UserKeyRecord current) {
// See if we can migrate an old sandbox's data to the new sandbox.
if (!lookForAndMigrateOldSandbox(current)) {
// TODO: Problem during migration! Should potentially try again instead of leaving old one
// Switching over to using the old record instead of failing
current = getInUserSandbox(current.getUsername(), app.getStorage(UserKeyRecord.class));
// Make sure we didn't somehow not get a new sandbox
if (current == null) {
Logger.log(AndroidLogger.TYPE_ERROR_ASSERTION,
"Somehow we both failed to migrate an old DB and also didn't _havE_ an old db");
return false;
}
// Otherwise we're now keyed up with the old DB and we should be fine to log in
}
return true;
}
private boolean processLegacyUserKeyRecord(UserKeyRecord current) {
// Transition the legacy storage to the new format. We don't have a new record,
// so don't worry
try {
this.publishProgress(Localization.get("key.manage.legacy.begin"));
LegacyInstallUtils.transitionLegacyUserStorage(getContext(), CommCareApplication.instance().getCurrentApp(), current.unWrapKey(password), current);
return true;
} catch (Exception e) {
e.printStackTrace();
// Ugh, high level trap catch
// Problem during migration! We should try again? Maybe?
// Or just leave the old one?
Logger.log(AndroidLogger.TYPE_ERROR_ASSERTION, "Error while trying to migrate legacy database! Exception: " + e.getMessage());
// For now, fail.
return false;
}
}
private void setupLoggedInUser() {
// So we may have logged in a key record but not a user (if we just received the
// key, but not the user's data, for instance).
try {
User u = CommCareApplication.instance().getSession().getLoggedInUser();
if (u != null) {
u.setCachedPwd(password);
loggedIn = u;
}
} catch (SessionUnavailableException sue) {
}
}
private UserKeyRecord getInUserSandbox(String username, SqlStorage<UserKeyRecord> storage) {
UserKeyRecord oldSandboxToMigrate = null;
for (UserKeyRecord ukr : storage.getRecordsForValue(UserKeyRecord.META_USERNAME, username)) {
if (ukr.getType() == UserKeyRecord.TYPE_NEW) {
// This record is also new (which is kind of sketchy) so it's not helpful
continue;
}
// Ok, so we have an old record that's been in use for this user. See if this password is the same
if (!ukr.isPasswordValid(password)) {
//Otherwise, this was saved for a different password. We would have simply overwritten the record
//if our sandboxes matched, so we can't do anything with it.
continue;
}
// Ok, so only one more question: We may have migrated a sandbox in the past already,
// so we should only overwrite this record if it's the newest (although it's a bad sign
// if we have two existing sandboxes which can be unlocked with the same password)
if (oldSandboxToMigrate == null || ukr.getValidFrom().after(oldSandboxToMigrate.getValidFrom())) {
oldSandboxToMigrate = ukr;
} else {
Logger.log(AndroidLogger.TYPE_ERROR_ASSERTION, "Two old sandboxes exist with the same username");
}
}
return oldSandboxToMigrate;
}
//TODO: This can be its own method/process somewhere
private boolean lookForAndMigrateOldSandbox(UserKeyRecord newRecord) {
//So we have a new record here. We want to look through our old records now and see if we can
//(A) Migrate over any of their old data to this new sandbox.
//(B) Wipe that old record once the migrated record is completed (and see if we should wipe the
//sandbox's data).
SqlStorage<UserKeyRecord> storage = app.getStorage(UserKeyRecord.class);
UserKeyRecord oldSandboxToMigrate = getInUserSandbox(newRecord.getUsername(), storage);
//Our new record is completely new. Easy and awesome. Record and move on.
if (oldSandboxToMigrate == null) {
newRecord.setType(UserKeyRecord.TYPE_NORMAL);
storage.write(newRecord);
} else {
//Otherwise we should start migrating that data over.
return migrate(oldSandboxToMigrate, newRecord);
}
return true;
}
private boolean migrate(UserKeyRecord oldSandboxToMigrate, UserKeyRecord newRecord) {
byte[] oldKey = oldSandboxToMigrate.unWrapKey(password);
migrateLegacySandbox(oldSandboxToMigrate, oldKey);
try {
//Otherwise we need to copy the old sandbox to a new location atomically (in case we fail).
UserSandboxUtils.migrateData(getContext(), app, oldSandboxToMigrate, oldKey, newRecord,
ByteEncrypter.unwrapByteArrayWithString(newRecord.getEncryptedKey(), password));
publishProgress(Localization.get("key.manage.migrate"));
} catch (IOException ioe) {
Logger.exception(ioe);
Logger.log(AndroidLogger.TYPE_MAINTENANCE, "IO Error while migrating database: " + ioe.getMessage());
return false;
} catch (Exception e) {
Logger.exception(e);
Logger.log(AndroidLogger.TYPE_MAINTENANCE, "Unexpected error while migrating database: " + ForceCloseLogger.getStackTrace(e));
return false;
}
return true;
}
private void migrateLegacySandbox(UserKeyRecord oldSandboxToMigrate, byte[] oldKey) {
if (oldSandboxToMigrate.getType() == UserKeyRecord.TYPE_LEGACY_TRANSITION) {
//transition the old storage into the new format before we copy the DB over.
LegacyInstallUtils.transitionLegacyUserStorage(getContext(), CommCareApplication.instance().getCurrentApp(), oldKey, oldSandboxToMigrate);
publishProgress(Localization.get("key.manage.legacy.begin"));
}
}
@Override
protected HttpCalloutOutcomes doResponseOther(HttpResponse response) {
return HttpCalloutOutcomes.BadResponse;
}
// NOTE PLM: getCurrentValidRecord is called w/ acceptExpired set to
// true. Eventually we will enforce user key record expiration, but
// can't do so until we proactively refresh records that are going to
// expire in the next few months. Otherwise, devices that haven't
// accessed the internet in a while won't be able to perform logins.
private UserKeyRecord getCurrentValidRecord() {
if (loginMode == LoginMode.PIN) {
return UserKeyRecord.getCurrentValidRecordByPin(app, username, pin, true);
} else if (loginMode == LoginMode.PASSWORD) {
return UserKeyRecord.getCurrentValidRecordByPassword(app, username, password, true);
} else {
// primed mode
return UserKeyRecord.getMatchingPrimedRecord(app, username);
}
}
}