[7.x] Refactor SamlAuthenticationIT (#57162) (#61568)

Refactor the tests to not require a mock HTTP Server. This has been
the cause of flakiness and removing it doesn't affect the logical
coverage of this suite. The "fake UI" is now simulated by an
http client that makes the necessary requests to Elasticsearch APIs.
This commit is contained in:
Ioannis Kakavas 2020-08-26 15:34:56 +03:00 committed by GitHub
parent 4701832879
commit 283eaabc71
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
1 changed files with 63 additions and 303 deletions

View File

@ -5,9 +5,6 @@
*/ */
package org.elasticsearch.xpack.security.authc.saml; package org.elasticsearch.xpack.security.authc.saml;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpHandler;
import com.sun.net.httpserver.HttpServer;
import org.apache.http.Header; import org.apache.http.Header;
import org.apache.http.HttpEntity; import org.apache.http.HttpEntity;
import org.apache.http.HttpHost; import org.apache.http.HttpHost;
@ -20,24 +17,15 @@ import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.client.methods.HttpGet; import org.apache.http.client.methods.HttpGet;
import org.apache.http.client.methods.HttpPost; import org.apache.http.client.methods.HttpPost;
import org.apache.http.client.methods.HttpRequestBase; import org.apache.http.client.methods.HttpRequestBase;
import org.apache.http.client.utils.URLEncodedUtils;
import org.apache.http.cookie.Cookie;
import org.apache.http.cookie.CookieOrigin;
import org.apache.http.cookie.MalformedCookieException;
import org.apache.http.impl.client.CloseableHttpClient; import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClients; import org.apache.http.impl.client.HttpClients;
import org.apache.http.impl.cookie.DefaultCookieSpec;
import org.apache.http.message.BasicHeader; import org.apache.http.message.BasicHeader;
import org.apache.http.message.BasicNameValuePair; import org.apache.http.message.BasicNameValuePair;
import org.apache.http.protocol.BasicHttpContext; import org.apache.http.protocol.BasicHttpContext;
import org.apache.http.protocol.HTTP;
import org.apache.http.protocol.HttpContext; import org.apache.http.protocol.HttpContext;
import org.apache.http.protocol.HttpCoreContext; import org.apache.http.protocol.HttpCoreContext;
import org.apache.http.util.CharArrayBuffer;
import org.apache.http.util.EntityUtils; import org.apache.http.util.EntityUtils;
import org.apache.logging.log4j.message.ParameterizedMessage; import org.apache.logging.log4j.message.ParameterizedMessage;
import org.elasticsearch.ElasticsearchException;
import org.elasticsearch.cli.SuppressForbidden;
import org.elasticsearch.client.Request; import org.elasticsearch.client.Request;
import org.elasticsearch.client.RequestOptions; import org.elasticsearch.client.RequestOptions;
import org.elasticsearch.client.Response; import org.elasticsearch.client.Response;
@ -55,16 +43,12 @@ import org.elasticsearch.common.util.concurrent.ThreadContext;
import org.elasticsearch.common.xcontent.XContentBuilder; import org.elasticsearch.common.xcontent.XContentBuilder;
import org.elasticsearch.common.xcontent.XContentFactory; import org.elasticsearch.common.xcontent.XContentFactory;
import org.elasticsearch.common.xcontent.XContentType; import org.elasticsearch.common.xcontent.XContentType;
import org.elasticsearch.mocksocket.MockHttpServer;
import org.elasticsearch.test.rest.ESRestTestCase; import org.elasticsearch.test.rest.ESRestTestCase;
import org.elasticsearch.xpack.core.common.socket.SocketAccess; import org.elasticsearch.xpack.core.common.socket.SocketAccess;
import org.elasticsearch.xpack.core.security.authc.support.UsernamePasswordToken; import org.elasticsearch.xpack.core.security.authc.support.UsernamePasswordToken;
import org.elasticsearch.xpack.core.ssl.CertParsingUtils; import org.elasticsearch.xpack.core.ssl.CertParsingUtils;
import org.hamcrest.Matchers; import org.hamcrest.Matchers;
import org.junit.After;
import org.junit.AfterClass;
import org.junit.Before; import org.junit.Before;
import org.junit.BeforeClass;
import javax.net.ssl.KeyManager; import javax.net.ssl.KeyManager;
import javax.net.ssl.SSLContext; import javax.net.ssl.SSLContext;
@ -72,11 +56,7 @@ import javax.net.ssl.TrustManager;
import javax.net.ssl.X509ExtendedTrustManager; import javax.net.ssl.X509ExtendedTrustManager;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.io.InputStreamReader;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.net.URI; import java.net.URI;
import java.net.URISyntaxException;
import java.nio.file.Path; import java.nio.file.Path;
import java.security.SecureRandom; import java.security.SecureRandom;
import java.security.cert.Certificate; import java.security.cert.Certificate;
@ -84,8 +64,6 @@ import java.util.ArrayList;
import java.util.Collections; import java.util.Collections;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.concurrent.Executor;
import java.util.concurrent.ExecutorService;
import java.util.regex.Matcher; import java.util.regex.Matcher;
import java.util.regex.Pattern; import java.util.regex.Pattern;
@ -95,77 +73,16 @@ import static org.hamcrest.Matchers.contains;
import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.equalTo;
import static org.hamcrest.Matchers.greaterThan; import static org.hamcrest.Matchers.greaterThan;
import static org.hamcrest.Matchers.instanceOf; import static org.hamcrest.Matchers.instanceOf;
import static org.hamcrest.Matchers.iterableWithSize;
import static org.hamcrest.Matchers.notNullValue; import static org.hamcrest.Matchers.notNullValue;
import static org.hamcrest.Matchers.startsWith; import static org.hamcrest.Matchers.startsWith;
/** /**
* An integration test for validating SAML authentication against a real Identity Provider (Shibboleth) * An integration test for validating SAML authentication against a real Identity Provider (Shibboleth)
*/ */
@SuppressForbidden(reason = "uses sun http server")
public class SamlAuthenticationIT extends ESRestTestCase { public class SamlAuthenticationIT extends ESRestTestCase {
private static final String SP_LOGIN_PATH = "/saml/login";
private static final String SP_ACS_PATH_1 = "/saml/acs1";
private static final String SP_ACS_PATH_2 = "/saml/acs2";
private static final String SP_ACS_PATH_WRONG_REALM = "/saml/acs3";
private static final String SAML_RESPONSE_FIELD = "SAMLResponse"; private static final String SAML_RESPONSE_FIELD = "SAMLResponse";
private static final String SAML_REQUEST_COOKIE = "saml-request";
private static final String KIBANA_PASSWORD = "K1b@na K1b@na K1b@na"; private static final String KIBANA_PASSWORD = "K1b@na K1b@na K1b@na";
private static HttpServer httpServer;
private URI acs;
@BeforeClass
public static void setupHttpServer() throws IOException {
InetSocketAddress address = new InetSocketAddress(InetAddress.getLoopbackAddress().getHostAddress(), 0);
httpServer = MockHttpServer.createHttp(address, 0);
httpServer.start();
}
@AfterClass
public static void shutdownHttpServer() {
final Executor executor = httpServer.getExecutor();
if (executor instanceof ExecutorService) {
terminate((ExecutorService) executor);
}
httpServer.stop(0);
httpServer = null;
}
@Before
public void setupHttpContext() {
httpServer.createContext(SP_LOGIN_PATH, wrapFailures(this::httpLogin));
httpServer.createContext(SP_ACS_PATH_1, wrapFailures(this::httpAcs));
httpServer.createContext(SP_ACS_PATH_2, wrapFailures(this::httpAcs));
httpServer.createContext(SP_ACS_PATH_WRONG_REALM, wrapFailures(this::httpAcsFailure));
}
/**
* Wraps a {@code HttpHandler} in a {@code try-catch} block that returns a
* 500 server error if an exception or an {@link AssertionError} occurs.
*/
private HttpHandler wrapFailures(HttpHandler handler) {
return http -> {
try {
handler.handle(http);
} catch (AssertionError | Exception e) {
logger.warn(new ParameterizedMessage("Failure while handling {}", http.getRequestURI()), e);
http.getResponseHeaders().add("x-test-failure", e.toString());
http.sendResponseHeaders(500, 0);
http.close();
throw e;
}
};
}
@After
public void clearHttpContext() {
httpServer.removeContext(SP_LOGIN_PATH);
httpServer.removeContext(SP_ACS_PATH_1);
httpServer.removeContext(SP_ACS_PATH_2);
}
@Override @Override
protected Settings restAdminSettings() { protected Settings restAdminSettings() {
@ -176,19 +93,19 @@ public class SamlAuthenticationIT extends ESRestTestCase {
} }
/** /**
* We perform all requests to Elasticsearch as the "kibana" user, as this is the user that will be used * We perform all requests to Elasticsearch as the "kibana_system" user, as this is the user that will be used
* in a typical SAML deployment (where Kibana is providing the UI for the SAML Web SSO interactions). * in a typical SAML deployment (where Kibana is providing the UI for the SAML Web SSO interactions).
* Before we can use the Kibana user, we need to set its password to something we know. * Before we can use the Kibana user, we need to set its password to something we know.
*/ */
@Before @Before
public void setKibanaPassword() throws IOException { public void setKibanaPassword() throws IOException {
Request request = new Request("PUT", "/_security/user/kibana/_password"); Request request = new Request("PUT", "/_security/user/kibana_system/_password");
request.setJsonEntity("{ \"password\" : \"" + KIBANA_PASSWORD + "\" }"); request.setJsonEntity("{ \"password\" : \"" + KIBANA_PASSWORD + "\" }");
adminClient().performRequest(request); adminClient().performRequest(request);
} }
/** /**
* This is a simple mapping that maps the "thor" user in the "shibboleth" realm to the "kibana_users" role. * This is a simple mapping that maps the "thor" user in the "shibboleth" realm to the "kibana_admin" role.
* We could do something more complex, but we have unit tests for role-mapping - this is just to verify that * We could do something more complex, but we have unit tests for role-mapping - this is just to verify that
* the mapping runs OK in a real environment. * the mapping runs OK in a real environment.
*/ */
@ -196,8 +113,8 @@ public class SamlAuthenticationIT extends ESRestTestCase {
public void setupRoleMapping() throws IOException { public void setupRoleMapping() throws IOException {
Request request = new Request("PUT", "/_security/role_mapping/thor-kibana"); Request request = new Request("PUT", "/_security/role_mapping/thor-kibana");
request.setJsonEntity(Strings.toString(XContentBuilder.builder(XContentType.JSON.xContent()) request.setJsonEntity(Strings.toString(XContentBuilder.builder(XContentType.JSON.xContent())
.startObject() .startObject()
.array("roles", new String[] { "kibana_user"} ) .array("roles", new String[]{"kibana_admin"})
.field("enabled", true) .field("enabled", true)
.startObject("rules") .startObject("rules")
.startArray("all") .startArray("all")
@ -215,7 +132,7 @@ public class SamlAuthenticationIT extends ESRestTestCase {
@Before @Before
public void setupNativeUser() throws IOException { public void setupNativeUser() throws IOException {
final Map<String, Object> body = MapBuilder.<String, Object>newMapBuilder() final Map<String, Object> body = MapBuilder.<String, Object>newMapBuilder()
.put("roles", Collections.singletonList("kibana_dashboard_only_user")) .put("roles", Collections.singletonList("kibana_admin"))
.put("full_name", "Thor Son of Odin") .put("full_name", "Thor Son of Odin")
.put("password", randomAlphaOfLengthBetween(8, 16)) .put("password", randomAlphaOfLengthBetween(8, 16))
.put("metadata", Collections.singletonMap("is_native", true)) .put("metadata", Collections.singletonMap("is_native", true))
@ -229,55 +146,51 @@ public class SamlAuthenticationIT extends ESRestTestCase {
* It uses: * It uses:
* <ul> * <ul>
* <li>A real IdP (Shibboleth, running locally)</li> * <li>A real IdP (Shibboleth, running locally)</li>
* <li>A fake UI, running in this JVM, that roughly mimic Kibana (see {@link #httpLogin}, {@link #httpAcs})</li>
* <li>A fake web browser (apache http client)</li> * <li>A fake web browser (apache http client)</li>
* <li>A fake "UI" ( same apache http client)</li>
* </ul> * </ul>
* It takes the following steps: * It takes the following steps:
* <ol> * <ol>
* <li>Requests a "login" on the local UI</li>
* <li>Walks through the login process at the IdP</li> * <li>Walks through the login process at the IdP</li>
* <li>Receives a JSON response from the local UI that has a Bearer token</li> * <li>Receives a JSON response that has a Bearer token</li>
* <li>Uses that token to verify the user details</li> * <li>Uses that token to verify the user details</li>
* </ol> * </ol>
*/ */
@AwaitsFix(bugUrl = "https://github.com/elastic/elasticsearch/issues/44410")
public void testLoginUserWithSamlRoleMapping() throws Exception { public void testLoginUserWithSamlRoleMapping() throws Exception {
// this ACS comes from the config in build.gradle // this realm name comes from the config in build.gradle
final Tuple<String, String> authTokens = loginViaSaml("http://localhost:54321" + SP_ACS_PATH_1); final Tuple<String, String> authTokens = loginViaSaml("shibboleth");
verifyElasticsearchAccessTokenForRoleMapping(authTokens.v1()); verifyElasticsearchAccessTokenForRoleMapping(authTokens.v1());
final String accessToken = verifyElasticsearchRefreshToken(authTokens.v2()); final String accessToken = verifyElasticsearchRefreshToken(authTokens.v2());
verifyElasticsearchAccessTokenForRoleMapping(accessToken); verifyElasticsearchAccessTokenForRoleMapping(accessToken);
} }
@AwaitsFix(bugUrl = "https://github.com/elastic/elasticsearch/issues/44410")
public void testLoginUserWithAuthorizingRealm() throws Exception { public void testLoginUserWithAuthorizingRealm() throws Exception {
// this ACS comes from the config in build.gradle // this realm name comes from the config in build.gradle
final Tuple<String, String> authTokens = loginViaSaml("http://localhost:54321" + SP_ACS_PATH_2); final Tuple<String, String> authTokens = loginViaSaml("shibboleth_native");
verifyElasticsearchAccessTokenForAuthorizingRealms(authTokens.v1()); verifyElasticsearchAccessTokenForAuthorizingRealms(authTokens.v1());
final String accessToken = verifyElasticsearchRefreshToken(authTokens.v2()); final String accessToken = verifyElasticsearchRefreshToken(authTokens.v2());
verifyElasticsearchAccessTokenForAuthorizingRealms(accessToken); verifyElasticsearchAccessTokenForAuthorizingRealms(accessToken);
} }
@AwaitsFix(bugUrl = "https://github.com/elastic/elasticsearch/issues/44410")
public void testLoginWithWrongRealmFails() throws Exception { public void testLoginWithWrongRealmFails() throws Exception {
this.acs = new URI("http://localhost:54321" + SP_ACS_PATH_WRONG_REALM);
final BasicHttpContext context = new BasicHttpContext(); final BasicHttpContext context = new BasicHttpContext();
try (CloseableHttpClient client = getHttpClient()) { try (CloseableHttpClient client = getHttpClient()) {
final URI loginUri = goToLoginPage(client, context); // this realm name comes from the config in build.gradle
final URI consentUri = submitLoginForm(client, context, loginUri); final Tuple<URI, String> idAndLoginUri = getIdpLoginPage(client, context, "shibboleth_negative");
final Tuple<URI, String> tuple = submitConsentForm(context, client, consentUri); final URI consentUri = submitLoginForm(client, context, idAndLoginUri.v1());
submitSamlResponse(context, client, tuple.v1(), tuple.v2(), false); final String samlResponse = submitConsentForm(context, client, consentUri);
submitSamlResponse(samlResponse, idAndLoginUri.v2(), "shibboleth", false);
} }
} }
private Tuple<String, String> loginViaSaml(String acs) throws Exception { private Tuple<String, String> loginViaSaml(String realmName) throws Exception {
this.acs = new URI(acs);
final BasicHttpContext context = new BasicHttpContext(); final BasicHttpContext context = new BasicHttpContext();
try (CloseableHttpClient client = getHttpClient()) { try (CloseableHttpClient client = getHttpClient()) {
final URI loginUri = goToLoginPage(client, context); final Tuple<URI, String> loginAndId = getIdpLoginPage(client, context, realmName);
final URI consentUri = submitLoginForm(client, context, loginUri); final URI consentUri = submitLoginForm(client, context, loginAndId.v1());
final Tuple<URI, String> tuple = submitConsentForm(context, client, consentUri); final String samlResponse = submitConsentForm(context, client, consentUri);
final Map<String, Object> result = submitSamlResponse(context, client, tuple.v1(), tuple.v2(), true); final Map<String, Object> result = submitSamlResponse(samlResponse, loginAndId.v2(), realmName, true);
assertThat(result.get("username"), equalTo("thor")); assertThat(result.get("username"), equalTo("thor"));
final Object expiresIn = result.get("expires_in"); final Object expiresIn = result.get("expires_in");
@ -304,7 +217,7 @@ public class SamlAuthenticationIT extends ESRestTestCase {
final Map<String, Object> map = callAuthenticateApiUsingAccessToken(accessToken); final Map<String, Object> map = callAuthenticateApiUsingAccessToken(accessToken);
assertThat(map.get("username"), equalTo("thor")); assertThat(map.get("username"), equalTo("thor"));
assertThat(map.get("full_name"), equalTo("Thor Odinson")); assertThat(map.get("full_name"), equalTo("Thor Odinson"));
assertSingletonList(map.get("roles"), "kibana_user"); assertSingletonList(map.get("roles"), "kibana_admin");
assertThat(map.get("metadata"), instanceOf(Map.class)); assertThat(map.get("metadata"), instanceOf(Map.class));
final Map<?, ?> metadata = (Map<?, ?>) map.get("metadata"); final Map<?, ?> metadata = (Map<?, ?>) map.get("metadata");
@ -322,7 +235,7 @@ public class SamlAuthenticationIT extends ESRestTestCase {
final Map<String, Object> map = callAuthenticateApiUsingAccessToken(accessToken); final Map<String, Object> map = callAuthenticateApiUsingAccessToken(accessToken);
assertThat(map.get("username"), equalTo("thor")); assertThat(map.get("username"), equalTo("thor"));
assertThat(map.get("full_name"), equalTo("Thor Son of Odin")); assertThat(map.get("full_name"), equalTo("Thor Son of Odin"));
assertSingletonList(map.get("roles"), "kibana_dashboard_only_user"); assertSingletonList(map.get("roles"), "kibana_admin");
assertThat(map.get("metadata"), instanceOf(Map.class)); assertThat(map.get("metadata"), instanceOf(Map.class));
final Map<?, ?> metadata = (Map<?, ?>) map.get("metadata"); final Map<?, ?> metadata = (Map<?, ?>) map.get("metadata");
@ -357,12 +270,19 @@ public class SamlAuthenticationIT extends ESRestTestCase {
} }
/** /**
* Navigates to the login page on the local (in memory) HTTP UI. * Gets the SingleSignOnService endpoint of the IDP by calling the appropriate ES API, navigates to that URL and parses the form
* URI that we can use to login to the Shibboleth IDP
* *
* @return A URI to which the "login form" should be submitted. * @return a Tuple with the URL of the login form in the IDP and the ID of the authentication request
*/ */
private URI goToLoginPage(CloseableHttpClient client, BasicHttpContext context) throws IOException { private Tuple<URI, String> getIdpLoginPage(CloseableHttpClient client, BasicHttpContext context, String realmNane) throws Exception {
HttpGet login = new HttpGet(getUrl(SP_LOGIN_PATH)); final Map<String, String> body = Collections.singletonMap("realm", realmNane);
Request request = buildRequest("POST", "/_security/saml/prepare", body, kibanaAuth());
final Response prepare = client().performRequest(request);
assertOK(prepare);
final Map<String, Object> responseBody = parseResponseAsMap(prepare.getEntity());
logger.info("Created SAML authentication request {}", responseBody);
HttpGet login = new HttpGet((String) responseBody.get("redirect"));
String target = execute(client, login, context, response -> { String target = execute(client, login, context, response -> {
assertHttpOk(response.getStatusLine()); assertHttpOk(response.getStatusLine());
return getFormTarget(response.getEntity().getContent()); return getFormTarget(response.getEntity().getContent());
@ -372,9 +292,8 @@ public class SamlAuthenticationIT extends ESRestTestCase {
assertThat("Target must be an absolute path", target, startsWith("/")); assertThat("Target must be an absolute path", target, startsWith("/"));
final Object host = context.getAttribute(HttpCoreContext.HTTP_TARGET_HOST); final Object host = context.getAttribute(HttpCoreContext.HTTP_TARGET_HOST);
assertThat(host, instanceOf(HttpHost.class)); assertThat(host, instanceOf(HttpHost.class));
final String uri = ((HttpHost) host).toURI() + target; final String uri = ((HttpHost) host).toURI() + target;
return toUri(uri); return Tuple.tuple(new URI(uri), (String) responseBody.get("id"));
} }
/** /**
@ -409,9 +328,9 @@ public class SamlAuthenticationIT extends ESRestTestCase {
* The consent form is a step that Shibboleth inserts into the login flow to confirm that the user is willing to send their * The consent form is a step that Shibboleth inserts into the login flow to confirm that the user is willing to send their
* personal details to the application (SP) that they are logging in to. * personal details to the application (SP) that they are logging in to.
* *
* @return A tuple of ( URI to SP's Assertion-Consumer-Service, SAMLResponse to post to the service ) * @return The SAMLResponse to post to the service
*/ */
private Tuple<URI, String> submitConsentForm(BasicHttpContext context, CloseableHttpClient client, URI consentUri) throws IOException { private String submitConsentForm(BasicHttpContext context, CloseableHttpClient client, URI consentUri) throws IOException {
final HttpPost form = new HttpPost(consentUri); final HttpPost form = new HttpPost(consentUri);
List<NameValuePair> params = new ArrayList<>(); List<NameValuePair> params = new ArrayList<>();
params.add(new BasicNameValuePair("_shib_idp_consentOptions", "_shib_idp_globalConsent")); params.add(new BasicNameValuePair("_shib_idp_consentOptions", "_shib_idp_globalConsent"));
@ -423,33 +342,32 @@ public class SamlAuthenticationIT extends ESRestTestCase {
} }
/** /**
* Submits a SAML assertion to the ACS URI. * Submits a SAML Response to the _security/saml/authenticate endpoint.
* *
* @param acs The URI to the Service Provider's Assertion-Consumer-Service. * @param saml The (deflated + base64 encoded) {@code SAMLResponse} parameter to post
* @param saml The (deflated + base64 encoded) {@code SAMLResponse} parameter to post the ACS * @param id The SAML authentication request ID this response is InResponseTo
* @param shouldSucceed Whether we expect this authentication to succeed
*/ */
private Map<String, Object> submitSamlResponse(BasicHttpContext context, CloseableHttpClient client, URI acs, String saml, private Map<String, Object> submitSamlResponse(String saml, String id, String realmName, boolean shouldSucceed) throws IOException {
boolean shouldSucceed) // By POSTing to the ES API directly, we miss the check that the IDP would post this to the ACS that we would expect them to, but
throws IOException { // we implicitly check this while checking the `Destination` element of the SAML response in the SAML realm.
assertThat("SAML submission target", acs, notNullValue()); final MapBuilder<String, Object> bodyBuilder = new MapBuilder<String, Object>()
assertThat(acs, equalTo(this.acs)); .put("content", saml)
assertThat("SAML submission content", saml, notNullValue()); .put("realm", realmName)
.put("ids", Collections.singletonList(id));
// The ACS url provided from the SP is going to be wrong because the gradle try {
// build doesn't know what the web server's port is, so it uses a fake one. final Response response =
final HttpPost form = new HttpPost(getUrl(this.acs.getPath())); client().performRequest(buildRequest("POST", "/_security/saml/authenticate", bodyBuilder.map(), kibanaAuth()));
List<NameValuePair> params = new ArrayList<>();
params.add(new BasicNameValuePair(SAML_RESPONSE_FIELD, saml));
form.setEntity(new UrlEncodedFormEntity(params));
return execute(client, form, context, response -> {
if (shouldSucceed) { if (shouldSucceed) {
assertHttpOk(response.getStatusLine()); assertHttpOk(response.getStatusLine());
} else {
assertHttpUnauthorized(response.getStatusLine());
} }
return parseResponseAsMap(response.getEntity()); return parseResponseAsMap(response.getEntity());
}); } catch (ResponseException e) {
if (shouldSucceed == false) {
assertHttpUnauthorized(e.getResponse().getStatusLine());
}
return Collections.emptyMap();
}
} }
/** /**
@ -461,14 +379,11 @@ public class SamlAuthenticationIT extends ESRestTestCase {
} }
/** /**
* Finds the target URL and {@code SAMLResponse} for the HTML form from the provided content. * Finds the {@code SAMLResponse} for the HTML form from the provided content.
*/ */
private Tuple<URI, String> parseSamlSubmissionForm(InputStream content) throws IOException { private String parseSamlSubmissionForm(InputStream content) throws IOException {
final List<String> lines = Streams.readAllLines(content); final List<String> lines = Streams.readAllLines(content);
return new Tuple<>( return findLine(lines, "name=\"" + SAML_RESPONSE_FIELD + "\" value=\"([^\"]+)\"");
toUri(htmlDecode(findLine(lines, "<form action=\"([^\"]+)\""))),
findLine(lines, "name=\"" + SAML_RESPONSE_FIELD + "\" value=\"([^\"]+)\"")
);
} }
private String findLine(List<String> lines, String regex) { private String findLine(List<String> lines, String regex) {
@ -482,27 +397,6 @@ public class SamlAuthenticationIT extends ESRestTestCase {
return null; return null;
} }
private String htmlDecode(String text) {
final Pattern hexEntity = Pattern.compile("&#x([0-9a-f]{2});");
while (true) {
final Matcher matcher = hexEntity.matcher(text);
if (matcher.find() == false) {
return text;
}
char ch = (char) Integer.parseInt(matcher.group(1), 16);
text = matcher.replaceFirst(Character.toString(ch));
}
}
private URI toUri(String uri) {
try {
return new URI(uri);
} catch (URISyntaxException e) {
fail("Cannot parse URI " + uri + " - " + e);
return null;
}
}
private Map<String, Object> parseResponseAsMap(HttpEntity entity) throws IOException { private Map<String, Object> parseResponseAsMap(HttpEntity entity) throws IOException {
return convertToMap(XContentType.JSON.xContent(), entity.getContent(), false); return convertToMap(XContentType.JSON.xContent(), entity.getContent(), false);
} }
@ -526,130 +420,6 @@ public class SamlAuthenticationIT extends ESRestTestCase {
} }
} }
private String getUrl(String path) {
return getWebServerUri().resolve(path).toString();
}
/**
* Provides the "login" handler for the fake WebApp.
* This interacts with Elasticsearch (using the rest client) to find the login page for the IdP, and then
* sends a redirect to that page.
*/
private void httpLogin(HttpExchange http) throws IOException {
final Map<String, String> body = Collections.singletonMap("acs", this.acs.toString());
Request request = buildRequest("POST", "/_security/saml/prepare", body, kibanaAuth());
final Response prepare = client().performRequest(request);
assertOK(prepare);
final Map<String, Object> responseBody = parseResponseAsMap(prepare.getEntity());
logger.info("Created SAML authentication request {}", responseBody);
http.getResponseHeaders().add("Set-Cookie", SAML_REQUEST_COOKIE + "=" + responseBody.get("id") + "&" + responseBody.get("realm"));
http.getResponseHeaders().add("Location", (String) responseBody.get("redirect"));
http.sendResponseHeaders(302, 0);
http.close();
}
/**
* Provides the "Assertion-Consumer-Service" handler for the fake WebApp.
* This interacts with Elasticsearch (using the rest client) to perform a SAML login, and just
* forwards the JSON response back to the client.
*/
private void httpAcs(HttpExchange http) throws IOException {
final Response saml = samlAuthenticate(http);
assertOK(saml);
final byte[] content = Streams.copyToString(new InputStreamReader(saml.getEntity().getContent())).getBytes();
http.getResponseHeaders().add("Content-Type", "application/json");
http.sendResponseHeaders(200, content.length);
http.getResponseBody().write(content);
http.close();
}
/**
* Provides the "Assertion-Consumer-Service" handler for the fake WebApp that can handle failures.
* This interacts with Elasticsearch (using the rest client) to perform a SAML login, asserts that it
* failed with a 401 and returns 401 to the browser.
*/
private void httpAcsFailure(HttpExchange http) throws IOException {
final List<NameValuePair> pairs = parseRequestForm(http);
assertThat(pairs, iterableWithSize(1));
final String saml = getSamlContentFromParams(pairs);
final Tuple<String, String> storedValues = getCookie(http);
assertThat(storedValues, notNullValue());
final String id = storedValues.v1();
assertThat(id, notNullValue());
final String realmName = randomFrom("shibboleth_" + randomAlphaOfLength(8), "shibboleth_native");
final Map<String, ?> body = MapBuilder.<String, Object>newMapBuilder()
.put("content", saml)
.put("ids", Collections.singletonList(id))
.put("realm", realmName)
.map();
ResponseException e = expectThrows(ResponseException.class, () -> {
client().performRequest(buildRequest("POST", "/_security/saml/authenticate", body, kibanaAuth()));
});
assertThat(401, equalTo(e.getResponse().getStatusLine().getStatusCode()));
http.sendResponseHeaders(401, 0);
http.close();
}
private Response samlAuthenticate(HttpExchange http) throws IOException {
final List<NameValuePair> pairs = parseRequestForm(http);
assertThat(pairs, iterableWithSize(1));
final String saml = getSamlContentFromParams(pairs);
final Tuple<String, String> storedValues = getCookie(http);
assertThat(storedValues, notNullValue());
final String id = storedValues.v1();
final String realmName = storedValues.v2();
assertThat(id, notNullValue());
assertThat(realmName, notNullValue());
final MapBuilder<String, Object> bodyBuilder = new MapBuilder<String, Object>()
.put("content", saml)
.put("ids", Collections.singletonList(id));
if (randomBoolean()) {
bodyBuilder.put("realm", realmName);
}
return client().performRequest(buildRequest("POST", "/_security/saml/authenticate", bodyBuilder.map(), kibanaAuth()));
}
private String getSamlContentFromParams(List<NameValuePair> params) {
return params.stream()
.filter(p -> SAML_RESPONSE_FIELD.equals(p.getName()))
.map(p -> p.getValue())
.findFirst()
.orElseGet(() -> {
fail("Cannot find " + SAML_RESPONSE_FIELD + " in form fields");
return null;
});
}
private List<NameValuePair> parseRequestForm(HttpExchange http) throws IOException {
String reqContent = Streams.copyToString(new InputStreamReader(http.getRequestBody()));
final CharArrayBuffer buffer = new CharArrayBuffer(reqContent.length());
buffer.append(reqContent);
return URLEncodedUtils.parse(buffer, HTTP.DEF_CONTENT_CHARSET, '&');
}
private Tuple<String, String> getCookie(HttpExchange http) throws IOException {
try {
final String cookies = http.getRequestHeaders().getFirst("Cookie");
if (cookies == null) {
logger.warn("No cookies in: {}", http.getResponseHeaders());
return null;
}
Header header = new BasicHeader("Cookie", cookies);
final URI serverUri = getWebServerUri();
final URI requestURI = http.getRequestURI();
final CookieOrigin origin = new CookieOrigin(serverUri.getHost(), serverUri.getPort(), requestURI.getPath(), false);
final List<Cookie> parsed = new DefaultCookieSpec().parse(header, origin);
return parsed.stream().filter(c -> SAML_REQUEST_COOKIE.equals(c.getName())).map(c -> {
String[] values = c.getValue().split("&");
return Tuple.tuple(values[0], values[1]);
}).findFirst().orElse(null);
} catch (MalformedCookieException e) {
throw new IOException("Cannot read cookies", e);
}
}
private void assertHttpOk(StatusLine status) { private void assertHttpOk(StatusLine status) {
assertThat("Unexpected HTTP Response status: " + status, status.getStatusCode(), Matchers.equalTo(200)); assertThat("Unexpected HTTP Response status: " + status, status.getStatusCode(), Matchers.equalTo(200));
} }
@ -678,7 +448,7 @@ public class SamlAuthenticationIT extends ESRestTestCase {
} }
private static BasicHeader kibanaAuth() { private static BasicHeader kibanaAuth() {
final String auth = UsernamePasswordToken.basicAuthHeaderValue("kibana", new SecureString(KIBANA_PASSWORD.toCharArray())); final String auth = UsernamePasswordToken.basicAuthHeaderValue("kibana_system", new SecureString(KIBANA_PASSWORD.toCharArray()));
return new BasicHeader(UsernamePasswordToken.BASIC_AUTH_HEADER, auth); return new BasicHeader(UsernamePasswordToken.BASIC_AUTH_HEADER, auth);
} }
@ -695,14 +465,4 @@ public class SamlAuthenticationIT extends ESRestTestCase {
return context; return context;
} }
private URI getWebServerUri() {
final InetSocketAddress address = httpServer.getAddress();
final String host = address.getHostString();
final int port = address.getPort();
try {
return new URI("http", null, host, port, "/", null, null);
} catch (URISyntaxException e) {
throw new ElasticsearchException("Cannot construct URI for httpServer @ {}:{}", e, host, port);
}
}
} }