Skip to content

Commit

Permalink
[7.7] Add end to end QA authentication test (#54215) (#54658)
Browse files Browse the repository at this point in the history
Use the same ES cluster as both an SP and an IDP and perform
IDP initiated and SP initiated SSO. The REST client plays the role
of both the Cloud UI and Kibana in these flows
  • Loading branch information
jkakavas committed Apr 3, 2020
1 parent cc06160 commit 8b534a2
Show file tree
Hide file tree
Showing 8 changed files with 332 additions and 75 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -1142,16 +1142,17 @@ protected static boolean isXPackTemplate(String name) {
return true;
}
switch (name) {
case ".triggered_watches":
case ".watches":
case "logstash-index-template":
case ".logstash-management":
case "security_audit_log":
case ".slm-history":
case ".async-search":
return true;
default:
return false;
case ".triggered_watches":
case ".watches":
case "logstash-index-template":
case ".logstash-management":
case "security_audit_log":
case ".slm-history":
case ".async-search":
case "saml-service-provider":
return true;
default:
return false;
}
}

Expand Down
18 changes: 16 additions & 2 deletions x-pack/plugin/identity-provider/qa/idp-rest-tests/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,26 @@ testClusters.integTest {
setting 'xpack.security.enabled', 'true'
setting 'xpack.security.authc.token.enabled', 'true'
setting 'xpack.security.authc.api_key.enabled', 'true'

setting 'xpack.security.authc.realms.file.file.order', '0'
setting 'xpack.security.authc.realms.native.native.order', '1'
setting 'xpack.security.authc.realms.saml.cloud-saml.order', '2'
setting 'xpack.security.authc.realms.saml.cloud-saml.idp.entity_id', 'https://idp.test.es.elasticsearch.org/'
setting 'xpack.security.authc.realms.saml.cloud-saml.idp.metadata.path', 'idp-metadata.xml'
setting 'xpack.security.authc.realms.saml.cloud-saml.sp.entity_id', 'ec:123456:abcdefg'
// This is a dummy one, we simulate the browser and a web app in our tests
setting 'xpack.security.authc.realms.saml.cloud-saml.sp.acs', 'https://sp1.test.es.elasticsearch.org/saml/acs'
setting 'xpack.security.authc.realms.saml.cloud-saml.attributes.principal', 'https://idp.test.es.elasticsearch.org/attribute/principal'
setting 'xpack.security.authc.realms.saml.cloud-saml.attributes.name', 'https://idp.test.es.elasticsearch.org/attribute/name'
setting 'logger.org.elasticsearch.xpack.security.authc.saml', 'TRACE'
setting 'logger.org.elasticsearch.xpack.idp', 'TRACE'
extraConfigFile 'roles.yml', file('src/test/resources/roles.yml')
extraConfigFile 'idp-sign.crt', file('src/test/resources/idp-sign.crt')
extraConfigFile 'idp-sign.key', file('src/test/resources/idp-sign.key')
extraConfigFile 'wildcard_services.json', file('src/test/resources/wildcard_services.json')
// The SAML SP is preconfigured with the metadata of the IDP
extraConfigFile 'idp-metadata.xml', file('src/test/resources/idp-metadata.xml')

user username: "admin_user", password: "admin-password"
user username: "idp_user", password: "idp-password", role: "idp_role"
user username: "idp_admin", password: "idp-password", role: "idp_admin"
user username: "idp_user", password: "idp-password", role: "idp_user"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/

package org.elasticsearch.xpack.idp;

import org.apache.http.HttpHost;
import org.elasticsearch.client.Request;
import org.elasticsearch.client.RequestOptions;
import org.elasticsearch.client.Response;
import org.elasticsearch.client.RestClient;
import org.elasticsearch.common.Nullable;
import org.elasticsearch.common.Strings;
import org.elasticsearch.common.settings.SecureString;
import org.elasticsearch.common.settings.Settings;
import org.elasticsearch.common.util.concurrent.ThreadContext;
import org.elasticsearch.common.xcontent.ObjectPath;
import org.elasticsearch.common.xcontent.json.JsonXContent;
import org.elasticsearch.xpack.core.security.action.saml.SamlPrepareAuthenticationResponse;
import org.elasticsearch.xpack.idp.saml.sp.SamlServiceProviderIndex;
import org.junit.Before;

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

import static org.elasticsearch.xpack.core.security.authc.support.UsernamePasswordToken.basicAuthHeaderValue;
import static org.hamcrest.Matchers.contains;
import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.equalTo;
import static org.hamcrest.Matchers.hasSize;
import static org.hamcrest.Matchers.instanceOf;

public class IdentityProviderAuthenticationIT extends IdpRestTestCase {

// From build.gradle
private final String SP_ENTITY_ID = "ec:123456:abcdefg";
private final String SP_ACS = "https://sp1.test.es.elasticsearch.org/saml/acs";
private final String REALM_NAME = "cloud-saml";

@Before
public void createUsers() throws IOException {
setUserPassword("kibana", new SecureString("kibana".toCharArray()));
}

public void testRegistrationAndIdpInitiatedSso() throws Exception {
final Map<String, Object> request = new HashMap<>();
request.put("name", "Test SP");
request.put("acs", SP_ACS);
final Map<String, Object> privilegeMap = new HashMap<>();
privilegeMap.put("resource", SP_ENTITY_ID);
final Map<String, String> roleMap = new HashMap<>();
roleMap.put("superuser", "role:superuser");
roleMap.put("viewer", "role:viewer");
privilegeMap.put("roles", roleMap);
request.put("privileges", privilegeMap);
final Map<String, String> attributeMap = new HashMap<>();
attributeMap.put("principal", "https://idp.test.es.elasticsearch.org/attribute/principal");
attributeMap.put("name", "https://idp.test.es.elasticsearch.org/attribute/name");
attributeMap.put("email", "https://idp.test.es.elasticsearch.org/attribute/email");
attributeMap.put("roles", "https://idp.test.es.elasticsearch.org/attribute/roles");
request.put("attributes", attributeMap);
final SamlServiceProviderIndex.DocumentVersion docVersion = createServiceProvider(SP_ENTITY_ID, request);
checkIndexDoc(docVersion);
ensureGreen(SamlServiceProviderIndex.INDEX_NAME);
final String samlResponse = generateSamlResponse(SP_ENTITY_ID, SP_ACS, null);
authenticateWithSamlResponse(samlResponse, null);
}

public void testRegistrationAndSpInitiatedSso() throws Exception {
final Map<String, Object> request = new HashMap<>();
request.put("name", "Test SP");
request.put("acs", SP_ACS);
final Map<String, Object> privilegeMap = new HashMap<>();
privilegeMap.put("resource", SP_ENTITY_ID);
final Map<String, String> roleMap = new HashMap<>();
roleMap.put("superuser", "role:superuser");
roleMap.put("viewer", "role:viewer");
privilegeMap.put("roles", roleMap);
request.put("privileges", privilegeMap);
final Map<String, String> attributeMap = new HashMap<>();
attributeMap.put("principal", "https://idp.test.es.elasticsearch.org/attribute/principal");
attributeMap.put("name", "https://idp.test.es.elasticsearch.org/attribute/name");
attributeMap.put("email", "https://idp.test.es.elasticsearch.org/attribute/email");
attributeMap.put("roles", "https://idp.test.es.elasticsearch.org/attribute/roles");
request.put("attributes", attributeMap);
final SamlServiceProviderIndex.DocumentVersion docVersion = createServiceProvider(SP_ENTITY_ID, request);
checkIndexDoc(docVersion);
ensureGreen(SamlServiceProviderIndex.INDEX_NAME);
SamlPrepareAuthenticationResponse samlPrepareAuthenticationResponse = generateSamlAuthnRequest(REALM_NAME);
final String requestId = samlPrepareAuthenticationResponse.getRequestId();
final String query = samlPrepareAuthenticationResponse.getRedirectUrl().split("\\?")[1];
Map<String, Object> authnState = validateAuthnRequest(SP_ENTITY_ID, query);
final String samlResponse = generateSamlResponse(SP_ENTITY_ID, SP_ACS, authnState);
assertThat(samlResponse, containsString("InResponseTo=\"" + requestId + "\""));
authenticateWithSamlResponse(samlResponse, requestId);
}

private Map<String, Object> validateAuthnRequest(String entityId, String authnRequestQuery) throws Exception {
final Request request = new Request("POST", "/_idp/saml/validate");
request.setJsonEntity("{\"authn_request_query\":\"" + authnRequestQuery + "\"}");
final Response response = client().performRequest(request);
final Map<String, Object> map = entityAsMap(response);
assertThat(ObjectPath.eval("service_provider.entity_id", map), instanceOf(String.class));
assertThat(ObjectPath.eval("service_provider.entity_id", map), equalTo(entityId));
assertThat(ObjectPath.eval("authn_state", map), instanceOf(Map.class));
return ObjectPath.eval("authn_state", map);
}

private SamlPrepareAuthenticationResponse generateSamlAuthnRequest(String realmName) throws Exception {
final Request request = new Request("POST", "/_security/saml/prepare");
request.setJsonEntity("{\"realm\":\"" + realmName + "\"}");
try (RestClient kibanaClient = restClientAsKibana()) {
final Response response = kibanaClient.performRequest(request);
final Map<String, Object> map = entityAsMap(response);
assertThat(ObjectPath.eval("realm", map), equalTo(realmName));
assertThat(ObjectPath.eval("id", map), instanceOf(String.class));
assertThat(ObjectPath.eval("redirect", map), instanceOf(String.class));
return new SamlPrepareAuthenticationResponse(realmName, ObjectPath.eval("id", map), ObjectPath.eval("redirect", map));
}
}

private String generateSamlResponse(String entityId, String acs, @Nullable Map<String, Object> authnState) throws Exception {
final Request request = new Request("POST", "/_idp/saml/init");
if (authnState != null && authnState.isEmpty() == false) {
request.setJsonEntity("{\"entity_id\":\"" + entityId + "\", \"acs\":\"" + acs + "\"," +
"\"authn_state\":" + Strings.toString(JsonXContent.contentBuilder().map(authnState)) + "}");
} else {
request.setJsonEntity("{\"entity_id\":\"" + entityId + "\", \"acs\":\"" + acs + "\"}");
}
request.setOptions(RequestOptions.DEFAULT.toBuilder()
.addHeader("es-secondary-authorization", basicAuthHeaderValue("idp_user",
new SecureString("idp-password".toCharArray())))
.build());
final Response response = client().performRequest(request);
final Map<String, Object> map = entityAsMap(response);
assertThat(ObjectPath.eval("service_provider.entity_id", map), equalTo(entityId));
assertThat(ObjectPath.eval("post_url", map), equalTo(acs));
assertThat(ObjectPath.eval("saml_response", map), instanceOf(String.class));
return (String) ObjectPath.eval("saml_response", map);
}

private void authenticateWithSamlResponse(String samlResponse, @Nullable String id) throws Exception {
final String encodedResponse = Base64.getEncoder().encodeToString(samlResponse.getBytes(StandardCharsets.UTF_8));
final Request request = new Request("POST", "/_security/saml/authenticate");
if (Strings.hasText(id)) {
request.setJsonEntity("{\"content\":\"" + encodedResponse + "\", \"realm\":\"" + REALM_NAME + "\", \"ids\":[\"" + id + "\"]}");
} else {
request.setJsonEntity("{\"content\":\"" + encodedResponse + "\", \"realm\":\"" + REALM_NAME + "\"}");
}
final String accessToken;
try (RestClient kibanaClient = restClientAsKibana()) {
final Response response = kibanaClient.performRequest(request);
final Map<String, Object> map = entityAsMap(response);
assertThat(ObjectPath.eval("username", map), instanceOf(String.class));
assertThat(ObjectPath.eval("username", map), equalTo("idp_user"));
assertThat(ObjectPath.eval("realm", map), instanceOf(String.class));
assertThat(ObjectPath.eval("realm", map), equalTo(REALM_NAME));
assertThat(ObjectPath.eval("access_token", map), instanceOf(String.class));
accessToken = ObjectPath.eval("access_token", map);
assertThat(ObjectPath.eval("refresh_token", map), instanceOf(String.class));
}
try (RestClient accessTokenClient = restClientWithToken(accessToken)) {
final Request authenticateRequest = new Request("GET", "/_security/_authenticate");
final Response authenticateResponse = accessTokenClient.performRequest(authenticateRequest);
final Map<String, Object> authMap = entityAsMap(authenticateResponse);
assertThat(ObjectPath.eval("username", authMap), instanceOf(String.class));
assertThat(ObjectPath.eval("username", authMap), equalTo("idp_user"));
assertThat(ObjectPath.eval("metadata.saml_nameid_format", authMap), instanceOf(String.class));
assertThat(ObjectPath.eval("metadata.saml_nameid_format", authMap),
equalTo("urn:oasis:names:tc:SAML:2.0:nameid-format:transient"));
assertThat(ObjectPath.eval("metadata.saml_roles", authMap), instanceOf(List.class));
assertThat(ObjectPath.eval("metadata.saml_roles", authMap), hasSize(1));
assertThat(ObjectPath.eval("metadata.saml_roles", authMap), contains("viewer"));
}
}

private RestClient restClientWithToken(String accessToken) throws IOException {
return buildClient(
Settings.builder().put(ThreadContext.PREFIX + ".Authorization", "Bearer " + accessToken).build(),
getClusterHosts().toArray(new HttpHost[getClusterHosts().size()]));
}

private RestClient restClientAsKibana() throws IOException {
return buildClient(
Settings.builder().put(ThreadContext.PREFIX + ".Authorization", basicAuthHeaderValue("kibana",
new SecureString("kibana".toCharArray()))).build(),
getClusterHosts().toArray(new HttpHost[getClusterHosts().size()]));
}
}

Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,11 @@
*/
package org.elasticsearch.xpack.idp;

import org.elasticsearch.client.Request;
import org.elasticsearch.client.RequestOptions;
import org.elasticsearch.client.Response;
import org.elasticsearch.client.RestHighLevelClient;
import org.elasticsearch.client.security.ChangePasswordRequest;
import org.elasticsearch.client.security.DeleteRoleRequest;
import org.elasticsearch.client.security.DeleteUserRequest;
import org.elasticsearch.client.security.PutRoleRequest;
Expand All @@ -16,18 +19,28 @@
import org.elasticsearch.client.security.user.privileges.ApplicationResourcePrivileges;
import org.elasticsearch.client.security.user.privileges.IndicesPrivileges;
import org.elasticsearch.client.security.user.privileges.Role;
import org.elasticsearch.common.Strings;
import org.elasticsearch.common.settings.SecureString;
import org.elasticsearch.common.settings.Settings;
import org.elasticsearch.common.util.concurrent.ThreadContext;
import org.elasticsearch.common.xcontent.ObjectPath;
import org.elasticsearch.common.xcontent.json.JsonXContent;
import org.elasticsearch.test.rest.ESRestTestCase;
import org.elasticsearch.xpack.idp.saml.sp.SamlServiceProviderIndex;

import java.io.IOException;
import java.io.UnsupportedEncodingException;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.Collection;
import java.util.Collections;
import java.util.Map;

import static java.util.Arrays.asList;
import static java.util.Collections.emptyMap;
import static org.elasticsearch.xpack.core.security.authc.support.UsernamePasswordToken.basicAuthHeaderValue;
import static org.hamcrest.Matchers.equalTo;
import static org.hamcrest.Matchers.instanceOf;

public abstract class IdpRestTestCase extends ESRestTestCase {

Expand All @@ -43,7 +56,7 @@ protected Settings restAdminSettings() {

@Override
protected Settings restClientSettings() {
String token = basicAuthHeaderValue("idp_user", new SecureString("idp-password".toCharArray()));
String token = basicAuthHeaderValue("idp_admin", new SecureString("idp-password".toCharArray()));
return Settings.builder()
.put(ThreadContext.PREFIX + ".Authorization", token)
.build();
Expand Down Expand Up @@ -92,4 +105,61 @@ protected void deleteRole(String name) throws IOException {
final DeleteRoleRequest request = new DeleteRoleRequest(name, RefreshPolicy.WAIT_UNTIL);
client.security().deleteRole(request, RequestOptions.DEFAULT);
}

protected void setUserPassword(String username, SecureString password) throws IOException {
final RestHighLevelClient client = getHighLevelAdminClient();
final ChangePasswordRequest request = new ChangePasswordRequest(username, password.getChars(), RefreshPolicy.NONE);
client.security().changePassword(request, RequestOptions.DEFAULT);
}

protected SamlServiceProviderIndex.DocumentVersion createServiceProvider(String entityId, Map<String, Object> body) throws IOException {
// so that we don't hit [SERVICE_UNAVAILABLE/1/state not recovered / initialized]
ensureGreen("");
final Request request = new Request("PUT", "/_idp/saml/sp/" + encode(entityId) + "?refresh=" + RefreshPolicy.IMMEDIATE.getValue());
final String entity = Strings.toString(JsonXContent.contentBuilder().map(body));
request.setJsonEntity(entity);
final Response response = client().performRequest(request);
final Map<String, Object> map = entityAsMap(response);
assertThat(ObjectPath.eval("service_provider.entity_id", map), equalTo(entityId));
assertThat(ObjectPath.eval("service_provider.enabled", map), equalTo(true));

final Object docId = ObjectPath.eval("document._id", map);
final Object seqNo = ObjectPath.eval("document._seq_no", map);
final Object primaryTerm = ObjectPath.eval("document._primary_term", map);
assertThat(docId, instanceOf(String.class));
assertThat(seqNo, instanceOf(Number.class));
assertThat(primaryTerm, instanceOf(Number.class));
return new SamlServiceProviderIndex.DocumentVersion((String) docId, asLong(primaryTerm), asLong(seqNo));
}

protected void checkIndexDoc(SamlServiceProviderIndex.DocumentVersion docVersion) throws IOException {
final Request request = new Request("GET", SamlServiceProviderIndex.INDEX_NAME + "/_doc/" + docVersion.id);
final Response response = adminClient().performRequest(request);
final Map<String, Object> map = entityAsMap(response);
assertThat(map.get("_index"), equalTo(SamlServiceProviderIndex.INDEX_NAME));
assertThat(map.get("_id"), equalTo(docVersion.id));
assertThat(asLong(map.get("_seq_no")), equalTo(docVersion.seqNo));
assertThat(asLong(map.get("_primary_term")), equalTo(docVersion.primaryTerm));
}

protected Long asLong(Object val) {
if (val == null) {
return null;
}
if (val instanceof Long) {
return (Long) val;
}
if (val instanceof Number) {
return ((Number) val).longValue();
}
if (val instanceof String) {
return Long.parseLong((String) val);
}
throw new IllegalArgumentException("Value [" + val + "] of type [" + val.getClass() + "] is not a Long");
}

protected String encode(String param) throws UnsupportedEncodingException {
return URLEncoder.encode(param, StandardCharsets.UTF_8.name());
}

}

0 comments on commit 8b534a2

Please sign in to comment.