001 /* 002 * Licensed to the Apache Software Foundation (ASF) under one 003 * or more contributor license agreements. See the NOTICE file 004 * distributed with this work for additional information 005 * regarding copyright ownership. The ASF licenses this file 006 * to you under the Apache License, Version 2.0 (the 007 * "License"); you may not use this file except in compliance 008 * with the License. You may obtain a copy of the License at 009 * 010 * http://www.apache.org/licenses/LICENSE-2.0 011 * 012 * Unless required by applicable law or agreed to in writing, 013 * software distributed under the License is distributed on an 014 * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 015 * KIND, either express or implied. See the License for the 016 * specific language governing permissions and limitations 017 * under the License. 018 */ 019 020 package org.apache.geronimo.javamail.authentication; 021 022 import java.io.UnsupportedEncodingException; 023 import java.security.MessageDigest; 024 import java.security.NoSuchAlgorithmException; 025 import java.security.SecureRandom; 026 import java.util.ArrayList; 027 028 import javax.mail.AuthenticationFailedException; 029 import javax.mail.MessagingException; 030 031 import org.apache.geronimo.mail.util.Base64; 032 import org.apache.geronimo.mail.util.Hex; 033 034 /** 035 * Process a DIGEST-MD5 authentication, using the challenge/response mechanisms. 036 */ 037 public class DigestMD5Authenticator implements ClientAuthenticator { 038 039 protected static final int AUTHENTICATE_CLIENT = 0; 040 041 protected static final int AUTHENTICATE_SERVER = 1; 042 043 protected static final int AUTHENTICATION_COMPLETE = 2; 044 045 // the host server name 046 protected String host; 047 048 // the user we're authenticating 049 protected String username; 050 051 // the user's password (the "shared secret") 052 protected String password; 053 054 // the target login realm 055 protected String realm; 056 057 // our message digest for processing the challenges. 058 MessageDigest digest; 059 060 // the string we send to the server on the first challenge. 061 protected String clientResponse; 062 063 // the response back from an authentication challenge. 064 protected String authenticationResponse = null; 065 066 // our list of realms received from the server (normally just one). 067 protected ArrayList realms; 068 069 // the nonce value sent from the server 070 protected String nonce; 071 072 // indicates whether we've gone through the entire challenge process. 073 protected int stage = AUTHENTICATE_CLIENT; 074 075 /** 076 * Main constructor. 077 * 078 * @param host 079 * The server host name. 080 * @param username 081 * The login user name. 082 * @param password 083 * The login password. 084 * @param realm 085 * The target login realm (can be null). 086 */ 087 public DigestMD5Authenticator(String host, String username, String password, String realm) { 088 this.host = host; 089 this.username = username; 090 this.password = password; 091 this.realm = realm; 092 } 093 094 /** 095 * Respond to the hasInitialResponse query. This mechanism does not have an 096 * initial response. 097 * 098 * @return Always returns false. 099 */ 100 public boolean hasInitialResponse() { 101 return false; 102 } 103 104 /** 105 * Indicate whether the challenge/response process is complete. 106 * 107 * @return True if the last challenge has been processed, false otherwise. 108 */ 109 public boolean isComplete() { 110 return stage == AUTHENTICATION_COMPLETE; 111 } 112 113 /** 114 * Retrieve the authenticator mechanism name. 115 * 116 * @return Always returns the string "DIGEST-MD5" 117 */ 118 public String getMechanismName() { 119 return "DIGEST-MD5"; 120 } 121 122 /** 123 * Evaluate a DIGEST-MD5 login challenge, returning the a result string that 124 * should satisfy the clallenge. 125 * 126 * @param challenge 127 * The decoded challenge data, as a string. 128 * 129 * @return A formatted challege response, as an array of bytes. 130 * @exception MessagingException 131 */ 132 public byte[] evaluateChallenge(byte[] challenge) throws MessagingException { 133 134 // DIGEST-MD5 authentication goes in two stages. First state involves us 135 // validating with the 136 // server, the second stage is the server validating with us, using the 137 // shared secret. 138 switch (stage) { 139 // stage one of the process. 140 case AUTHENTICATE_CLIENT: { 141 // get the response and advance the processing stage. 142 byte[] response = authenticateClient(challenge); 143 stage = AUTHENTICATE_SERVER; 144 return response; 145 } 146 147 // stage two of the process. 148 case AUTHENTICATE_SERVER: { 149 // get the response and advance the processing stage to completed. 150 byte[] response = authenticateServer(challenge); 151 stage = AUTHENTICATION_COMPLETE; 152 return response; 153 } 154 155 // should never happen. 156 default: 157 throw new MessagingException("Invalid LOGIN challenge"); 158 } 159 } 160 161 /** 162 * Evaluate a DIGEST-MD5 login server authentication challenge, returning 163 * the a result string that should satisfy the clallenge. 164 * 165 * @param challenge 166 * The decoded challenge data, as a string. 167 * 168 * @return A formatted challege response, as an array of bytes. 169 * @exception MessagingException 170 */ 171 public byte[] authenticateServer(byte[] challenge) throws MessagingException { 172 // parse the challenge string and validate. 173 if (!parseChallenge(challenge)) { 174 return null; 175 } 176 177 try { 178 // like all of the client validation steps, the following is order 179 // critical. 180 // first add in the URI information. 181 digest.update((":smtp/" + host).getBytes("US-ASCII")); 182 // now mix in the response we sent originally 183 String responseString = clientResponse + new String(Hex.encode(digest.digest())); 184 digest.update(responseString.getBytes("US-ASCII")); 185 186 // now convert that into a hex encoded string. 187 String validationText = new String(Hex.encode(digest.digest())); 188 189 // if everything went well, this calculated value should match what 190 // we got back from the server. 191 // our response back is just a null string.... 192 if (validationText.equals(authenticationResponse)) { 193 return new byte[0]; 194 } 195 throw new AuthenticationFailedException("Invalid DIGEST-MD5 response from server"); 196 } catch (UnsupportedEncodingException e) { 197 throw new MessagingException("Invalid character encodings"); 198 } 199 200 } 201 202 /** 203 * Evaluate a DIGEST-MD5 login client authentication challenge, returning 204 * the a result string that should satisfy the clallenge. 205 * 206 * @param challenge 207 * The decoded challenge data, as a string. 208 * 209 * @return A formatted challege response, as an array of bytes. 210 * @exception MessagingException 211 */ 212 public byte[] authenticateClient(byte[] challenge) throws MessagingException { 213 // parse the challenge string and validate. 214 if (!parseChallenge(challenge)) { 215 return null; 216 } 217 218 SecureRandom randomGenerator; 219 // before doing anything, make sure we can get the required crypto 220 // support. 221 try { 222 randomGenerator = new SecureRandom(); 223 digest = MessageDigest.getInstance("MD5"); 224 } catch (NoSuchAlgorithmException e) { 225 throw new MessagingException("Unable to access cryptography libraries"); 226 } 227 228 // if not configured for a realm, take the first realm from the list, if 229 // any 230 if (realm == null) { 231 // if not handed any realms, just use the host name. 232 if (realms.isEmpty()) { 233 realm = host; 234 } else { 235 // pretty arbitrary at this point, so just use the first one. 236 realm = (String) realms.get(0); 237 } 238 } 239 240 // use secure random to generate a collection of bytes. that is our 241 // cnonce value. 242 byte[] cnonceBytes = new byte[32]; 243 244 randomGenerator.nextBytes(cnonceBytes); 245 // and get this as a base64 encoded string. 246 String cnonce = new String(Base64.encode(cnonceBytes)); 247 248 // Now the digest computation part. This gets a bit tricky, and must be 249 // done in strict order. 250 251 try { 252 // this identifies where we're logging into. 253 String idString = username + ":" + realm + ":" + password; 254 // we get a digest for this string, then use the digest for the 255 // first stage 256 // of the next digest operation. 257 digest.update(digest.digest(idString.getBytes("US-ASCII"))); 258 259 // now we add the nonce strings to the digest. 260 String nonceString = ":" + nonce + ":" + cnonce; 261 digest.update(nonceString.getBytes("US-ASCII")); 262 263 // hex encode this digest, and add on the string values 264 // NB, we only support "auth" for the quality of protection value 265 // (qop). We save this in an 266 // instance variable because we'll need this to validate the 267 // response back from the server. 268 clientResponse = new String(Hex.encode(digest.digest())) + ":" + nonce + ":00000001:" + cnonce + ":auth:"; 269 270 // now we add in identification values to the hash. 271 String authString = "AUTHENTICATE:smtp/" + host; 272 digest.update(authString.getBytes("US-ASCII")); 273 274 // this gets added on to the client response 275 String responseString = clientResponse + new String(Hex.encode(digest.digest())); 276 // and this gets fed back into the digest 277 digest.update(responseString.getBytes("US-ASCII")); 278 279 // and FINALLY, the challege digest is hex encoded for sending back 280 // to the server (whew). 281 String challengeResponse = new String(Hex.encode(digest.digest())); 282 283 // now finally build the keyword/value part of the challenge 284 // response. These can be 285 // in any order. 286 StringBuffer response = new StringBuffer(); 287 288 response.append("username=\""); 289 response.append(username); 290 response.append("\""); 291 292 response.append(",realm=\""); 293 response.append(realm); 294 response.append("\""); 295 296 // we only support auth qop values, and the nonce-count (nc) is 297 // always 1. 298 response.append(",qop=auth"); 299 response.append(",nc=00000001"); 300 301 response.append(",nonce=\""); 302 response.append(nonce); 303 response.append("\""); 304 305 response.append(",cnonce=\""); 306 response.append(cnonce); 307 response.append("\""); 308 309 response.append(",digest-uri=\"smtp/"); 310 response.append(host); 311 response.append("\""); 312 313 response.append(",response="); 314 response.append(challengeResponse); 315 316 return response.toString().getBytes("US-ASCII"); 317 318 } catch (UnsupportedEncodingException e) { 319 throw new MessagingException("Invalid character encodings"); 320 } 321 } 322 323 /** 324 * Parse the challege string, pulling out information required for our 325 * challenge response. 326 * 327 * @param challenge 328 * The challenge data. 329 * 330 * @return true if there were no errors parsing the string, false otherwise. 331 * @exception MessagingException 332 */ 333 protected boolean parseChallenge(byte[] challenge) throws MessagingException { 334 realms = new ArrayList(); 335 336 DigestParser parser = new DigestParser(new String(challenge)); 337 338 // parse the entire string...but we ignore everything but the options we 339 // support. 340 while (parser.hasMore()) { 341 NameValuePair pair = parser.parseNameValuePair(); 342 343 String name = pair.name; 344 345 // realm to add to our list? 346 if (name.equalsIgnoreCase("realm")) { 347 realms.add(pair.value); 348 } 349 // we need the nonce to evaluate the client challenge. 350 else if (name.equalsIgnoreCase("nonce")) { 351 nonce = pair.value; 352 } 353 // rspauth is the challenge replay back, which allows us to validate 354 // that server is also legit. 355 else if (name.equalsIgnoreCase("rspauth")) { 356 authenticationResponse = pair.value; 357 } 358 } 359 360 return true; 361 } 362 363 /** 364 * Inner class for parsing a DIGEST-MD5 challenge string, which is composed 365 * of "name=value" pairs, separated by "," characters. 366 */ 367 class DigestParser { 368 // the challenge we're parsing 369 String challenge; 370 371 // length of the challenge 372 int length; 373 374 // current parsing position 375 int position; 376 377 /** 378 * Normal constructor. 379 * 380 * @param challenge 381 * The challenge string to be parsed. 382 */ 383 public DigestParser(String challenge) { 384 this.challenge = challenge; 385 this.length = challenge.length(); 386 position = 0; 387 } 388 389 /** 390 * Test if there are more values to parse. 391 * 392 * @return true if we've not reached the end of the challenge string, 393 * false if the challenge has been completely consumed. 394 */ 395 private boolean hasMore() { 396 return position < length; 397 } 398 399 /** 400 * Return the character at the current parsing position. 401 * 402 * @return The string character for the current parse position. 403 */ 404 private char currentChar() { 405 return challenge.charAt(position); 406 } 407 408 /** 409 * step forward to the next character position. 410 */ 411 private void nextChar() { 412 position++; 413 } 414 415 /** 416 * Skip over any white space characters in the challenge string. 417 */ 418 private void skipSpaces() { 419 while (position < length && Character.isWhitespace(currentChar())) { 420 position++; 421 } 422 } 423 424 /** 425 * Parse a quoted string used with a name/value pair, accounting for 426 * escape characters embedded within the string. 427 * 428 * @return The string value of the character string. 429 */ 430 private String parseQuotedValue() { 431 // we're here because we found the starting double quote. Step over 432 // it and parse to the closing 433 // one. 434 nextChar(); 435 436 StringBuffer value = new StringBuffer(); 437 438 while (hasMore()) { 439 char ch = currentChar(); 440 441 // is this an escape char? 442 if (ch == '\\') { 443 // step past this, and grab the following character 444 nextChar(); 445 // we have an invalid quoted string.... 446 if (!hasMore()) { 447 return null; 448 } 449 value.append(currentChar()); 450 } 451 // end of the string? 452 else if (ch == '"') { 453 // step over this so the caller doesn't process it. 454 nextChar(); 455 // return the constructed string. 456 return value.toString(); 457 } else { 458 // step over the character and contine with the next 459 // characteer1 460 value.append(ch); 461 } 462 nextChar(); 463 } 464 /* fell off the end without finding a closing quote! */ 465 return null; 466 } 467 468 /** 469 * Parse a token value used with a name/value pair. 470 * 471 * @return The string value of the token. Returns null if nothing is 472 * found up to the separater. 473 */ 474 private String parseTokenValue() { 475 476 StringBuffer value = new StringBuffer(); 477 478 while (hasMore()) { 479 char ch = currentChar(); 480 switch (ch) { 481 // process the token separators. 482 case ' ': 483 case '\t': 484 case '(': 485 case ')': 486 case '<': 487 case '>': 488 case '@': 489 case ',': 490 case ';': 491 case ':': 492 case '\\': 493 case '"': 494 case '/': 495 case '[': 496 case ']': 497 case '?': 498 case '=': 499 case '{': 500 case '}': 501 // no token characters found? this is bad. 502 if (value.length() == 0) { 503 return null; 504 } 505 // return the accumulated characters. 506 return value.toString(); 507 508 default: 509 // is this a control character? That's a delimiter (likely 510 // invalid for the next step, 511 // but it is a token terminator. 512 if (ch < 32 || ch > 127) { 513 // no token characters found? this is bad. 514 if (value.length() == 0) { 515 return null; 516 } 517 // return the accumulated characters. 518 return value.toString(); 519 } 520 value.append(ch); 521 break; 522 } 523 // step to the next character. 524 nextChar(); 525 } 526 // no token characters found? this is bad. 527 if (value.length() == 0) { 528 return null; 529 } 530 // return the accumulated characters. 531 return value.toString(); 532 } 533 534 /** 535 * Parse out a name token of a name/value pair. 536 * 537 * @return The string value of the name. 538 */ 539 private String parseName() { 540 // skip to the value start 541 skipSpaces(); 542 543 // the name is a token. 544 return parseTokenValue(); 545 } 546 547 /** 548 * Parse out a a value of a name/value pair. 549 * 550 * @return The string value associated with the name. 551 */ 552 private String parseValue() { 553 // skip to the value start 554 skipSpaces(); 555 556 // start of a quoted string? 557 if (currentChar() == '"') { 558 // parse it out as a string. 559 return parseQuotedValue(); 560 } 561 // the value must be a token. 562 return parseTokenValue(); 563 } 564 565 /** 566 * Parse a name/value pair in an DIGEST-MD5 string. 567 * 568 * @return A NameValuePair object containing the two parts of the value. 569 * @exception MessagingException 570 */ 571 public NameValuePair parseNameValuePair() throws MessagingException { 572 // get the name token 573 String name = parseName(); 574 if (name == null) { 575 throw new MessagingException("Name syntax error"); 576 } 577 578 // the name should be followed by an "=" sign 579 if (!hasMore() || currentChar() != '=') { 580 throw new MessagingException("Name/value pair syntax error"); 581 } 582 583 // step over the equals 584 nextChar(); 585 586 // now get the value part 587 String value = parseValue(); 588 if (value == null) { 589 throw new MessagingException("Name/value pair syntax error"); 590 } 591 592 // skip forward to the terminator, which should either be the end of 593 // the line or a "," 594 skipSpaces(); 595 // all that work, only to have a syntax error at the end (sigh) 596 if (hasMore()) { 597 if (currentChar() != ',') { 598 throw new MessagingException("Name/value pair syntax error"); 599 } 600 // step over, and make sure we position ourselves at either the 601 // end or the first 602 // real character for parsing the next name/value pair. 603 nextChar(); 604 skipSpaces(); 605 } 606 return new NameValuePair(name, value); 607 } 608 } 609 610 /** 611 * Simple inner class to represent a name/value pair. 612 */ 613 public class NameValuePair { 614 public String name; 615 616 public String value; 617 618 NameValuePair(String name, String value) { 619 this.name = name; 620 this.value = value; 621 } 622 623 } 624 }