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 }