diff --git a/src/integrationTest/java/org/opensearch/security/http/CertificateAuthenticationTest.java b/src/integrationTest/java/org/opensearch/security/http/CertificateAuthenticationTest.java new file mode 100644 index 0000000000..18a79abcef --- /dev/null +++ b/src/integrationTest/java/org/opensearch/security/http/CertificateAuthenticationTest.java @@ -0,0 +1,145 @@ +/* +* Copyright OpenSearch Contributors +* SPDX-License-Identifier: Apache-2.0 +* +* The OpenSearch Contributors require contributions made to +* this file be licensed under the Apache-2.0 license or a +* compatible open source license. +* +*/ +package org.opensearch.security.http; + +import java.util.List; +import java.util.Map; + +import com.carrotsearch.randomizedtesting.annotations.ThreadLeakScope; +import org.junit.ClassRule; +import org.junit.Test; +import org.junit.runner.RunWith; + +import org.opensearch.test.framework.RolesMapping; +import org.opensearch.test.framework.TestSecurityConfig.AuthcDomain; +import org.opensearch.test.framework.TestSecurityConfig.AuthcDomain.HttpAuthenticator; +import org.opensearch.test.framework.TestSecurityConfig.Role; +import org.opensearch.test.framework.TestSecurityConfig.User; +import org.opensearch.test.framework.certificate.CertificateData; +import org.opensearch.test.framework.certificate.TestCertificates; +import org.opensearch.test.framework.cluster.ClusterManager; +import org.opensearch.test.framework.cluster.LocalCluster; +import org.opensearch.test.framework.cluster.TestRestClient; +import org.opensearch.test.framework.cluster.TestRestClient.HttpResponse; + +import static org.apache.hc.core5.http.HttpStatus.SC_OK; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsInAnyOrder; +import static org.hamcrest.Matchers.hasSize; +import static org.opensearch.test.framework.TestSecurityConfig.AuthcDomain.AUTHC_HTTPBASIC_INTERNAL; +import static org.opensearch.test.framework.TestSecurityConfig.Role.ALL_ACCESS; + +@RunWith(com.carrotsearch.randomizedtesting.RandomizedRunner.class) +@ThreadLeakScope(ThreadLeakScope.Scope.NONE) +public class CertificateAuthenticationTest { + + private static final User USER_ADMIN = new User("admin").roles(ALL_ACCESS); + + public static final String POINTER_BACKEND_ROLES = "/backend_roles"; + public static final String POINTER_ROLES = "/roles"; + + private static final String USER_SPOCK = "spock"; + private static final String USER_KIRK = "kirk"; + + private static final String BACKEND_ROLE_BRIDGE = "bridge"; + private static final String BACKEND_ROLE_CAPTAIN = "captain"; + + private static final Role ROLE_ALL_INDEX_SEARCH = new Role("all-index-search").indexPermissions("indices:data/read/search") + .on("*"); + + private static final Map CERT_AUTH_CONFIG = Map.of( + "username_attribute", "cn", + "roles_attribute", "ou" + ); + + @ClassRule + public static final LocalCluster cluster = new LocalCluster.Builder() + .nodeSettings(Map.of("plugins.security.ssl.http.clientauth_mode", "OPTIONAL")) + .clusterManager(ClusterManager.THREE_CLUSTER_MANAGERS).anonymousAuth(false) + .authc(new AuthcDomain("clientcert_auth_domain", -1, true) + .httpAuthenticator(new HttpAuthenticator("clientcert").challenge(false) + .config(CERT_AUTH_CONFIG)).backend("noop")) + .authc(AUTHC_HTTPBASIC_INTERNAL).roles(ROLE_ALL_INDEX_SEARCH).users(USER_ADMIN) + .rolesMapping(new RolesMapping(ROLE_ALL_INDEX_SEARCH).backendRoles(BACKEND_ROLE_BRIDGE)).build(); + + private static final TestCertificates TEST_CERTIFICATES = cluster.getTestCertificates(); + + @Test + public void shouldAuthenticateUserWithBasicAuthWhenCertificateAuthenticationIsConfigured() { + try (TestRestClient client = cluster.getRestClient(USER_ADMIN)) { + + HttpResponse response = client.getAuthInfo(); + + response.assertStatusCode(SC_OK); + } + } + + @Test + public void shouldAuthenticateUserWithCertificate_positiveUserSpoke() { + CertificateData userSpockCertificate = TEST_CERTIFICATES.issueUserCertificate(BACKEND_ROLE_BRIDGE, USER_SPOCK); + try (TestRestClient client = cluster.getRestClient(userSpockCertificate)) { + + client.assertCorrectCredentials(USER_SPOCK); + } + } + + @Test + public void shouldAuthenticateUserWithCertificate_positiveUserKirk() { + CertificateData userSpockCertificate = TEST_CERTIFICATES.issueUserCertificate(BACKEND_ROLE_BRIDGE, USER_KIRK); + try (TestRestClient client = cluster.getRestClient(userSpockCertificate)) { + + client.assertCorrectCredentials(USER_KIRK); + } + } + + @Test + public void shouldAuthenticateUserWithCertificate_negative() { + CertificateData untrustedUserCertificate = TEST_CERTIFICATES.createSelfSignedCertificate("CN=untrusted"); + try (TestRestClient client = cluster.getRestClient(untrustedUserCertificate)) { + + HttpResponse response = client.getAuthInfo(); + + response.assertStatusCode(401); + } + } + + @Test + public void shouldRetrieveBackendRoleFromCertificate_positiveRoleBridge() { + CertificateData userSpockCertificate = TEST_CERTIFICATES.issueUserCertificate(BACKEND_ROLE_BRIDGE, USER_KIRK); + try (TestRestClient client = cluster.getRestClient(userSpockCertificate)) { + + HttpResponse response = client.getAuthInfo(); + + response.assertStatusCode(200); + List backendRoles = response.getTextArrayFromJsonBody(POINTER_BACKEND_ROLES); + assertThat(backendRoles, hasSize(1)); + assertThat(backendRoles, containsInAnyOrder(BACKEND_ROLE_BRIDGE)); + List roles = response.getTextArrayFromJsonBody(POINTER_ROLES); + assertThat(roles, hasSize(1)); + assertThat(roles, containsInAnyOrder(ROLE_ALL_INDEX_SEARCH.getName())); + } + } + + @Test + public void shouldRetrieveBackendRoleFromCertificate_positiveRoleCaptain() { + CertificateData userSpockCertificate = TEST_CERTIFICATES.issueUserCertificate(BACKEND_ROLE_CAPTAIN, USER_KIRK); + try (TestRestClient client = cluster.getRestClient(userSpockCertificate)) { + + HttpResponse response = client.getAuthInfo(); + + response.assertStatusCode(200); + List backendRoles = response.getTextArrayFromJsonBody(POINTER_BACKEND_ROLES); + assertThat(backendRoles, hasSize(1)); + assertThat(backendRoles, containsInAnyOrder(BACKEND_ROLE_CAPTAIN)); + List roles = response.getTextArrayFromJsonBody(POINTER_ROLES); + assertThat(roles, hasSize(0)); + } + } +} diff --git a/src/integrationTest/java/org/opensearch/test/framework/RolesMapping.java b/src/integrationTest/java/org/opensearch/test/framework/RolesMapping.java new file mode 100644 index 0000000000..d66a55eb36 --- /dev/null +++ b/src/integrationTest/java/org/opensearch/test/framework/RolesMapping.java @@ -0,0 +1,69 @@ +/* +* Copyright OpenSearch Contributors +* SPDX-License-Identifier: Apache-2.0 +* +* The OpenSearch Contributors require contributions made to +* this file be licensed under the Apache-2.0 license or a +* compatible open source license. +* +*/ +package org.opensearch.test.framework; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import org.opensearch.common.xcontent.ToXContentObject; +import org.opensearch.common.xcontent.XContentBuilder; +import org.opensearch.test.framework.TestSecurityConfig.Role; + +import static java.util.Objects.requireNonNull; + +public class RolesMapping implements ToXContentObject { + private String roleName; + private List backendRoles; + private List hosts; + private List users; + + private boolean reserved = false; + + public RolesMapping(Role role) { + requireNonNull(role); + this.roleName = requireNonNull(role.getName()); + this.backendRoles = new ArrayList<>(); + } + + public RolesMapping backendRoles(String...backendRoles) { + this.backendRoles.addAll(Arrays.asList(backendRoles)); + return this; + } + + public RolesMapping hosts(List hosts) { + this.hosts = hosts; + return this; + } + + public RolesMapping users(List users) { + this.users = users; + return this; + } + + public RolesMapping reserved(boolean reserved) { + this.reserved = reserved; + return this; + } + + public String getRoleName() { + return roleName; + } + + @Override + public XContentBuilder toXContent(XContentBuilder xContentBuilder, Params params) throws IOException { + xContentBuilder.startObject(); + xContentBuilder.field("reserved", reserved); + xContentBuilder.field("backend_roles", backendRoles); + xContentBuilder.endObject(); + return xContentBuilder; + } +} diff --git a/src/integrationTest/java/org/opensearch/test/framework/TestSecurityConfig.java b/src/integrationTest/java/org/opensearch/test/framework/TestSecurityConfig.java index ca64c1e00b..3214df7205 100644 --- a/src/integrationTest/java/org/opensearch/test/framework/TestSecurityConfig.java +++ b/src/integrationTest/java/org/opensearch/test/framework/TestSecurityConfig.java @@ -81,8 +81,8 @@ public class TestSecurityConfig { private Config config = new Config(); private Map internalUsers = new LinkedHashMap<>(); private Map roles = new LinkedHashMap<>(); - private AuditConfiguration auditConfiguration; + private Map rolesMapping = new LinkedHashMap<>(); private String indexName = ".opendistro_security"; @@ -121,6 +121,9 @@ public TestSecurityConfig user(User user) { public TestSecurityConfig roles(Role... roles) { for (Role role : roles) { + if(this.roles.containsKey(role.name)) { + throw new IllegalStateException("Role with name " + role.name + " is already defined"); + } this.roles.put(role.name, role); } @@ -132,6 +135,17 @@ public TestSecurityConfig audit(AuditConfiguration auditConfiguration) { return this; } + public TestSecurityConfig rolesMapping(RolesMapping...mappings) { + for (RolesMapping mapping : mappings) { + String roleName = mapping.getRoleName(); + if(rolesMapping.containsKey(roleName)) { + throw new IllegalArgumentException("Role mapping " + roleName + " already exists"); + } + this.rolesMapping.put(roleName, mapping); + } + return this; + } + public static class Config implements ToXContentObject { private boolean anonymousAuth; @@ -529,7 +543,7 @@ public void initIndex(Client client) { } writeConfigToIndex(client, CType.ROLES, roles); writeConfigToIndex(client, CType.INTERNALUSERS, internalUsers); - writeEmptyConfigToIndex(client, CType.ROLESMAPPING); + writeConfigToIndex(client, CType.ROLESMAPPING, rolesMapping); writeEmptyConfigToIndex(client, CType.ACTIONGROUPS); writeEmptyConfigToIndex(client, CType.TENANTS); diff --git a/src/integrationTest/java/org/opensearch/test/framework/certificate/CertificateData.java b/src/integrationTest/java/org/opensearch/test/framework/certificate/CertificateData.java index 52aab926dc..3712bd1763 100644 --- a/src/integrationTest/java/org/opensearch/test/framework/certificate/CertificateData.java +++ b/src/integrationTest/java/org/opensearch/test/framework/certificate/CertificateData.java @@ -26,15 +26,19 @@ package org.opensearch.test.framework.certificate; +import java.security.Key; import java.security.KeyPair; +import java.security.cert.CertificateException; +import java.security.cert.X509Certificate; import org.bouncycastle.asn1.x500.X500Name; import org.bouncycastle.cert.X509CertificateHolder; +import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; /** * The class contains all data related to Certificate including private key which is considered to be a secret. */ -class CertificateData { +public class CertificateData { private final X509CertificateHolder certificate; private final KeyPair keyPair; @@ -53,6 +57,14 @@ public String certificateInPemFormat() { return PemConverter.toPem(certificate); } + public X509Certificate certificate() { + try { + return new JcaX509CertificateConverter().getCertificate(certificate); + } catch (CertificateException e) { + throw new RuntimeException("Cannot retrieve certificate", e); + } + } + /** * It returns the private key associated with certificate encoded in PEM format. PEM format is defined by * RFC 1421. @@ -70,4 +82,8 @@ X500Name getCertificateSubject() { KeyPair getKeyPair() { return keyPair; } + + public Key getKey() { + return keyPair.getPrivate(); + } } diff --git a/src/integrationTest/java/org/opensearch/test/framework/certificate/TestCertificates.java b/src/integrationTest/java/org/opensearch/test/framework/certificate/TestCertificates.java index c770def8a8..93cbce5c84 100644 --- a/src/integrationTest/java/org/opensearch/test/framework/certificate/TestCertificates.java +++ b/src/integrationTest/java/org/opensearch/test/framework/certificate/TestCertificates.java @@ -93,6 +93,13 @@ private CertificateData createAdminCertificate() { .issueSelfSignedCertificate(metadata); } + public CertificateData createSelfSignedCertificate(String distinguishedName) { + CertificateMetadata metadata = CertificateMetadata.basicMetadata(distinguishedName, CERTIFICATE_VALIDITY_DAYS); + return CertificatesIssuerFactory + .rsaBaseCertificateIssuer() + .issueSelfSignedCertificate(metadata); + } + /** * It returns the most trusted certificate. Certificates for nodes and users are derived from this certificate. * @return file which contains certificate in PEM format, defined by RFC 1421 @@ -131,6 +138,15 @@ private CertificateData createNodeCertificate(Integer node) { .issueSignedCertificate(metadata, caCertificate); } + public CertificateData issueUserCertificate(String organizationUnit, String username) { + String subject = String.format("DC=de,L=test,O=users,OU=%s,CN=%s", organizationUnit, username); + CertificateMetadata metadata = CertificateMetadata.basicMetadata(subject, CERTIFICATE_VALIDITY_DAYS) + .withKeyUsage(false, DIGITAL_SIGNATURE, NON_REPUDIATION, KEY_ENCIPHERMENT, CLIENT_AUTH, SERVER_AUTH); + return CertificatesIssuerFactory + .rsaBaseCertificateIssuer() + .issueSignedCertificate(metadata, caCertificate); + } + /** * It returns private key associated with node certificate returned by method {@link #getNodeCertificate(int)} * diff --git a/src/integrationTest/java/org/opensearch/test/framework/cluster/LocalCluster.java b/src/integrationTest/java/org/opensearch/test/framework/cluster/LocalCluster.java index 6c212ee10c..0ce041149f 100644 --- a/src/integrationTest/java/org/opensearch/test/framework/cluster/LocalCluster.java +++ b/src/integrationTest/java/org/opensearch/test/framework/cluster/LocalCluster.java @@ -49,6 +49,7 @@ import org.opensearch.plugins.Plugin; import org.opensearch.security.support.ConfigConstants; import org.opensearch.test.framework.AuditConfiguration; +import org.opensearch.test.framework.RolesMapping; import org.opensearch.test.framework.TestIndex; import org.opensearch.test.framework.TestSecurityConfig; import org.opensearch.test.framework.TestSecurityConfig.Role; @@ -359,6 +360,11 @@ public Builder roles(Role... roles) { return this; } + public Builder rolesMapping(RolesMapping...mappings) { + testSecurityConfig.rolesMapping(mappings); + return this; + } + public Builder authc(TestSecurityConfig.AuthcDomain authc) { testSecurityConfig.authc(authc); return this; diff --git a/src/integrationTest/java/org/opensearch/test/framework/cluster/OpenSearchClientProvider.java b/src/integrationTest/java/org/opensearch/test/framework/cluster/OpenSearchClientProvider.java index 5679788d39..7e925123ac 100644 --- a/src/integrationTest/java/org/opensearch/test/framework/cluster/OpenSearchClientProvider.java +++ b/src/integrationTest/java/org/opensearch/test/framework/cluster/OpenSearchClientProvider.java @@ -32,6 +32,7 @@ import java.net.URI; import java.nio.charset.StandardCharsets; import java.security.KeyStore; +import java.security.cert.Certificate; import java.security.cert.X509Certificate; import java.util.Arrays; import java.util.Base64; @@ -42,6 +43,8 @@ import java.util.stream.Collectors; import java.util.stream.Stream; +import javax.net.ssl.KeyManager; +import javax.net.ssl.KeyManagerFactory; import javax.net.ssl.SSLContext; import javax.net.ssl.SSLEngine; import javax.net.ssl.TrustManagerFactory; @@ -65,6 +68,7 @@ import org.opensearch.client.RestClientBuilder; import org.opensearch.client.RestHighLevelClient; import org.opensearch.security.support.PemKeyReader; +import org.opensearch.test.framework.certificate.CertificateData; import org.opensearch.test.framework.certificate.TestCertificates; /** @@ -96,8 +100,12 @@ default URI getHttpAddressAsURI() { * This method should be usually preferred. The other getRestClient() methods shall be only used for specific * situations. */ + default TestRestClient getRestClient(UserCredentialsHolder user, CertificateData useCertificateData, Header... headers) { + return getRestClient(user.getName(), user.getPassword(), useCertificateData, headers); + } + default TestRestClient getRestClient(UserCredentialsHolder user, Header... headers) { - return getRestClient(user.getName(), user.getPassword(), headers); + return getRestClient(user.getName(), user.getPassword(), null, headers); } default RestHighLevelClient getRestHighLevelClient(UserCredentialsHolder user) { @@ -160,29 +168,37 @@ default CloseableHttpClient getClosableHttpClient(String[] supportedCipherSuit) * Normally, you should use the method with the User object argument instead. Use this only if you need more * control over username and password - for example, when you want to send a wrong password. */ - default TestRestClient getRestClient(String user, String password, Header... headers) { + default TestRestClient getRestClient(String user, String password, CertificateData useCertificateData, Header... headers) { BasicHeader basicAuthHeader = getBasicAuthHeader(user, password); if (headers != null && headers.length > 0) { List
concatenatedHeaders = Stream.concat(Stream.of(basicAuthHeader), Stream.of(headers)).collect(Collectors.toList()); - return getRestClient(concatenatedHeaders); + return getRestClient(concatenatedHeaders, useCertificateData); } - return getRestClient(basicAuthHeader); + return getRestClient(useCertificateData, basicAuthHeader); + } + + default TestRestClient getRestClient(String user, String password, Header... headers) { + return getRestClient(user, password, null, headers); } /** * Returns a REST client. You can specify additional HTTP headers that will be sent with each request. Use this * method to test non-basic authentication, such as JWT bearer authentication. */ + default TestRestClient getRestClient(CertificateData useCertificateData, Header... headers) { + return getRestClient(Arrays.asList(headers), useCertificateData); + } + default TestRestClient getRestClient(Header... headers) { - return getRestClient(Arrays.asList(headers)); + return getRestClient((CertificateData) null, headers); } - default TestRestClient getRestClient(List
headers) { - return createGenericClientRestClient(headers); + default TestRestClient getRestClient(List
headers, CertificateData useCertificateData) { + return createGenericClientRestClient(headers, useCertificateData); } - default TestRestClient createGenericClientRestClient(List
headers) { - return new TestRestClient(getHttpAddress(), headers, getSSLContext()); + default TestRestClient createGenericClientRestClient(List
headers, CertificateData useCertificateData) { + return new TestRestClient(getHttpAddress(), headers, getSSLContext(useCertificateData)); } default BasicHeader getBasicAuthHeader(String user, String password) { @@ -191,24 +207,37 @@ default BasicHeader getBasicAuthHeader(String user, String password) { } private SSLContext getSSLContext() { + return getSSLContext(null); + } + + private SSLContext getSSLContext(CertificateData useCertificateData) { X509Certificate[] trustCertificates; try { trustCertificates = PemKeyReader.loadCertificatesFromFile(getTestCertificates().getRootCertificate() ); TrustManagerFactory tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); - KeyStore ks = KeyStore.getInstance(KeyStore.getDefaultType()); + KeyStore ks = KeyStore.getInstance(KeyStore.getDefaultType()); ks.load(null); for (int i = 0; i < trustCertificates.length; i++) { ks.setCertificateEntry("caCert-" + i, trustCertificates[i]); } + KeyManager[] keyManagers = null; + if(useCertificateData != null) { + Certificate[] chainOfTrust = {useCertificateData.certificate()}; + ks.setKeyEntry("admin-certificate", useCertificateData.getKey(), null, chainOfTrust); + KeyManagerFactory keyManagerFactory = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); + keyManagerFactory.init(ks, null); + keyManagers = keyManagerFactory.getKeyManagers(); + } tmf.init(ks); SSLContext sslContext = SSLContext.getInstance("TLS"); - sslContext.init(null, tmf.getTrustManagers(), null); + + sslContext.init(keyManagers, tmf.getTrustManagers(), null); return sslContext; } catch (Exception e) { diff --git a/src/integrationTest/java/org/opensearch/test/framework/cluster/TestRestClient.java b/src/integrationTest/java/org/opensearch/test/framework/cluster/TestRestClient.java index 092723f9dd..4295b05a0c 100644 --- a/src/integrationTest/java/org/opensearch/test/framework/cluster/TestRestClient.java +++ b/src/integrationTest/java/org/opensearch/test/framework/cluster/TestRestClient.java @@ -75,6 +75,7 @@ import static java.util.Objects.requireNonNull; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.notNullValue; /** * A OpenSearch REST client, which is tailored towards use in integration tests. Instances of this class can be @@ -118,6 +119,15 @@ public HttpResponse getAuthInfo( Header... headers) { return executeRequest(new HttpGet(getHttpServerUri() + "/_opendistro/_security/authinfo?pretty"), headers); } + public void assertCorrectCredentials(String expectedUserName) { + HttpResponse response = getAuthInfo(); + assertThat(response, notNullValue()); + response.assertStatusCode(200); + String username = response.getTextFromJsonBody("/user_name"); + String message = String.format("Expected user name is '%s', but was '%s'", expectedUserName, username); + assertThat(message, username, equalTo(expectedUserName)); + } + public HttpResponse head(String path, Header... headers) { return executeRequest(new HttpHead(getHttpServerUri() + "/" + path), headers); } @@ -316,7 +326,7 @@ public List getTextArrayFromJsonBody(String jsonPointer) { .map(JsonNode::textValue) .collect(Collectors.toList()); } - + public int getIntFromJsonBody(String jsonPointer) { return getJsonNodeAt(jsonPointer).asInt(); }