Skip to content

Commit 8a89306

Browse files
authored
Preserve REST client auth despite 401 response (#30558)
The default behaviour for Apache HTTP client is to mimic the standard browser behaviour of clearing the authentication cache (for a given host) if that host responds with 401. This behaviour is appropriate in a interactive browser environment where the user is given the opportunity to provide alternative credentials, but it is not the preferred behaviour for the ES REST client. X-Pack may respond with a 401 status if a request is made before the node/cluster has recovered sufficient state to know how to handle the provided authentication credentials - for example the security index need to be recovered before we can authenticate native users. In these cases the correct behaviour is to retry with the same credentials (rather than discarding those credentials).
1 parent 890afad commit 8a89306

File tree

3 files changed

+99
-13
lines changed

3 files changed

+99
-13
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
/*
2+
* Licensed to Elasticsearch under one or more contributor
3+
* license agreements. See the NOTICE file distributed with
4+
* this work for additional information regarding copyright
5+
* ownership. Elasticsearch licenses this file to you under
6+
* the Apache License, Version 2.0 (the "License"); you may
7+
* not use this file except in compliance with the License.
8+
* You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*
19+
*
20+
*/
21+
22+
package org.elasticsearch.client;
23+
24+
import org.apache.commons.logging.Log;
25+
import org.apache.commons.logging.LogFactory;
26+
import org.apache.http.HttpHost;
27+
import org.apache.http.auth.AuthScheme;
28+
import org.apache.http.impl.client.TargetAuthenticationStrategy;
29+
import org.apache.http.protocol.HttpContext;
30+
31+
/**
32+
* An {@link org.apache.http.client.AuthenticationStrategy} implementation that does <em>not</em> perform
33+
* any special handling if authentication fails.
34+
* The default handler in Apache HTTP client mimics standard browser behaviour of clearing authentication
35+
* credentials if it receives a 401 response from the server. While this can be useful for browser, it is
36+
* rarely the desired behaviour with the Elasticsearch REST API.
37+
* If the code using the REST client has configured credentials for the REST API, then we can and should
38+
* assume that this is intentional, and those credentials represent the best possible authentication
39+
* mechanism to the Elasticsearch node.
40+
* If we receive a 401 status, a probably cause is that the authentication mechanism in place was unable
41+
* to perform the requisite password checks (the node has not yet recovered its state, or an external
42+
* authentication provider was unavailable).
43+
* If this occurs, then the desired behaviour is for the Rest client to retry with the same credentials
44+
* (rather than trying with no credentials, or expecting the calling code to provide alternate credentials).
45+
*/
46+
final class PersistentCredentialsAuthenticationStrategy extends TargetAuthenticationStrategy {
47+
48+
private final Log logger = LogFactory.getLog(PersistentCredentialsAuthenticationStrategy.class);
49+
50+
@Override
51+
public void authFailed(HttpHost host, AuthScheme authScheme, HttpContext context) {
52+
if (logger.isDebugEnabled()) {
53+
logger.debug("Authentication to " + host + " failed (scheme: " + authScheme.getSchemeName()
54+
+ "). Preserving credentials for next request");
55+
}
56+
// Do nothing.
57+
// The superclass implementation of method will clear the credentials from the cache, but we don't
58+
}
59+
}

client/rest/src/main/java/org/elasticsearch/client/RestClientBuilder.java

+2-1
Original file line numberDiff line numberDiff line change
@@ -204,7 +204,8 @@ private CloseableHttpAsyncClient createHttpClient() {
204204
HttpAsyncClientBuilder httpClientBuilder = HttpAsyncClientBuilder.create().setDefaultRequestConfig(requestConfigBuilder.build())
205205
//default settings for connection pooling may be too constraining
206206
.setMaxConnPerRoute(DEFAULT_MAX_CONN_PER_ROUTE).setMaxConnTotal(DEFAULT_MAX_CONN_TOTAL)
207-
.setSSLContext(SSLContext.getDefault());
207+
.setSSLContext(SSLContext.getDefault())
208+
.setTargetAuthenticationStrategy(new PersistentCredentialsAuthenticationStrategy());
208209
if (httpClientConfigCallback != null) {
209210
httpClientBuilder = httpClientConfigCallback.customizeHttpClient(httpClientBuilder);
210211
}

client/rest/src/test/java/org/elasticsearch/client/RestClientSingleHostIntegTests.java

+38-12
Original file line numberDiff line numberDiff line change
@@ -31,14 +31,14 @@
3131
import org.apache.http.entity.ContentType;
3232
import org.apache.http.entity.StringEntity;
3333
import org.apache.http.impl.client.BasicCredentialsProvider;
34+
import org.apache.http.impl.client.TargetAuthenticationStrategy;
3435
import org.apache.http.impl.nio.client.HttpAsyncClientBuilder;
36+
import org.apache.http.message.BasicHeader;
3537
import org.apache.http.nio.entity.NStringEntity;
3638
import org.apache.http.util.EntityUtils;
3739
import org.elasticsearch.mocksocket.MockHttpServer;
3840
import org.junit.After;
39-
import org.junit.AfterClass;
4041
import org.junit.Before;
41-
import org.junit.BeforeClass;
4242

4343
import java.io.IOException;
4444
import java.io.InputStreamReader;
@@ -147,6 +147,8 @@ public HttpAsyncClientBuilder customizeHttpClient(final HttpAsyncClientBuilder h
147147
if (usePreemptiveAuth == false) {
148148
// disable preemptive auth by ignoring any authcache
149149
httpClientBuilder.disableAuthCaching();
150+
// don't use the "persistent credentials strategy"
151+
httpClientBuilder.setTargetAuthenticationStrategy(new TargetAuthenticationStrategy());
150152
}
151153

152154
return httpClientBuilder.setDefaultCredentialsProvider(credentialsProvider);
@@ -193,7 +195,7 @@ public void onFailure(Exception exception) {
193195
assertTrue("timeout waiting for requests to be sent", latch.await(10, TimeUnit.SECONDS));
194196
if (exceptions.isEmpty() == false) {
195197
AssertionError error = new AssertionError("expected no failures but got some. see suppressed for first 10 of ["
196-
+ exceptions.size() + "] failures");
198+
+ exceptions.size() + "] failures");
197199
for (Exception exception : exceptions.subList(0, Math.min(10, exceptions.size()))) {
198200
error.addSuppressed(exception);
199201
}
@@ -217,7 +219,7 @@ public void testHeaders() throws IOException {
217219
Response esResponse;
218220
try {
219221
esResponse = restClient.performRequest(method, "/" + statusCode, Collections.<String, String>emptyMap(), requestHeaders);
220-
} catch(ResponseException e) {
222+
} catch (ResponseException e) {
221223
esResponse = e.getResponse();
222224
}
223225

@@ -291,8 +293,8 @@ public void testEncodeParams() throws IOException {
291293
/**
292294
* Verify that credentials are sent on the first request with preemptive auth enabled (default when provided with credentials).
293295
*/
294-
public void testPreemptiveAuthEnabled() throws IOException {
295-
final String[] methods = { "POST", "PUT", "GET", "DELETE" };
296+
public void testPreemptiveAuthEnabled() throws IOException {
297+
final String[] methods = {"POST", "PUT", "GET", "DELETE"};
296298

297299
try (RestClient restClient = createRestClient(true, true)) {
298300
for (final String method : methods) {
@@ -306,8 +308,8 @@ public void testPreemptiveAuthEnabled() throws IOException {
306308
/**
307309
* Verify that credentials are <em>not</em> sent on the first request with preemptive auth disabled.
308310
*/
309-
public void testPreemptiveAuthDisabled() throws IOException {
310-
final String[] methods = { "POST", "PUT", "GET", "DELETE" };
311+
public void testPreemptiveAuthDisabled() throws IOException {
312+
final String[] methods = {"POST", "PUT", "GET", "DELETE"};
311313

312314
try (RestClient restClient = createRestClient(true, false)) {
313315
for (final String method : methods) {
@@ -318,12 +320,31 @@ public void testPreemptiveAuthDisabled() throws IOException {
318320
}
319321
}
320322

323+
/**
324+
* Verify that credentials continue to be sent even if a 401 (Unauthorized) response is received
325+
*/
326+
public void testAuthCredentialsAreNotClearedOnAuthChallenge() throws IOException {
327+
final String[] methods = {"POST", "PUT", "GET", "DELETE"};
328+
329+
try (RestClient restClient = createRestClient(true, true)) {
330+
for (final String method : methods) {
331+
Header realmHeader = new BasicHeader("WWW-Authenticate", "Basic realm=\"test\"");
332+
final Response response401 = bodyTest(restClient, method, 401, new Header[]{realmHeader});
333+
assertThat(response401.getHeader("Authorization"), startsWith("Basic"));
334+
335+
final Response response200 = bodyTest(restClient, method, 200, new Header[0]);
336+
assertThat(response200.getHeader("Authorization"), startsWith("Basic"));
337+
}
338+
}
339+
340+
}
341+
321342
public void testUrlWithoutLeadingSlash() throws Exception {
322343
if (pathPrefix.length() == 0) {
323344
try {
324345
restClient.performRequest("GET", "200");
325346
fail("request should have failed");
326-
} catch(ResponseException e) {
347+
} catch (ResponseException e) {
327348
assertEquals(404, e.getResponse().getStatusLine().getStatusCode());
328349
}
329350
} else {
@@ -335,8 +356,8 @@ public void testUrlWithoutLeadingSlash() throws Exception {
335356
{
336357
//pathPrefix is not required to start with '/', will be added automatically
337358
try (RestClient restClient = RestClient.builder(
338-
new HttpHost(httpServer.getAddress().getHostString(), httpServer.getAddress().getPort()))
339-
.setPathPrefix(pathPrefix.substring(1)).build()) {
359+
new HttpHost(httpServer.getAddress().getHostString(), httpServer.getAddress().getPort()))
360+
.setPathPrefix(pathPrefix.substring(1)).build()) {
340361
Response response = restClient.performRequest("GET", "200");
341362
//a trailing slash gets automatically added if a pathPrefix is configured
342363
assertEquals(200, response.getStatusLine().getStatusCode());
@@ -350,10 +371,15 @@ private Response bodyTest(final String method) throws IOException {
350371
}
351372

352373
private Response bodyTest(final RestClient restClient, final String method) throws IOException {
353-
String requestBody = "{ \"field\": \"value\" }";
354374
int statusCode = randomStatusCode(getRandom());
375+
return bodyTest(restClient, method, statusCode, new Header[0]);
376+
}
377+
378+
private Response bodyTest(RestClient restClient, String method, int statusCode, Header[] headers) throws IOException {
379+
String requestBody = "{ \"field\": \"value\" }";
355380
Request request = new Request(method, "/" + statusCode);
356381
request.setJsonEntity(requestBody);
382+
request.setHeaders(headers);
357383
Response esResponse;
358384
try {
359385
esResponse = restClient.performRequest(request);

0 commit comments

Comments
 (0)