/*
* Licensed to Elasticsearch under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch 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.elasticsearch.index.reindex;
import org.elasticsearch.ElasticsearchSecurityException;
import org.elasticsearch.ElasticsearchStatusException;
import org.elasticsearch.action.ActionListener;
import org.elasticsearch.action.ActionRequest;
import org.elasticsearch.action.ActionResponse;
import org.elasticsearch.action.admin.cluster.node.info.NodeInfo;
import org.elasticsearch.action.search.SearchAction;
import org.elasticsearch.action.support.ActionFilter;
import org.elasticsearch.action.support.ActionFilterChain;
import org.elasticsearch.action.support.WriteRequest.RefreshPolicy;
import org.elasticsearch.common.bytes.BytesArray;
import org.elasticsearch.common.inject.Inject;
import org.elasticsearch.common.network.NetworkModule;
import org.elasticsearch.common.settings.Settings;
import org.elasticsearch.common.transport.TransportAddress;
import org.elasticsearch.common.util.concurrent.ThreadContext;
import org.elasticsearch.plugins.ActionPlugin;
import org.elasticsearch.plugins.Plugin;
import org.elasticsearch.rest.RestStatus;
import org.elasticsearch.tasks.Task;
import org.elasticsearch.test.ESSingleNodeTestCase;
import org.elasticsearch.threadpool.ThreadPool;
import org.elasticsearch.transport.Netty4Plugin;
import org.junit.Before;
import java.util.Arrays;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import static java.util.Collections.emptyMap;
import static java.util.Collections.singletonList;
import static java.util.Collections.singletonMap;
import static org.elasticsearch.index.reindex.ReindexTestCase.matcher;
import static org.hamcrest.Matchers.containsString;
public class ReindexFromRemoteWithAuthTests extends ESSingleNodeTestCase {
private TransportAddress address;
@Override
protected Collection<Class<? extends Plugin>> getPlugins() {
return Arrays.asList(
Netty4Plugin.class,
ReindexFromRemoteWithAuthTests.TestPlugin.class,
ReindexPlugin.class);
}
@Override
protected Settings nodeSettings() {
Settings.Builder settings = Settings.builder().put(super.nodeSettings());
settings.put(NetworkModule.HTTP_ENABLED.getKey(), true);
// Whitelist reindexing from the http host we're going to use
settings.put(TransportReindexAction.REMOTE_CLUSTER_WHITELIST.getKey(), "127.0.0.1:*");
settings.put(NetworkModule.HTTP_TYPE_KEY, Netty4Plugin.NETTY_HTTP_TRANSPORT_NAME);
return settings.build();
}
@Before
public void setupSourceIndex() {
client().prepareIndex("source", "test").setSource("test", "test").setRefreshPolicy(RefreshPolicy.IMMEDIATE).get();
}
@Before
public void fetchTransportAddress() {
NodeInfo nodeInfo = client().admin().cluster().prepareNodesInfo().get().getNodes().get(0);
address = nodeInfo.getHttp().getAddress().publishAddress();
}
/**
* Build a {@link RemoteInfo}, defaulting values that we don't care about in this test to values that don't hurt anything.
*/
private RemoteInfo newRemoteInfo(String username, String password, Map<String, String> headers) {
return new RemoteInfo("http", address.getAddress(), address.getPort(), new BytesArray("{\"match_all\":{}}"), username, password,
headers, RemoteInfo.DEFAULT_SOCKET_TIMEOUT, RemoteInfo.DEFAULT_CONNECT_TIMEOUT);
}
public void testReindexFromRemoteWithAuthentication() throws Exception {
ReindexRequestBuilder request = ReindexAction.INSTANCE.newRequestBuilder(client()).source("source").destination("dest")
.setRemoteInfo(newRemoteInfo("Aladdin", "open sesame", emptyMap()));
assertThat(request.get(), matcher().created(1));
}
public void testReindexSendsHeaders() throws Exception {
ReindexRequestBuilder request = ReindexAction.INSTANCE.newRequestBuilder(client()).source("source").destination("dest")
.setRemoteInfo(newRemoteInfo(null, null, singletonMap(TestFilter.EXAMPLE_HEADER, "doesn't matter")));
ElasticsearchStatusException e = expectThrows(ElasticsearchStatusException.class, () -> request.get());
assertEquals(RestStatus.BAD_REQUEST, e.status());
assertThat(e.getMessage(), containsString("Hurray! Sent the header!"));
}
public void testReindexWithoutAuthenticationWhenRequired() throws Exception {
ReindexRequestBuilder request = ReindexAction.INSTANCE.newRequestBuilder(client()).source("source").destination("dest")
.setRemoteInfo(newRemoteInfo(null, null, emptyMap()));
ElasticsearchStatusException e = expectThrows(ElasticsearchStatusException.class, () -> request.get());
assertEquals(RestStatus.UNAUTHORIZED, e.status());
assertThat(e.getMessage(), containsString("\"reason\":\"Authentication required\""));
assertThat(e.getMessage(), containsString("\"WWW-Authenticate\":\"Basic realm=auth-realm\""));
}
public void testReindexWithBadAuthentication() throws Exception {
ReindexRequestBuilder request = ReindexAction.INSTANCE.newRequestBuilder(client()).source("source").destination("dest")
.setRemoteInfo(newRemoteInfo("junk", "auth", emptyMap()));
ElasticsearchStatusException e = expectThrows(ElasticsearchStatusException.class, () -> request.get());
assertThat(e.getMessage(), containsString("\"reason\":\"Bad Authorization\""));
}
/**
* Plugin that demands authentication.
*/
public static class TestPlugin extends Plugin implements ActionPlugin {
@Override
public List<Class<? extends ActionFilter>> getActionFilters() {
return singletonList(ReindexFromRemoteWithAuthTests.TestFilter.class);
}
@Override
public Collection<String> getRestHeaders() {
return Arrays.asList(TestFilter.AUTHORIZATION_HEADER, TestFilter.EXAMPLE_HEADER);
}
}
/**
* Action filter that will reject the request if it isn't authenticated.
*/
public static class TestFilter implements ActionFilter {
/**
* The authorization required. Corresponds to username="Aladdin" and password="open sesame". It is the example in
* <a href="https://tools.ietf.org/html/rfc1945#section-11.1">HTTP/1.0's RFC</a>.
*/
private static final String REQUIRED_AUTH = "Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ==";
private static final String AUTHORIZATION_HEADER = "Authorization";
private static final String EXAMPLE_HEADER = "Example-Header";
private final ThreadContext context;
@Inject
public TestFilter(ThreadPool threadPool) {
context = threadPool.getThreadContext();
}
@Override
public int order() {
return Integer.MIN_VALUE;
}
@Override
public <Request extends ActionRequest, Response extends ActionResponse> void apply(Task task, String action,
Request request, ActionListener<Response> listener, ActionFilterChain<Request, Response> chain) {
if (false == action.equals(SearchAction.NAME)) {
chain.proceed(task, action, request, listener);
return;
}
if (context.getHeader(EXAMPLE_HEADER) != null) {
throw new IllegalArgumentException("Hurray! Sent the header!");
}
String auth = context.getHeader(AUTHORIZATION_HEADER);
if (auth == null) {
ElasticsearchSecurityException e = new ElasticsearchSecurityException("Authentication required",
RestStatus.UNAUTHORIZED);
e.addHeader("WWW-Authenticate", "Basic realm=auth-realm");
throw e;
}
if (false == REQUIRED_AUTH.equals(auth)) {
throw new ElasticsearchSecurityException("Bad Authorization", RestStatus.FORBIDDEN);
}
chain.proceed(task, action, request, listener);
}
}
}