Make OAuth work

Now you can authenticate your account using oauth
for google hosted accounts (e.g. google.com, gmail.com)
The setup ui is still not up to spec.

Change-Id: Ib2826653550a823b4d1b8739c1e483746cccbc22
This commit is contained in:
Martin Hibdon 2013-12-11 11:58:58 -08:00
parent 5f4fb9bb14
commit e8eb6e659b
13 changed files with 874 additions and 143 deletions

View File

@ -742,14 +742,12 @@ public final class Account extends EmailContent implements AccountColumns, Parce
// Also, remember which operation in the array they represent
ArrayList<ContentProviderOperation> ops = new ArrayList<ContentProviderOperation>();
if (mHostAuthRecv != null) {
// TODO: This causes problems because it's incompatible with Exchange.
// if (mHostAuthRecv.mCredential != null) {
// recvCredentialsIndex = index++;
// ops.add(ContentProviderOperation.newInsert(mHostAuthRecv.mCredential.mBaseUri)
// .withValues(mHostAuthRecv.mCredential.toContentValues())
// .build());
// }
if (mHostAuthRecv.mCredential != null) {
recvCredentialsIndex = index++;
ops.add(ContentProviderOperation.newInsert(mHostAuthRecv.mCredential.mBaseUri)
.withValues(mHostAuthRecv.mCredential.toContentValues())
.build());
}
recvIndex = index++;
final ContentProviderOperation.Builder b = ContentProviderOperation.newInsert(
mHostAuthRecv.mBaseUri);
@ -762,19 +760,18 @@ public final class Account extends EmailContent implements AccountColumns, Parce
ops.add(b.build());
}
if (mHostAuthSend != null) {
// TODO: This causes problems because it's incompatible with Exchange.
// if (mHostAuthSend.mCredential != null) {
// if (mHostAuthRecv.mCredential != null &&
// mHostAuthRecv.mCredential.equals(mHostAuthSend.mCredential)) {
if (mHostAuthSend.mCredential != null) {
if (mHostAuthRecv.mCredential != null &&
mHostAuthRecv.mCredential.equals(mHostAuthSend.mCredential)) {
// These two credentials are identical, use the same row.
// sendCredentialsIndex = recvCredentialsIndex;
// } else {
// sendCredentialsIndex = index++;
// ops.add(ContentProviderOperation.newInsert(mHostAuthRecv.mCredential.mBaseUri)
// .withValues(mHostAuthRecv.mCredential.toContentValues())
// .build());
// }
// }
sendCredentialsIndex = recvCredentialsIndex;
} else {
sendCredentialsIndex = index++;
ops.add(ContentProviderOperation.newInsert(mHostAuthSend.mCredential.mBaseUri)
.withValues(mHostAuthSend.mCredential.toContentValues())
.build());
}
}
sendIndex = index++;
final ContentProviderOperation.Builder b = ContentProviderOperation.newInsert(
mHostAuthSend.mBaseUri);

View File

@ -16,7 +16,7 @@ public class Credential extends EmailContent implements Parcelable {
public static final String TABLE_NAME = "Credential";
public static Uri CONTENT_URI;
public static final Credential EMPTY = new Credential(-1, "", "", 0);
public static final Credential EMPTY = new Credential(-1, "", "", "", 0);
public static void initCredential() {
CONTENT_URI = Uri.parse(EmailContent.CONTENT_URI + "/credential");
@ -24,8 +24,12 @@ public class Credential extends EmailContent implements Parcelable {
public static final String TYPE_OAUTH = "oauth";
// This is the Id of the oauth provider. It can be used to lookup an oauth provider
// from oauth.xml.
public String mProviderId;
public String mAccessToken;
public String mRefreshToken;
// This is the wall clock time, in milliseconds since Midnight, Jan 1, 1970.
public long mExpiration;
// Name of the authentication provider.
@ -58,9 +62,11 @@ public class Credential extends EmailContent implements Parcelable {
mBaseUri = CONTENT_URI;
}
public Credential(long id, String accessToken, String refreshToken, long expiration) {
public Credential(long id, String providerId, String accessToken, String refreshToken,
long expiration) {
mBaseUri = CONTENT_URI;
mId = id;
mProviderId = providerId;
mAccessToken = accessToken;
mRefreshToken = refreshToken;
mExpiration = expiration;
@ -81,6 +87,7 @@ public class Credential extends EmailContent implements Parcelable {
public void restore(Cursor cursor) {
mBaseUri = CONTENT_URI;
mId = cursor.getLong(CredentialQuery.ID_COLUMN_INDEX);
mProviderId = cursor.getString(CredentialQuery.PROVIDER_COLUMN_INDEX);
mAccessToken = cursor.getString(CredentialQuery.ACCESS_TOKEN_COLUMN_INDEX);
mRefreshToken = cursor.getString(CredentialQuery.REFRESH_TOKEN_COLUMN_INDEX);
mExpiration = cursor.getInt(CredentialQuery.EXPIRATION_COLUMN_INDEX);
@ -114,6 +121,7 @@ public class Credential extends EmailContent implements Parcelable {
public void writeToParcel(Parcel dest, int flags) {
// mBaseUri is not parceled
dest.writeLong(mId);
dest.writeString(mProviderId);
dest.writeString(mAccessToken);
dest.writeString(mRefreshToken);
dest.writeLong(mExpiration);
@ -125,6 +133,7 @@ public class Credential extends EmailContent implements Parcelable {
public Credential(Parcel in) {
mBaseUri = CONTENT_URI;
mId = in.readLong();
mProviderId = in.readString();
mAccessToken = in.readString();
mRefreshToken = in.readString();
mExpiration = in.readLong();
@ -136,7 +145,8 @@ public class Credential extends EmailContent implements Parcelable {
return false;
}
Credential that = (Credential)o;
return Utility.areStringsEqual(mAccessToken, that.mAccessToken)
return Utility.areStringsEqual(mProviderId, that.mProviderId)
&& Utility.areStringsEqual(mAccessToken, that.mAccessToken)
&& Utility.areStringsEqual(mRefreshToken, that.mRefreshToken)
&& mExpiration == that.mExpiration;
}
@ -149,6 +159,7 @@ public class Credential extends EmailContent implements Parcelable {
@Override
public ContentValues toContentValues() {
ContentValues values = new ContentValues();
values.put(PROVIDER_COLUMN, mProviderId);
values.put(ACCESS_TOKEN_COLUMN, mAccessToken);
values.put(REFRESH_TOKEN_COLUMN, mRefreshToken);
values.put(EXPIRATION_COLUMN, mExpiration);

View File

@ -32,7 +32,7 @@ import com.android.emailcommon.utility.Utility;
import java.net.URI;
import java.net.URISyntaxException;
public final class HostAuth extends EmailContent implements HostAuthColumns, Parcelable {
public class HostAuth extends EmailContent implements HostAuthColumns, Parcelable {
public static final String TABLE_NAME = "HostAuth";
public static Uri CONTENT_URI;
@ -68,6 +68,8 @@ public final class HostAuth extends EmailContent implements HostAuthColumns, Par
public byte[] mServerCert = null;
public long mCredentialKey;
public transient Credential mCredential;
public static final int CONTENT_ID_COLUMN = 0;
public static final int CONTENT_PROTOCOL_COLUMN = 1;
public static final int CONTENT_ADDRESS_COLUMN = 2;
@ -80,55 +82,16 @@ public final class HostAuth extends EmailContent implements HostAuthColumns, Par
public static final int CONTENT_CREDENTIAL_KEY_COLUMN = 9;
public static final String[] CONTENT_PROJECTION = new String[] {
RECORD_ID, HostAuthColumns.PROTOCOL, HostAuthColumns.ADDRESS, HostAuthColumns.PORT,
HostAuthColumns.FLAGS, HostAuthColumns.LOGIN,
HostAuthColumns.PASSWORD, HostAuthColumns.DOMAIN, HostAuthColumns.CLIENT_CERT_ALIAS,
HostAuthColumns.CREDENTIAL_KEY
RECORD_ID, HostAuthColumns.PROTOCOL, HostAuthColumns.ADDRESS, HostAuthColumns.PORT,
HostAuthColumns.FLAGS, HostAuthColumns.LOGIN,
HostAuthColumns.PASSWORD, HostAuthColumns.DOMAIN, HostAuthColumns.CLIENT_CERT_ALIAS,
HostAuthColumns.CREDENTIAL_KEY
};
public HostAuth() {
mBaseUri = CONTENT_URI;
// other defaults policy)
mPort = PORT_UNKNOWN;
}
/**
* getOrCreateCredential
* Return the credential object for this HostAuth, creating it if it does not yet exist.
* This should not be called on the main thread.
* @param context
* @return the credential object for this HostAuth
*/
public Credential getOrCreateCredential(Context context) {
// TODO: This causes problems because it's incompatible with Exchange.
// if (mCredential == null) {
// if (mCredentialKey >= 0) {
// mCredential = Credential.restoreCredentialsWithId(context, mCredentialKey);
// } else {
// mCredential = new Credential();
// }
// }
// return mCredential;
return null;
}
/**
* getCredentials
* Return the credential object for this HostAuth, or null if it does not exist.
* This should not be called on the main thread.
* @param context
* @return
*/
public Credential getCredentials(Context context) {
// TODO: This causes problems because it's incompatible with Exchange.
// if (mCredential == null) {
// if (mCredentialKey >= 0) {
// mCredential = Credential.restoreCredentialsWithId(context, mCredentialKey);
// }
// }
// return mCredential;
return null;
mCredentialKey = -1;
}
/**
@ -142,7 +105,6 @@ public final class HostAuth extends EmailContent implements HostAuthColumns, Par
HostAuth.CONTENT_URI, HostAuth.CONTENT_PROJECTION, id);
}
/**
* Returns the scheme for the specified flags.
*/
@ -151,8 +113,41 @@ public final class HostAuth extends EmailContent implements HostAuthColumns, Par
}
/**
* Builds a URI scheme name given the parameters for a {@code HostAuth}.
* If a {@code clientAlias} is provided, this indicates that a secure connection must be used.
* Returns the credential object for this HostAuth. This will load from the
* database if the HosAuth has a valid credential key, or return null if not.
*/
public Credential getCredential(Context context) {
if (mCredential == null) {
if (mCredentialKey >= 0) {
mCredential = Credential.restoreCredentialsWithId(context, mCredentialKey);
}
}
return mCredential;
}
/**
* getOrCreateCredential Return the credential object for this HostAuth,
* creating it if it does not yet exist. This should not be called on the
* main thread.
*
* @param context
* @return the credential object for this HostAuth
*/
public Credential getOrCreateCredential(Context context) {
if (mCredential == null) {
if (mCredentialKey >= 0) {
mCredential = Credential.restoreCredentialsWithId(context, mCredentialKey);
} else {
mCredential = new Credential();
}
}
return mCredential;
}
/**
* Builds a URI scheme name given the parameters for a {@code HostAuth}. If
* a {@code clientAlias} is provided, this indicates that a secure
* connection must be used.
*/
public static String getSchemeString(String protocol, int flags, String clientAlias) {
String security = "";
@ -236,6 +231,7 @@ public final class HostAuth extends EmailContent implements HostAuthColumns, Par
values.put(HostAuthColumns.CLIENT_CERT_ALIAS, mClientCertAlias);
values.put(HostAuthColumns.CREDENTIAL_KEY, mCredentialKey);
values.put(HostAuthColumns.ACCOUNT_KEY, 0); // Need something to satisfy the DB
return values;
}
@ -371,13 +367,18 @@ public final class HostAuth extends EmailContent implements HostAuthColumns, Par
dest.writeString(mPassword);
dest.writeString(mDomain);
dest.writeString(mClientCertAlias);
// dest.writeLong(mCredentialKey);
// TODO: This causes problems because it's incompatible with Exchange.
// if (mCredential == null) {
// Credential.EMPTY.writeToParcel(dest, flags);
// } else {
// mCredential.writeToParcel(dest, flags);
// }
if ((mFlags & FLAG_OAUTH) != 0) {
// TODO: This is nasty, but to be compatible with backward Exchange, we can't make any
// change to the parcelable format. But we need Credential objects to be here.
// So... only parcel or unparcel Credentials if the OAUTH flag is set. This will never
// be set on HostAuth going to or coming from Exchange.
dest.writeLong(mCredentialKey);
if (mCredential == null) {
Credential.EMPTY.writeToParcel(dest, flags);
} else {
mCredential.writeToParcel(dest, flags);
}
}
}
/**
@ -394,11 +395,17 @@ public final class HostAuth extends EmailContent implements HostAuthColumns, Par
mPassword = in.readString();
mDomain = in.readString();
mClientCertAlias = in.readString();
// mCredentialKey = in.readLong();
// mCredential = new Credential(in);
// if (mCredential.equals(Credential.EMPTY)) {
// mCredential = null;
// }
if ((mFlags & FLAG_OAUTH) != 0) {
// TODO: This is nasty, but to be compatible with backward Exchange, we can't make any
// change to the parcelable format. But we need Credential objects to be here.
// So... only parcel or unparcel Credentials if the OAUTH flag is set. This will never
// be set on HostAuth going to or coming from Exchange.
mCredentialKey = in.readLong();
mCredential = new Credential(in);
if (mCredential.equals(Credential.EMPTY)) {
mCredential = null;
}
}
}
@Override
@ -415,8 +422,7 @@ public final class HostAuth extends EmailContent implements HostAuthColumns, Par
&& Utility.areStringsEqual(mLogin, that.mLogin)
&& Utility.areStringsEqual(mPassword, that.mPassword)
&& Utility.areStringsEqual(mDomain, that.mDomain)
&& Utility.areStringsEqual(mClientCertAlias, that.mClientCertAlias)
&& mCredentialKey == that.mCredentialKey;
&& Utility.areStringsEqual(mClientCertAlias, that.mClientCertAlias);
// We don't care about the server certificate for equals
}

View File

@ -33,10 +33,13 @@ import android.content.Loader;
import android.database.Cursor;
import android.os.AsyncTask;
import android.os.Bundle;
import android.os.Handler;
import android.os.ResultReceiver;
import android.provider.ContactsContract;
import android.text.Editable;
import android.text.TextUtils;
import android.text.TextWatcher;
import android.text.format.DateUtils;
import android.view.View;
import android.view.View.OnClickListener;
import android.widget.Button;
@ -52,6 +55,7 @@ import com.android.email.service.EmailServiceUtils.EmailServiceInfo;
import com.android.emailcommon.Logging;
import com.android.emailcommon.VendorPolicyLoader.Provider;
import com.android.emailcommon.provider.Account;
import com.android.emailcommon.provider.Credential;
import com.android.emailcommon.provider.EmailContent;
import com.android.emailcommon.provider.HostAuth;
import com.android.emailcommon.utility.Utility;
@ -107,6 +111,16 @@ public class AccountSetupBasics extends AccountSetupActivity
private static final String STATE_KEY_PROVIDER = "AccountSetupBasics.provider";
public static final int REQUEST_OAUTH = 1;
public static final int RESULT_OAUTH_SUCCESS = 0;
public static final int RESULT_OAUTH_USER_CANCELED = -1;
public static final int RESULT_OAUTH_FAILURE = -2;
public static final String EXTRA_OAUTH_ACCESS_TOKEN = "accessToken";
public static final String EXTRA_OAUTH_REFRESH_TOKEN = "refreshToken";
public static final String EXTRA_OAUTH_EXPIRES_IN = "expiresIn";
// Support for UI
private EditText mEmailView;
private EditText mPasswordView;
@ -136,6 +150,22 @@ public class AccountSetupBasics extends AccountSetupActivity
fromActivity.startActivity(i);
}
@Override
public void onActivityResult(int requestCode, int resultCode, Intent data) {
if (requestCode == REQUEST_OAUTH && resultCode == RESULT_OAUTH_SUCCESS) {
final String accessToken = data.getStringExtra(EXTRA_OAUTH_ACCESS_TOKEN);
final String refreshToken = data.getStringExtra(EXTRA_OAUTH_REFRESH_TOKEN);
final int expiresInSeconds = data.getIntExtra(EXTRA_OAUTH_EXPIRES_IN, 0);
finishOAuthSetup(accessToken, refreshToken, expiresInSeconds);
} else {
// TODO: STOPSHIP: This setup UI is not correct, we need to figure out what to do
// in case of errors and have localized strings.
Toast.makeText(AccountSetupBasics.this,
"Failed to get token", Toast.LENGTH_LONG).show();
}
}
/**
* This generates setup data that can be used to start a self-contained account creation flow
* for exchange accounts.
@ -400,7 +430,7 @@ public class AccountSetupBasics extends AccountSetupActivity
final Intent i = new Intent(this, OAuthAuthenticationActivity.class);
i.putExtra(OAuthAuthenticationActivity.EXTRA_EMAIL_ADDRESS, email);
i.putExtra(OAuthAuthenticationActivity.EXTRA_PROVIDER, provider.oauth);
startActivity(i);
startActivityForResult(i, REQUEST_OAUTH);
}
break;
}
@ -504,6 +534,71 @@ public class AccountSetupBasics extends AccountSetupActivity
}
}
/**
* Finish the oauth setup process.
*/
private void finishOAuthSetup(final String accessToken, final String refreshToken,
int expiresInSeconds) {
final String email = mEmailView.getText().toString().trim();
final String[] emailParts = email.split("@");
final String domain = emailParts[1].trim();
mProvider = AccountSettingsUtils.findProviderForDomain(this, domain);
if (mProvider == null) {
// TODO: STOPSHIP: Need better error handling here.
Toast.makeText(AccountSetupBasics.this,
"No provider, can't proceed", Toast.LENGTH_SHORT).show();
return;
}
try {
mProvider.expandTemplates(email);
final Account account = mSetupData.getAccount();
final HostAuth recvAuth = account.getOrCreateHostAuthRecv(this);
HostAuth.setHostAuthFromString(recvAuth, mProvider.incomingUri);
recvAuth.setLogin(mProvider.incomingUsername, null);
Credential cred = recvAuth.getOrCreateCredential(this);
cred.mProviderId = mProvider.oauth;
cred.mAccessToken = accessToken;
cred.mRefreshToken = refreshToken;
cred.mExpiration = System.currentTimeMillis() +
expiresInSeconds * DateUtils.SECOND_IN_MILLIS;
// TODO: For now, assume that we will use SSL because that's what
// gmail wants. This needs to be parameterized from providers.xml
recvAuth.mFlags |= HostAuth.FLAG_SSL;
recvAuth.mFlags |= HostAuth.FLAG_OAUTH;
final EmailServiceInfo info = EmailServiceUtils.getServiceInfo(this,
recvAuth.mProtocol);
recvAuth.mPort =
((recvAuth.mFlags & HostAuth.FLAG_SSL) != 0) ? info.portSsl : info.port;
final HostAuth sendAuth = account.getOrCreateHostAuthSend(this);
HostAuth.setHostAuthFromString(sendAuth, mProvider.outgoingUri);
sendAuth.setLogin(mProvider.outgoingUsername, null);
sendAuth.mCredential = cred;
sendAuth.mFlags |= HostAuth.FLAG_SSL;
sendAuth.mFlags |= HostAuth.FLAG_OAUTH;
// Populate the setup data, assuming that the duplicate account check will succeed
populateSetupData(getOwnerName(), email);
// Stop here if the login credentials duplicate an existing account
// Launch an Async task to do the work
new DuplicateCheckTask(this, email, true)
.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
} catch (URISyntaxException e) {
/*
* If there is some problem with the URI we give up and go on to manual setup.
* Technically speaking, AutoDiscover is OK here, since the user clicked "Next"
* to get here. This will not happen in practice because we don't expect to
* find any EAS accounts in the providers list.
*/
onManualSetup(true);
}
}
/**
* Async task that continues the work of finishAutoSetup(). Checks for a duplicate
* account and then either alerts the user, or continues.
@ -579,7 +674,7 @@ public class AccountSetupBasics extends AccountSetupActivity
finishAutoSetup();
}
} else {
// Can't use auto setup (although EAS accounts may still be able to AutoDiscover)
// Can't use auto setup (although EAS accounts may still be able to AutoDiscover)
new DuplicateCheckTask(this, email, false)
.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
}

View File

@ -1,27 +1,29 @@
package com.android.email.activity.setup;
import android.app.Activity;
import android.app.LoaderManager.LoaderCallbacks;
import android.content.Context;
import android.content.Intent;
import android.graphics.Bitmap;
import android.content.Loader;
import android.net.Uri;
import android.os.Bundle;
import android.os.Handler;
import android.text.TextUtils;
import android.webkit.CookieManager;
import android.webkit.CookieSyncManager;
import android.webkit.WebResourceResponse;
import android.webkit.WebView;
import android.webkit.WebViewClient;
import android.widget.Toast;
import com.android.email.R;
import com.android.email.mail.internet.OAuthAuthenticator;
import com.android.email.mail.internet.OAuthAuthenticator.AuthenticationResult;
import com.android.emailcommon.Logging;
import com.android.emailcommon.VendorPolicyLoader.OAuthProvider;
import com.android.emailcommon.mail.AuthenticationFailedException;
import com.android.emailcommon.mail.MessagingException;
import com.android.mail.ui.MailAsyncTaskLoader;
import com.android.mail.utils.LogUtils;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URL;
import java.io.IOException;
/**
@ -29,14 +31,20 @@ import java.net.URL;
* should obtain an authorization code, which can be used to obtain access and
* refresh tokens.
*/
public class OAuthAuthenticationActivity extends Activity {
public class OAuthAuthenticationActivity extends Activity implements
LoaderCallbacks<AuthenticationResult> {
private final static String TAG = Logging.LOG_TAG;
public static final String EXTRA_EMAIL_ADDRESS = "email_address";
public static final String EXTRA_PROVIDER = "provider";
public static final String EXTRA_PROVIDER_ID = "provider_id";
public static final String EXTRA_AUTHENTICATION_CODE = "authentication_code";
WebView mWv;
OAuthProvider mProvider;
public static final int LOADER_ID_OAUTH_TOKEN = 1;
private WebView mWv;
private OAuthProvider mProvider;
private String mAuthenticationCode;
private class MyWebViewClient extends WebViewClient {
@ -45,7 +53,6 @@ public class OAuthAuthenticationActivity extends Activity {
// TODO: This method works for Google's redirect url to https://localhost.
// Does it work for the general case? I don't know what redirect url other
// providers use, or how the authentication code is returned.
LogUtils.d(TAG, "shouldOverrideUrlLoading %s", url);
final String deparameterizedUrl;
int i = url.lastIndexOf('?');
if (i == -1) {
@ -59,19 +66,19 @@ public class OAuthAuthenticationActivity extends Activity {
// Check the params of this uri, they contain success/failure info,
// along with the authentication token.
final String error = uri.getQueryParameter("error");
if (error != null) {
// TODO display failure screen
LogUtils.d(TAG, "error code %s", error);
Toast.makeText(OAuthAuthenticationActivity.this,
"Couldn't authenticate", Toast.LENGTH_LONG).show();
final Intent intent = new Intent();
setResult(AccountSetupBasics.RESULT_OAUTH_USER_CANCELED, intent);
finish();
} else {
// TODO use this token to request the access and refresh tokens
final String code = uri.getQueryParameter("code");
LogUtils.d(TAG, "authorization code %s", code);
Toast.makeText(OAuthAuthenticationActivity.this,
"OAuth not implemented", Toast.LENGTH_LONG).show();
mAuthenticationCode = uri.getQueryParameter("code");
Bundle params = new Bundle();
params.putString(EXTRA_PROVIDER_ID, mProvider.id);
params.putString(EXTRA_AUTHENTICATION_CODE, mAuthenticationCode);
getLoaderManager().initLoader(LOADER_ID_OAUTH_TOKEN, params,
OAuthAuthenticationActivity.this);
}
finish();
return true;
} else {
return false;
@ -96,7 +103,93 @@ public class OAuthAuthenticationActivity extends Activity {
final String providerName = i.getStringExtra(EXTRA_PROVIDER);
mProvider = AccountSettingsUtils.findOAuthProvider(this, providerName);
final Uri uri = AccountSettingsUtils.createOAuthRegistrationRequest(this, mProvider, email);
LogUtils.d(Logging.LOG_TAG, "launching '%s'", uri);
mWv.loadUrl(uri.toString());
if (bundle != null) {
mAuthenticationCode = bundle.getString(EXTRA_AUTHENTICATION_CODE);
} else {
mAuthenticationCode = null;
}
if (mAuthenticationCode != null) {
Bundle params = new Bundle();
params.putString(EXTRA_PROVIDER_ID, mProvider.id);
params.putString(EXTRA_AUTHENTICATION_CODE, mAuthenticationCode);
getLoaderManager().initLoader(LOADER_ID_OAUTH_TOKEN, params,
OAuthAuthenticationActivity.this);
}
}
@Override
protected void onSaveInstanceState(Bundle outState) {
super.onSaveInstanceState(outState);
outState.putString(EXTRA_AUTHENTICATION_CODE, mAuthenticationCode);
}
private static class OAuthTokenLoader extends MailAsyncTaskLoader<AuthenticationResult> {
private final String mProviderId;
private final String mCode;
public OAuthTokenLoader(Context context, String providerId, String code) {
super(context);
mProviderId = providerId;
mCode = code;
}
@Override
protected void onDiscardResult(AuthenticationResult result) {
}
@Override
public AuthenticationResult loadInBackground() {
try {
final OAuthAuthenticator authenticator = new OAuthAuthenticator();
final AuthenticationResult result = authenticator.requestAccess(
getContext(), mProviderId, mCode);
LogUtils.d(Logging.LOG_TAG, "authentication result %s", result);
return result;
// TODO: do I need a better UI for displaying exceptions?
} catch (AuthenticationFailedException e) {
} catch (MessagingException e) {
} catch (IOException e) {
}
return null;
}
}
@Override
public Loader<AuthenticationResult> onCreateLoader(int id, Bundle data) {
if (id == LOADER_ID_OAUTH_TOKEN) {
final String providerId = data.getString(EXTRA_PROVIDER_ID);
final String code = data.getString(EXTRA_AUTHENTICATION_CODE);
return new OAuthTokenLoader(this, providerId, code);
}
return null;
}
@Override
public void onLoadFinished(Loader<AuthenticationResult> loader,
AuthenticationResult data) {
if (data == null) {
// STOPSHIP: need a better way to display errors. We might get IO or
// MessagingExceptions.
Toast.makeText(this, "Error getting tokens", Toast.LENGTH_SHORT).show();
} else {
final Intent intent = new Intent();
intent.putExtra(AccountSetupBasics.EXTRA_OAUTH_ACCESS_TOKEN,
data.mAccessToken);
intent.putExtra(AccountSetupBasics.EXTRA_OAUTH_REFRESH_TOKEN,
data.mRefreshToken);
intent.putExtra(AccountSetupBasics.EXTRA_OAUTH_EXPIRES_IN,
data.mExpiresInSeconds);
setResult(AccountSetupBasics.RESULT_OAUTH_SUCCESS, intent);
}
finish();
}
@Override
public void onLoaderReset(Loader<AuthenticationResult> loader) {
}
}

View File

@ -202,4 +202,8 @@ public abstract class Store {
public void closeConnections() {
// Base implementation does nothing.
}
public Account getAccount() {
return mAccount;
}
}

View File

@ -0,0 +1,161 @@
package com.android.email.mail.internet;
import android.content.Context;
import android.text.format.DateUtils;
import com.android.email.mail.internet.OAuthAuthenticator.AuthenticationResult;
import com.android.emailcommon.Logging;
import com.android.emailcommon.mail.AuthenticationFailedException;
import com.android.emailcommon.mail.MessagingException;
import com.android.emailcommon.provider.Account;
import com.android.emailcommon.provider.Credential;
import com.android.emailcommon.provider.HostAuth;
import com.android.mail.utils.LogUtils;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
public class AuthenticationCache {
private static AuthenticationCache sCache;
// Threshold for refreshing a token. If the token is expected to expire within this amount of
// time, we won't even bother attempting to use it and will simply force a refresh.
private static final long EXPIRATION_THRESHOLD = 5 * DateUtils.MINUTE_IN_MILLIS;
private final Map<Long, CacheEntry> mCache;
private final OAuthAuthenticator mAuthenticator;
private class CacheEntry {
CacheEntry(long accountId, String providerId, String accessToken, String refreshToken,
long expirationTime) {
mAccountId = accountId;
mProviderId = providerId;
mAccessToken = accessToken;
mRefreshToken = refreshToken;
mExpirationTime = expirationTime;
}
final long mAccountId;
String mProviderId;
String mAccessToken;
String mRefreshToken;
long mExpirationTime;
}
public static AuthenticationCache getInstance() {
synchronized (AuthenticationCache.class) {
if (sCache == null) {
sCache = new AuthenticationCache();
}
return sCache;
}
}
private AuthenticationCache() {
mCache = new HashMap<Long, CacheEntry>();
mAuthenticator = new OAuthAuthenticator();
}
// Gets an access token for the given account. This may be whatever is currently cached, or
// it may query the server to get a new one if the old one is expired or nearly expired.
public String retrieveAccessToken(Context context, Account account) throws
MessagingException, IOException {
// Currently, we always use the same OAuth info for both sending and receiving.
// If we start to allow different credential objects for sending and receiving, this
// will need to be updated.
CacheEntry entry = null;
synchronized (mCache) {
entry = getEntry(context, account);
}
synchronized (entry) {
final long actualExpiration = entry.mExpirationTime - EXPIRATION_THRESHOLD;
if (System.currentTimeMillis() > actualExpiration) {
// This access token is pretty close to end of life. Don't bother trying to use it,
// it might just time out while we're trying to sync. Go ahead and refresh it
// immediately.
refreshEntry(context, entry);
}
return entry.mAccessToken;
}
}
public String refreshAccessToken(Context context, Account account) throws
MessagingException, IOException {
CacheEntry entry = getEntry(context, account);
synchronized (entry) {
refreshEntry(context, entry);
return entry.mAccessToken;
}
}
private CacheEntry getEntry(Context context, Account account) {
CacheEntry entry;
if (account.isSaved()) {
entry = mCache.get(account.mId);
if (entry == null) {
LogUtils.d(Logging.LOG_TAG, "initializing entry from database");
final HostAuth hostAuth = account.getOrCreateHostAuthRecv(context);
final Credential credential = hostAuth.getOrCreateCredential(context);
entry = new CacheEntry(account.mId, credential.mProviderId, credential.mAccessToken,
credential.mRefreshToken, credential.mExpiration);
mCache.put(account.mId, entry);
}
} else {
// This account is not yet saved, just create a temporary entry. Don't store
// it in the cache, it won't be findable because we don't yet have an account Id.
final HostAuth hostAuth = account.getOrCreateHostAuthRecv(context);
final Credential credential = hostAuth.getCredential(context);
entry = new CacheEntry(account.mId, credential.mProviderId, credential.mAccessToken,
credential.mRefreshToken, credential.mExpiration);
}
return entry;
}
private void refreshEntry(Context context, CacheEntry entry) throws
IOException, MessagingException {
LogUtils.d(Logging.LOG_TAG, "AuthenticationCache refreshEntry %d", entry.mAccountId);
try {
final AuthenticationResult result = mAuthenticator.requestRefresh(context,
entry.mProviderId, entry.mRefreshToken);
// Don't set the refresh token here, it's not returned by the refresh response,
// so setting it here would make it blank.
entry.mAccessToken = result.mAccessToken;
entry.mExpirationTime = result.mExpiresInSeconds * DateUtils.SECOND_IN_MILLIS +
System.currentTimeMillis();
saveEntry(context, entry);
} catch (AuthenticationFailedException e) {
// This is fatal. Clear the tokens and rethrow the exception.
LogUtils.d(Logging.LOG_TAG, "authentication failed, clearning");
clearEntry(context, entry);
throw e;
} catch (MessagingException e) {
LogUtils.d(Logging.LOG_TAG, "messaging exception");
throw e;
} catch (IOException e) {
LogUtils.d(Logging.LOG_TAG, "IO exception");
throw e;
}
}
private void saveEntry(Context context, CacheEntry entry) {
LogUtils.d(Logging.LOG_TAG, "saveEntry");
final Account account = Account.restoreAccountWithId(context, entry.mAccountId);
final HostAuth hostAuth = account.getOrCreateHostAuthRecv(context);
final Credential cred = hostAuth.getOrCreateCredential(context);
cred.mProviderId = entry.mProviderId;
cred.mAccessToken = entry.mAccessToken;
cred.mRefreshToken = entry.mRefreshToken;
cred.mExpiration = entry.mExpirationTime;
cred.update(context, cred.toContentValues());
}
private void clearEntry(Context context, CacheEntry entry) {
LogUtils.d(Logging.LOG_TAG, "clearEntry");
entry.mAccessToken = "";
entry.mRefreshToken = "";
entry.mExpirationTime = 0;
saveEntry(context, entry);
}
}

View File

@ -0,0 +1,191 @@
package com.android.email.mail.internet;
import android.content.Context;
import android.text.format.DateUtils;
import com.android.email.activity.setup.AccountSettingsUtils;
import com.android.emailcommon.Logging;
import com.android.emailcommon.VendorPolicyLoader.OAuthProvider;
import com.android.emailcommon.mail.AuthenticationFailedException;
import com.android.emailcommon.mail.MessagingException;
import com.android.mail.utils.LogUtils;
import org.apache.http.HttpResponse;
import org.apache.http.HttpStatus;
import org.apache.http.client.HttpClient;
import org.apache.http.client.entity.UrlEncodedFormEntity;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.impl.client.DefaultHttpClient;
import org.apache.http.message.BasicNameValuePair;
import org.apache.http.params.BasicHttpParams;
import org.apache.http.params.HttpConnectionParams;
import org.apache.http.params.HttpParams;
import org.json.JSONException;
import org.json.JSONObject;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.UnsupportedEncodingException;
import java.util.ArrayList;
import java.util.List;
public class OAuthAuthenticator {
private static final String TAG = Logging.LOG_TAG;
public static final String OAUTH_REQUEST_CODE = "code";
public static final String OAUTH_REQUEST_REFRESH_TOKEN = "refresh_token";
public static final String OAUTH_REQUEST_CLIENT_ID = "client_id";
public static final String OAUTH_REQUEST_CLIENT_SECRET = "client_secret";
public static final String OAUTH_REQUEST_REDIRECT_URI = "redirect_uri";
public static final String OAUTH_REQUEST_GRANT_TYPE = "grant_type";
public static final String JSON_ACCESS_TOKEN = "access_token";
public static final String JSON_REFRESH_TOKEN = "refresh_token";
public static final String JSON_EXPIRES_IN = "expires_in";
private static final long CONNECTION_TIMEOUT = 20 * DateUtils.SECOND_IN_MILLIS;
private static final long COMMAND_TIMEOUT = 30 * DateUtils.SECOND_IN_MILLIS;
final HttpClient mClient;
public static class AuthenticationResult {
public AuthenticationResult(final String accessToken, final String refreshToken,
final int expiresInSeconds) {
mAccessToken = accessToken;
mRefreshToken = refreshToken;
mExpiresInSeconds = expiresInSeconds;
}
@Override
public String toString() {
return "result access " + (mAccessToken==null?"null":"[REDACTED]") +
" refresh " + (mRefreshToken==null?"null":"[REDACTED]") +
" expiresInSeconds " + mExpiresInSeconds;
}
public final String mAccessToken;
public final String mRefreshToken;
public final int mExpiresInSeconds;
}
public OAuthAuthenticator() {
final HttpParams params = new BasicHttpParams();
HttpConnectionParams.setConnectionTimeout(params, (int)(CONNECTION_TIMEOUT));
HttpConnectionParams.setSoTimeout(params, (int)(COMMAND_TIMEOUT));
HttpConnectionParams.setSocketBufferSize(params, 8192);
mClient = new DefaultHttpClient(params);
}
public AuthenticationResult requestAccess(final Context context, final String providerId,
final String code) throws MessagingException, IOException {
final OAuthProvider provider = AccountSettingsUtils.findOAuthProvider(context, providerId);
if (provider == null) {
LogUtils.e(TAG, "invalid provider %s", providerId);
// This shouldn't happen, but if it does, it's a fatal. Throw an authentication failed
// exception, this will at least give the user a heads up to set up their account again.
throw new AuthenticationFailedException("Invalid provider" + providerId);
}
final HttpPost post = new HttpPost(provider.tokenEndpoint);
post.setHeader("Content-Type", "application/x-www-form-urlencoded");
final List<BasicNameValuePair> nvp = new ArrayList<BasicNameValuePair>();
nvp.add(new BasicNameValuePair(OAUTH_REQUEST_CODE, code));
nvp.add(new BasicNameValuePair(OAUTH_REQUEST_CLIENT_ID, provider.clientId));
nvp.add(new BasicNameValuePair(OAUTH_REQUEST_CLIENT_SECRET, provider.clientSecret));
nvp.add(new BasicNameValuePair(OAUTH_REQUEST_REDIRECT_URI, provider.redirectUri));
nvp.add(new BasicNameValuePair(OAUTH_REQUEST_GRANT_TYPE, "authorization_code"));
try {
post.setEntity(new UrlEncodedFormEntity(nvp));
} catch (UnsupportedEncodingException e) {
LogUtils.e(TAG, e, "unsupported encoding");
// This shouldn't happen, but if it does, it's a fatal. Throw an authentication failed
// exception, this will at least give the user a heads up to set up their account again.
throw new AuthenticationFailedException("Unsupported encoding", e);
}
return doRequest(post);
}
public AuthenticationResult requestRefresh(final Context context, final String providerId,
final String refreshToken) throws MessagingException, IOException {
final OAuthProvider provider = AccountSettingsUtils.findOAuthProvider(context, providerId);
if (provider == null) {
LogUtils.e(TAG, "invalid provider %s", providerId);
// This shouldn't happen, but if it does, it's a fatal. Throw an authentication failed
// exception, this will at least give the user a heads up to set up their account again.
throw new AuthenticationFailedException("Invalid provider" + providerId);
}
final HttpPost post = new HttpPost(provider.refreshEndpoint);
post.setHeader("Content-Type", "application/x-www-form-urlencoded");
final List<BasicNameValuePair> nvp = new ArrayList<BasicNameValuePair>();
nvp.add(new BasicNameValuePair(OAUTH_REQUEST_REFRESH_TOKEN, refreshToken));
nvp.add(new BasicNameValuePair(OAUTH_REQUEST_CLIENT_ID, provider.clientId));
nvp.add(new BasicNameValuePair(OAUTH_REQUEST_CLIENT_SECRET, provider.clientSecret));
nvp.add(new BasicNameValuePair(OAUTH_REQUEST_GRANT_TYPE, "refresh_token"));
try {
post.setEntity(new UrlEncodedFormEntity(nvp));
} catch (UnsupportedEncodingException e) {
LogUtils.e(TAG, e, "unsupported encoding");
// This shouldn't happen, but if it does, it's a fatal. Throw an authentication failed
// exception, this will at least give the user a heads up to set up their account again.
throw new AuthenticationFailedException("Unsuported encoding", e);
}
return doRequest(post);
}
private AuthenticationResult doRequest(HttpPost post) throws MessagingException,
IOException {
final HttpResponse response;
response = mClient.execute(post);
final int status = response.getStatusLine().getStatusCode();
if (status == HttpStatus.SC_OK) {
return parseResponse(response);
} else if (status == HttpStatus.SC_FORBIDDEN || status == HttpStatus.SC_UNAUTHORIZED ||
status == HttpStatus.SC_BAD_REQUEST) {
LogUtils.e(TAG, "HTTP Authentication error getting oauth tokens %d", status);
// This is fatal, and we probably should clear our tokens after this.
throw new AuthenticationFailedException("Auth error getting auth token");
} else {
LogUtils.e(TAG, "HTTP Error %d getting oauth tokens", status);
// This is probably a transient error, we can try again later.
throw new MessagingException("HTTPError " + status + " getting oauth token");
}
}
private AuthenticationResult parseResponse(HttpResponse response) throws IOException,
MessagingException {
final BufferedReader reader = new BufferedReader(new InputStreamReader(
response.getEntity().getContent(), "UTF-8"));
final StringBuilder builder = new StringBuilder();
for (String line = null; (line = reader.readLine()) != null;) {
builder.append(line).append("\n");
}
try {
final JSONObject jsonResult = new JSONObject(builder.toString());
final String accessToken = jsonResult.getString(JSON_ACCESS_TOKEN);
final String expiresIn = jsonResult.getString(JSON_EXPIRES_IN);
final String refreshToken;
if (jsonResult.has(JSON_REFRESH_TOKEN)) {
refreshToken = jsonResult.getString(JSON_REFRESH_TOKEN);
} else {
refreshToken = null;
}
try {
int expiresInSeconds = Integer.valueOf(expiresIn);
return new AuthenticationResult(accessToken, refreshToken, expiresInSeconds);
} catch (NumberFormatException e) {
LogUtils.e(TAG, e, "Invalid expiration %s", expiresIn);
// This indicates a server error, we can try again later.
throw new MessagingException("Invalid number format", e);
}
} catch (JSONException e) {
LogUtils.e(TAG, e, "Invalid JSON");
// This indicates a server error, we can try again later.
throw new MessagingException("Invalid JSON", e);
}
}
}

View File

@ -17,7 +17,9 @@
package com.android.email.mail.store;
import android.text.TextUtils;
import android.util.Base64;
import com.android.email.mail.internet.AuthenticationCache;
import com.android.email.mail.store.ImapStore.ImapException;
import com.android.email.mail.store.imap.ImapConstants;
import com.android.email.mail.store.imap.ImapList;
@ -59,13 +61,14 @@ class ImapConnection {
/** The capabilities supported; a set of CAPABILITY_* values. */
private int mCapabilities;
private static final String IMAP_REDACTED_LOG = "[IMAP command redacted]";
static final String IMAP_REDACTED_LOG = "[IMAP command redacted]";
MailTransport mTransport;
private ImapResponseParser mParser;
private ImapStore mImapStore;
private String mUsername;
private String mLoginPhrase;
private String mAccessToken;
private String mIdPhrase = null;
/** # of command/response lines to log upon crash. */
private static final int DISCOURSE_LOGGER_SIZE = 64;
private final DiscourseLogger mDiscourse = new DiscourseLogger(DISCOURSE_LOGGER_SIZE);
@ -77,23 +80,54 @@ class ImapConnection {
*/
private final AtomicInteger mNextCommandTag = new AtomicInteger(0);
// Keep others from instantiating directly
ImapConnection(ImapStore store, String username, String password) {
setStore(store, username, password);
ImapConnection(ImapStore store) {
setStore(store);
}
void setStore(ImapStore store, String username, String password) {
if (username != null && password != null) {
mUsername = username;
// build the LOGIN string once (instead of over-and-over again.)
// apply the quoting here around the built-up password
mLoginPhrase = ImapConstants.LOGIN + " " + mUsername + " "
+ ImapUtility.imapQuoted(password);
}
void setStore(ImapStore store) {
// TODO: maybe we should throw an exception if the connection is not closed here,
// if it's not currently closed, then we won't reopen it, so if the credentials have
// changed, the connection will not be reestablished.
mImapStore = store;
mLoginPhrase = null;
}
/**
* Generates and returns the phrase to be used for authentication. This will be a LOGIN with
* username and password, or an OAUTH authentication string, with username and access token.
* Currently, these are the only two auth mechanisms supported.
* @return
* @throws IOException
* @throws AuthenticationFailedException
*/
String getLoginPhrase() throws MessagingException, IOException {
// build the LOGIN string once (instead of over-and-over again.)
if (mImapStore.getUseOAuth()) {
// We'll recreate the login phrase if it's null, or if the access token
// has changed.
final String accessToken = AuthenticationCache.getInstance().retrieveAccessToken(
mImapStore.getContext(), mImapStore.getAccount());
if (mLoginPhrase == null || !TextUtils.equals(mAccessToken, accessToken)) {
mAccessToken = accessToken;
final String oauthCode = "user=" + mImapStore.getUsername() + '\001' +
"auth=Bearer " + mAccessToken + '\001' + '\001';
mLoginPhrase = ImapConstants.AUTHENTICATE + " " + ImapConstants.XOAUTH2 + " " +
Base64.encodeToString(oauthCode.getBytes(), Base64.NO_WRAP);
}
} else {
if (mLoginPhrase == null) {
if (mImapStore.getUsername() != null && mImapStore.getPassword() != null) {
// build the LOGIN string once (instead of over-and-over again.)
// apply the quoting here around the built-up password
mLoginPhrase = ImapConstants.LOGIN + " " + mImapStore.getUsername() + " "
+ ImapUtility.imapQuoted(mImapStore.getPassword());
}
}
}
return mLoginPhrase;
}
void open() throws IOException, MessagingException {
if (mTransport != null && mTransport.isOpen()) {
return;
@ -237,7 +271,8 @@ class ImapConnection {
* @return Returns the command tag that was sent
*/
String sendCommand(String command, boolean sensitive)
throws MessagingException, IOException {
throws MessagingException, IOException {
LogUtils.d(Logging.LOG_TAG, "sendCommand %s", command);
open();
String tag = Integer.toString(mNextCommandTag.incrementAndGet());
String commandToSend = tag + " " + command;
@ -320,8 +355,10 @@ class ImapConnection {
*/
List<ImapResponse> executeSimpleCommand(String command, boolean sensitive)
throws IOException, MessagingException {
sendCommand(command, sensitive);
return getCommandResponses();
// TODO: It may be nice to catch IOExceptions and close the connection here.
// Currently, we expect callers to do that, but if they fail to we'll be in a broken state.
sendCommand(command, sensitive);
return getCommandResponses();
}
/**
@ -336,9 +373,9 @@ class ImapConnection {
*/
List<ImapResponse> executeComplexCommand(List<String> commands, boolean sensitive)
throws IOException, MessagingException {
sendComplexCommand(commands, sensitive);
return getCommandResponses();
}
sendComplexCommand(commands, sensitive);
return getCommandResponses();
}
/**
* Query server for capabilities.
@ -374,7 +411,8 @@ class ImapConnection {
// Assign user-agent string (for RFC2971 ID command)
String mUserAgent =
ImapStore.getImapId(mImapStore.getContext(), mUsername, host, capabilities);
ImapStore.getImapId(mImapStore.getContext(), mImapStore.getUsername(), host,
capabilities);
if (mUserAgent != null) {
mIdPhrase = ImapConstants.ID + " (" + mUserAgent + ")";
@ -441,9 +479,13 @@ class ImapConnection {
private void doLogin()
throws IOException, MessagingException, AuthenticationFailedException {
try {
// TODO eventually we need to add additional authentication
// options such as SASL
executeSimpleCommand(mLoginPhrase, true);
if (mImapStore.getUseOAuth()) {
// SASL authentication can take multiple steps. Currently the only SASL
// authentication supported is OAuth.
doSASLAuth();
} else {
executeSimpleCommand(getLoginPhrase(), true);
}
} catch (ImapException ie) {
if (MailActivityEmail.DEBUG) {
LogUtils.d(Logging.LOG_TAG, ie, "ImapException");
@ -455,6 +497,56 @@ class ImapConnection {
}
}
/**
* Performs an SASL authentication. Currently, the only type of SASL authentication supported
* is OAuth.
* @throws MessagingException
* @throws IOException
*/
private void doSASLAuth() throws MessagingException, IOException {
LogUtils.d(Logging.LOG_TAG, "doSASLAuth");
ImapResponse response = getOAuthResponse();
if (!response.isOk()) {
// Failed to authenticate. This may be just due to an expired token.
LogUtils.d(Logging.LOG_TAG, "failed to authenticate, retrying");
destroyResponses();
// Clear the login phrase, this will force us to refresh the auth token.
mLoginPhrase = null;
// Close the transport so that we'll retry the authentication.
if (mTransport != null) {
mTransport.close();
mTransport = null;
}
response = getOAuthResponse();
if (!response.isOk()) {
LogUtils.d(Logging.LOG_TAG, "failed to authenticate, giving up");
destroyResponses();
throw new AuthenticationFailedException("OAuth failed after refresh");
}
}
}
private ImapResponse getOAuthResponse() throws IOException, MessagingException {
ImapResponse response;
LogUtils.d(Logging.LOG_TAG, "sending command %s", getLoginPhrase());
sendCommand(getLoginPhrase(), true);
do {
response = mParser.readResponse();
} while (!response.isTagged() && !response.isContinuationRequest());
if (response.isContinuationRequest()) {
// SASL allows for a challenge/response type authentication, so if it doesn't yet have
// enough info, it will send back a continuation request.
// Currently, the only type of authentication we support is OAuth. The only case where
// it will send a continuation request is when we fail to authenticate. We need to
// reply with a CR/LF, and it will then return with a NO response.
sendCommand("", true);
response = readResponse();
}
return response;
}
/**
* Gets the path separator per the LIST command in RFC 3501. If the path separator
* was obtained while obtaining the namespace or there is no prefix defined, this
@ -514,4 +606,4 @@ class ImapConnection {
void logLastDiscourse() {
mDiscourse.logLastDiscourse();
}
}
}

View File

@ -39,6 +39,7 @@ import com.android.emailcommon.mail.Folder;
import com.android.emailcommon.mail.Message;
import com.android.emailcommon.mail.MessagingException;
import com.android.emailcommon.provider.Account;
import com.android.emailcommon.provider.Credential;
import com.android.emailcommon.provider.HostAuth;
import com.android.emailcommon.provider.Mailbox;
import com.android.emailcommon.service.EmailServiceProxy;
@ -86,6 +87,8 @@ public class ImapStore extends Store {
@VisibleForTesting String mPathPrefix;
@VisibleForTesting String mPathSeparator;
private boolean mUseOAuth;
private final ConcurrentLinkedQueue<ImapConnection> mConnectionPool =
new ConcurrentLinkedQueue<ImapConnection>();
@ -118,9 +121,23 @@ public class ImapStore extends Store {
mUsername = null;
mPassword = null;
}
final Credential cred = recvAuth.getCredential(context);
mUseOAuth = (cred != null);
mPathPrefix = recvAuth.mDomain;
}
boolean getUseOAuth() {
return mUseOAuth;
}
String getUsername() {
return mUsername;
}
String getPassword() {
return mPassword;
}
@VisibleForTesting
Collection<ImapConnection> getConnectionPoolForTest() {
return mConnectionPool;
@ -374,7 +391,7 @@ public class ImapStore extends Store {
// using it.
ImapConnection connection = getConnection();
try {
HashMap<String, ImapFolder> mailboxes = new HashMap<String, ImapFolder>();
final HashMap<String, ImapFolder> mailboxes = new HashMap<String, ImapFolder>();
// Establish a connection to the IMAP server; if necessary
// This ensures a valid prefix if the prefix is automatically set by the server
connection.executeSimpleCommand(ImapConstants.NOOP);
@ -420,7 +437,7 @@ public class ImapStore extends Store {
return mailboxes.values().toArray(new Folder[] {});
} catch (IOException ioe) {
connection.close();
throw new MessagingException("Unable to get folder list.", ioe);
throw new MessagingException("Unable to get folder list", ioe);
} catch (AuthenticationFailedException afe) {
// We do NOT want this connection pooled, or we will continue to send NOOP and SELECT
// commands to the server
@ -429,6 +446,8 @@ public class ImapStore extends Store {
throw afe;
} finally {
if (connection != null) {
// We keep our connection out of the pool as long as we are using it, then
// put it back into the pool so it can be reused.
poolConnection(connection);
}
}
@ -438,7 +457,10 @@ public class ImapStore extends Store {
public Bundle checkSettings() throws MessagingException {
int result = MessagingException.NO_ERROR;
Bundle bundle = new Bundle();
ImapConnection connection = new ImapConnection(this, mUsername, mPassword);
// TODO: why doesn't this use getConnection()? I guess this is only done during setup,
// so there's need to look for a pooled connection?
// But then why doesn't it use poolConnection() after it's done?
ImapConnection connection = new ImapConnection(this);
try {
connection.open();
connection.close();
@ -497,10 +519,13 @@ public class ImapStore extends Store {
* Gets a connection if one is available from the pool, or creates a new one if not.
*/
ImapConnection getConnection() {
// TODO Why would we ever have (or need to have) more than one active connection?
// TODO We set new username/password each time, but we don't actually close the transport
// when we do this. So if that information has changed, this connection will fail.
ImapConnection connection = null;
while ((connection = mConnectionPool.poll()) != null) {
try {
connection.setStore(this, mUsername, mPassword);
connection.setStore(this);
connection.executeSimpleCommand(ImapConstants.NOOP);
break;
} catch (MessagingException e) {
@ -511,8 +536,9 @@ public class ImapStore extends Store {
connection.close();
connection = null;
}
if (connection == null) {
connection = new ImapConnection(this, mUsername, mPassword);
connection = new ImapConnection(this);
}
return connection;
}

View File

@ -32,6 +32,7 @@ public final class ImapConstants {
public static final String ALERT = "ALERT";
public static final String APPEND = "APPEND";
public static final String AUTHENTICATE = "AUTHENTICATE";
public static final String BAD = "BAD";
public static final String BADCHARSET = "BADCHARSET";
public static final String BODY = "BODY";
@ -92,6 +93,7 @@ public final class ImapConstants {
public static final String UIDVALIDITY = "UIDVALIDITY";
public static final String UNSEEN = "UNSEEN";
public static final String UNSUBSCRIBE = "UNSUBSCRIBE";
public static final String XOAUTH2 = "XOAUTH2";
public static final String APPENDUID = "APPENDUID";
public static final String NIL = "NIL";
}

View File

@ -20,6 +20,8 @@ import android.content.Context;
import android.util.Base64;
import com.android.email.mail.Sender;
import com.android.email.mail.internet.AuthenticationCache;
import com.android.email.mail.store.imap.ImapConstants;
import com.android.email2.ui.MailActivityEmail;
import com.android.emailcommon.Logging;
import com.android.emailcommon.internet.Rfc822Output;
@ -28,6 +30,7 @@ import com.android.emailcommon.mail.AuthenticationFailedException;
import com.android.emailcommon.mail.CertificateValidationException;
import com.android.emailcommon.mail.MessagingException;
import com.android.emailcommon.provider.Account;
import com.android.emailcommon.provider.Credential;
import com.android.emailcommon.provider.EmailContent.Message;
import com.android.emailcommon.provider.HostAuth;
import com.android.emailcommon.utility.EOLConvertingOutputStream;
@ -46,8 +49,10 @@ public class SmtpSender extends Sender {
private final Context mContext;
private MailTransport mTransport;
private Account mAccount;
private String mUsername;
private String mPassword;
private boolean mUseOAuth;
/**
* Static named constructor.
@ -61,6 +66,7 @@ public class SmtpSender extends Sender {
*/
public SmtpSender(Context context, Account account) {
mContext = context;
mAccount = account;
HostAuth sendAuth = account.getOrCreateHostAuthSend(context);
mTransport = new MailTransport(context, "SMTP", sendAuth);
String[] userInfoParts = sendAuth.getLogin();
@ -68,6 +74,10 @@ public class SmtpSender extends Sender {
mUsername = userInfoParts[0];
mPassword = userInfoParts[1];
}
Credential cred = sendAuth.getCredential(context);
if (cred != null) {
mUseOAuth = true;
}
}
/**
@ -133,8 +143,15 @@ public class SmtpSender extends Sender {
*/
boolean authLoginSupported = result.matches(".*AUTH.*LOGIN.*$");
boolean authPlainSupported = result.matches(".*AUTH.*PLAIN.*$");
boolean authOAuthSupported = result.matches(".*AUTH.*XOAUTH2.*$");
if (mUsername != null && mUsername.length() > 0 && mPassword != null
if (mUseOAuth) {
if (!authOAuthSupported) {
LogUtils.w(Logging.LOG_TAG, "OAuth requested, but not supported.");
throw new MessagingException(MessagingException.OAUTH_NOT_SUPPORTED);
}
saslAuthOAuth(mUsername);
} else if (mUsername != null && mUsername.length() > 0 && mPassword != null
&& mPassword.length() > 0) {
if (authPlainSupported) {
saslAuthPlain(mUsername, mPassword);
@ -143,11 +160,15 @@ public class SmtpSender extends Sender {
saslAuthLogin(mUsername, mPassword);
}
else {
if (MailActivityEmail.DEBUG) {
LogUtils.d(Logging.LOG_TAG, "No valid authentication mechanism found.");
}
LogUtils.w(Logging.LOG_TAG, "No valid authentication mechanism found.");
throw new MessagingException(MessagingException.AUTH_REQUIRED);
}
} else {
// TODO: STOPSHIP Currently, if we have no username or password, we skip
// the authentication step. We need to figure out if this is intentional and/or
// desirable.
//LogUtils.w(Logging.LOG_TAG, "No valid username and password found.");
//throw new MessagingException(MessagingException.AUTH_REQUIRED);
}
} catch (SSLException e) {
if (MailActivityEmail.DEBUG) {
@ -308,4 +329,32 @@ public class SmtpSender extends Sender {
throw me;
}
}
private void saslAuthOAuth(String username) throws MessagingException,
AuthenticationFailedException, IOException {
final AuthenticationCache cache = AuthenticationCache.getInstance();
String accessToken = cache.retrieveAccessToken(mContext, mAccount);
try {
saslAuthOAuth(username, accessToken);
} catch (AuthenticationFailedException e) {
accessToken = cache.refreshAccessToken(mContext, mAccount);
saslAuthOAuth(username, accessToken);
}
}
private void saslAuthOAuth(final String username, final String accessToken) throws IOException,
MessagingException {
final String authPhrase = "user=" + username + '\001' + "auth=Bearer " + accessToken +
'\001' + '\001';
byte[] data = Base64.encode(authPhrase.getBytes(), Base64.NO_WRAP);
try {
executeSensitiveCommand("AUTH XOAUTH2 " + new String(data),
"AUTH XOAUTH2 /redacted/");
} catch (MessagingException me) {
if (me.getMessage().length() > 1 && me.getMessage().charAt(1) == '3') {
throw new AuthenticationFailedException(me.getMessage());
}
throw me;
}
}
}

View File

@ -1182,6 +1182,7 @@ public class EmailProvider extends ContentProvider {
case MAILBOX_ID:
case ACCOUNT_ID:
case HOSTAUTH_ID:
case CREDENTIAL_ID:
case POLICY_ID:
return new MatrixCursorWithCachedColumns(projection, 0);
}
@ -1262,6 +1263,7 @@ public class EmailProvider extends ContentProvider {
case MAILBOX:
case ACCOUNT:
case HOSTAUTH:
case CREDENTIAL:
case POLICY:
c = db.query(tableName, projection,
selection, selectionArgs, null, null, sortOrder, limit);
@ -1277,6 +1279,7 @@ public class EmailProvider extends ContentProvider {
case MAILBOX_ID:
case ACCOUNT_ID:
case HOSTAUTH_ID:
case CREDENTIAL_ID:
case POLICY_ID:
id = uri.getPathSegments().get(1);
c = db.query(tableName, projection, whereWithId(id, selection),
@ -1763,6 +1766,7 @@ public class EmailProvider extends ContentProvider {
case MAILBOX_ID:
case ACCOUNT_ID:
case HOSTAUTH_ID:
case CREDENTIAL_ID:
case QUICK_RESPONSE_ID:
case POLICY_ID:
id = uri.getPathSegments().get(1);