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.DebugUtils; 23 import com.android.email.mail.internet.AuthenticationCache; 24 import com.android.email.mail.store.ImapStore.ImapException; 25 import com.android.email.mail.store.imap.ImapConstants; 26 import com.android.email.mail.store.imap.ImapList; 27 import com.android.email.mail.store.imap.ImapResponse; 28 import com.android.email.mail.store.imap.ImapResponseParser; 29 import com.android.email.mail.store.imap.ImapUtility; 30 import com.android.email.mail.transport.DiscourseLogger; 31 import com.android.email.mail.transport.MailTransport; 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 (DebugUtils.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 (DebugUtils.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 status = response.getStatusOrEmpty().getString(); 348 final String alert = response.getAlertTextOrEmpty().getString(); 349 final String responseCode = response.getResponseCodeOrEmpty().getString(); 350 destroyResponses(); 351 352 // if the response code indicates an error occurred within the server, indicate that 353 if (ImapConstants.UNAVAILABLE.equals(responseCode)) { 354 throw new MessagingException(MessagingException.SERVER_ERROR, alert); 355 } 356 357 throw new ImapException(toString, status, alert, responseCode); 358 } 359 return responses; 360 } 361 362 /** 363 * Execute a simple command at the server, a simple command being one that is sent in a single 364 * line of text 365 * 366 * @param command the command to send to the server 367 * @param sensitive whether the command should be redacted in logs (used for login) 368 * @return a list of ImapResponses 369 * @throws IOException 370 * @throws MessagingException 371 */ 372 List<ImapResponse> executeSimpleCommand(String command, boolean sensitive) 373 throws IOException, MessagingException { 374 // TODO: It may be nice to catch IOExceptions and close the connection here. 375 // Currently, we expect callers to do that, but if they fail to we'll be in a broken state. 376 sendCommand(command, sensitive); 377 return getCommandResponses(); 378 } 379 380 /** 381 * Execute a complex command at the server, a complex command being one that must be sent in 382 * multiple lines due to the use of string literals 383 * 384 * @param commands a list of strings that comprise the command to be sent to the server 385 * @param sensitive whether the command should be redacted in logs (used for login) 386 * @return a list of ImapResponses 387 * @throws IOException 388 * @throws MessagingException 389 */ 390 List<ImapResponse> executeComplexCommand(List<String> commands, boolean sensitive) 391 throws IOException, MessagingException { 392 sendComplexCommand(commands, sensitive); 393 return getCommandResponses(); 394 } 395 396 /** 397 * Query server for capabilities. 398 */ 399 private ImapResponse queryCapabilities() throws IOException, MessagingException { 400 ImapResponse capabilityResponse = null; 401 for (ImapResponse r : executeSimpleCommand(ImapConstants.CAPABILITY)) { 402 if (r.is(0, ImapConstants.CAPABILITY)) { 403 capabilityResponse = r; 404 break; 405 } 406 } 407 if (capabilityResponse == null) { 408 throw new MessagingException("Invalid CAPABILITY response received"); 409 } 410 return capabilityResponse; 411 } 412 413 /** 414 * Sends client identification information to the IMAP server per RFC 2971. If 415 * the server does not support the ID command, this will perform no operation. 416 * 417 * Interoperability hack: Never send ID to *.secureserver.net, which sends back a 418 * malformed response that our parser can't deal with. 419 */ 420 private void doSendId(boolean hasIdCapability, String capabilities) 421 throws MessagingException { 422 if (!hasIdCapability) return; 423 424 // Never send ID to *.secureserver.net 425 String host = mTransport.getHost(); 426 if (host.toLowerCase().endsWith(".secureserver.net")) return; 427 428 // Assign user-agent string (for RFC2971 ID command) 429 String mUserAgent = 430 ImapStore.getImapId(mImapStore.getContext(), mImapStore.getUsername(), host, 431 capabilities); 432 433 if (mUserAgent != null) { 434 mIdPhrase = ImapConstants.ID + " (" + mUserAgent + ")"; 435 } else if (DEBUG_FORCE_SEND_ID) { 436 mIdPhrase = ImapConstants.ID + " " + ImapConstants.NIL; 437 } 438 // else: mIdPhrase = null, no ID will be emitted 439 440 // Send user-agent in an RFC2971 ID command 441 if (mIdPhrase != null) { 442 try { 443 executeSimpleCommand(mIdPhrase); 444 } catch (ImapException ie) { 445 // Log for debugging, but this is not a fatal problem. 446 if (DebugUtils.DEBUG) { 447 LogUtils.d(Logging.LOG_TAG, ie, "ImapException"); 448 } 449 } catch (IOException ioe) { 450 // Special case to handle malformed OK responses and ignore them. 451 // A true IOException will recur on the following login steps 452 // This can go away after the parser is fixed - see bug 2138981 453 } 454 } 455 } 456 457 /** 458 * Gets the user's Personal Namespace from the IMAP server per RFC 2342. If the user 459 * explicitly sets a namespace (using setup UI) or if the server does not support the 460 * namespace command, this will perform no operation. 461 */ 462 private void doGetNamespace(boolean hasNamespaceCapability) throws MessagingException { 463 // user did not specify a hard-coded prefix; try to get it from the server 464 if (hasNamespaceCapability && !mImapStore.isUserPrefixSet()) { 465 List<ImapResponse> responseList = Collections.emptyList(); 466 467 try { 468 responseList = executeSimpleCommand(ImapConstants.NAMESPACE); 469 } catch (ImapException ie) { 470 // Log for debugging, but this is not a fatal problem. 471 if (DebugUtils.DEBUG) { 472 LogUtils.d(Logging.LOG_TAG, ie, "ImapException"); 473 } 474 } catch (IOException ioe) { 475 // Special case to handle malformed OK responses and ignore them. 476 } 477 478 for (ImapResponse response: responseList) { 479 if (response.isDataResponse(0, ImapConstants.NAMESPACE)) { 480 ImapList namespaceList = response.getListOrEmpty(1); 481 ImapList namespace = namespaceList.getListOrEmpty(0); 482 String namespaceString = namespace.getStringOrEmpty(0).getString(); 483 if (!TextUtils.isEmpty(namespaceString)) { 484 mImapStore.setPathPrefix(ImapStore.decodeFolderName(namespaceString, null)); 485 mImapStore.setPathSeparator(namespace.getStringOrEmpty(1).getString()); 486 } 487 } 488 } 489 } 490 } 491 492 /** 493 * Logs into the IMAP server 494 */ 495 private void doLogin() throws IOException, MessagingException, AuthenticationFailedException { 496 try { 497 if (mImapStore.getUseOAuth()) { 498 // SASL authentication can take multiple steps. Currently the only SASL 499 // authentication supported is OAuth. 500 doSASLAuth(); 501 } else { 502 executeSimpleCommand(getLoginPhrase(), true); 503 } 504 } catch (ImapException ie) { 505 if (DebugUtils.DEBUG) { 506 LogUtils.d(Logging.LOG_TAG, ie, "ImapException"); 507 } 508 509 final String status = ie.getStatus(); 510 final String code = ie.getResponseCode(); 511 final String alertText = ie.getAlertText(); 512 513 // if the response code indicates expired or bad credentials, throw a special exception 514 if (ImapConstants.AUTHENTICATIONFAILED.equals(code) || 515 ImapConstants.EXPIRED.equals(code) || 516 (ImapConstants.NO.equals(status) && TextUtils.isEmpty(code))) { 517 throw new AuthenticationFailedException(alertText, ie); 518 } 519 520 throw new MessagingException(alertText, ie); 521 } 522 } 523 524 /** 525 * Performs an SASL authentication. Currently, the only type of SASL authentication supported 526 * is OAuth. 527 * @throws MessagingException 528 * @throws IOException 529 */ 530 private void doSASLAuth() throws MessagingException, IOException { 531 LogUtils.d(Logging.LOG_TAG, "doSASLAuth"); 532 ImapResponse response = getOAuthResponse(); 533 if (!response.isOk()) { 534 // Failed to authenticate. This may be just due to an expired token. 535 LogUtils.d(Logging.LOG_TAG, "failed to authenticate, retrying"); 536 destroyResponses(); 537 // Clear the login phrase, this will force us to refresh the auth token. 538 mLoginPhrase = null; 539 // Close the transport so that we'll retry the authentication. 540 if (mTransport != null) { 541 mTransport.close(); 542 mTransport = null; 543 } 544 response = getOAuthResponse(); 545 if (!response.isOk()) { 546 LogUtils.d(Logging.LOG_TAG, "failed to authenticate, giving up"); 547 destroyResponses(); 548 throw new AuthenticationFailedException("OAuth failed after refresh"); 549 } 550 } 551 } 552 553 private ImapResponse getOAuthResponse() throws IOException, MessagingException { 554 ImapResponse response; 555 sendCommandInternal(getLoginPhrase(), true); 556 do { 557 response = mParser.readResponse(); 558 } while (!response.isTagged() && !response.isContinuationRequest()); 559 560 if (response.isContinuationRequest()) { 561 // SASL allows for a challenge/response type authentication, so if it doesn't yet have 562 // enough info, it will send back a continuation request. 563 // Currently, the only type of authentication we support is OAuth. The only case where 564 // it will send a continuation request is when we fail to authenticate. We need to 565 // reply with a CR/LF, and it will then return with a NO response. 566 sendCommandInternal("", true); 567 response = readResponse(); 568 } 569 570 // if the response code indicates an error occurred within the server, indicate that 571 final String responseCode = response.getResponseCodeOrEmpty().getString(); 572 if (ImapConstants.UNAVAILABLE.equals(responseCode)) { 573 final String alert = response.getAlertTextOrEmpty().getString(); 574 throw new MessagingException(MessagingException.SERVER_ERROR, alert); 575 } 576 577 return response; 578 } 579 580 /** 581 * Gets the path separator per the LIST command in RFC 3501. If the path separator 582 * was obtained while obtaining the namespace or there is no prefix defined, this 583 * will perform no operation. 584 */ 585 private void doGetPathSeparator() throws MessagingException { 586 // user did not specify a hard-coded prefix; try to get it from the server 587 if (mImapStore.isUserPrefixSet()) { 588 List<ImapResponse> responseList = Collections.emptyList(); 589 590 try { 591 responseList = executeSimpleCommand(ImapConstants.LIST + " \"\" \"\""); 592 } catch (ImapException ie) { 593 // Log for debugging, but this is not a fatal problem. 594 if (DebugUtils.DEBUG) { 595 LogUtils.d(Logging.LOG_TAG, ie, "ImapException"); 596 } 597 } catch (IOException ioe) { 598 // Special case to handle malformed OK responses and ignore them. 599 } 600 601 for (ImapResponse response: responseList) { 602 if (response.isDataResponse(0, ImapConstants.LIST)) { 603 mImapStore.setPathSeparator(response.getStringOrEmpty(2).getString()); 604 } 605 } 606 } 607 } 608 609 /** 610 * Starts a TLS session with the IMAP server per RFC 3501. If the user has not opted 611 * to use TLS or the server does not support the TLS capability, this will perform 612 * no operation. 613 */ 614 private ImapResponse doStartTls(boolean hasStartTlsCapability) 615 throws IOException, MessagingException { 616 if (mTransport.canTryTlsSecurity()) { 617 if (hasStartTlsCapability) { 618 // STARTTLS 619 executeSimpleCommand(ImapConstants.STARTTLS); 620 621 mTransport.reopenTls(); 622 createParser(); 623 // Per RFC requirement (3501-6.2.1) gather new capabilities 624 return(queryCapabilities()); 625 } else { 626 if (DebugUtils.DEBUG) { 627 LogUtils.d(Logging.LOG_TAG, "TLS not supported but required"); 628 } 629 throw new MessagingException(MessagingException.TLS_REQUIRED); 630 } 631 } 632 return null; 633 } 634 635 /** @see DiscourseLogger#logLastDiscourse() */ 636 void logLastDiscourse() { 637 mDiscourse.logLastDiscourse(); 638 } 639 } 640