/*
* DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS HEADER.
*
* Copyright (c) 2010-2011 Oracle and/or its affiliates. All rights reserved.
*
* The contents of this file are subject to the terms of either the GNU
* General Public License Version 2 only ("GPL") or the Common Development
* and Distribution License("CDDL") (collectively, the "License"). You
* may not use this file except in compliance with the License. You can
* obtain a copy of the License at
* http://glassfish.java.net/public/CDDL+GPL_1_1.html
* or packager/legal/LICENSE.txt. See the License for the specific
* language governing permissions and limitations under the License.
*
* When distributing the software, include this License Header Notice in each
* file and include the License file at packager/legal/LICENSE.txt.
*
* GPL Classpath Exception:
* Oracle designates this particular file as subject to the "Classpath"
* exception as provided by Oracle in the GPL Version 2 section of the License
* file that accompanied this code.
*
* Modifications:
* If applicable, add the following below the License Header, with the fields
* enclosed by brackets [] replaced by your own identifying information:
* "Portions Copyright [year] [name of copyright owner]"
*
* Contributor(s):
* If you wish your version of this file to be governed by only the CDDL or
* only the GPL Version 2, indicate your decision by adding "[Contributor]
* elects to include this software in this distribution under the [CDDL or GPL
* Version 2] license." If you don't indicate a single choice of license, a
* recipient has the option to distribute your version of this file under
* either the CDDL, the GPL Version 2 or to extend the choice of license to
* its licensees as provided above. However, if you add GPL Version 2 code
* and therefore, elected the GPL Version 2 license, then the option applies
* only if the new code is made subject to such option by the copyright
* holder.
*/
package com.sun.jersey.api.client.filter;
import com.sun.jersey.api.client.ClientHandlerException;
import com.sun.jersey.api.client.ClientRequest;
import com.sun.jersey.api.client.ClientResponse;
import com.sun.jersey.api.client.ClientResponse.Status;
import javax.ws.rs.core.HttpHeaders;
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.util.Collection;
import java.util.HashMap;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Client filter adding HTTP Digest authentication headers in the request
*
* @author raphael.jolivet@gmail.com
*/
public final class HTTPDigestAuthFilter extends ClientFilter {
// -------------------------------------------------------
// Static constants
// -------------------------------------------------------
/**
* Number of bytes use in the random number generated by client
*/
static private final int CNONCE_NB_BYTES = 4;
/**
* Init random generator
*/
static private final SecureRandom randomGenerator;
static {
try {
randomGenerator = SecureRandom.getInstance("SHA1PRNG");
} catch (Exception e) {
throw new Error(e);
}
}
/**
* Pattern to parse key value pairs like: 'foobar="foo bar",toto=titi,...'
*/
static private final Pattern TOKENS_PATTERN = Pattern.compile("(\\w+)\\s*=\\s*(\"([^\"]+)\"|(\\w+))\\s*,?\\s*");
// -------------------------------------------------------
// Inner enums
// -------------------------------------------------------
/**
* Types of "qop"
*/
private enum QOP {
AUTH,
AUTH_INT
}
// -------------------------------------------------------
// Attributes
// -------------------------------------------------------
private final String user;
private final String pass;
// State
private class State {
String nextNonce;
String realm;
String opaque;
String algorithm;
String domain;
QOP qop = null;
int counter = 1;
}
private final ThreadLocal<State> state = new ThreadLocal<State>() {
@Override
protected State initialValue() {
return new State();
}
};
// -------------------------------------------------------
// Constructor
// -------------------------------------------------------
/**
* Creates a new HTTP Digest Authentication filter using provided username
* and password credentials.
*
* @param user username
* @param pass password
*/
public HTTPDigestAuthFilter (
final String user,
final String pass) {
this.user = user;
this.pass = pass;
}
// -------------------------------------------------------
// Private utils
// -------------------------------------------------------
/**
* Append 'key="val",' to a buffer
*/
static private void addKeyVal(
StringBuffer buffer,
String key,
String val,
boolean withQuotes) {
String quote = (withQuotes) ? "\"" : "";
buffer.append(
key + '=' + quote + val + quote + ',');
}
static private void addKeyVal(
StringBuffer buffer,
String key,
String val) {
addKeyVal(buffer, key, val, true);
}
/**
* Converts array of bytes in hexadecimal format.
*
* @param data data to be converted to hex
*/
private static String convertToHex(byte[] data) {
StringBuffer buf = new StringBuffer();
for (int i = 0; i < data.length; i++) {
int halfbyte = (data[i] >>> 4) & 0x0F;
int two_halfs = 0;
do {
if ((0 <= halfbyte) && (halfbyte <= 9))
buf.append((char) ('0' + halfbyte));
else
buf.append((char) ('a' + (halfbyte - 10)));
halfbyte = data[i] & 0x0F;
} while (two_halfs++ < 1);
}
return buf.toString();
}
/**
* Compute md5 hash of a string and returns the hexadecimal representation of it.
*
* @param text data from which would be returned hash value computed.
* @return computed hash.
*/
static String MD5(String text) {
try {
MessageDigest md;
md = MessageDigest.getInstance("MD5");
md.update(text.getBytes("iso-8859-1"), 0, text.length());
byte[] md5hash = md.digest();
return convertToHex(md5hash);
} catch (Exception e) {
throw new Error(e);
}
}
/**
* Concatenate the strings with ':' and then pass it to md5
*
* @param vals values to be concatenated.
* @return concatenated string.
*/
static String concatMD5(String... vals) {
// Loop on vals : populate a buffer
StringBuilder buff = new StringBuilder();
for (String val : vals) {
buff.append(val);
buff.append(':');
} // End of loop on vals
// Remove last separator
buff.deleteCharAt(buff.length() - 1);
return MD5(buff.toString());
}
/**
* Generate a random sequence of bytes and return it hexadecimal representation.
*
* @param nbBytes number of random bytes.
* @return random data.
*/
String randHexBytes(int nbBytes) {
// Init array of bytes
byte[] bytes = new byte[nbBytes];
// Fill it with randomness
randomGenerator.nextBytes(bytes);
// Transform to Hexa
return convertToHex(bytes);
}
/**
* Parse the "authenticate" header of the server response.
* Key/Value pairs are filled.
* If several schemes are found, only the DIGEST one is returned.
* If no www-authenticate field is found with the "Digest" scheme, null is returned
*
* @param lines All www-authenticate lines of the header.
* @return parsed headers, null when "Digest" scheme not present.
*/
static HashMap<String, String> parseHeaders(Collection<String> lines) {
// Loop on lines
for (String line : lines) {
// Split spaces
String[] parts = line.trim().split("\\s+", 2);
// There should be 2 parts
if (parts.length != 2) continue;
// Check the scheme
if (!parts[0].toLowerCase().equals("digest")) continue;
// Parse the tokens
Matcher match = TOKENS_PATTERN.matcher(parts[1]);
// Create map
HashMap<String, String> result = new HashMap<String, String>();
// Find next pair
while (match.find()) {
// Defensive check, we should have 4 groups (key)=("(val)" | (val))
int nbGroups = match.groupCount();
if (nbGroups != 4) continue;
// Key without quotes
String key = match.group(1);
String valNoQuotes = match.group(3);
String valQuotes = match.group(4);
// Put pairs in maps
result.put(
key,
(valNoQuotes == null) ? valQuotes : valNoQuotes);
} // End of loop on pairs
return result;
} // End of loop on lines
// No line with scheme "digest" found
return null;
}
// -------------------------------------------------------
// Main filter method
// -------------------------------------------------------
@Override
public ClientResponse handle(final ClientRequest request) throws ClientHandlerException {
// Remember if we sent a request a with headers
boolean reqHadAuthHeaders = false;
// Have we already login ? : Then add authorization info to the headers
if (state.get().nextNonce != null) {
// Remember we sent headers
reqHadAuthHeaders = true;
// Alias to string representation of qop
String qopStr = null;
if (state.get().qop != null) qopStr = (state.get().qop == QOP.AUTH_INT) ? "auth-int" : "auth";
// Init the value of the "authorized" header
StringBuffer buff = new StringBuffer();
// Authorization scheme
buff.append("Digest ");
// Key/val pairs
addKeyVal(buff, "username", this.user);
addKeyVal(buff, "realm", state.get().realm);
addKeyVal(buff, "nonce", state.get().nextNonce);
if (state.get().opaque != null) addKeyVal(buff, "opaque", state.get().opaque);
if (state.get().algorithm != null) addKeyVal(buff, "algorithm", state.get().algorithm, false);
if (state.get().qop != null) addKeyVal(buff, "qop", qopStr, false);
//if (this.domain != null) addKeyVal(buff, "domain", this.domain);
// -------------------------------------------------------
// Compute the Digest Hash
// -------------------------------------------------------
// HA1
String HA1 = concatMD5(
this.user,
state.get().realm,
this.pass);
// Get exact requested URI
String uri = request.getURI().getPath();
// Repeat URI in header
addKeyVal(buff, "uri", uri);
// HA2 : Switch on qop
String HA2;
if (state.get().qop == QOP.AUTH_INT && (request.getEntity() != null)) {
HA2 = concatMD5(
request.getMethod(),
uri,
request.getEntity().toString());
} else {
HA2 = concatMD5(
request.getMethod(),
uri);
}
// Compute response
String response;
if (state.get().qop == null) { // Simple response
response = concatMD5(
HA1,
state.get().nextNonce,
HA2);
} else { // Quality of protection is set
// Generate client nonce (UID)
String cnonce = randHexBytes(CNONCE_NB_BYTES);
// Counter in hexadecimal
String nc = String.format("%08x", state.get().counter);
state.get().counter += 1;
// Add them to key/value pairs
addKeyVal(buff, "cnonce", cnonce);
addKeyVal(buff, "nc", nc, false);
response = concatMD5(
HA1,
state.get().nextNonce,
nc,
cnonce,
qopStr,
HA2);
}
// Append the response
addKeyVal(buff, "response", response);
// Remove the last coma
buff.deleteCharAt(buff.length() - 1);
String authLine = buff.toString();
// Add the whole Authorization line to the header
request.getHeaders().add(
HttpHeaders.AUTHORIZATION,
authLine);
} // End of "we already logged in ?"
// Forward the request to the next filter and get the result back
ClientResponse response = getNext().handle(request);
// The server asked for authentication ? (status 401)
if (response.getClientResponseStatus() == Status.UNAUTHORIZED) {
// Parse the www-authentication headers
HashMap<String, String> map = parseHeaders(
response.getHeaders().get(
HttpHeaders.WWW_AUTHENTICATE));
// No digest authentication request found ? => We can do nothing more
if (map == null) return response;
// Get header values
state.get().realm = map.get("realm");
state.get().nextNonce = map.get("nonce");
state.get().opaque = map.get("opaque");
state.get().algorithm = map.get("algorithm");
state.get().domain = map.get("domain");
// Parse Qop
String qop = map.get("qop");
if (qop == null) {
state.get().qop = null;
} else {
if (qop.contains("auth-int")) {
state.get().qop = QOP.AUTH_INT;
} else if (qop.contains("auth")) {
state.get().qop = QOP.AUTH;
} else {
state.get().qop = null;
}
}
// Parse "stale"
String staleStr = map.get("stale");
boolean stale = (staleStr != null) && staleStr.toLowerCase().equals("true");
// Did we send the initial request without headers ?
// Or the server asked to retry with new nonce ?
if (stale || !reqHadAuthHeaders) {
// Then try to resent same request with updated headers
return this.handle(request);
} else {
// We already tried to log, but the authentication failed :
// Just forward this response
return response;
}
}
// Not 401 status : no authentication issue
return response;
} // End of #handle()
}