blob: 0272b2411c9ef4579e3b65852adb2b6d6b90a6cd [file] [log] [blame]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.geronimo.javamail.authentication;
import java.io.UnsupportedEncodingException;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.util.ArrayList;
import javax.mail.AuthenticationFailedException;
import javax.mail.MessagingException;
import org.apache.geronimo.mail.util.Base64;
import org.apache.geronimo.mail.util.Hex;
/**
* Process a DIGEST-MD5 authentication, using the challenge/response mechanisms.
*/
public class DigestMD5Authenticator implements ClientAuthenticator {
protected static final int AUTHENTICATE_CLIENT = 0;
protected static final int AUTHENTICATE_SERVER = 1;
protected static final int AUTHENTICATION_COMPLETE = 2;
// the host server name
protected String host;
// the user we're authenticating
protected String username;
// the user's password (the "shared secret")
protected String password;
// the target login realm
protected String realm;
// our message digest for processing the challenges.
MessageDigest digest;
// the string we send to the server on the first challenge.
protected String clientResponse;
// the response back from an authentication challenge.
protected String authenticationResponse = null;
// our list of realms received from the server (normally just one).
protected ArrayList realms;
// the nonce value sent from the server
protected String nonce;
// indicates whether we've gone through the entire challenge process.
protected int stage = AUTHENTICATE_CLIENT;
/**
* Main constructor.
*
* @param host
* The server host name.
* @param username
* The login user name.
* @param password
* The login password.
* @param realm
* The target login realm (can be null).
*/
public DigestMD5Authenticator(String host, String username, String password, String realm) {
this.host = host;
this.username = username;
this.password = password;
this.realm = realm;
}
/**
* Respond to the hasInitialResponse query. This mechanism does not have an
* initial response.
*
* @return Always returns false.
*/
public boolean hasInitialResponse() {
return false;
}
/**
* Indicate whether the challenge/response process is complete.
*
* @return True if the last challenge has been processed, false otherwise.
*/
public boolean isComplete() {
return stage == AUTHENTICATION_COMPLETE;
}
/**
* Retrieve the authenticator mechanism name.
*
* @return Always returns the string "DIGEST-MD5"
*/
public String getMechanismName() {
return "DIGEST-MD5";
}
/**
* Evaluate a DIGEST-MD5 login challenge, returning the a result string that
* should satisfy the clallenge.
*
* @param challenge
* The decoded challenge data, as a string.
*
* @return A formatted challege response, as an array of bytes.
* @exception MessagingException
*/
public byte[] evaluateChallenge(byte[] challenge) throws MessagingException {
// DIGEST-MD5 authentication goes in two stages. First state involves us
// validating with the
// server, the second stage is the server validating with us, using the
// shared secret.
switch (stage) {
// stage one of the process.
case AUTHENTICATE_CLIENT: {
// get the response and advance the processing stage.
byte[] response = authenticateClient(challenge);
stage = AUTHENTICATE_SERVER;
return response;
}
// stage two of the process.
case AUTHENTICATE_SERVER: {
// get the response and advance the processing stage to completed.
byte[] response = authenticateServer(challenge);
stage = AUTHENTICATION_COMPLETE;
return response;
}
// should never happen.
default:
throw new MessagingException("Invalid LOGIN challenge");
}
}
/**
* Evaluate a DIGEST-MD5 login server authentication challenge, returning
* the a result string that should satisfy the clallenge.
*
* @param challenge
* The decoded challenge data, as a string.
*
* @return A formatted challege response, as an array of bytes.
* @exception MessagingException
*/
public byte[] authenticateServer(byte[] challenge) throws MessagingException {
// parse the challenge string and validate.
if (!parseChallenge(challenge)) {
return null;
}
try {
// like all of the client validation steps, the following is order
// critical.
// first add in the URI information.
digest.update((":smtp/" + host).getBytes("US-ASCII"));
// now mix in the response we sent originally
String responseString = clientResponse + new String(Hex.encode(digest.digest()), "US-ASCII");
digest.update(responseString.getBytes("US-ASCII"));
// now convert that into a hex encoded string.
String validationText = new String(Hex.encode(digest.digest()), "US-ASCII");
// if everything went well, this calculated value should match what
// we got back from the server.
// our response back is just a null string....
if (validationText.equals(authenticationResponse)) {
return new byte[0];
}
throw new AuthenticationFailedException("Invalid DIGEST-MD5 response from server");
} catch (UnsupportedEncodingException e) {
throw new MessagingException("Invalid character encodings");
}
}
/**
* Evaluate a DIGEST-MD5 login client authentication challenge, returning
* the a result string that should satisfy the clallenge.
*
* @param challenge
* The decoded challenge data, as a string.
*
* @return A formatted challege response, as an array of bytes.
* @exception MessagingException
*/
public byte[] authenticateClient(byte[] challenge) throws MessagingException {
// parse the challenge string and validate.
if (!parseChallenge(challenge)) {
return null;
}
SecureRandom randomGenerator;
// before doing anything, make sure we can get the required crypto
// support.
try {
randomGenerator = new SecureRandom();
digest = MessageDigest.getInstance("MD5");
} catch (NoSuchAlgorithmException e) {
throw new MessagingException("Unable to access cryptography libraries");
}
// if not configured for a realm, take the first realm from the list, if
// any
if (realm == null) {
// if not handed any realms, just use the host name.
if (realms.isEmpty()) {
realm = host;
} else {
// pretty arbitrary at this point, so just use the first one.
realm = (String) realms.get(0);
}
}
// use secure random to generate a collection of bytes. that is our
// cnonce value.
byte[] cnonceBytes = new byte[32];
randomGenerator.nextBytes(cnonceBytes);
try {
// and get this as a base64 encoded string.
String cnonce = new String(Base64.encode(cnonceBytes), "US-ASCII");
// Now the digest computation part. This gets a bit tricky, and must be
// done in strict order.
// this identifies where we're logging into.
String idString = username + ":" + realm + ":" + password;
// we get a digest for this string, then use the digest for the
// first stage
// of the next digest operation.
digest.update(digest.digest(idString.getBytes("US-ASCII")));
// now we add the nonce strings to the digest.
String nonceString = ":" + nonce + ":" + cnonce;
digest.update(nonceString.getBytes("US-ASCII"));
// hex encode this digest, and add on the string values
// NB, we only support "auth" for the quality of protection value
// (qop). We save this in an
// instance variable because we'll need this to validate the
// response back from the server.
clientResponse = new String(Hex.encode(digest.digest()), "US-ASCII") + ":" + nonce + ":00000001:" + cnonce + ":auth:";
// now we add in identification values to the hash.
String authString = "AUTHENTICATE:smtp/" + host;
digest.update(authString.getBytes("US-ASCII"));
// this gets added on to the client response
String responseString = clientResponse + new String(Hex.encode(digest.digest()), "US-ASCII");
// and this gets fed back into the digest
digest.update(responseString.getBytes("US-ASCII"));
// and FINALLY, the challege digest is hex encoded for sending back
// to the server (whew).
String challengeResponse = new String(Hex.encode(digest.digest()), "US-ASCII");
// now finally build the keyword/value part of the challenge
// response. These can be
// in any order.
StringBuffer response = new StringBuffer();
response.append("username=\"");
response.append(username);
response.append("\"");
response.append(",realm=\"");
response.append(realm);
response.append("\"");
// we only support auth qop values, and the nonce-count (nc) is
// always 1.
response.append(",qop=auth");
response.append(",nc=00000001");
response.append(",nonce=\"");
response.append(nonce);
response.append("\"");
response.append(",cnonce=\"");
response.append(cnonce);
response.append("\"");
response.append(",digest-uri=\"smtp/");
response.append(host);
response.append("\"");
response.append(",response=");
response.append(challengeResponse);
return response.toString().getBytes("US-ASCII");
} catch (UnsupportedEncodingException e) {
throw new MessagingException("Invalid character encodings");
}
}
/**
* Parse the challege string, pulling out information required for our
* challenge response.
*
* @param challenge
* The challenge data.
*
* @return true if there were no errors parsing the string, false otherwise.
* @exception MessagingException
*/
protected boolean parseChallenge(byte[] challenge) throws MessagingException {
realms = new ArrayList();
DigestParser parser = null;
try {
parser = new DigestParser(new String(challenge, "US-ASCII"));
} catch (UnsupportedEncodingException ex) {
}
// parse the entire string...but we ignore everything but the options we
// support.
while (parser.hasMore()) {
NameValuePair pair = parser.parseNameValuePair();
String name = pair.name;
// realm to add to our list?
if (name.equalsIgnoreCase("realm")) {
realms.add(pair.value);
}
// we need the nonce to evaluate the client challenge.
else if (name.equalsIgnoreCase("nonce")) {
nonce = pair.value;
}
// rspauth is the challenge replay back, which allows us to validate
// that server is also legit.
else if (name.equalsIgnoreCase("rspauth")) {
authenticationResponse = pair.value;
}
}
return true;
}
/**
* Inner class for parsing a DIGEST-MD5 challenge string, which is composed
* of "name=value" pairs, separated by "," characters.
*/
class DigestParser {
// the challenge we're parsing
String challenge;
// length of the challenge
int length;
// current parsing position
int position;
/**
* Normal constructor.
*
* @param challenge
* The challenge string to be parsed.
*/
public DigestParser(String challenge) {
this.challenge = challenge;
this.length = challenge.length();
position = 0;
}
/**
* Test if there are more values to parse.
*
* @return true if we've not reached the end of the challenge string,
* false if the challenge has been completely consumed.
*/
private boolean hasMore() {
return position < length;
}
/**
* Return the character at the current parsing position.
*
* @return The string character for the current parse position.
*/
private char currentChar() {
return challenge.charAt(position);
}
/**
* step forward to the next character position.
*/
private void nextChar() {
position++;
}
/**
* Skip over any white space characters in the challenge string.
*/
private void skipSpaces() {
while (position < length && Character.isWhitespace(currentChar())) {
position++;
}
}
/**
* Parse a quoted string used with a name/value pair, accounting for
* escape characters embedded within the string.
*
* @return The string value of the character string.
*/
private String parseQuotedValue() {
// we're here because we found the starting double quote. Step over
// it and parse to the closing
// one.
nextChar();
StringBuffer value = new StringBuffer();
while (hasMore()) {
char ch = currentChar();
// is this an escape char?
if (ch == '\\') {
// step past this, and grab the following character
nextChar();
// we have an invalid quoted string....
if (!hasMore()) {
return null;
}
value.append(currentChar());
}
// end of the string?
else if (ch == '"') {
// step over this so the caller doesn't process it.
nextChar();
// return the constructed string.
return value.toString();
} else {
// step over the character and contine with the next
// characteer1
value.append(ch);
}
nextChar();
}
/* fell off the end without finding a closing quote! */
return null;
}
/**
* Parse a token value used with a name/value pair.
*
* @return The string value of the token. Returns null if nothing is
* found up to the separater.
*/
private String parseTokenValue() {
StringBuffer value = new StringBuffer();
while (hasMore()) {
char ch = currentChar();
switch (ch) {
// process the token separators.
case ' ':
case '\t':
case '(':
case ')':
case '<':
case '>':
case '@':
case ',':
case ';':
case ':':
case '\\':
case '"':
case '/':
case '[':
case ']':
case '?':
case '=':
case '{':
case '}':
// no token characters found? this is bad.
if (value.length() == 0) {
return null;
}
// return the accumulated characters.
return value.toString();
default:
// is this a control character? That's a delimiter (likely
// invalid for the next step,
// but it is a token terminator.
if (ch < 32 || ch > 127) {
// no token characters found? this is bad.
if (value.length() == 0) {
return null;
}
// return the accumulated characters.
return value.toString();
}
value.append(ch);
break;
}
// step to the next character.
nextChar();
}
// no token characters found? this is bad.
if (value.length() == 0) {
return null;
}
// return the accumulated characters.
return value.toString();
}
/**
* Parse out a name token of a name/value pair.
*
* @return The string value of the name.
*/
private String parseName() {
// skip to the value start
skipSpaces();
// the name is a token.
return parseTokenValue();
}
/**
* Parse out a a value of a name/value pair.
*
* @return The string value associated with the name.
*/
private String parseValue() {
// skip to the value start
skipSpaces();
// start of a quoted string?
if (currentChar() == '"') {
// parse it out as a string.
return parseQuotedValue();
}
// the value must be a token.
return parseTokenValue();
}
/**
* Parse a name/value pair in an DIGEST-MD5 string.
*
* @return A NameValuePair object containing the two parts of the value.
* @exception MessagingException
*/
public NameValuePair parseNameValuePair() throws MessagingException {
// get the name token
String name = parseName();
if (name == null) {
throw new MessagingException("Name syntax error");
}
// the name should be followed by an "=" sign
if (!hasMore() || currentChar() != '=') {
throw new MessagingException("Name/value pair syntax error");
}
// step over the equals
nextChar();
// now get the value part
String value = parseValue();
if (value == null) {
throw new MessagingException("Name/value pair syntax error");
}
// skip forward to the terminator, which should either be the end of
// the line or a ","
skipSpaces();
// all that work, only to have a syntax error at the end (sigh)
if (hasMore()) {
if (currentChar() != ',') {
throw new MessagingException("Name/value pair syntax error");
}
// step over, and make sure we position ourselves at either the
// end or the first
// real character for parsing the next name/value pair.
nextChar();
skipSpaces();
}
return new NameValuePair(name, value);
}
}
/**
* Simple inner class to represent a name/value pair.
*/
public class NameValuePair {
public String name;
public String value;
NameValuePair(String name, String value) {
this.name = name;
this.value = value;
}
}
}