jetty-9 - HTTP client: implemented digest authentication.

This commit is contained in:
Simone Bordet 2012-09-12 19:16:01 +02:00
parent 52accdf761
commit f5d68f0caf
9 changed files with 301 additions and 146 deletions

View File

@ -18,6 +18,7 @@
package org.eclipse.jetty.client;
import java.nio.ByteBuffer;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
@ -25,21 +26,32 @@ import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.eclipse.jetty.client.api.Authentication;
import org.eclipse.jetty.client.api.ContentResponse;
import org.eclipse.jetty.client.api.Request;
import org.eclipse.jetty.client.api.Response;
import org.eclipse.jetty.client.api.Result;
import org.eclipse.jetty.http.HttpHeader;
import org.eclipse.jetty.util.log.Log;
import org.eclipse.jetty.util.log.Logger;
public class AuthenticationProtocolHandler extends Response.Listener.Adapter implements ProtocolHandler
public class AuthenticationProtocolHandler implements ProtocolHandler
{
private static final Pattern WWW_AUTHENTICATE_PATTERN = Pattern.compile("([^\\s]+)\\s+realm=\"([^\"]+)\"(\\s*,\\s*)?(.*)", Pattern.CASE_INSENSITIVE);
public static final Logger LOG = Log.getLogger(AuthenticationProtocolHandler.class);
private static final Pattern WWW_AUTHENTICATE_PATTERN = Pattern.compile("([^\\s]+)\\s+realm=\"([^\"]+)\".*", Pattern.CASE_INSENSITIVE);
private final ResponseNotifier notifier = new ResponseNotifier();
private final HttpClient client;
private final int maxContentLength;
public AuthenticationProtocolHandler(HttpClient client)
{
this(client, 4096);
}
public AuthenticationProtocolHandler(HttpClient client, int maxContentLength)
{
this.client = client;
this.maxContentLength = maxContentLength;
}
@Override
@ -51,107 +63,146 @@ public class AuthenticationProtocolHandler extends Response.Listener.Adapter imp
@Override
public Response.Listener getResponseListener()
{
return this;
return new AuthenticationListener();
}
@Override
public void onComplete(Result result)
private class AuthenticationListener extends Response.Listener.Adapter
{
if (!result.isFailed())
private byte[] buffer = new byte[0];
@Override
public void onContent(Response response, ByteBuffer content)
{
List<WWWAuthenticate> wwwAuthenticates = parseWWWAuthenticate(result.getResponse());
if (buffer.length == maxContentLength)
return;
long newLength = buffer.length + content.remaining();
if (newLength > maxContentLength)
newLength = maxContentLength;
byte[] newBuffer = new byte[(int)newLength];
System.arraycopy(buffer, 0, newBuffer, 0, buffer.length);
content.get(newBuffer, buffer.length, content.remaining());
buffer = newBuffer;
}
@Override
public void onComplete(Result result)
{
Request request = result.getRequest();
ContentResponse response = new HttpContentResponse(result.getResponse(), buffer);
if (result.isFailed())
{
Throwable failure = result.getFailure();
LOG.debug("Authentication challenge failed {}", failure);
forwardFailure(request, response, failure);
return;
}
List<WWWAuthenticate> wwwAuthenticates = parseWWWAuthenticate(response);
if (wwwAuthenticates.isEmpty())
{
// TODO
LOG.debug("Authentication challenge without WWW-Authenticate header");
forwardFailure(request, response, new HttpResponseException("HTTP protocol violation: 401 without WWW-Authenticate header", response));
return;
}
else
final String uri = request.uri();
Authentication authentication = null;
WWWAuthenticate wwwAuthenticate = null;
for (WWWAuthenticate wwwAuthn : wwwAuthenticates)
{
Request request = result.getRequest();
final String uri = request.uri();
Authentication authentication = null;
String params = null;
for (WWWAuthenticate wwwAuthenticate : wwwAuthenticates)
{
authentication = client.getAuthenticationStore().findAuthentication(wwwAuthenticate.type, uri, wwwAuthenticate.realm);
if (authentication != null)
{
params = wwwAuthenticate.params;
break;
}
}
authentication = client.getAuthenticationStore().findAuthentication(wwwAuthn.type, uri, wwwAuthn.realm);
if (authentication != null)
{
final Authentication authn = authentication;
authn.authenticate(request, params, client.getConversation(request));
request.send(new Adapter()
{
@Override
public void onComplete(Result result)
{
if (!result.isFailed())
{
Authentication.Result authnResult = new Authentication.Result(uri, authn);
client.getAuthenticationStore().addAuthenticationResult(authnResult);
}
}
});
}
else
{
noAuthentication(request, result.getResponse());
wwwAuthenticate = wwwAuthn;
break;
}
}
}
}
private List<WWWAuthenticate> parseWWWAuthenticate(Response response)
{
// TODO: these should be ordered by strength
List<WWWAuthenticate> result = new ArrayList<>();
List<String> values = Collections.list(response.headers().getValues(HttpHeader.WWW_AUTHENTICATE.asString()));
for (String value : values)
{
Matcher matcher = WWW_AUTHENTICATE_PATTERN.matcher(value);
if (matcher.matches())
if (authentication == null)
{
String type = matcher.group(1);
String realm = matcher.group(2);
String params = matcher.group(4);
WWWAuthenticate wwwAuthenticate = new WWWAuthenticate(type, realm, params);
result.add(wwwAuthenticate);
LOG.debug("No authentication available for {}", request);
forwardSuccess(request, response);
return;
}
}
return result;
}
private void noAuthentication(Request request, Response response)
{
HttpConversation conversation = client.getConversation(request);
Response.Listener listener = conversation.exchanges().peekFirst().listener();
notifier.notifyBegin(listener, response);
notifier.notifyHeaders(listener, response);
notifier.notifySuccess(listener, response);
// TODO: this call here is horrid, but needed... but here it is too late for the exchange
// TODO: to figure out that the conversation is finished, so we need to manually do it here, no matter what.
// TODO: However, we also need to make sure that requests are not resent with the same ID
// TODO: because here the connection has already been returned to the pool, so the "new" request may see
// TODO: the same conversation but it's not really the case.
// TODO: perhaps the factory for requests should be the conversation ?
conversation.complete();
notifier.notifyComplete(listener, new Result(request, response));
HttpConversation conversation = client.getConversation(request);
final Authentication.Result authnResult = authentication.authenticate(request, response, wwwAuthenticate.value, conversation);
LOG.debug("Authentication result {}", authnResult);
if (authnResult == null)
{
forwardSuccess(request, response);
return;
}
authnResult.apply(request);
request.send(new Adapter()
{
@Override
public void onSuccess(Response response)
{
client.getAuthenticationStore().addAuthenticationResult(authnResult);
}
});
}
private void forwardFailure(Request request, Response response, Throwable failure)
{
HttpConversation conversation = client.getConversation(request);
Response.Listener listener = conversation.exchanges().peekFirst().listener();
notifier.notifyBegin(listener, response);
notifier.notifyHeaders(listener, response);
if (response instanceof ContentResponse)
notifier.notifyContent(listener, response, ByteBuffer.wrap(((ContentResponse)response).content()));
notifier.notifyFailure(listener, response, failure);
conversation.complete();
notifier.notifyComplete(listener, new Result(request, response, failure));
}
private void forwardSuccess(Request request, Response response)
{
HttpConversation conversation = client.getConversation(request);
Response.Listener listener = conversation.exchanges().peekFirst().listener();
notifier.notifyBegin(listener, response);
notifier.notifyHeaders(listener, response);
if (response instanceof ContentResponse)
notifier.notifyContent(listener, response, ByteBuffer.wrap(((ContentResponse)response).content()));
notifier.notifySuccess(listener, response);
conversation.complete();
notifier.notifyComplete(listener, new Result(request, response));
}
private List<WWWAuthenticate> parseWWWAuthenticate(Response response)
{
// TODO: these should be ordered by strength
List<WWWAuthenticate> result = new ArrayList<>();
List<String> values = Collections.list(response.headers().getValues(HttpHeader.WWW_AUTHENTICATE.asString()));
for (String value : values)
{
Matcher matcher = WWW_AUTHENTICATE_PATTERN.matcher(value);
if (matcher.matches())
{
String type = matcher.group(1);
String realm = matcher.group(2);
WWWAuthenticate wwwAuthenticate = new WWWAuthenticate(value, type, realm);
result.add(wwwAuthenticate);
}
}
return result;
}
}
private class WWWAuthenticate
{
private final String value;
private final String type;
private final String realm;
private final String params;
public WWWAuthenticate(String type, String realm, String params)
public WWWAuthenticate(String value, String type, String realm)
{
this.value = value;
this.type = type;
this.realm = realm;
this.params = params;
}
}
}

View File

@ -29,7 +29,7 @@ import org.eclipse.jetty.client.api.AuthenticationStore;
public class HttpAuthenticationStore implements AuthenticationStore
{
private final List<Authentication> authentications = new CopyOnWriteArrayList<>();
private final Map<String, Authentication> results = new ConcurrentHashMap<>();
private final Map<String, Authentication.Result> results = new ConcurrentHashMap<>();
@Override
public void addAuthentication(Authentication authentication)
@ -57,7 +57,7 @@ public class HttpAuthenticationStore implements AuthenticationStore
@Override
public void addAuthenticationResult(Authentication.Result result)
{
results.put(result.getURI(), result.getAuthentication());
results.put(result.getURI(), result);
}
@Override
@ -67,10 +67,10 @@ public class HttpAuthenticationStore implements AuthenticationStore
}
@Override
public Authentication findAuthenticationResult(String uri)
public Authentication.Result findAuthenticationResult(String uri)
{
// TODO: I should match the longest URI
for (Map.Entry<String, Authentication> entry : results.entrySet())
for (Map.Entry<String, Authentication.Result> entry : results.entrySet())
{
if (uri.startsWith(entry.getKey()))
return entry.getValue();

View File

@ -161,9 +161,9 @@ public class HttpConnection extends AbstractConnection implements Connection
request.header(HttpHeader.COOKIE.asString(), cookieString.toString());
// Authorization
Authentication authentication = client.getAuthenticationStore().findAuthenticationResult(request.uri());
if (authentication != null)
authentication.authenticate(request);
Authentication.Result authnResult = client.getAuthenticationStore().findAuthenticationResult(request.uri());
if (authnResult != null)
authnResult.apply(request);
// TODO: decoder headers

View File

@ -24,27 +24,12 @@ public interface Authentication
{
boolean matches(String type, String uri, String realm);
boolean authenticate(Request request, String params, Attributes context);
Result authenticate(Request request, ContentResponse response, String wwwAuthenticate, Attributes context);
public static class Result
public static interface Result
{
private final String uri;
private final Authentication authentication;
String getURI();
public Result(String uri, Authentication authentication)
{
this.uri = uri;
this.authentication = authentication;
}
public String getURI()
{
return uri;
}
public Authentication getAuthentication()
{
return authentication;
}
void apply(Request request);
}
}

View File

@ -30,5 +30,5 @@ public interface AuthenticationStore
public void removeAuthenticationResults();
public Authentication findAuthenticationResult(String uri);
public Authentication.Result findAuthenticationResult(String uri);
}

View File

@ -22,6 +22,7 @@ import java.io.UnsupportedEncodingException;
import java.nio.charset.UnsupportedCharsetException;
import org.eclipse.jetty.client.api.Authentication;
import org.eclipse.jetty.client.api.ContentResponse;
import org.eclipse.jetty.client.api.Request;
import org.eclipse.jetty.http.HttpHeader;
import org.eclipse.jetty.util.Attributes;
@ -56,17 +57,47 @@ public class BasicAuthentication implements Authentication
}
@Override
public boolean authenticate(Request request, String params, Attributes context)
public Result authenticate(Request request, ContentResponse response, String wwwAuthenticate, Attributes context)
{
String encoding = StringUtil.__ISO_8859_1;
try
{
String value = "Basic " + B64Code.encode(user + ":" + password, encoding);
request.header(HttpHeader.AUTHORIZATION.asString(), value);
return new BasicResult(request.uri(), value);
}
catch (UnsupportedEncodingException x)
{
throw new UnsupportedCharsetException(encoding);
}
}
private static class BasicResult implements Result
{
private final String uri;
private final String value;
public BasicResult(String uri, String value)
{
this.uri = uri;
this.value = value;
}
@Override
public String getURI()
{
return uri;
}
@Override
public void apply(Request request)
{
request.header(HttpHeader.AUTHORIZATION.asString(), value);
}
@Override
public String toString()
{
return String.format("Basic authentication result for %s", uri);
}
}
}

View File

@ -26,10 +26,13 @@ import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Random;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.eclipse.jetty.client.api.Authentication;
import org.eclipse.jetty.client.api.ContentResponse;
import org.eclipse.jetty.client.api.Request;
import org.eclipse.jetty.http.HttpHeader;
import org.eclipse.jetty.util.Attributes;
@ -65,19 +68,23 @@ public class DigestAuthentication implements Authentication
}
@Override
public boolean authenticate(Request request, String paramString, Attributes context)
public Result authenticate(Request request, ContentResponse response, String wwwAuthenticate, Attributes context)
{
Map<String, String> params = parseParams(paramString);
// Avoid case sensitivity problems on the 'D' character
String type = "igest";
wwwAuthenticate = wwwAuthenticate.substring(wwwAuthenticate.indexOf(type) + type.length());
Map<String, String> params = parseParams(wwwAuthenticate);
String nonce = params.get("nonce");
if (nonce == null || nonce.length() == 0)
return false;
return null;
String opaque = params.get("opaque");
String algorithm = params.get("algorithm");
if (algorithm == null)
algorithm = "MD5";
MessageDigest digester = getMessageDigest(algorithm);
if (digester == null)
return false;
return null;
String serverQOP = params.get("qop");
String clientQOP = null;
if (serverQOP != null)
@ -89,32 +96,24 @@ public class DigestAuthentication implements Authentication
clientQOP = "auth-int";
}
String hash = compute(digester, clientQOP, content, nonce);
StringBuilder value = new StringBuilder("Digest");
value.append(" username=\"").append(user).append("\"");
value.append(", realm=\"").append(realm).append("\"");
value.append(", nonce=\"").append(nonce).append("\"");
if (opaque != null)
value.append(", opaque=\"").append(opaque).append("\"");
value.append(", algorithm=\"").append(algorithm).append("\"");
value.append(", uri=\"").append(request.uri()).append("\"");
if (clientQOP != null)
value.append(", qop=\"").append(clientQOP).append("\"");
value.append(", response=\"").append(hash).append("\"");
request.header(HttpHeader.AUTHORIZATION.asString(), value.toString());
return new DigestResult(request.uri(), response.content(), realm, user, password, algorithm, nonce, clientQOP, opaque);
}
private Map<String, String> parseParams(String paramString)
private Map<String, String> parseParams(String wwwAuthenticate)
{
Map<String, String> result = new HashMap<>();
List<String> parts = splitParams(paramString);
List<String> parts = splitParams(wwwAuthenticate);
for (String part : parts)
{
Matcher matcher = PARAM_PATTERN.matcher(part);
if (matcher.matches())
result.put(matcher.group(1).trim().toLowerCase(), matcher.group(2).trim());
{
String name = matcher.group(1).trim().toLowerCase();
String value = matcher.group(2).trim();
if (value.startsWith("\"") && value.endsWith("\""))
value = value.substring(1, value.length() - 1);
result.put(name, value);
}
}
return result;
}
@ -129,6 +128,9 @@ public class DigestAuthentication implements Authentication
char ch = paramString.charAt(i);
switch (ch)
{
case '\\':
++i;
break;
case '"':
++quotes;
break;
@ -159,23 +161,108 @@ public class DigestAuthentication implements Authentication
}
}
private String compute(Request request, MessageDigest digester, String qop, byte[] content, String serverNonce)
private class DigestResult implements Result
{
Charset charset = Charset.forName("ISO-8859-1");
String A1 = user + ":" + realm + ":" + password;
String hashA1 = TypeUtil.toHexString(digester.digest(A1.getBytes(charset)));
private final AtomicInteger nonceCount = new AtomicInteger();
private final String uri;
private final byte[] content;
private final String realm;
private final String user;
private final String password;
private final String algorithm;
private final String nonce;
private final String qop;
private final String opaque;
String A2 = request.method().asString() + ":" + request.uri();
if ("auth-int".equals(qop))
A2 += ":" + TypeUtil.toHexString(digester.digest(content));
String hashA2 = TypeUtil.toHexString(digester.digest(A2.getBytes(charset)));
public DigestResult(String uri, byte[] content, String realm, String user, String password, String algorithm, String nonce, String qop, String opaque)
{
this.uri = uri;
this.content = content;
this.realm = realm;
this.user = user;
this.password = password;
this.algorithm = algorithm;
this.nonce = nonce;
this.qop = qop;
this.opaque = opaque;
}
String A3;
if (qop != null)
A3 = hashA1 + ":" + serverNonce + ":" + nonceCount + ":" + clientNonce + ":" + qop + ":" + hashA2;
else
A3 = hashA1 + ":" + serverNonce + ":" + hashA2;
@Override
public String getURI()
{
return uri;
}
return TypeUtil.toHexString(digester.digest(A3.getBytes(charset)));
@Override
public void apply(Request request)
{
MessageDigest digester = getMessageDigest(algorithm);
if (digester == null)
return;
Charset charset = Charset.forName("ISO-8859-1");
String A1 = user + ":" + realm + ":" + password;
String hashA1 = toHexString(digester.digest(A1.getBytes(charset)));
String A2 = request.method().asString() + ":" + request.uri();
if ("auth-int".equals(qop))
A2 += ":" + toHexString(digester.digest(content));
String hashA2 = toHexString(digester.digest(A2.getBytes(charset)));
String nonceCount;
String clientNonce;
String A3;
if (qop != null)
{
nonceCount = nextNonceCount();
clientNonce = newClientNonce();
A3 = hashA1 + ":" + nonce + ":" + nonceCount + ":" + clientNonce + ":" + qop + ":" + hashA2;
}
else
{
nonceCount = null;
clientNonce = null;
A3 = hashA1 + ":" + nonce + ":" + hashA2;
}
String hashA3 = toHexString(digester.digest(A3.getBytes(charset)));
StringBuilder value = new StringBuilder("Digest");
value.append(" username=\"").append(user).append("\"");
value.append(", realm=\"").append(realm).append("\"");
value.append(", nonce=\"").append(nonce).append("\"");
if (opaque != null)
value.append(", opaque=\"").append(opaque).append("\"");
value.append(", algorithm=\"").append(algorithm).append("\"");
value.append(", uri=\"").append(request.uri()).append("\"");
if (qop != null)
{
value.append(", qop=\"").append(qop).append("\"");
value.append(", nc=\"").append(nonceCount).append("\"");
value.append(", cnonce=\"").append(clientNonce).append("\"");
}
value.append(", response=\"").append(hashA3).append("\"");
request.header(HttpHeader.AUTHORIZATION.asString(), value.toString());
}
private String nextNonceCount()
{
String padding = "00000000";
String next = Integer.toHexString(nonceCount.incrementAndGet()).toLowerCase();
return padding.substring(0, padding.length() - next.length()) + next;
}
private String newClientNonce()
{
Random random = new Random();
byte[] bytes = new byte[8];
random.nextBytes(bytes);
return toHexString(bytes);
}
private String toHexString(byte[] bytes)
{
return TypeUtil.toHexString(bytes).toLowerCase();
}
}
}

View File

@ -93,7 +93,7 @@ public class HttpClientAuthenticationTest extends AbstractHttpClientServerTest
public void test_DigestAuthentication() throws Exception
{
startDigest(new EmptyHandler());
test_Authentication(new DigestAuthentication("http://localhost:" + connector.getLocalPort(), realm));
test_Authentication(new DigestAuthentication("http://localhost:" + connector.getLocalPort(), realm, "digest", "digest"));
}
private void test_Authentication(Authentication authentication) throws Exception
@ -113,7 +113,7 @@ public class HttpClientAuthenticationTest extends AbstractHttpClientServerTest
// Request without Authentication causes a 401
Request request = client.newRequest("localhost", connector.getLocalPort()).path("/test");
ContentResponse response = request.send().get(5, TimeUnit.SECONDS);
ContentResponse response = request.send().get(555, TimeUnit.SECONDS);
Assert.assertNotNull(response);
Assert.assertEquals(401, response.status());
Assert.assertEquals(1, requests.get());
@ -133,7 +133,7 @@ public class HttpClientAuthenticationTest extends AbstractHttpClientServerTest
client.getRequestListeners().add(requestListener);
// Request with authentication causes a 401 (no previous successful authentication) + 200
response = request.send().get(5, TimeUnit.SECONDS);
response = request.send().get(555, TimeUnit.SECONDS);
Assert.assertNotNull(response);
Assert.assertEquals(200, response.status());
Assert.assertEquals(2, requests.get());

View File

@ -1,2 +1,3 @@
# Format is <user>:<password>,<roles>
basic:basic
digest:digest