1 /* 2 * Copyright (C) 2011 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17 package com.android.email.mail.store; 18 19 import android.text.TextUtils; 20 import android.util.Base64; 21 22 import com.android.email.mail.internet.AuthenticationCache; 23 import com.android.email.mail.store.ImapStore.ImapException; 24 import com.android.email.mail.store.imap.ImapConstants; 25 import com.android.email.mail.store.imap.ImapList; 26 import com.android.email.mail.store.imap.ImapResponse; 27 import com.android.email.mail.store.imap.ImapResponseParser; 28 import com.android.email.mail.store.imap.ImapUtility; 29 import com.android.email.mail.transport.DiscourseLogger; 30 import com.android.email.mail.transport.MailTransport; 31 import com.android.email2.ui.MailActivityEmail; 32 import com.android.emailcommon.Logging; 33 import com.android.emailcommon.mail.AuthenticationFailedException; 34 import com.android.emailcommon.mail.CertificateValidationException; 35 import com.android.emailcommon.mail.MessagingException; 36 import com.android.mail.utils.LogUtils; 37 38 import java.io.IOException; 39 import java.util.ArrayList; 40 import java.util.Collections; 41 import java.util.List; 42 import java.util.concurrent.atomic.AtomicInteger; 43 44 import javax.net.ssl.SSLException; 45 46 /** 47 * A cacheable class that stores the details for a single IMAP connection. 48 */ 49 class ImapConnection { 50 // Always check in FALSE 51 private static final boolean DEBUG_FORCE_SEND_ID = false; 52 53 /** ID capability per RFC 2971*/ 54 public static final int CAPABILITY_ID = 1 << 0; 55 /** NAMESPACE capability per RFC 2342 */ 56 public static final int CAPABILITY_NAMESPACE = 1 << 1; 57 /** STARTTLS capability per RFC 3501 */ 58 public static final int CAPABILITY_STARTTLS = 1 << 2; 59 /** UIDPLUS capability per RFC 4315 */ 60 public static final int CAPABILITY_UIDPLUS = 1 << 3; 61 62 /** The capabilities supported; a set of CAPABILITY_* values. */ 63 private int mCapabilities; 64 static final String IMAP_REDACTED_LOG = "[IMAP command redacted]"; 65 MailTransport mTransport; 66 private ImapResponseParser mParser; 67 private ImapStore mImapStore; 68 private String mLoginPhrase; 69 private String mAccessToken; 70 private String mIdPhrase = null; 71 72 /** # of command/response lines to log upon crash. */ 73 private static final int DISCOURSE_LOGGER_SIZE = 64; 74 private final DiscourseLogger mDiscourse = new DiscourseLogger(DISCOURSE_LOGGER_SIZE); 75 /** 76 * Next tag to use. All connections associated to the same ImapStore instance share the same 77 * counter to make tests simpler. 78 * (Some of the tests involve multiple connections but only have a single counter to track the 79 * tag.) 80 */ 81 private final AtomicInteger mNextCommandTag = new AtomicInteger(0); 82 83 // Keep others from instantiating directly 84 ImapConnection(ImapStore store) { 85 setStore(store); 86 } 87 88 void setStore(ImapStore store) { 89 // TODO: maybe we should throw an exception if the connection is not closed here, 90 // if it's not currently closed, then we won't reopen it, so if the credentials have 91 // changed, the connection will not be reestablished. 92 mImapStore = store; 93 mLoginPhrase = null; 94 } 95 96 /** 97 * Generates and returns the phrase to be used for authentication. This will be a LOGIN with 98 * username and password, or an OAUTH authentication string, with username and access token. 99 * Currently, these are the only two auth mechanisms supported. 100 * 101 * @throws IOException 102 * @throws AuthenticationFailedException 103 * @return the login command string to sent to the IMAP server 104 */ 105 String getLoginPhrase() throws MessagingException, IOException { 106 // build the LOGIN string once (instead of over-and-over again.) 107 if (mImapStore.getUseOAuth()) { 108 // We'll recreate the login phrase if it's null, or if the access token 109 // has changed. 110 final String accessToken = AuthenticationCache.getInstance().retrieveAccessToken( 111 mImapStore.getContext(), mImapStore.getAccount()); 112 if (mLoginPhrase == null || !TextUtils.equals(mAccessToken, accessToken)) { 113 mAccessToken = accessToken; 114 final String oauthCode = "user=" + mImapStore.getUsername() + '\001' + 115 "auth=Bearer " + mAccessToken + '\001' + '\001'; 116 mLoginPhrase = ImapConstants.AUTHENTICATE + " " + ImapConstants.XOAUTH2 + " " + 117 Base64.encodeToString(oauthCode.getBytes(), Base64.NO_WRAP); 118 } 119 } else { 120 if (mLoginPhrase == null) { 121 if (mImapStore.getUsername() != null && mImapStore.getPassword() != null) { 122 // build the LOGIN string once (instead of over-and-over again.) 123 // apply the quoting here around the built-up password 124 mLoginPhrase = ImapConstants.LOGIN + " " + mImapStore.getUsername() + " " 125 + ImapUtility.imapQuoted(mImapStore.getPassword()); 126 } 127 } 128 } 129 return mLoginPhrase; 130 } 131 132 void open() throws IOException, MessagingException { 133 if (mTransport != null && mTransport.isOpen()) { 134 return; 135 } 136 137 try { 138 // copy configuration into a clean transport, if necessary 139 if (mTransport == null) { 140 mTransport = mImapStore.cloneTransport(); 141 } 142 143 mTransport.open(); 144 145 createParser(); 146 147 // BANNER 148 mParser.readResponse(); 149 150 // CAPABILITY 151 ImapResponse capabilities = queryCapabilities(); 152 153 boolean hasStartTlsCapability = 154 capabilities.contains(ImapConstants.STARTTLS); 155 156 // TLS 157 ImapResponse newCapabilities = doStartTls(hasStartTlsCapability); 158 if (newCapabilities != null) { 159 capabilities = newCapabilities; 160 } 161 162 // NOTE: An IMAP response MUST be processed before issuing any new IMAP 163 // requests. Subsequent requests may destroy previous response data. As 164 // such, we save away capability information here for future use. 165 setCapabilities(capabilities); 166 String capabilityString = capabilities.flatten(); 167 168 // ID 169 doSendId(isCapable(CAPABILITY_ID), capabilityString); 170 171 // LOGIN 172 doLogin(); 173 174 // NAMESPACE (only valid in the Authenticated state) 175 doGetNamespace(isCapable(CAPABILITY_NAMESPACE)); 176 177 // Gets the path separator from the server 178 doGetPathSeparator(); 179 180 mImapStore.ensurePrefixIsValid(); 181 } catch (SSLException e) { 182 if (MailActivityEmail.DEBUG) { 183 LogUtils.d(Logging.LOG_TAG, e, "SSLException"); 184 } 185 throw new CertificateValidationException(e.getMessage(), e); 186 } catch (IOException ioe) { 187 // NOTE: Unlike similar code in POP3, I'm going to rethrow as-is. There is a lot 188 // of other code here that catches IOException and I don't want to break it. 189 // This catch is only here to enhance logging of connection-time issues. 190 if (MailActivityEmail.DEBUG) { 191 LogUtils.d(Logging.LOG_TAG, ioe, "IOException"); 192 } 193 throw ioe; 194 } finally { 195 destroyResponses(); 196 } 197 } 198 199 /** 200 * Closes the connection and releases all resources. This connection can not be used again 201 * until {@link #setStore(ImapStore)} is called. 202 */ 203 void close() { 204 if (mTransport != null) { 205 mTransport.close(); 206 mTransport = null; 207 } 208 destroyResponses(); 209 mParser = null; 210 mImapStore = null; 211 } 212 213 /** 214 * Returns whether or not the specified capability is supported by the server. 215 */ 216 private boolean isCapable(int capability) { 217 return (mCapabilities & capability) != 0; 218 } 219 220 /** 221 * Sets the capability flags according to the response provided by the server. 222 * Note: We only set the capability flags that we are interested in. There are many IMAP 223 * capabilities that we do not track. 224 */ 225 private void setCapabilities(ImapResponse capabilities) { 226 if (capabilities.contains(ImapConstants.ID)) { 227 mCapabilities |= CAPABILITY_ID; 228 } 229 if (capabilities.contains(ImapConstants.NAMESPACE)) { 230 mCapabilities |= CAPABILITY_NAMESPACE; 231 } 232 if (capabilities.contains(ImapConstants.UIDPLUS)) { 233 mCapabilities |= CAPABILITY_UIDPLUS; 234 } 235 if (capabilities.contains(ImapConstants.STARTTLS)) { 236 mCapabilities |= CAPABILITY_STARTTLS; 237 } 238 } 239 240 /** 241 * Create an {@link ImapResponseParser} from {@code mTransport.getInputStream()} and 242 * set it to {@link #mParser}. 243 * 244 * If we already have an {@link ImapResponseParser}, we 245 * {@link #destroyResponses()} and throw it away. 246 */ 247 private void createParser() { 248 destroyResponses(); 249 mParser = new ImapResponseParser(mTransport.getInputStream(), mDiscourse); 250 } 251 252 void destroyResponses() { 253 if (mParser != null) { 254 mParser.destroyResponses(); 255 } 256 } 257 258 boolean isTransportOpenForTest() { 259 return mTransport != null && mTransport.isOpen(); 260 } 261 262 ImapResponse readResponse() throws IOException, MessagingException { 263 return mParser.readResponse(); 264 } 265 266 /** 267 * Send a single command to the server. The command will be preceded by an IMAP command 268 * tag and followed by \r\n (caller need not supply them). 269 * 270 * @param command The command to send to the server 271 * @param sensitive If true, the command will not be logged 272 * @return Returns the command tag that was sent 273 */ 274 String sendCommand(String command, boolean sensitive) 275 throws MessagingException, IOException { 276 LogUtils.d(Logging.LOG_TAG, "sendCommand %s", (sensitive ? IMAP_REDACTED_LOG : command)); 277 open(); 278 return sendCommandInternal(command, sensitive); 279 } 280 281 String sendCommandInternal(String command, boolean sensitive) 282 throws MessagingException, IOException { 283 if (mTransport == null) { 284 throw new IOException("Null transport"); 285 } 286 String tag = Integer.toString(mNextCommandTag.incrementAndGet()); 287 String commandToSend = tag + " " + command; 288 mTransport.writeLine(commandToSend, sensitive ? IMAP_REDACTED_LOG : null); 289 mDiscourse.addSentCommand(sensitive ? IMAP_REDACTED_LOG : commandToSend); 290 return tag; 291 } 292 293 /** 294 * Send a single, complex command to the server. The command will be preceded by an IMAP 295 * command tag and followed by \r\n (caller need not supply them). After each piece of the 296 * command, a response will be read which MUST be a continuation request. 297 * 298 * @param commands An array of Strings comprising the command to be sent to the server 299 * @return Returns the command tag that was sent 300 */ 301 String sendComplexCommand(List<String> commands, boolean sensitive) throws MessagingException, 302 IOException { 303 open(); 304 String tag = Integer.toString(mNextCommandTag.incrementAndGet()); 305 int len = commands.size(); 306 for (int i = 0; i < len; i++) { 307 String commandToSend = commands.get(i); 308 // The first part of the command gets the tag 309 if (i == 0) { 310 commandToSend = tag + " " + commandToSend; 311 } else { 312 // Otherwise, read the response from the previous part of the command 313 ImapResponse response = readResponse(); 314 // If it isn't a continuation request, that's an error 315 if (!response.isContinuationRequest()) { 316 throw new MessagingException("Expected continuation request"); 317 } 318 } 319 // Send the command 320 mTransport.writeLine(commandToSend, null); 321 mDiscourse.addSentCommand(sensitive ? IMAP_REDACTED_LOG : commandToSend); 322 } 323 return tag; 324 } 325 326 List<ImapResponse> executeSimpleCommand(String command) throws IOException, MessagingException { 327 return executeSimpleCommand(command, false); 328 } 329 330 /** 331 * Read and return all of the responses from the most recent command sent to the server 332 * 333 * @return a list of ImapResponses 334 * @throws IOException 335 * @throws MessagingException 336 */ 337 List<ImapResponse> getCommandResponses() throws IOException, MessagingException { 338 final List<ImapResponse> responses = new ArrayList<ImapResponse>(); 339 ImapResponse response; 340 do { 341 response = mParser.readResponse(); 342 responses.add(response); 343 } while (!response.isTagged()); 344 345 if (!response.isOk()) { 346 final String toString = response.toString(); 347 final String alert = response.getAlertTextOrEmpty().getString(); 348 final String responseCode = response.getResponseCodeOrEmpty().getString(); 349 destroyResponses(); 350 351 // if the response code indicates an error occurred within the server, indicate that 352 if (ImapConstants.UNAVAILABLE.equals(responseCode)) { 353 throw new MessagingException(MessagingException.SERVER_ERROR, alert); 354 } 355 356 throw new ImapException(toString, alert, responseCode); 357 } 358 return responses; 359 } 360 361 /** 362 * Execute a simple command at the server, a simple command being one that is sent in a single 363 * line of text 364 * 365 * @param command the command to send to the server 366 * @param sensitive whether the command should be redacted in logs (used for login) 367 * @return a list of ImapResponses 368 * @throws IOException 369 * @throws MessagingException 370 */ 371 List<ImapResponse> executeSimpleCommand(String command, boolean sensitive) 372 throws IOException, MessagingException { 373 // TODO: It may be nice to catch IOExceptions and close the connection here. 374 // Currently, we expect callers to do that, but if they fail to we'll be in a broken state. 375 sendCommand(command, sensitive); 376 return getCommandResponses(); 377 } 378 379 /** 380 * Execute a complex command at the server, a complex command being one that must be sent in 381 * multiple lines due to the use of string literals 382 * 383 * @param commands a list of strings that comprise the command to be sent to the server 384 * @param sensitive whether the command should be redacted in logs (used for login) 385 * @return a list of ImapResponses 386 * @throws IOException 387 * @throws MessagingException 388 */ 389 List<ImapResponse> executeComplexCommand(List<String> commands, boolean sensitive) 390 throws IOException, MessagingException { 391 sendComplexCommand(commands, sensitive); 392 return getCommandResponses(); 393 } 394 395 /** 396 * Query server for capabilities. 397 */ 398 private ImapResponse queryCapabilities() throws IOException, MessagingException { 399 ImapResponse capabilityResponse = null; 400 for (ImapResponse r : executeSimpleCommand(ImapConstants.CAPABILITY)) { 401 if (r.is(0, ImapConstants.CAPABILITY)) { 402 capabilityResponse = r; 403 break; 404 } 405 } 406 if (capabilityResponse == null) { 407 throw new MessagingException("Invalid CAPABILITY response received"); 408 } 409 return capabilityResponse; 410 } 411 412 /** 413 * Sends client identification information to the IMAP server per RFC 2971. If 414 * the server does not support the ID command, this will perform no operation. 415 * 416 * Interoperability hack: Never send ID to *.secureserver.net, which sends back a 417 * malformed response that our parser can't deal with. 418 */ 419 private void doSendId(boolean hasIdCapability, String capabilities) 420 throws MessagingException { 421 if (!hasIdCapability) return; 422 423 // Never send ID to *.secureserver.net 424 String host = mTransport.getHost(); 425 if (host.toLowerCase().endsWith(".secureserver.net")) return; 426 427 // Assign user-agent string (for RFC2971 ID command) 428 String mUserAgent = 429 ImapStore.getImapId(mImapStore.getContext(), mImapStore.getUsername(), host, 430 capabilities); 431 432 if (mUserAgent != null) { 433 mIdPhrase = ImapConstants.ID + " (" + mUserAgent + ")"; 434 } else if (DEBUG_FORCE_SEND_ID) { 435 mIdPhrase = ImapConstants.ID + " " + ImapConstants.NIL; 436 } 437 // else: mIdPhrase = null, no ID will be emitted 438 439 // Send user-agent in an RFC2971 ID command 440 if (mIdPhrase != null) { 441 try { 442 executeSimpleCommand(mIdPhrase); 443 } catch (ImapException ie) { 444 // Log for debugging, but this is not a fatal problem. 445 if (MailActivityEmail.DEBUG) { 446 LogUtils.d(Logging.LOG_TAG, ie, "ImapException"); 447 } 448 } catch (IOException ioe) { 449 // Special case to handle malformed OK responses and ignore them. 450 // A true IOException will recur on the following login steps 451 // This can go away after the parser is fixed - see bug 2138981 452 } 453 } 454 } 455 456 /** 457 * Gets the user's Personal Namespace from the IMAP server per RFC 2342. If the user 458 * explicitly sets a namespace (using setup UI) or if the server does not support the 459 * namespace command, this will perform no operation. 460 */ 461 private void doGetNamespace(boolean hasNamespaceCapability) throws MessagingException { 462 // user did not specify a hard-coded prefix; try to get it from the server 463 if (hasNamespaceCapability && !mImapStore.isUserPrefixSet()) { 464 List<ImapResponse> responseList = Collections.emptyList(); 465 466 try { 467 responseList = executeSimpleCommand(ImapConstants.NAMESPACE); 468 } catch (ImapException ie) { 469 // Log for debugging, but this is not a fatal problem. 470 if (MailActivityEmail.DEBUG) { 471 LogUtils.d(Logging.LOG_TAG, ie, "ImapException"); 472 } 473 } catch (IOException ioe) { 474 // Special case to handle malformed OK responses and ignore them. 475 } 476 477 for (ImapResponse response: responseList) { 478 if (response.isDataResponse(0, ImapConstants.NAMESPACE)) { 479 ImapList namespaceList = response.getListOrEmpty(1); 480 ImapList namespace = namespaceList.getListOrEmpty(0); 481 String namespaceString = namespace.getStringOrEmpty(0).getString(); 482 if (!TextUtils.isEmpty(namespaceString)) { 483 mImapStore.setPathPrefix(ImapStore.decodeFolderName(namespaceString, null)); 484 mImapStore.setPathSeparator(namespace.getStringOrEmpty(1).getString()); 485 } 486 } 487 } 488 } 489 } 490 491 /** 492 * Logs into the IMAP server 493 */ 494 private void doLogin() throws IOException, MessagingException, AuthenticationFailedException { 495 try { 496 if (mImapStore.getUseOAuth()) { 497 // SASL authentication can take multiple steps. Currently the only SASL 498 // authentication supported is OAuth. 499 doSASLAuth(); 500 } else { 501 executeSimpleCommand(getLoginPhrase(), true); 502 } 503 } catch (ImapException ie) { 504 if (MailActivityEmail.DEBUG) { 505 LogUtils.d(Logging.LOG_TAG, ie, "ImapException"); 506 } 507 508 final String code = ie.getResponseCode(); 509 final String alertText = ie.getAlertText(); 510 511 // if the response code indicates expired or bad credentials, throw a special exception 512 if (ImapConstants.AUTHENTICATIONFAILED.equals(code) || 513 ImapConstants.EXPIRED.equals(code)) { 514 throw new AuthenticationFailedException(alertText, ie); 515 } 516 517 throw new MessagingException(alertText, ie); 518 } 519 } 520 521 /** 522 * Performs an SASL authentication. Currently, the only type of SASL authentication supported 523 * is OAuth. 524 * @throws MessagingException 525 * @throws IOException 526 */ 527 private void doSASLAuth() throws MessagingException, IOException { 528 LogUtils.d(Logging.LOG_TAG, "doSASLAuth"); 529 ImapResponse response = getOAuthResponse(); 530 if (!response.isOk()) { 531 // Failed to authenticate. This may be just due to an expired token. 532 LogUtils.d(Logging.LOG_TAG, "failed to authenticate, retrying"); 533 destroyResponses(); 534 // Clear the login phrase, this will force us to refresh the auth token. 535 mLoginPhrase = null; 536 // Close the transport so that we'll retry the authentication. 537 if (mTransport != null) { 538 mTransport.close(); 539 mTransport = null; 540 } 541 response = getOAuthResponse(); 542 if (!response.isOk()) { 543 LogUtils.d(Logging.LOG_TAG, "failed to authenticate, giving up"); 544 destroyResponses(); 545 throw new AuthenticationFailedException("OAuth failed after refresh"); 546 } 547 } 548 } 549 550 private ImapResponse getOAuthResponse() throws IOException, MessagingException { 551 ImapResponse response; 552 sendCommandInternal(getLoginPhrase(), true); 553 do { 554 response = mParser.readResponse(); 555 } while (!response.isTagged() && !response.isContinuationRequest()); 556 557 if (response.isContinuationRequest()) { 558 // SASL allows for a challenge/response type authentication, so if it doesn't yet have 559 // enough info, it will send back a continuation request. 560 // Currently, the only type of authentication we support is OAuth. The only case where 561 // it will send a continuation request is when we fail to authenticate. We need to 562 // reply with a CR/LF, and it will then return with a NO response. 563 sendCommandInternal("", true); 564 response = readResponse(); 565 } 566 567 // if the response code indicates an error occurred within the server, indicate that 568 final String responseCode = response.getResponseCodeOrEmpty().getString(); 569 if (ImapConstants.UNAVAILABLE.equals(responseCode)) { 570 final String alert = response.getAlertTextOrEmpty().getString(); 571 throw new MessagingException(MessagingException.SERVER_ERROR, alert); 572 } 573 574 return response; 575 } 576 577 /** 578 * Gets the path separator per the LIST command in RFC 3501. If the path separator 579 * was obtained while obtaining the namespace or there is no prefix defined, this 580 * will perform no operation. 581 */ 582 private void doGetPathSeparator() throws MessagingException { 583 // user did not specify a hard-coded prefix; try to get it from the server 584 if (mImapStore.isUserPrefixSet()) { 585 List<ImapResponse> responseList = Collections.emptyList(); 586 587 try { 588 responseList = executeSimpleCommand(ImapConstants.LIST + " \"\" \"\""); 589 } catch (ImapException ie) { 590 // Log for debugging, but this is not a fatal problem. 591 if (MailActivityEmail.DEBUG) { 592 LogUtils.d(Logging.LOG_TAG, ie, "ImapException"); 593 } 594 } catch (IOException ioe) { 595 // Special case to handle malformed OK responses and ignore them. 596 } 597 598 for (ImapResponse response: responseList) { 599 if (response.isDataResponse(0, ImapConstants.LIST)) { 600 mImapStore.setPathSeparator(response.getStringOrEmpty(2).getString()); 601 } 602 } 603 } 604 } 605 606 /** 607 * Starts a TLS session with the IMAP server per RFC 3501. If the user has not opted 608 * to use TLS or the server does not support the TLS capability, this will perform 609 * no operation. 610 */ 611 private ImapResponse doStartTls(boolean hasStartTlsCapability) 612 throws IOException, MessagingException { 613 if (mTransport.canTryTlsSecurity()) { 614 if (hasStartTlsCapability) { 615 // STARTTLS 616 executeSimpleCommand(ImapConstants.STARTTLS); 617 618 mTransport.reopenTls(); 619 createParser(); 620 // Per RFC requirement (3501-6.2.1) gather new capabilities 621 return(queryCapabilities()); 622 } else { 623 if (MailActivityEmail.DEBUG) { 624 LogUtils.d(Logging.LOG_TAG, "TLS not supported but required"); 625 } 626 throw new MessagingException(MessagingException.TLS_REQUIRED); 627 } 628 } 629 return null; 630 } 631 632 /** @see DiscourseLogger#logLastDiscourse() */ 633 void logLastDiscourse() { 634 mDiscourse.logLastDiscourse(); 635 } 636 } 637