Fixes #1949 - Client-side problems with digest authentication. (#1991)

Introduced ContentProvider.isReproducible() to detect whether the
request content can be provided more than once, and modified
ContentProvider implementation accordingly.

Modified AuthenticationProtocolHandler to not send an authenticated
request if the content is not reproducible.

Modified AuthenticationProtocolHandler to tolerate request failures.

Signed-off-by: Simone Bordet <simone.bordet@gmail.com>
This commit is contained in:
Simone Bordet 2017-12-21 02:34:39 -08:00 committed by GitHub
parent 18208a13aa
commit 005ae95bea
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 193 additions and 40 deletions

View File

@ -27,6 +27,7 @@ import java.util.regex.Pattern;
import org.eclipse.jetty.client.api.Authentication; import org.eclipse.jetty.client.api.Authentication;
import org.eclipse.jetty.client.api.Connection; import org.eclipse.jetty.client.api.Connection;
import org.eclipse.jetty.client.api.ContentProvider;
import org.eclipse.jetty.client.api.ContentResponse; import org.eclipse.jetty.client.api.ContentResponse;
import org.eclipse.jetty.client.api.Request; import org.eclipse.jetty.client.api.Request;
import org.eclipse.jetty.client.api.Response; import org.eclipse.jetty.client.api.Response;
@ -86,7 +87,7 @@ public abstract class AuthenticationProtocolHandler implements ProtocolHandler
{ {
HttpRequest request = (HttpRequest)result.getRequest(); HttpRequest request = (HttpRequest)result.getRequest();
ContentResponse response = new HttpContentResponse(result.getResponse(), getContent(), getMediaType(), getEncoding()); ContentResponse response = new HttpContentResponse(result.getResponse(), getContent(), getMediaType(), getEncoding());
if (result.isFailed()) if (result.getResponseFailure() != null)
{ {
if (LOG.isDebugEnabled()) if (LOG.isDebugEnabled())
LOG.debug("Authentication challenge failed {}", result.getFailure()); LOG.debug("Authentication challenge failed {}", result.getFailure());
@ -98,7 +99,7 @@ public abstract class AuthenticationProtocolHandler implements ProtocolHandler
HttpConversation conversation = request.getConversation(); HttpConversation conversation = request.getConversation();
if (conversation.getAttribute(authenticationAttribute) != null) if (conversation.getAttribute(authenticationAttribute) != null)
{ {
// We have already tried to authenticate, but we failed again // We have already tried to authenticate, but we failed again.
if (LOG.isDebugEnabled()) if (LOG.isDebugEnabled())
LOG.debug("Bad credentials for {}", request); LOG.debug("Bad credentials for {}", request);
forwardSuccessComplete(request, response); forwardSuccessComplete(request, response);
@ -111,7 +112,7 @@ public abstract class AuthenticationProtocolHandler implements ProtocolHandler
{ {
if (LOG.isDebugEnabled()) if (LOG.isDebugEnabled())
LOG.debug("Authentication challenge without {} header", header); LOG.debug("Authentication challenge without {} header", header);
forwardFailureComplete(request, null, response, new HttpResponseException("HTTP protocol violation: Authentication challenge without " + header + " header", response)); forwardFailureComplete(request, result.getRequestFailure(), response, new HttpResponseException("HTTP protocol violation: Authentication challenge without " + header + " header", response));
return; return;
} }
@ -138,9 +139,18 @@ public abstract class AuthenticationProtocolHandler implements ProtocolHandler
return; return;
} }
ContentProvider requestContent = request.getContent();
if (requestContent != null && !requestContent.isReproducible())
{
if (LOG.isDebugEnabled())
LOG.debug("Request content not reproducible for {}", request);
forwardSuccessComplete(request, response);
return;
}
try try
{ {
final Authentication.Result authnResult = authentication.authenticate(request, response, headerInfo, conversation); Authentication.Result authnResult = authentication.authenticate(request, response, headerInfo, conversation);
if (LOG.isDebugEnabled()) if (LOG.isDebugEnabled())
LOG.debug("Authentication result {}", authnResult); LOG.debug("Authentication result {}", authnResult);
if (authnResult == null) if (authnResult == null)

View File

@ -22,6 +22,7 @@ import java.io.Closeable;
import java.nio.ByteBuffer; import java.nio.ByteBuffer;
import java.util.Iterator; import java.util.Iterator;
import org.eclipse.jetty.client.HttpClient;
import org.eclipse.jetty.client.util.ByteBufferContentProvider; import org.eclipse.jetty.client.util.ByteBufferContentProvider;
import org.eclipse.jetty.client.util.PathContentProvider; import org.eclipse.jetty.client.util.PathContentProvider;
@ -48,6 +49,22 @@ public interface ContentProvider extends Iterable<ByteBuffer>
*/ */
long getLength(); long getLength();
/**
* <p>Whether this ContentProvider can produce exactly the same content more
* than once.</p>
* <p>Implementations should return {@code true} only if the content can be
* produced more than once, which means that invocations to {@link #iterator()}
* must return a new, independent, iterator instance over the content.</p>
* <p>The {@link HttpClient} implementation may use this method in particular
* cases where it detects that it is safe to retry a request that failed.</p>
*
* @return whether the content can be produced more than once
*/
default boolean isReproducible()
{
return false;
}
/** /**
* An extension of {@link ContentProvider} that provides a content type string * An extension of {@link ContentProvider} that provides a content type string
* to be used as a {@code Content-Type} HTTP header in requests. * to be used as a {@code Content-Type} HTTP header in requests.

View File

@ -57,6 +57,12 @@ public class ByteBufferContentProvider extends AbstractTypedContentProvider
return length; return length;
} }
@Override
public boolean isReproducible()
{
return true;
}
@Override @Override
public Iterator<ByteBuffer> iterator() public Iterator<ByteBuffer> iterator()
{ {
@ -85,12 +91,6 @@ public class ByteBufferContentProvider extends AbstractTypedContentProvider
throw new NoSuchElementException(); throw new NoSuchElementException();
} }
} }
@Override
public void remove()
{
throw new UnsupportedOperationException();
}
}; };
} }
} }

View File

@ -53,6 +53,12 @@ public class BytesContentProvider extends AbstractTypedContentProvider
return length; return length;
} }
@Override
public boolean isReproducible()
{
return true;
}
@Override @Override
public Iterator<ByteBuffer> iterator() public Iterator<ByteBuffer> iterator()
{ {
@ -78,12 +84,6 @@ public class BytesContentProvider extends AbstractTypedContentProvider
throw new NoSuchElementException(); throw new NoSuchElementException();
} }
} }
@Override
public void remove()
{
throw new UnsupportedOperationException();
}
}; };
} }
} }

View File

@ -85,6 +85,12 @@ public class PathContentProvider extends AbstractTypedContentProvider
return fileSize; return fileSize;
} }
@Override
public boolean isReproducible()
{
return true;
}
public ByteBufferPool getByteBufferPool() public ByteBufferPool getByteBufferPool()
{ {
return bufferPool; return bufferPool;

View File

@ -27,6 +27,7 @@ import org.eclipse.jetty.server.Handler;
import org.eclipse.jetty.server.Server; import org.eclipse.jetty.server.Server;
import org.eclipse.jetty.server.ServerConnector; import org.eclipse.jetty.server.ServerConnector;
import org.eclipse.jetty.toolchain.test.TestTracker; import org.eclipse.jetty.toolchain.test.TestTracker;
import org.eclipse.jetty.util.SocketAddressResolver;
import org.eclipse.jetty.util.ssl.SslContextFactory; import org.eclipse.jetty.util.ssl.SslContextFactory;
import org.eclipse.jetty.util.thread.QueuedThreadPool; import org.eclipse.jetty.util.thread.QueuedThreadPool;
import org.junit.After; import org.junit.After;
@ -96,6 +97,7 @@ public abstract class AbstractHttpClientServerTest
clientThreads.setName("client"); clientThreads.setName("client");
client = new HttpClient(transport, sslContextFactory); client = new HttpClient(transport, sslContextFactory);
client.setExecutor(clientThreads); client.setExecutor(clientThreads);
client.setSocketAddressResolver(new SocketAddressResolver.Sync());
client.start(); client.start();
} }

View File

@ -22,11 +22,14 @@ import java.io.File;
import java.io.IOException; import java.io.IOException;
import java.net.URI; import java.net.URI;
import java.nio.ByteBuffer; import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets; import java.util.Iterator;
import java.util.NoSuchElementException;
import java.util.concurrent.CountDownLatch; import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference; import java.util.concurrent.atomic.AtomicReference;
import java.util.function.IntFunction;
import javax.servlet.ServletException; import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletRequest;
@ -34,6 +37,7 @@ import javax.servlet.http.HttpServletResponse;
import org.eclipse.jetty.client.api.Authentication; import org.eclipse.jetty.client.api.Authentication;
import org.eclipse.jetty.client.api.AuthenticationStore; import org.eclipse.jetty.client.api.AuthenticationStore;
import org.eclipse.jetty.client.api.ContentProvider;
import org.eclipse.jetty.client.api.ContentResponse; import org.eclipse.jetty.client.api.ContentResponse;
import org.eclipse.jetty.client.api.Request; import org.eclipse.jetty.client.api.Request;
import org.eclipse.jetty.client.api.Response; import org.eclipse.jetty.client.api.Response;
@ -41,6 +45,7 @@ import org.eclipse.jetty.client.api.Result;
import org.eclipse.jetty.client.util.BasicAuthentication; import org.eclipse.jetty.client.util.BasicAuthentication;
import org.eclipse.jetty.client.util.DeferredContentProvider; import org.eclipse.jetty.client.util.DeferredContentProvider;
import org.eclipse.jetty.client.util.DigestAuthentication; import org.eclipse.jetty.client.util.DigestAuthentication;
import org.eclipse.jetty.http.HttpStatus;
import org.eclipse.jetty.security.Authenticator; import org.eclipse.jetty.security.Authenticator;
import org.eclipse.jetty.security.ConstraintMapping; import org.eclipse.jetty.security.ConstraintMapping;
import org.eclipse.jetty.security.ConstraintSecurityHandler; import org.eclipse.jetty.security.ConstraintSecurityHandler;
@ -220,7 +225,7 @@ public class HttpClientAuthenticationTest extends AbstractHttpClientServerTest
{ {
baseRequest.setHandled(true); baseRequest.setHandled(true);
if (requests.incrementAndGet() == 1) if (requests.incrementAndGet() == 1)
response.sendRedirect(URIUtil.newURI(scheme,request.getServerName(),request.getServerPort(),request.getRequestURI(),null)); response.sendRedirect(URIUtil.newURI(scheme, request.getServerName(), request.getServerPort(), request.getRequestURI(), null));
} }
}); });
@ -259,7 +264,7 @@ public class HttpClientAuthenticationTest extends AbstractHttpClientServerTest
{ {
baseRequest.setHandled(true); baseRequest.setHandled(true);
if (request.getRequestURI().endsWith("/redirect")) if (request.getRequestURI().endsWith("/redirect"))
response.sendRedirect(URIUtil.newURI(scheme,request.getServerName(),request.getServerPort(),"/secure",null)); response.sendRedirect(URIUtil.newURI(scheme, request.getServerName(), request.getServerPort(), "/secure", null));
} }
}); });
@ -424,6 +429,40 @@ public class HttpClientAuthenticationTest extends AbstractHttpClientServerTest
Assert.assertEquals(1, requests.get()); Assert.assertEquals(1, requests.get());
} }
@Test
public void test_NonReproducibleContent() throws Exception
{
startBasic(new EmptyServerHandler());
AuthenticationStore authenticationStore = client.getAuthenticationStore();
URI uri = URI.create(scheme + "://localhost:" + connector.getLocalPort());
BasicAuthentication authentication = new BasicAuthentication(uri, realm, "basic", "basic");
authenticationStore.addAuthentication(authentication);
CountDownLatch resultLatch = new CountDownLatch(1);
byte[] data = new byte[]{'h', 'e', 'l', 'l', 'o'};
DeferredContentProvider content = new DeferredContentProvider(ByteBuffer.wrap(data))
{
@Override
public boolean isReproducible()
{
return false;
}
};
Request request = client.newRequest(uri)
.path("/secure")
.content(content);
request.send(result ->
{
if (result.isSucceeded() && result.getResponse().getStatus() == HttpStatus.UNAUTHORIZED_401)
resultLatch.countDown();
});
content.close();
Assert.assertTrue(resultLatch.await(5, TimeUnit.SECONDS));
}
@Test @Test
public void test_RequestFailsAfterResponse() throws Exception public void test_RequestFailsAfterResponse() throws Exception
{ {
@ -434,32 +473,111 @@ public class HttpClientAuthenticationTest extends AbstractHttpClientServerTest
BasicAuthentication authentication = new BasicAuthentication(uri, realm, "basic", "basic"); BasicAuthentication authentication = new BasicAuthentication(uri, realm, "basic", "basic");
authenticationStore.addAuthentication(authentication); authenticationStore.addAuthentication(authentication);
CountDownLatch successLatch = new CountDownLatch(1); AtomicBoolean fail = new AtomicBoolean(true);
GeneratingContentProvider content = new GeneratingContentProvider(index ->
{
switch (index)
{
case 0:
return ByteBuffer.wrap(new byte[]{'h', 'e', 'l', 'l', 'o'});
case 1:
return ByteBuffer.wrap(new byte[]{'w', 'o', 'r', 'l', 'd'});
case 2:
if (fail.compareAndSet(true, false))
{
// Wait for the 401 response to arrive
// to the authentication protocol handler.
sleep(1000);
// Trigger request failure.
throw new RuntimeException();
}
else
{
return null;
}
default:
throw new IllegalStateException();
}
});
CountDownLatch resultLatch = new CountDownLatch(1); CountDownLatch resultLatch = new CountDownLatch(1);
DeferredContentProvider content = new DeferredContentProvider(); client.newRequest("localhost", connector.getLocalPort())
Request request = client.newRequest("localhost", connector.getLocalPort())
.scheme(scheme) .scheme(scheme)
.path("/secure") .path("/secure")
.content(content) .content(content)
.onResponseSuccess(response -> successLatch.countDown()); .send(result ->
request.send(result -> {
{ if (result.isSucceeded() && result.getResponse().getStatus() == HttpStatus.OK_200)
if (result.isFailed() && result.getResponseFailure() == null) resultLatch.countDown();
resultLatch.countDown(); });
});
// Send some content to make sure the request is dispatched on the server.
content.offer(ByteBuffer.wrap("hello".getBytes(StandardCharsets.UTF_8)));
// Wait for the response to arrive to
// the authentication protocol handler.
Thread.sleep(1000);
// Trigger request failure.
request.abort(new Exception());
// Verify that the response was successful, it's the request that failed.
Assert.assertTrue(successLatch.await(5, TimeUnit.SECONDS));
Assert.assertTrue(resultLatch.await(5, TimeUnit.SECONDS)); Assert.assertTrue(resultLatch.await(5, TimeUnit.SECONDS));
} }
private void sleep(long time)
{
try
{
Thread.sleep(time);
}
catch (InterruptedException x)
{
throw new RuntimeException(x);
}
}
private static class GeneratingContentProvider implements ContentProvider
{
private static final ByteBuffer DONE = ByteBuffer.allocate(0);
private final IntFunction<ByteBuffer> generator;
private GeneratingContentProvider(IntFunction<ByteBuffer> generator)
{
this.generator = generator;
}
@Override
public long getLength()
{
return -1;
}
@Override
public boolean isReproducible()
{
return true;
}
@Override
public Iterator<ByteBuffer> iterator()
{
return new Iterator<ByteBuffer>()
{
private int index;
public ByteBuffer current;
@Override
public boolean hasNext()
{
if (current == null)
{
current = generator.apply(index++);
if (current == null)
current = DONE;
}
return current != DONE;
}
@Override
public ByteBuffer next()
{
ByteBuffer result = current;
current = null;
if (result == null)
throw new NoSuchElementException();
return result;
}
};
}
}
} }