View Javadoc

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