Back-end changes for Google XOAUTH2

This commit is contained in:
Philip Whitehouse 2016-10-24 12:27:45 +01:00
parent 0342648568
commit 7774ebc788
22 changed files with 1020 additions and 72 deletions

View file

@ -16,6 +16,13 @@ public enum AuthType {
CRAM_MD5,
EXTERNAL,
/**
* XOAUTH2 is an OAuth2.0 protocol designed/used by GMail.
*
* https://developers.google.com/gmail/xoauth2_protocol#the_sasl_xoauth2_mechanism
*/
XOAUTH2,
/*
* The following are obsolete authentication settings that were used with
* SMTP. They are no longer presented to the user as options, but they may

View file

@ -1,5 +1,6 @@
package com.fsck.k9.mail;
import java.io.UnsupportedEncodingException;
import java.security.MessageDigest;
import com.fsck.k9.mail.filter.Base64;
@ -7,6 +8,7 @@ import com.fsck.k9.mail.filter.Hex;
public class Authentication {
private static final String US_ASCII = "US-ASCII";
private static final String XOAUTH_FORMAT = "user=%1s\001auth=Bearer %2s\001\001";
/**
* Computes the response for CRAM-MD5 authentication mechanism given the user credentials and
@ -82,4 +84,10 @@ public class Authentication {
throw new MessagingException("Something went wrong during CRAM-MD5 computation", e);
}
}
public static String computeXoauth(String username, String authToken) throws UnsupportedEncodingException {
return new String(
Base64.encodeBase64(String.format(XOAUTH_FORMAT, username, authToken).getBytes()),
US_ASCII);
}
}

View file

@ -3,6 +3,7 @@ package com.fsck.k9.mail;
import android.content.Context;
import com.fsck.k9.mail.oauth.OAuth2TokenProvider;
import com.fsck.k9.mail.ssl.DefaultTrustedSocketFactory;
import com.fsck.k9.mail.store.StoreConfig;
import com.fsck.k9.mail.ServerSettings.Type;
@ -19,10 +20,12 @@ public abstract class Transport {
// RFC 1047
protected static final int SOCKET_READ_TIMEOUT = 300000;
public synchronized static Transport getInstance(Context context, StoreConfig storeConfig) throws MessagingException {
public synchronized static Transport getInstance(Context context, StoreConfig storeConfig,
OAuth2TokenProvider oauth2TokenProvider) throws MessagingException {
String uri = storeConfig.getTransportUri();
if (uri.startsWith("smtp")) {
return new SmtpTransport(storeConfig, new DefaultTrustedSocketFactory(context));
return new SmtpTransport(storeConfig, new DefaultTrustedSocketFactory(context),
oauth2TokenProvider);
} else if (uri.startsWith("webdav")) {
return new WebDavTransport(storeConfig);
} else {

View file

@ -0,0 +1,11 @@
package com.fsck.k9.mail.oauth;
public class AuthorizationException extends Exception {
public AuthorizationException(String detailMessage, Throwable throwable) {
super(detailMessage, throwable);
}
public AuthorizationException(String detailMessage) {
super(detailMessage);
}
}

View file

@ -0,0 +1,61 @@
package com.fsck.k9.mail.oauth;
import android.app.Activity;
import com.fsck.k9.mail.AuthenticationFailedException;
import java.util.List;
public interface OAuth2TokenProvider {
/**
* A default timeout value to use when fetching tokens.
*/
public static final int OAUTH2_TIMEOUT = 30000;
/**
* @return Accounts suitable for OAuth 2.0 token provision.
*/
List<String> getAccounts();
/**
* Provides an asynchronous response to an
* {@link OAuth2TokenProvider#authorizeAPI(String, Activity, OAuth2TokenProviderAuthCallback)} request
*/
interface OAuth2TokenProviderAuthCallback {
void success();
void failure(AuthorizationException e);
}
/**
* Request API authorization. This is a foreground action that may produce a dialog to interact with.
* @param username Username
* @param activity The responsible activity
* @param callback A callback to process the asynchronous response
*/
void authorizeAPI(String username, Activity activity,
OAuth2TokenProviderAuthCallback callback);
/**
* Fetch a token. No guarantees are provided for validity.
* @param username Username
* @return Token string
* @throws AuthenticationFailedException
*/
String getToken(String username, long timeoutMillis) throws AuthenticationFailedException;
/**
* Invalidate the token for this username.
* Note that the token should always be invalidated on credential failure.
* However invalidating a token every single time is not recommended.
*
* Invalidating a token and then failure with a new token
* should be treated as a permanent failure.
*
* @param username
*/
void invalidateToken(String username);
}

View file

@ -0,0 +1,35 @@
package com.fsck.k9.mail.oauth;
import android.util.Log;
import com.fsck.k9.mail.filter.Base64;
import org.json.JSONException;
import org.json.JSONObject;
import static com.fsck.k9.mail.K9MailLib.LOG_TAG;
/**
* Parses Google's Error/Challenge responses
* See: https://developers.google.com/gmail/xoauth2_protocol#error_response
*/
public class XOAuth2ChallengeParser {
public static final String BAD_RESPONSE = "400";
public static boolean shouldRetry(String response, String host) {
String decodedResponse = Base64.decode(response);
Log.v(LOG_TAG, "Challenge response: "+ decodedResponse);
try {
JSONObject json = new JSONObject(decodedResponse);
if(!json.getString("status").equals(BAD_RESPONSE)) {
return false;
}
} catch (JSONException jsonException) {
Log.i(LOG_TAG, "Error decoding JSON response from:"
+ host + ". Response was:" + decodedResponse);
}
return true;
}
}

View file

@ -1,5 +1,6 @@
package com.fsck.k9.mail.store;
import android.accounts.AccountManager;
import android.content.Context;
import android.net.ConnectivityManager;
@ -7,6 +8,7 @@ import com.fsck.k9.mail.MessagingException;
import com.fsck.k9.mail.ServerSettings;
import com.fsck.k9.mail.ServerSettings.Type;
import com.fsck.k9.mail.Store;
import com.fsck.k9.mail.oauth.OAuth2TokenProvider;
import com.fsck.k9.mail.ssl.DefaultTrustedSocketFactory;
import com.fsck.k9.mail.ssl.TrustedSocketFactory;
import com.fsck.k9.mail.store.imap.ImapStore;
@ -38,8 +40,9 @@ public abstract class RemoteStore extends Store {
/**
* Get an instance of a remote mail store.
*/
public synchronized static Store getInstance(Context context,
StoreConfig storeConfig) throws MessagingException {
public synchronized static Store getInstance(Context context, StoreConfig storeConfig,
OAuth2TokenProvider oAuth2TokenProvider)
throws MessagingException {
String uri = storeConfig.getStoreUri();
if (uri.startsWith("local")) {
@ -49,14 +52,19 @@ public abstract class RemoteStore extends Store {
Store store = sStores.get(uri);
if (store == null) {
if (uri.startsWith("imap")) {
store = new ImapStore(storeConfig,
new DefaultTrustedSocketFactory(context),
(ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE));
store = new ImapStore(
storeConfig,
new DefaultTrustedSocketFactory(context),
(ConnectivityManager) context
.getSystemService(Context.CONNECTIVITY_SERVICE),
oAuth2TokenProvider
);
} else if (uri.startsWith("pop3")) {
store = new Pop3Store(storeConfig,
new DefaultTrustedSocketFactory(context));
new DefaultTrustedSocketFactory(context));
} else if (uri.startsWith("webdav")) {
store = new WebDavStore(storeConfig, new WebDavHttpClient.WebDavHttpClientFactory());
store = new WebDavStore(storeConfig,
new WebDavHttpClient.WebDavHttpClientFactory());
}
if (store != null) {

View file

@ -3,6 +3,8 @@ package com.fsck.k9.mail.store.imap;
class Capabilities {
public static final String IDLE = "IDLE";
public static final String SASL_IR = "SASL-IR";
public static final String AUTH_XOAUTH2 = "AUTH=XOAUTH2";
public static final String AUTH_CRAM_MD5 = "AUTH=CRAM-MD5";
public static final String AUTH_PLAIN = "AUTH=PLAIN";
public static final String AUTH_EXTERNAL = "AUTH=EXTERNAL";

View file

@ -6,6 +6,7 @@ class Commands {
public static final String CAPABILITY = "CAPABILITY";
public static final String COMPRESS_DEFLATE = "COMPRESS DEFLATE";
public static final String STARTTLS = "STARTTLS";
public static final String AUTHENTICATE_XOAUTH2="AUTHENTICATE XOAUTH2";
public static final String AUTHENTICATE_CRAM_MD5 = "AUTHENTICATE CRAM-MD5";
public static final String AUTHENTICATE_PLAIN = "AUTHENTICATE PLAIN";
public static final String AUTHENTICATE_EXTERNAL = "AUTHENTICATE EXTERNAL";

View file

@ -39,6 +39,8 @@ import com.fsck.k9.mail.MessagingException;
import com.fsck.k9.mail.NetworkType;
import com.fsck.k9.mail.filter.Base64;
import com.fsck.k9.mail.filter.PeekableInputStream;
import com.fsck.k9.mail.oauth.OAuth2TokenProvider;
import com.fsck.k9.mail.oauth.XOAuth2ChallengeParser;
import com.fsck.k9.mail.ssl.TrustedSocketFactory;
import com.jcraft.jzlib.JZlib;
import com.jcraft.jzlib.ZOutputStream;
@ -61,6 +63,7 @@ class ImapConnection {
private final ConnectivityManager connectivityManager;
private final OAuth2TokenProvider oauthTokenProvider;
private final TrustedSocketFactory socketFactory;
private final int socketConnectTimeout;
private final int socketReadTimeout;
@ -74,22 +77,26 @@ class ImapConnection {
private ImapSettings settings;
private Exception stacktraceForClose;
private boolean open = false;
private boolean retryXoauth2WithNewToken = true;
public ImapConnection(ImapSettings settings, TrustedSocketFactory socketFactory,
ConnectivityManager connectivityManager) {
ConnectivityManager connectivityManager, OAuth2TokenProvider oauthTokenProvider) {
this.settings = settings;
this.socketFactory = socketFactory;
this.connectivityManager = connectivityManager;
this.oauthTokenProvider = oauthTokenProvider;
this.socketConnectTimeout = SOCKET_CONNECT_TIMEOUT;
this.socketReadTimeout = SOCKET_READ_TIMEOUT;
}
ImapConnection(ImapSettings settings, TrustedSocketFactory socketFactory, ConnectivityManager connectivityManager,
ImapConnection(ImapSettings settings, TrustedSocketFactory socketFactory,
ConnectivityManager connectivityManager, OAuth2TokenProvider oauthTokenProvider,
int socketConnectTimeout, int socketReadTimeout) {
this.settings = settings;
this.socketFactory = socketFactory;
this.connectivityManager = connectivityManager;
this.oauthTokenProvider = oauthTokenProvider;
this.socketConnectTimeout = socketConnectTimeout;
this.socketReadTimeout = socketReadTimeout;
}
@ -322,6 +329,13 @@ class ImapConnection {
@SuppressWarnings("EnumSwitchStatementWhichMissesCases")
private void authenticate() throws MessagingException, IOException {
switch (settings.getAuthType()) {
case XOAUTH2:
if (hasCapability(Capabilities.AUTH_XOAUTH2) && hasCapability(Capabilities.SASL_IR)) {
authXoauth2withSASLIR();
} else {
throw new MessagingException("Server doesn't support SASL XOAUTH2.");
}
break;
case CRAM_MD5: {
if (hasCapability(Capabilities.AUTH_CRAM_MD5)) {
authCramMD5();
@ -356,6 +370,75 @@ class ImapConnection {
}
}
private void authXoauth2withSASLIR() throws IOException, MessagingException {
retryXoauth2WithNewToken = true;
try {
attemptXOAuth2();
} catch (NegativeImapResponseException e) {
//We couldn't login with the token so invalidate it
oauthTokenProvider.invalidateToken(settings.getUsername());
if (!retryXoauth2WithNewToken) {
handlePermanentXoauth2Failure(e);
} else {
handleTemporaryXoauth2Failure(e);
}
}
}
private void handlePermanentXoauth2Failure(NegativeImapResponseException e) throws AuthenticationFailedException {
Log.v(LOG_TAG, "Permanent failure during XOAUTH2", e);
throw new AuthenticationFailedException(e.getMessage(), e);
}
private void handleTemporaryXoauth2Failure(NegativeImapResponseException e) throws IOException, MessagingException {
//We got a response indicating a retry might suceed after token refresh
//We could avoid this if we had a reasonable chance of knowing
//if a token was invalid before use (e.g. due to expiry). But we don't
//This is the intended behaviour per AccountManager
Log.v(LOG_TAG, "Temporary failure - retrying with new token", e);
try {
attemptXOAuth2();
} catch (NegativeImapResponseException e2) {
//Okay, we failed on a new token.
//Invalidate the token anyway but assume it's permanent.
Log.v(LOG_TAG, "Authentication exception for new token, permanent error assumed", e);
oauthTokenProvider.invalidateToken(settings.getUsername());
handlePermanentXoauth2Failure(e2);
}
}
private void attemptXOAuth2() throws MessagingException, IOException {
String token = oauthTokenProvider.getToken(settings.getUsername(),
OAuth2TokenProvider.OAUTH2_TIMEOUT);
String tag = sendSaslIrCommand(Commands.AUTHENTICATE_XOAUTH2,
Authentication.computeXoauth(settings.getUsername(), token), true);
extractCapabilities(
responseParser.readStatusResponse(tag, Commands.AUTHENTICATE_XOAUTH2, getLogId(),
new UntaggedHandler() {
@Override
public void handleAsyncUntaggedResponse(ImapResponse response) throws IOException {
handleXOAuthUntaggedResponse(response);
}
})
);
}
private void handleXOAuthUntaggedResponse(ImapResponse response) throws IOException {
if (response.isString(0)) {
retryXoauth2WithNewToken = XOAuth2ChallengeParser.shouldRetry(
response.getString(0), settings.getHost());
}
if(response.isContinuationRequested()) {
outputStream.write("\r\n".getBytes());
outputStream.flush();
}
}
private void authCramMD5() throws MessagingException, IOException {
String command = Commands.AUTHENTICATE_CRAM_MD5;
String tag = sendCommand(command, false);
@ -635,6 +718,31 @@ class ImapConnection {
return responseParser.readStatusResponse(tag, commandToLog, getLogId(), untaggedHandler);
}
public String sendSaslIrCommand(String command, String initialClientResponse, boolean sensitive)
throws IOException, MessagingException {
try {
open();
String tag = Integer.toString(nextCommandTag++);
String commandToSend = tag + " " + command + " " + initialClientResponse+ "\r\n";
outputStream.write(commandToSend.getBytes());
outputStream.flush();
if (K9MailLib.isDebug() && DEBUG_PROTOCOL_IMAP) {
if (sensitive && !K9MailLib.isDebugSensitive()) {
Log.v(LOG_TAG, getLogId() + ">>> [Command Hidden, Enable Sensitive Debug Logging To Show]");
} else {
Log.v(LOG_TAG, getLogId() + ">>> " + tag + " " + command+ " " + initialClientResponse);
}
}
return tag;
} catch (IOException | MessagingException e) {
close();
throw e;
}
}
public String sendCommand(String command, boolean sensitive) throws MessagingException, IOException {
try {
open();

View file

@ -109,7 +109,7 @@ class ImapResponseParser {
continue;
}
if (untaggedHandler != null) {
if (response.getTag() == null && untaggedHandler != null) {
untaggedHandler.handleAsyncUntaggedResponse(response);
}

View file

@ -14,6 +14,7 @@ import java.util.List;
import java.util.Map;
import java.util.Set;
import android.accounts.AccountManager;
import android.net.ConnectivityManager;
import android.util.Log;
@ -26,6 +27,7 @@ import com.fsck.k9.mail.NetworkType;
import com.fsck.k9.mail.PushReceiver;
import com.fsck.k9.mail.Pusher;
import com.fsck.k9.mail.ServerSettings;
import com.fsck.k9.mail.oauth.OAuth2TokenProvider;
import com.fsck.k9.mail.ssl.TrustedSocketFactory;
import com.fsck.k9.mail.store.RemoteStore;
import com.fsck.k9.mail.store.StoreConfig;
@ -42,6 +44,7 @@ import static com.fsck.k9.mail.K9MailLib.LOG_TAG;
public class ImapStore extends RemoteStore {
private Set<Flag> permanentFlagsIndex = EnumSet.noneOf(Flag.class);
private ConnectivityManager connectivityManager;
private OAuth2TokenProvider oauthTokenProvider;
private String host;
private int port;
@ -73,8 +76,10 @@ public class ImapStore extends RemoteStore {
return ImapStoreUriCreator.create(server);
}
public ImapStore(StoreConfig storeConfig, TrustedSocketFactory trustedSocketFactory,
ConnectivityManager connectivityManager) throws MessagingException {
public ImapStore(StoreConfig storeConfig,
TrustedSocketFactory trustedSocketFactory,
ConnectivityManager connectivityManager,
OAuth2TokenProvider oauthTokenProvider) throws MessagingException {
super(storeConfig, trustedSocketFactory);
ImapStoreSettings settings;
@ -89,6 +94,7 @@ public class ImapStore extends RemoteStore {
connectionSecurity = settings.connectionSecurity;
this.connectivityManager = connectivityManager;
this.oauthTokenProvider = oauthTokenProvider;
authType = settings.authenticationType;
username = settings.username;
@ -340,7 +346,11 @@ public class ImapStore extends RemoteStore {
}
ImapConnection createImapConnection() {
return new ImapConnection(new StoreImapSettings(), mTrustedSocketFactory, connectivityManager);
return new ImapConnection(
new StoreImapSettings(),
mTrustedSocketFactory,
connectivityManager,
oauthTokenProvider);
}
FolderNameCodec getFolderNameCodec() {

View file

@ -1,6 +1,8 @@
package com.fsck.k9.mail.store.imap;
import android.text.TextUtils;
import java.net.URI;
import java.net.URISyntaxException;
@ -83,14 +85,26 @@ class ImapStoreUriDecoder {
String[] userInfoParts = userinfo.split(":");
if (userinfo.endsWith(":")) {
// Password is empty. This can only happen after an account was imported.
authenticationType = AuthType.valueOf(userInfoParts[0]);
username = decodeUtf8(userInfoParts[1]);
// Last field (password/certAlias) is empty.
// For imports e.g.: PLAIN:username: or username:
// Or XOAUTH2 where it's a valid config - XOAUTH:username:
if(userInfoParts.length > 1) {
authenticationType = AuthType.valueOf(userInfoParts[0]);
username = decodeUtf8(userInfoParts[1]);
} else {
authenticationType = AuthType.PLAIN;
username = decodeUtf8(userInfoParts[0]);
}
} else if (userInfoParts.length == 2) {
// Old/standard style of encoding - PLAIN auth only:
// username:password
authenticationType = AuthType.PLAIN;
username = decodeUtf8(userInfoParts[0]);
password = decodeUtf8(userInfoParts[1]);
} else if (userInfoParts.length == 3) {
// Standard encoding
// PLAIN:username:password
// EXTERNAL:username:certAlias
authenticationType = AuthType.valueOf(userInfoParts[0]);
username = decodeUtf8(userInfoParts[1]);
@ -122,5 +136,6 @@ class ImapStoreUriDecoder {
return new ImapStoreSettings(host, port, connectionSecurity, authenticationType, username,
password, clientCertificateAlias, autoDetectNamespace, pathPrefix);
}
}

View file

@ -5,10 +5,8 @@ import com.fsck.k9.mail.MessagingException;
class NegativeImapResponseException extends MessagingException {
private static final long serialVersionUID = 3725007182205882394L;
private final String alertText;
public NegativeImapResponseException(String message, String alertText) {
super(message, true);
this.alertText = alertText;

View file

@ -1,5 +1,7 @@
package com.fsck.k9.mail.store.imap;
import java.io.IOException;
interface UntaggedHandler {
void handleAsyncUntaggedResponse(ImapResponse response);
void handleAsyncUntaggedResponse(ImapResponse response) throws IOException;
}

View file

@ -2,6 +2,7 @@
package com.fsck.k9.mail.transport;
import android.support.annotation.VisibleForTesting;
import android.text.TextUtils;
import android.util.Log;
import com.fsck.k9.mail.*;
@ -13,6 +14,8 @@ import com.fsck.k9.mail.filter.PeekableInputStream;
import com.fsck.k9.mail.filter.SmtpDataStuffing;
import com.fsck.k9.mail.internet.CharsetSupport;
import com.fsck.k9.mail.CertificateValidationException;
import com.fsck.k9.mail.oauth.OAuth2TokenProvider;
import com.fsck.k9.mail.oauth.XOAuth2ChallengeParser;
import com.fsck.k9.mail.ssl.TrustedSocketFactory;
import com.fsck.k9.mail.store.StoreConfig;
@ -31,7 +34,11 @@ import static com.fsck.k9.mail.K9MailLib.LOG_TAG;
import static com.fsck.k9.mail.CertificateValidationException.Reason.MissingCapability;
public class SmtpTransport extends Transport {
public static final int SMTP_CONTINUE_REQUEST = 334;
public static final int SMTP_AUTHENTICATION_FAILURE_ERROR_CODE = 535;
private TrustedSocketFactory mTrustedSocketFactory;
private OAuth2TokenProvider oauthTokenProvider;
/**
* Decodes a SmtpTransport URI.
@ -103,7 +110,7 @@ public class SmtpTransport extends Transport {
username = decodeUtf8(userInfoParts[0]);
password = decodeUtf8(userInfoParts[1]);
} else if (userInfoParts.length == 3) {
// NOTE: In SmptTransport URIs, the authType comes last!
// NOTE: In SmtpTransport URIs, the authType comes last!
authType = AuthType.valueOf(userInfoParts[2]);
username = decodeUtf8(userInfoParts[0]);
if (authType == AuthType.EXTERNAL) {
@ -184,8 +191,10 @@ public class SmtpTransport extends Transport {
private OutputStream mOut;
private boolean m8bitEncodingAllowed;
private int mLargestAcceptableMessage;
private boolean retryXoauthWithNewToken;
public SmtpTransport(StoreConfig storeConfig, TrustedSocketFactory trustedSocketFactory)
public SmtpTransport(StoreConfig storeConfig, TrustedSocketFactory trustedSocketFactory,
OAuth2TokenProvider oauth2TokenProvider)
throws MessagingException {
ServerSettings settings;
try {
@ -204,6 +213,7 @@ public class SmtpTransport extends Transport {
mPassword = settings.password;
mClientCertificateAlias = settings.clientCertificateAlias;
mTrustedSocketFactory = trustedSocketFactory;
oauthTokenProvider = oauth2TokenProvider;
}
@Override
@ -301,18 +311,21 @@ public class SmtpTransport extends Transport {
boolean authPlainSupported = false;
boolean authCramMD5Supported = false;
boolean authExternalSupported = false;
boolean authXoauth2Supported = false;
if (extensions.containsKey("AUTH")) {
List<String> saslMech = Arrays.asList(extensions.get("AUTH").split(" "));
authLoginSupported = saslMech.contains("LOGIN");
authPlainSupported = saslMech.contains("PLAIN");
authCramMD5Supported = saslMech.contains("CRAM-MD5");
authExternalSupported = saslMech.contains("EXTERNAL");
authXoauth2Supported = saslMech.contains("XOAUTH2");
}
parseOptionalSizeValue(extensions);
if (mUsername != null
&& mUsername.length() > 0
&& (mPassword != null && mPassword.length() > 0 || AuthType.EXTERNAL == mAuthType)) {
if (!TextUtils.isEmpty(mUsername)
&& (!TextUtils.isEmpty(mPassword) ||
AuthType.EXTERNAL == mAuthType ||
AuthType.XOAUTH2 == mAuthType)) {
switch (mAuthType) {
@ -340,7 +353,13 @@ public class SmtpTransport extends Transport {
throw new MessagingException("Authentication method CRAM-MD5 is unavailable.");
}
break;
case XOAUTH2:
if (authXoauth2Supported) {
saslXoauth2(mUsername);
} else {
throw new MessagingException("Authentication method XOAUTH2 is unavailable.");
}
break;
case EXTERNAL:
if (authExternalSupported) {
saslAuthExternal(mUsername);
@ -499,7 +518,6 @@ public class SmtpTransport extends Transport {
private void sendMessageTo(List<String> addresses, Message message)
throws MessagingException {
close();
open();
@ -638,12 +656,23 @@ public class SmtpTransport extends Transport {
throw new NegativeSmtpReplyException(replyCode, message);
}
}
}
@Deprecated
private List<String> executeSimpleCommand(String command) throws IOException, MessagingException {
return executeSimpleCommand(command, false);
}
/**
* TODO: All responses should be checked to confirm that they start with a valid
* reply code, and that the reply code is appropriate for the command being executed.
* That means it should either be a 2xx code (generally) or a 3xx code in special cases
* (e.g., DATA & AUTH LOGIN commands). Reply codes should be made available as part of
* the returned object.
*
* This should be doing using the non-deprecated API below.
*/
@Deprecated
private List<String> executeSimpleCommand(String command, boolean sensitive)
throws IOException, MessagingException {
List<String> results = new ArrayList<String>();
@ -651,17 +680,66 @@ public class SmtpTransport extends Transport {
writeLine(command, sensitive);
}
/*
* Read lines as long as the length is 4 or larger, e.g. "220-banner text here".
* Shorter lines are either errors of contain only a reply code. Those cases will
* be handled by checkLine() below.
*
* TODO: All responses should be checked to confirm that they start with a valid
* reply code, and that the reply code is appropriate for the command being executed.
* That means it should either be a 2xx code (generally) or a 3xx code in special cases
* (e.g., DATA & AUTH LOGIN commands). Reply codes should be made available as part of
* the returned object.
*/
String line = readCommandResponseLine(results);
// Check if the reply code indicates an error.
checkLine(line);
return results;
}
private static class CommandResponse {
private final int replyCode;
private final String message;
public CommandResponse(int replyCode, String message) {
this.replyCode = replyCode;
this.message = message;
}
}
private CommandResponse executeSimpleCommandWithResponse(String command, boolean sensitive) throws IOException, MessagingException {
List<String> results = new ArrayList<String>();
if (command != null) {
writeLine(command, sensitive);
}
String line = readCommandResponseLine(results);
int length = line.length();
if (length < 1) {
throw new MessagingException("SMTP response is 0 length");
}
int replyCode = -1;
String message = line;
if (length >= 3) {
try {
replyCode = Integer.parseInt(line.substring(0, 3));
} catch (NumberFormatException e) { /* ignore */ }
if (length > 4) {
message = line.substring(4);
} else {
message = "";
}
}
char c = line.charAt(0);
if ((c == '4') || (c == '5')) {
throw new NegativeSmtpReplyException(replyCode, message);
}
return new CommandResponse(replyCode, message);
}
/*
* Read lines as long as the length is 4 or larger, e.g. "220-banner text here".
* Shorter lines are either errors of contain only a reply code.
*/
private String readCommandResponseLine(List<String> results) throws IOException {
String line = readLine();
while (line.length() >= 4) {
if (line.length() > 4) {
@ -675,11 +753,7 @@ public class SmtpTransport extends Transport {
}
line = readLine();
}
// Check if the reply code indicates an error.
checkLine(line);
return results;
return line;
}
@ -707,7 +781,7 @@ public class SmtpTransport extends Transport {
executeSimpleCommand(Base64.encode(username), true);
executeSimpleCommand(Base64.encode(password), true);
} catch (NegativeSmtpReplyException exception) {
if (exception.getReplyCode() == 535) {
if (exception.getReplyCode() == SMTP_AUTHENTICATION_FAILURE_ERROR_CODE) {
// Authentication credentials invalid
throw new AuthenticationFailedException("AUTH LOGIN failed ("
+ exception.getMessage() + ")");
@ -723,7 +797,7 @@ public class SmtpTransport extends Transport {
try {
executeSimpleCommand("AUTH PLAIN " + data, true);
} catch (NegativeSmtpReplyException exception) {
if (exception.getReplyCode() == 535) {
if (exception.getReplyCode() == SMTP_AUTHENTICATION_FAILURE_ERROR_CODE) {
// Authentication credentials invalid
throw new AuthenticationFailedException("AUTH PLAIN failed ("
+ exception.getMessage() + ")");
@ -747,7 +821,7 @@ public class SmtpTransport extends Transport {
try {
executeSimpleCommand(b64CRAMString, true);
} catch (NegativeSmtpReplyException exception) {
if (exception.getReplyCode() == 535) {
if (exception.getReplyCode() == SMTP_AUTHENTICATION_FAILURE_ERROR_CODE) {
// Authentication credentials invalid
throw new AuthenticationFailedException(exception.getMessage(), exception);
} else {
@ -756,6 +830,70 @@ public class SmtpTransport extends Transport {
}
}
private void saslXoauth2(String username) throws MessagingException, IOException {
retryXoauthWithNewToken = true;
try {
attemptXoauth2(username);
} catch (NegativeSmtpReplyException negativeResponse) {
if (negativeResponse.getReplyCode() != SMTP_AUTHENTICATION_FAILURE_ERROR_CODE) {
throw negativeResponse;
}
oauthTokenProvider.invalidateToken(username);
if (!retryXoauthWithNewToken) {
handlePermanentFailure(negativeResponse);
} else {
handleTemporaryFailure(username, negativeResponse);
}
}
}
private void handlePermanentFailure(NegativeSmtpReplyException negativeResponse) throws AuthenticationFailedException {
throw new AuthenticationFailedException(negativeResponse.getMessage(), negativeResponse);
}
private void handleTemporaryFailure(String username, NegativeSmtpReplyException negativeResponseFromOldToken)
throws IOException, MessagingException {
// Token was invalid
//We could avoid this double check if we had a reasonable chance of knowing
//if a token was invalid before use (e.g. due to expiry). But we don't
//This is the intended behaviour per AccountManager
Log.v(LOG_TAG, "Authentication exception, re-trying with new token", negativeResponseFromOldToken);
try {
attemptXoauth2(username);
} catch (NegativeSmtpReplyException negativeResponseFromNewToken) {
if (negativeResponseFromNewToken.getReplyCode() != SMTP_AUTHENTICATION_FAILURE_ERROR_CODE) {
throw negativeResponseFromNewToken;
}
//Okay, we failed on a new token.
//Invalidate the token anyway but assume it's permanent.
Log.v(LOG_TAG, "Authentication exception for new token, permanent error assumed",
negativeResponseFromNewToken);
oauthTokenProvider.invalidateToken(username);
handlePermanentFailure(negativeResponseFromNewToken);
}
}
private void attemptXoauth2(String username) throws MessagingException, IOException {
CommandResponse response = executeSimpleCommandWithResponse("AUTH XOAUTH2 " +
Authentication.computeXoauth(username,
oauthTokenProvider.getToken(username, OAuth2TokenProvider.OAUTH2_TIMEOUT)),
true);
if(response.replyCode == SMTP_CONTINUE_REQUEST) {
retryXoauthWithNewToken = XOAuth2ChallengeParser.shouldRetry(
response.message, mHost);
//Per Google spec, respond to challenge with empty response
executeSimpleCommandWithResponse("", false);
}
}
private void saslAuthExternal(String username) throws MessagingException, IOException {
executeSimpleCommand(
String.format("AUTH EXTERNAL %s",

View file

@ -0,0 +1,15 @@
package com.fsck.k9.mail;
import com.fsck.k9.mail.filter.Base64;
public class XOAuth2ChallengeParserTest {
public static final String STATUS_400_RESPONSE = Base64.encode(
"{\"status\":\"400\",\"schemes\":\"bearer mac\",\"scope\":\"https://mail.google.com/\"}");
public static final String STATUS_401_RESPONSE = Base64.encode(
"{\"status\":\"401\",\"schemes\":\"bearer mac\",\"scope\":\"https://mail.google.com/\"}");
public static final String MISSING_STATUS_RESPONSE = Base64.encode(
"{\"schemes\":\"bearer mac\",\"scope\":\"https://mail.google.com/\"}");
public static final String INVALID_RESPONSE = Base64.encode(
"{\"status\":\"401\",\"schemes\":\"bearer mac\",\"scope\":\"https://mail.google.com/\"");
}

View file

@ -13,6 +13,9 @@ import com.fsck.k9.mail.CertificateValidationException.Reason;
import com.fsck.k9.mail.ConnectionSecurity;
import com.fsck.k9.mail.K9MailLib;
import com.fsck.k9.mail.MessagingException;
import com.fsck.k9.mail.XOAuth2ChallengeParserTest;
import com.fsck.k9.mail.filter.Base64;
import com.fsck.k9.mail.oauth.OAuth2TokenProvider;
import com.fsck.k9.mail.ssl.TrustedSocketFactory;
import com.fsck.k9.mail.store.imap.mockserver.MockImapServer;
import com.fsck.k9.mail.helpers.TestTrustedSocketFactory;
@ -21,6 +24,7 @@ import okio.ByteString;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.InOrder;
import org.robolectric.RobolectricTestRunner;
import org.robolectric.annotation.Config;
import org.robolectric.shadows.ShadowLog;
@ -31,7 +35,9 @@ import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertThat;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;
import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
@RunWith(RobolectricTestRunner.class)
@ -45,14 +51,17 @@ public class ImapConnectionTest {
private static final int SOCKET_READ_TIMEOUT = 10000;
private TrustedSocketFactory socketFactory;
private ConnectivityManager connectivityManager;
private OAuth2TokenProvider oAuth2TokenProvider;
private SimpleImapSettings settings;
@Before
public void setUp() throws Exception {
connectivityManager = mock(ConnectivityManager.class);
oAuth2TokenProvider = mock(OAuth2TokenProvider.class);
socketFactory = new TestTrustedSocketFactory();
settings = new SimpleImapSettings();
@ -182,7 +191,7 @@ public class ImapConnectionTest {
fail("Expected exception");
} catch (AuthenticationFailedException e) {
//FIXME: improve exception message
assertThat(e.getMessage(), containsString("response: #3# [NO, Go away]"));
assertThat(e.getMessage(), containsString("Go away"));
}
server.verifyConnectionClosed();
@ -239,7 +248,7 @@ public class ImapConnectionTest {
fail("Expected exception");
} catch (AuthenticationFailedException e) {
//FIXME: improve exception message
assertThat(e.getMessage(), containsString("response: #2# [NO, Who are you?]"));
assertThat(e.getMessage(), containsString("Who are you?"));
}
server.verifyConnectionClosed();
@ -264,6 +273,192 @@ public class ImapConnectionTest {
server.verifyInteractionCompleted();
}
@Test
public void open_authXoauthWithSaslIr() throws Exception {
settings.setAuthType(AuthType.XOAUTH2);
when(oAuth2TokenProvider.getToken("user", OAuth2TokenProvider.OAUTH2_TIMEOUT))
.thenReturn("token");
MockImapServer server = new MockImapServer();
preAuthenticationDialog(server, "SASL-IR AUTH=XOAUTH AUTH=XOAUTH2");
server.expect("2 AUTHENTICATE XOAUTH2 "+ByteString.encodeUtf8(
"user=user\001auth=Bearer token\001\001"
).base64());
server.output("2 OK Success");
simplePostAuthenticationDialog(server);
ImapConnection imapConnection = startServerAndCreateImapConnection(server);
imapConnection.open();
server.verifyConnectionStillOpen();
server.verifyInteractionCompleted();
}
@Test
public void open_authXoauthWithSaslIrThrowsExeptionOn401Response() throws Exception {
settings.setAuthType(AuthType.XOAUTH2);
when(oAuth2TokenProvider.getToken("user", OAuth2TokenProvider.OAUTH2_TIMEOUT))
.thenReturn("token").thenReturn("token2");
MockImapServer server = new MockImapServer();
preAuthenticationDialog(server, "SASL-IR AUTH=XOAUTH AUTH=XOAUTH2");
server.expect("2 AUTHENTICATE XOAUTH2 "+ByteString.encodeUtf8(
"user=user\001auth=Bearer token\001\001"
).base64());
server.output("+ "+ XOAuth2ChallengeParserTest.STATUS_401_RESPONSE);
server.expect("");
server.output("2 NO SASL authentication failed");
ImapConnection imapConnection = startServerAndCreateImapConnection(server);
try {
imapConnection.open();
fail();
} catch (AuthenticationFailedException e) {
assertEquals(
"Command: AUTHENTICATE XOAUTH2; response: #2# [NO, SASL authentication failed]",
e.getMessage());
}
}
@Test
public void open_authXoauthWithSaslIrInvalidatesAndRetriesNewTokenOn400Response() throws Exception {
settings.setAuthType(AuthType.XOAUTH2);
when(oAuth2TokenProvider.getToken("user", OAuth2TokenProvider.OAUTH2_TIMEOUT))
.thenReturn("token").thenReturn("token2");
MockImapServer server = new MockImapServer();
preAuthenticationDialog(server, "SASL-IR AUTH=XOAUTH AUTH=XOAUTH2");
server.expect("2 AUTHENTICATE XOAUTH2 "+ByteString.encodeUtf8(
"user=user\001auth=Bearer token\001\001"
).base64());
server.output("+ "+XOAuth2ChallengeParserTest.STATUS_400_RESPONSE);
server.expect("");
server.output("2 NO SASL authentication failed");
server.expect("3 AUTHENTICATE XOAUTH2 "+ByteString.encodeUtf8(
"user=user\001auth=Bearer token2\001\001"
).base64());
server.output("3 OK Success");
simplePostAuthenticationDialog(server, "4");
ImapConnection imapConnection = startServerAndCreateImapConnection(server);
imapConnection.open();
server.verifyConnectionStillOpen();
server.verifyInteractionCompleted();
InOrder inOrder = inOrder(oAuth2TokenProvider);
inOrder.verify(oAuth2TokenProvider).getToken("user", OAuth2TokenProvider.OAUTH2_TIMEOUT);
inOrder.verify(oAuth2TokenProvider).invalidateToken("user");
inOrder.verify(oAuth2TokenProvider).getToken("user", OAuth2TokenProvider.OAUTH2_TIMEOUT);
}
@Test
public void open_authXoauthWithSaslIrInvalidatesAndRetriesNewTokenOnInvalidJsonResponse() throws Exception {
settings.setAuthType(AuthType.XOAUTH2);
when(oAuth2TokenProvider.getToken("user", OAuth2TokenProvider.OAUTH2_TIMEOUT))
.thenReturn("token").thenReturn("token2");
MockImapServer server = new MockImapServer();
preAuthenticationDialog(server, "SASL-IR AUTH=XOAUTH AUTH=XOAUTH2");
server.expect("2 AUTHENTICATE XOAUTH2 "+ByteString.encodeUtf8(
"user=user\001auth=Bearer token\001\001"
).base64());
server.output("+ "+XOAuth2ChallengeParserTest.INVALID_RESPONSE);
server.expect("");
server.output("2 NO SASL authentication failed");
server.expect("3 AUTHENTICATE XOAUTH2 "+ByteString.encodeUtf8(
"user=user\001auth=Bearer token2\001\001"
).base64());
server.output("3 OK Success");
simplePostAuthenticationDialog(server, "4");
ImapConnection imapConnection = startServerAndCreateImapConnection(server);
imapConnection.open();
server.verifyConnectionStillOpen();
server.verifyInteractionCompleted();
InOrder inOrder = inOrder(oAuth2TokenProvider);
inOrder.verify(oAuth2TokenProvider).getToken("user", OAuth2TokenProvider.OAUTH2_TIMEOUT);
inOrder.verify(oAuth2TokenProvider).invalidateToken("user");
inOrder.verify(oAuth2TokenProvider).getToken("user", OAuth2TokenProvider.OAUTH2_TIMEOUT);
}
@Test
public void open_authXoauthWithSaslIrInvalidatesAndRetriesNewTokenOnMissingStatusJsonResponse() throws Exception {
settings.setAuthType(AuthType.XOAUTH2);
when(oAuth2TokenProvider.getToken("user", OAuth2TokenProvider.OAUTH2_TIMEOUT))
.thenReturn("token").thenReturn("token2");
MockImapServer server = new MockImapServer();
preAuthenticationDialog(server, "SASL-IR AUTH=XOAUTH AUTH=XOAUTH2");
server.expect("2 AUTHENTICATE XOAUTH2 "+ByteString.encodeUtf8(
"user=user\001auth=Bearer token\001\001"
).base64());
server.output("+ "+XOAuth2ChallengeParserTest.MISSING_STATUS_RESPONSE);
server.expect("");
server.output("2 NO SASL authentication failed");
server.expect("3 AUTHENTICATE XOAUTH2 "+ByteString.encodeUtf8(
"user=user\001auth=Bearer token2\001\001"
).base64());
server.output("3 OK Success");
simplePostAuthenticationDialog(server, "4");
ImapConnection imapConnection = startServerAndCreateImapConnection(server);
imapConnection.open();
server.verifyConnectionStillOpen();
server.verifyInteractionCompleted();
InOrder inOrder = inOrder(oAuth2TokenProvider);
inOrder.verify(oAuth2TokenProvider).getToken("user", OAuth2TokenProvider.OAUTH2_TIMEOUT);
inOrder.verify(oAuth2TokenProvider).invalidateToken("user");
inOrder.verify(oAuth2TokenProvider).getToken("user", OAuth2TokenProvider.OAUTH2_TIMEOUT);
}
@Test
public void open_authXoauthWithSaslIrWithOldTokenThrowsExceptionIfRetryFails() throws Exception {
settings.setAuthType(AuthType.XOAUTH2);
when(oAuth2TokenProvider.getToken("user", OAuth2TokenProvider.OAUTH2_TIMEOUT))
.thenReturn("token").thenReturn("token2");
MockImapServer server = new MockImapServer();
preAuthenticationDialog(server, "SASL-IR AUTH=XOAUTH AUTH=XOAUTH2");
server.expect("2 AUTHENTICATE XOAUTH2 "+ByteString.encodeUtf8(
"user=user\001auth=Bearer token\001\001"
).base64());
server.output("+ r3j3krj3irj3oir3ojo");
server.expect("");
server.output("2 NO SASL authentication failed");
server.expect("3 AUTHENTICATE XOAUTH2 "+ByteString.encodeUtf8(
"user=user\001auth=Bearer token2\001\001"
).base64());
server.output("+ 433ba3a3a");
server.expect("");
server.output("3 NO SASL authentication failed");
simplePostAuthenticationDialog(server);
ImapConnection imapConnection = startServerAndCreateImapConnection(server);
try {
imapConnection.open();
fail();
} catch (AuthenticationFailedException e) {
assertEquals(
"Command: AUTHENTICATE XOAUTH2; response: #3# [NO, SASL authentication failed]",
e.getMessage());
}
}
@Test
public void open_authXoauthWithSaslIrParsesCapabilities() throws Exception {
settings.setAuthType(AuthType.XOAUTH2);
when(oAuth2TokenProvider.getToken("user", OAuth2TokenProvider.OAUTH2_TIMEOUT))
.thenReturn("token");
MockImapServer server = new MockImapServer();
preAuthenticationDialog(server, "SASL-IR AUTH=XOAUTH AUTH=XOAUTH2");
server.expect("2 AUTHENTICATE XOAUTH2 "+ByteString.encodeUtf8(
"user=user\001auth=Bearer token\001\001"
).base64());
server.output("2 OK [CAPABILITY IMAP4REV1 IDLE XM-GM-EXT-1]");
simplePostAuthenticationDialog(server);
ImapConnection imapConnection = startServerAndCreateImapConnection(server);
imapConnection.open();
server.verifyConnectionStillOpen();
server.verifyInteractionCompleted();
assertTrue(imapConnection.hasCapability("XM-GM-EXT-1"));
}
@Test
public void open_authExternal() throws Exception {
settings.setAuthType(AuthType.EXTERNAL);
@ -286,7 +481,7 @@ public class ImapConnectionTest {
MockImapServer server = new MockImapServer();
preAuthenticationDialog(server, "AUTH=EXTERNAL");
server.expect("2 AUTHENTICATE EXTERNAL " + ByteString.encodeUtf8(USERNAME).base64());
server.output("2 NO");
server.output("2 NO Bad certificate");
ImapConnection imapConnection = startServerAndCreateImapConnection(server);
try {
@ -294,7 +489,7 @@ public class ImapConnectionTest {
fail("Expected exception");
} catch (CertificateValidationException e) {
//FIXME: improve exception message
assertThat(e.getMessage(), containsString("response: #2# [NO]"));
assertThat(e.getMessage(), containsString("Bad certificate"));
}
server.verifyConnectionClosed();
@ -338,13 +533,13 @@ public class ImapConnectionTest {
public void open_withConnectionError_shouldThrow() throws Exception {
settings.setHost("127.1.2.3");
settings.setPort(143);
ImapConnection imapConnection = createImapConnection(settings, socketFactory, connectivityManager);
ImapConnection imapConnection = createImapConnection(
settings, socketFactory, connectivityManager, oAuth2TokenProvider);
try {
imapConnection.open();
fail("Expected exception");
} catch (MessagingException e) {
//FIXME: Throw ConnectException
assertEquals("Cannot connect to host", e.getMessage());
assertTrue(e.getCause() instanceof IOException);
}
@ -354,7 +549,8 @@ public class ImapConnectionTest {
public void open_withInvalidHostname_shouldThrow() throws Exception {
settings.setHost("host name");
settings.setPort(143);
ImapConnection imapConnection = createImapConnection(settings, socketFactory, connectivityManager);
ImapConnection imapConnection = createImapConnection(
settings, socketFactory, connectivityManager, oAuth2TokenProvider);
try {
imapConnection.open();
@ -423,7 +619,7 @@ public class ImapConnectionTest {
imapConnection.open();
fail("Expected exception");
} catch (NegativeImapResponseException e) {
assertThat(e.getMessage(), containsString("response: #2# [NO]"));
assertEquals(e.getMessage(), "Command: STARTTLS; response: #2# [NO]");
}
server.verifyConnectionClosed();
@ -522,7 +718,8 @@ public class ImapConnectionTest {
@Test
public void isConnected_withoutPreviousOpen_shouldReturnFalse() throws Exception {
ImapConnection imapConnection = createImapConnection(settings, socketFactory, connectivityManager);
ImapConnection imapConnection = createImapConnection(
settings, socketFactory, connectivityManager, oAuth2TokenProvider);
boolean result = imapConnection.isConnected();
@ -558,7 +755,8 @@ public class ImapConnectionTest {
@Test
public void close_withoutOpen_shouldNotThrow() throws Exception {
ImapConnection imapConnection = createImapConnection(settings, socketFactory, connectivityManager);
ImapConnection imapConnection = createImapConnection(
settings, socketFactory, connectivityManager, oAuth2TokenProvider);
imapConnection.close();
}
@ -620,16 +818,16 @@ public class ImapConnectionTest {
}
private ImapConnection createImapConnection(ImapSettings settings, TrustedSocketFactory socketFactory,
ConnectivityManager connectivityManager) {
return new ImapConnection(settings, socketFactory, connectivityManager, SOCKET_CONNECT_TIMEOUT,
SOCKET_READ_TIMEOUT);
ConnectivityManager connectivityManager, OAuth2TokenProvider oAuth2TokenProvider) {
return new ImapConnection(settings, socketFactory, connectivityManager, oAuth2TokenProvider,
SOCKET_CONNECT_TIMEOUT, SOCKET_READ_TIMEOUT);
}
private ImapConnection startServerAndCreateImapConnection(MockImapServer server) throws IOException {
server.start();
settings.setHost(server.getHost());
settings.setPort(server.getPort());
return createImapConnection(settings, socketFactory, connectivityManager);
return createImapConnection(settings, socketFactory, connectivityManager, oAuth2TokenProvider);
}
private ImapConnection simpleOpen(MockImapServer server) throws Exception {

View file

@ -84,7 +84,8 @@ public class ImapResponseParserTest {
@Test
public void testReadStatusResponseWithOKResponse() throws Exception {
ImapResponseParser parser = createParser("* COMMAND BAR\tBAZ\r\nTAG OK COMMAND completed\r\n");
ImapResponseParser parser = createParser("* COMMAND BAR\tBAZ\r\n" +
"TAG OK COMMAND completed\r\n");
List<ImapResponse> responses = parser.readStatusResponse("TAG", null, null, null);
@ -93,6 +94,19 @@ public class ImapResponseParserTest {
assertEquals(asList("OK", "COMMAND completed"), responses.get(1));
}
@Test
public void testReadStatusResponseUntaggedHandlerGetsUntaggedOnly() throws Exception {
ImapResponseParser parser = createParser(
"* UNTAGGED\r\n" +
"A2 OK COMMAND completed\r\n");
TestUntaggedHandler untaggedHandler = new TestUntaggedHandler();
parser.readStatusResponse("A2", null, null, untaggedHandler);
assertEquals(1, untaggedHandler.responses.size());
assertEquals(asList("UNTAGGED"), untaggedHandler.responses.get(0));
}
@Test
public void testReadStatusResponseSkippingWrongTag() throws Exception {
ImapResponseParser parser = createParser("* UNTAGGED\r\n" +
@ -113,6 +127,23 @@ public class ImapResponseParserTest {
assertEquals(responses.get(1), untaggedHandler.responses.get(2));
}
@Test
public void testReadStatusResponseUntaggedHandlerStillCalledOnNegativeReply() throws Exception {
ImapResponseParser parser = createParser(
"+ text\r\n" +
"A2 NO Bad response\r\n");
TestUntaggedHandler untaggedHandler = new TestUntaggedHandler();
try {
List<ImapResponse> responses = parser.readStatusResponse("A2", null, null, untaggedHandler);
} catch (NegativeImapResponseException e) {
}
assertEquals(1, untaggedHandler.responses.size());
assertEquals(asList("text"), untaggedHandler.responses.get(0));
}
@Test(expected = NegativeImapResponseException.class)
public void testReadStatusResponseWithErrorResponse() throws Exception {
ImapResponseParser parser = createParser("* COMMAND BAR BAZ\r\nTAG ERROR COMMAND errored\r\n");

View file

@ -14,6 +14,7 @@ import android.net.ConnectivityManager;
import com.fsck.k9.mail.Folder;
import com.fsck.k9.mail.MessagingException;
import com.fsck.k9.mail.oauth.OAuth2TokenProvider;
import com.fsck.k9.mail.ssl.TrustedSocketFactory;
import com.fsck.k9.mail.store.StoreConfig;
import org.junit.Before;
@ -44,8 +45,9 @@ public class ImapStoreTest {
storeConfig = createStoreConfig();
TrustedSocketFactory trustedSocketFactory = mock(TrustedSocketFactory.class);
ConnectivityManager connectivityManager = mock(ConnectivityManager.class);
OAuth2TokenProvider oauth2TokenProvider = mock(OAuth2TokenProvider.class);
imapStore = new TestImapStore(storeConfig, trustedSocketFactory, connectivityManager);
imapStore = new TestImapStore(storeConfig, trustedSocketFactory, connectivityManager, oauth2TokenProvider);
}
@Test
@ -313,8 +315,8 @@ public class ImapStoreTest {
private Deque<ImapConnection> imapConnections = new ArrayDeque<>();
public TestImapStore(StoreConfig storeConfig, TrustedSocketFactory trustedSocketFactory,
ConnectivityManager connectivityManager) throws MessagingException {
super(storeConfig, trustedSocketFactory, connectivityManager);
ConnectivityManager connectivityManager, OAuth2TokenProvider oauth2TokenProvider) throws MessagingException {
super(storeConfig, trustedSocketFactory, connectivityManager, oauth2TokenProvider);
}
@Override

View file

@ -16,6 +16,88 @@ import static org.junit.Assert.assertNull;
public class ImapStoreUriTest {
@Test
public void testDecodeStoreUriImapNoAuth() {
String uri = "imap://user:pass@server/";
ServerSettings settings = RemoteStore.decodeStoreUri(uri);
assertEquals(AuthType.PLAIN, settings.authenticationType);
assertEquals("user", settings.username);
assertEquals("pass", settings.password);
assertEquals("server", settings.host);
}
@Test
public void testDecodeStoreUriImapNoPassword() {
String uri = "imap://user:@server/";
ServerSettings settings = RemoteStore.decodeStoreUri(uri);
assertEquals(AuthType.PLAIN, settings.authenticationType);
assertEquals("user", settings.username);
assertEquals(null, settings.password);
assertEquals("server", settings.host);
}
@Test
public void testDecodeStoreUriImapPlainNoPassword() {
String uri = "imap://PLAIN:user:@server/";
ServerSettings settings = RemoteStore.decodeStoreUri(uri);
assertEquals(AuthType.PLAIN, settings.authenticationType);
assertEquals("user", settings.username);
assertEquals(null, settings.password);
assertEquals("server", settings.host);
}
@Test
public void testDecodeStoreUriImapExternalAuth() {
String uri = "imap://EXTERNAL:user:clientCertAlias@server/";
ServerSettings settings = RemoteStore.decodeStoreUri(uri);
assertEquals(AuthType.EXTERNAL, settings.authenticationType);
assertEquals("user", settings.username);
assertEquals(null, settings.password);
assertEquals("clientCertAlias", settings.clientCertificateAlias);
assertEquals("server", settings.host);
}
@Test
public void testDecodeStoreUriImapXOAuth2() {
String uri = "imap://XOAUTH2:user:@server/";
ServerSettings settings = RemoteStore.decodeStoreUri(uri);
assertEquals(AuthType.XOAUTH2, settings.authenticationType);
assertEquals("user", settings.username);
assertEquals(null, settings.password);
assertEquals(null, settings.clientCertificateAlias);
assertEquals("server", settings.host);
}
@Test
public void testDecodeStoreUriImapSSL() {
String uri = "imap+tls+://PLAIN:user:pass@server/";
ServerSettings settings = RemoteStore.decodeStoreUri(uri);
assertEquals(ConnectionSecurity.STARTTLS_REQUIRED, settings.connectionSecurity);
assertEquals(AuthType.PLAIN, settings.authenticationType);
assertEquals("user", settings.username);
assertEquals("pass", settings.password);
assertEquals("server", settings.host);
}
@Test
public void testDecodeStoreUriImapTLS() {
String uri = "imap+ssl+://PLAIN:user:pass@server/";
ServerSettings settings = RemoteStore.decodeStoreUri(uri);
assertEquals(ConnectionSecurity.SSL_TLS_REQUIRED, settings.connectionSecurity);
assertEquals(AuthType.PLAIN, settings.authenticationType);
assertEquals("user", settings.username);
assertEquals("pass", settings.password);
assertEquals("server", settings.host);
}
@Test
public void testDecodeStoreUriImapAllExtras() {
String uri = "imap://PLAIN:user:pass@server:143/0%7CcustomPathPrefix";

View file

@ -5,28 +5,38 @@ import java.io.IOException;
import java.net.InetAddress;
import com.fsck.k9.mail.AuthType;
import com.fsck.k9.mail.AuthenticationFailedException;
import com.fsck.k9.mail.CertificateValidationException;
import com.fsck.k9.mail.ConnectionSecurity;
import com.fsck.k9.mail.Message;
import com.fsck.k9.mail.MessagingException;
import com.fsck.k9.mail.ServerSettings;
import com.fsck.k9.mail.ServerSettings.Type;
import com.fsck.k9.mail.XOAuth2ChallengeParserTest;
import com.fsck.k9.mail.filter.Base64;
import com.fsck.k9.mail.helpers.TestMessageBuilder;
import com.fsck.k9.mail.helpers.TestTrustedSocketFactory;
import com.fsck.k9.mail.internet.MimeMessage;
import com.fsck.k9.mail.oauth.OAuth2TokenProvider;
import com.fsck.k9.mail.oauth.XOAuth2ChallengeParser;
import com.fsck.k9.mail.ssl.TrustedSocketFactory;
import com.fsck.k9.mail.store.StoreConfig;
import com.fsck.k9.mail.transport.mockServer.MockSmtpServer;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.InOrder;
import org.robolectric.RobolectricTestRunner;
import org.robolectric.annotation.Config;
import static junit.framework.Assert.assertTrue;
import static junit.framework.Assert.fail;
import static org.junit.Assert.assertEquals;
import static org.mockito.Matchers.any;
import static org.mockito.Matchers.anyInt;
import static org.mockito.Matchers.anyString;
import static org.mockito.Matchers.eq;
import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
@ -41,25 +51,29 @@ public class SmtpTransportTest {
private TrustedSocketFactory socketFactory;
private OAuth2TokenProvider oAuth2TokenProvider;
@Before
public void before() {
public void before() throws AuthenticationFailedException {
socketFactory = new TestTrustedSocketFactory();
oAuth2TokenProvider = mock(OAuth2TokenProvider.class);
when(oAuth2TokenProvider.getToken(eq(USERNAME), anyInt()))
.thenReturn("oldToken").thenReturn("newToken");
}
@Test
public void SmtpTransport_withValidTransportUri() throws Exception {
StoreConfig storeConfig = createStoreConfigWithTransportUri("smtp://user:password:CRAM_MD5@server:123456");
new SmtpTransport(storeConfig, socketFactory);
new SmtpTransport(storeConfig, socketFactory, oAuth2TokenProvider);
}
@Test(expected = MessagingException.class)
public void SmtpTransport_withInvalidTransportUri_shouldThrow() throws Exception {
StoreConfig storeConfig = createStoreConfigWithTransportUri("smpt://");
new SmtpTransport(storeConfig, socketFactory);
new SmtpTransport(storeConfig, socketFactory, oAuth2TokenProvider);
}
@Test
@ -174,6 +188,205 @@ public class SmtpTransportTest {
server.verifyInteractionCompleted();
}
@Test
public void open_withXoauth2Extension() throws Exception {
MockSmtpServer server = new MockSmtpServer();
server.output("220 localhost Simple Mail Transfer Service Ready");
server.expect("EHLO localhost");
server.output("250-localhost Hello client.localhost");
server.output("250 AUTH XOAUTH2");
server.expect("AUTH XOAUTH2 dXNlcj11c2VyAWF1dGg9QmVhcmVyIG9sZFRva2VuAQE=");
server.output("235 2.7.0 Authentication successful");
SmtpTransport transport = startServerAndCreateSmtpTransport(server, AuthType.XOAUTH2, ConnectionSecurity.NONE);
transport.open();
server.verifyConnectionStillOpen();
server.verifyInteractionCompleted();
}
@Test
public void open_withXoauth2Extension_shouldThrowOn401Response() throws Exception {
MockSmtpServer server = new MockSmtpServer();
server.output("220 localhost Simple Mail Transfer Service Ready");
server.expect("EHLO localhost");
server.output("250-localhost Hello client.localhost");
server.output("250 AUTH XOAUTH2");
server.expect("AUTH XOAUTH2 dXNlcj11c2VyAWF1dGg9QmVhcmVyIG9sZFRva2VuAQE=");
server.output("334 "+ XOAuth2ChallengeParserTest.STATUS_401_RESPONSE);
server.expect("");
server.output("535-5.7.1 Username and Password not accepted. Learn more at");
server.output("535 5.7.1 http://support.google.com/mail/bin/answer.py?answer=14257 hx9sm5317360pbc.68");
SmtpTransport transport = startServerAndCreateSmtpTransport(server, AuthType.XOAUTH2, ConnectionSecurity.NONE);
try {
transport.open();
fail("Exception expected");
} catch (AuthenticationFailedException e) {
assertEquals(
"Negative SMTP reply: 535 5.7.1 http://support.google.com/mail/bin/answer.py?answer=14257 hx9sm5317360pbc.68",
e.getMessage());
}
InOrder inOrder = inOrder(oAuth2TokenProvider);
inOrder.verify(oAuth2TokenProvider).getToken(eq(USERNAME), anyInt());
inOrder.verify(oAuth2TokenProvider).invalidateToken(USERNAME);
server.verifyConnectionStillOpen();
server.verifyInteractionCompleted();
}
@Test
public void open_withXoauth2Extension_shouldInvalidateAndRetryOn400Response() throws Exception {
MockSmtpServer server = new MockSmtpServer();
server.output("220 localhost Simple Mail Transfer Service Ready");
server.expect("EHLO localhost");
server.output("250-localhost Hello client.localhost");
server.output("250 AUTH XOAUTH2");
server.expect("AUTH XOAUTH2 dXNlcj11c2VyAWF1dGg9QmVhcmVyIG9sZFRva2VuAQE=");
server.output("334 "+ XOAuth2ChallengeParserTest.STATUS_400_RESPONSE);
server.expect("");
server.output("535-5.7.1 Username and Password not accepted. Learn more at");
server.output("535 5.7.1 http://support.google.com/mail/bin/answer.py?answer=14257 hx9sm5317360pbc.68");
server.expect("AUTH XOAUTH2 dXNlcj11c2VyAWF1dGg9QmVhcmVyIG5ld1Rva2VuAQE=");
server.output("235 2.7.0 Authentication successful");
SmtpTransport transport = startServerAndCreateSmtpTransport(server, AuthType.XOAUTH2, ConnectionSecurity.NONE);
transport.open();
InOrder inOrder = inOrder(oAuth2TokenProvider);
inOrder.verify(oAuth2TokenProvider).getToken(eq(USERNAME), anyInt());
inOrder.verify(oAuth2TokenProvider).invalidateToken(USERNAME);
inOrder.verify(oAuth2TokenProvider).getToken(eq(USERNAME), anyInt());
server.verifyConnectionStillOpen();
server.verifyInteractionCompleted();
}
@Test
public void open_withXoauth2Extension_shouldInvalidateAndRetryOnInvalidJsonResponse() throws Exception {
MockSmtpServer server = new MockSmtpServer();
server.output("220 localhost Simple Mail Transfer Service Ready");
server.expect("EHLO localhost");
server.output("250-localhost Hello client.localhost");
server.output("250 AUTH XOAUTH2");
server.expect("AUTH XOAUTH2 dXNlcj11c2VyAWF1dGg9QmVhcmVyIG9sZFRva2VuAQE=");
server.output("334 "+ XOAuth2ChallengeParserTest.INVALID_RESPONSE);
server.expect("");
server.output("535-5.7.1 Username and Password not accepted. Learn more at");
server.output("535 5.7.1 http://support.google.com/mail/bin/answer.py?answer=14257 hx9sm5317360pbc.68");
server.expect("AUTH XOAUTH2 dXNlcj11c2VyAWF1dGg9QmVhcmVyIG5ld1Rva2VuAQE=");
server.output("235 2.7.0 Authentication successful");
SmtpTransport transport = startServerAndCreateSmtpTransport(server, AuthType.XOAUTH2, ConnectionSecurity.NONE);
transport.open();
InOrder inOrder = inOrder(oAuth2TokenProvider);
inOrder.verify(oAuth2TokenProvider).getToken(eq(USERNAME), anyInt());
inOrder.verify(oAuth2TokenProvider).invalidateToken(USERNAME);
inOrder.verify(oAuth2TokenProvider).getToken(eq(USERNAME), anyInt());
server.verifyConnectionStillOpen();
server.verifyInteractionCompleted();
}
@Test
public void open_withXoauth2Extension_shouldInvalidateAndRetryOnMissingStatusJsonResponse() throws Exception {
MockSmtpServer server = new MockSmtpServer();
server.output("220 localhost Simple Mail Transfer Service Ready");
server.expect("EHLO localhost");
server.output("250-localhost Hello client.localhost");
server.output("250 AUTH XOAUTH2");
server.expect("AUTH XOAUTH2 dXNlcj11c2VyAWF1dGg9QmVhcmVyIG9sZFRva2VuAQE=");
server.output("334 "+ XOAuth2ChallengeParserTest.MISSING_STATUS_RESPONSE);
server.expect("");
server.output("535-5.7.1 Username and Password not accepted. Learn more at");
server.output("535 5.7.1 http://support.google.com/mail/bin/answer.py?answer=14257 hx9sm5317360pbc.68");
server.expect("AUTH XOAUTH2 dXNlcj11c2VyAWF1dGg9QmVhcmVyIG5ld1Rva2VuAQE=");
server.output("235 2.7.0 Authentication successful");
SmtpTransport transport = startServerAndCreateSmtpTransport(server, AuthType.XOAUTH2, ConnectionSecurity.NONE);
transport.open();
InOrder inOrder = inOrder(oAuth2TokenProvider);
inOrder.verify(oAuth2TokenProvider).getToken(eq(USERNAME), anyInt());
inOrder.verify(oAuth2TokenProvider).invalidateToken(USERNAME);
inOrder.verify(oAuth2TokenProvider).getToken(eq(USERNAME), anyInt());
server.verifyConnectionStillOpen();
server.verifyInteractionCompleted();
}
@Test
public void open_withXoauth2Extension_shouldThrowOnMultipleFailure() throws Exception {
MockSmtpServer server = new MockSmtpServer();
server.output("220 localhost Simple Mail Transfer Service Ready");
server.expect("EHLO localhost");
server.output("250-localhost Hello client.localhost");
server.output("250 AUTH XOAUTH2");
server.expect("AUTH XOAUTH2 dXNlcj11c2VyAWF1dGg9QmVhcmVyIG9sZFRva2VuAQE=");
server.output("334 " + XOAuth2ChallengeParserTest.STATUS_400_RESPONSE);
server.expect("");
server.output("535-5.7.1 Username and Password not accepted. Learn more at");
server.output("535 5.7.1 http://support.google.com/mail/bin/answer.py?answer=14257 hx9sm5317360pbc.68");
server.expect("AUTH XOAUTH2 dXNlcj11c2VyAWF1dGg9QmVhcmVyIG5ld1Rva2VuAQE=");
server.output("334 " + XOAuth2ChallengeParserTest.STATUS_400_RESPONSE);
server.expect("");
server.output("535-5.7.1 Username and Password not accepted. Learn more at");
server.output("535 5.7.1 http://support.google.com/mail/bin/answer.py?answer=14257 hx9sm5317360pbc.68");
SmtpTransport transport = startServerAndCreateSmtpTransport(server, AuthType.XOAUTH2, ConnectionSecurity.NONE);
try {
transport.open();
fail("Exception expected");
} catch (AuthenticationFailedException e) {
assertEquals(
"Negative SMTP reply: 535 5.7.1 http://support.google.com/mail/bin/answer.py?answer=14257 hx9sm5317360pbc.68",
e.getMessage());
}
server.verifyConnectionStillOpen();
server.verifyInteractionCompleted();
}
@Test
public void open_withXoauth2Extension_shouldThrowOnFailure_fetchingToken() throws Exception {
MockSmtpServer server = new MockSmtpServer();
server.output("220 localhost Simple Mail Transfer Service Ready");
server.expect("EHLO localhost");
server.output("250-localhost Hello client.localhost");
server.output("250 AUTH XOAUTH2");
when(oAuth2TokenProvider.getToken(anyString(), anyInt())).thenThrow(new AuthenticationFailedException("Failed to fetch token"));
SmtpTransport transport = startServerAndCreateSmtpTransport(server, AuthType.XOAUTH2, ConnectionSecurity.NONE);
try {
transport.open();
fail("Exception expected");
} catch (AuthenticationFailedException e) {
assertEquals("Failed to fetch token", e.getMessage());
}
server.verifyConnectionStillOpen();
server.verifyInteractionCompleted();
}
@Test
public void open_withoutXoauth2Extension_shouldThrow() throws Exception {
MockSmtpServer server = new MockSmtpServer();
server.output("220 localhost Simple Mail Transfer Service Ready");
server.expect("EHLO localhost");
server.output("250-localhost Hello client.localhost");
server.output("250 AUTH PLAIN LOGIN");
SmtpTransport transport = startServerAndCreateSmtpTransport(server, AuthType.XOAUTH2, ConnectionSecurity.NONE);
try {
transport.open();
fail("Exception expected");
} catch (MessagingException e) {
assertEquals("Authentication method XOAUTH2 is unavailable.", e.getMessage());
}
server.verifyConnectionStillOpen();
server.verifyInteractionCompleted();
}
@Test
public void open_withAuthExternalExtension() throws Exception {
MockSmtpServer server = new MockSmtpServer();
@ -413,7 +626,7 @@ public class SmtpTransportTest {
String uri = SmtpTransport.createUri(serverSettings);
StoreConfig storeConfig = createStoreConfigWithTransportUri(uri);
return new TestSmtpTransport(storeConfig, socketFactory);
return new TestSmtpTransport(storeConfig, socketFactory, oAuth2TokenProvider);
}
private StoreConfig createStoreConfigWithTransportUri(String value) {
@ -452,9 +665,9 @@ public class SmtpTransportTest {
static class TestSmtpTransport extends SmtpTransport {
TestSmtpTransport(StoreConfig storeConfig, TrustedSocketFactory trustedSocketFactory)
TestSmtpTransport(StoreConfig storeConfig, TrustedSocketFactory trustedSocketFactory, OAuth2TokenProvider oAuth2TokenProvider)
throws MessagingException {
super(storeConfig, trustedSocketFactory);
super(storeConfig, trustedSocketFactory, oAuth2TokenProvider);
}
@Override