Pārlūkot izejas kodu

Adding API for generating SAML SP metadata (#64517)

* Adding API for generating SAML SP metadata
Resolve #49018

* Adding API for generating SAML SP metadata
Resolves #49018

* Adding API for generating SAML SP metadata
Resolves #49018

* Adding API for generating SAML SP metadata
Resolves #49018

* Adding API for generating SAML SP metadata
Resolves #49018

* Adding API for generating SAML SP metadata
Resolves #49018

* Adding API for generating SAML SP metadata
Resolves #49018

Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
Lyudmila Fokina 5 gadi atpakaļ
vecāks
revīzija
ad658c6fb7

+ 18 - 0
x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/saml/SamlSpMetadataAction.java

@@ -0,0 +1,18 @@
+/*
+ * 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.core.security.action.saml;
+
+import org.elasticsearch.action.ActionType;
+
+public class SamlSpMetadataAction extends ActionType<SamlSpMetadataResponse> {
+    public static final String NAME = "cluster:monitor/xpack/security/saml/metadata";
+    public static final SamlSpMetadataAction INSTANCE = new SamlSpMetadataAction();
+
+    private SamlSpMetadataAction() {
+        super(NAME, SamlSpMetadataResponse::new);
+    }
+}

+ 61 - 0
x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/saml/SamlSpMetadataRequest.java

@@ -0,0 +1,61 @@
+/*
+ * 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.core.security.action.saml;
+
+import org.elasticsearch.action.ActionRequest;
+import org.elasticsearch.action.ActionRequestValidationException;
+import org.elasticsearch.common.Strings;
+import org.elasticsearch.common.io.stream.StreamInput;
+import org.elasticsearch.common.io.stream.StreamOutput;
+
+import java.io.IOException;
+
+import static org.elasticsearch.action.ValidateActions.addValidationError;
+
+public class SamlSpMetadataRequest extends ActionRequest {
+
+    private String realmName;
+
+    public SamlSpMetadataRequest(StreamInput in) throws IOException {
+        super(in);
+        realmName = in.readOptionalString();
+    }
+
+    public SamlSpMetadataRequest(String realmName) {
+        this.realmName = realmName;
+    }
+
+    @Override
+    public ActionRequestValidationException validate() {
+        ActionRequestValidationException validationException = null;
+        if (Strings.hasText(realmName) == false) {
+            validationException = addValidationError("Realm name may not be empty", validationException);
+        }
+        return validationException;
+    }
+
+    public String getRealmName() {
+        return realmName;
+    }
+
+    public void setRealmName(String realmName) {
+        this.realmName = realmName;
+    }
+
+    @Override
+    public String toString() {
+        return getClass().getSimpleName() + "{" +
+            "realmName=" + realmName +
+            '}';
+    }
+
+    @Override
+    public void writeTo(StreamOutput out) throws IOException {
+        super.writeTo(out);
+        out.writeOptionalString(realmName);
+    }
+}

+ 38 - 0
x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/saml/SamlSpMetadataResponse.java

@@ -0,0 +1,38 @@
+/*
+ * 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.core.security.action.saml;
+
+import org.elasticsearch.action.ActionResponse;
+import org.elasticsearch.common.io.stream.StreamInput;
+import org.elasticsearch.common.io.stream.StreamOutput;
+
+import java.io.IOException;
+
+/**
+ * Response containing a SAML SP metadata for a specific realm as XML.
+ */
+public class SamlSpMetadataResponse extends ActionResponse {
+    public String getXMLString() {
+        return XMLString;
+    }
+
+    private String XMLString;
+
+    public SamlSpMetadataResponse(StreamInput in) throws IOException {
+        super(in);
+        XMLString = in.readString();
+    }
+
+    public SamlSpMetadataResponse(String XMLString) {
+        this.XMLString = XMLString;
+    }
+
+    @Override
+    public void writeTo(StreamOutput out) throws IOException {
+        out.writeString(XMLString);
+    }
+}

+ 2 - 1
x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authz/privilege/ClusterPrivilegeResolver.java

@@ -22,6 +22,7 @@ import org.elasticsearch.xpack.core.ilm.action.StartILMAction;
 import org.elasticsearch.xpack.core.ilm.action.StopILMAction;
 import org.elasticsearch.xpack.core.security.action.DelegatePkiAuthenticationAction;
 import org.elasticsearch.xpack.core.security.action.GrantApiKeyAction;
+import org.elasticsearch.xpack.core.security.action.saml.SamlSpMetadataAction;
 import org.elasticsearch.xpack.core.security.action.token.InvalidateTokenAction;
 import org.elasticsearch.xpack.core.security.action.token.RefreshTokenAction;
 import org.elasticsearch.xpack.core.security.action.user.HasPrivilegesAction;
@@ -45,7 +46,7 @@ public class ClusterPrivilegeResolver {
     // shared automatons
     private static final Set<String> ALL_SECURITY_PATTERN = Set.of("cluster:admin/xpack/security/*");
     private static final Set<String> MANAGE_SAML_PATTERN = Set.of("cluster:admin/xpack/security/saml/*",
-        InvalidateTokenAction.NAME, RefreshTokenAction.NAME);
+        InvalidateTokenAction.NAME, RefreshTokenAction.NAME, SamlSpMetadataAction.NAME);
     private static final Set<String> MANAGE_OIDC_PATTERN = Set.of("cluster:admin/xpack/security/oidc/*");
     private static final Set<String> MANAGE_TOKEN_PATTERN = Set.of("cluster:admin/xpack/security/token/*");
     private static final Set<String> MANAGE_API_KEY_PATTERN = Set.of("cluster:admin/xpack/security/api_key/*");

+ 41 - 0
x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/saml/SamlSpMetadataRequestTests.java

@@ -0,0 +1,41 @@
+/*
+ * 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.core.security.action.saml;
+
+import org.elasticsearch.action.ActionRequestValidationException;
+import org.elasticsearch.common.io.stream.BytesStreamOutput;
+import org.elasticsearch.common.io.stream.StreamInput;
+import org.elasticsearch.test.ESTestCase;
+
+import java.io.IOException;
+
+import static org.hamcrest.Matchers.containsString;
+
+public class SamlSpMetadataRequestTests extends ESTestCase {
+
+    public void testValidateFailsWhenRealmEmpty() {
+        final SamlSpMetadataRequest samlSPMetadataRequest = new SamlSpMetadataRequest("");
+        final ActionRequestValidationException validationException = samlSPMetadataRequest.validate();
+        assertThat(validationException.getMessage(), containsString("Realm name may not be empty"));
+    }
+
+    public void testValidateSerialization()  throws IOException {
+        final SamlSpMetadataRequest samlSPMetadataRequest = new SamlSpMetadataRequest("saml1");
+        try (BytesStreamOutput out = new BytesStreamOutput()) {
+            samlSPMetadataRequest.writeTo(out);
+            try (StreamInput in = out.bytes().streamInput()) {
+                final SamlSpMetadataRequest serialized = new SamlSpMetadataRequest(in);
+                assertEquals(samlSPMetadataRequest.getRealmName(), serialized.getRealmName());
+            }
+        }
+    }
+
+    public void testValidateToString() {
+        final SamlSpMetadataRequest samlSPMetadataRequest = new SamlSpMetadataRequest("saml1");
+        assertThat(samlSPMetadataRequest.toString(), containsString("{realmName=saml1}"));
+    }
+}

+ 5 - 0
x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/Security.java

@@ -108,6 +108,7 @@ import org.elasticsearch.xpack.core.security.action.saml.SamlInvalidateSessionAc
 import org.elasticsearch.xpack.core.security.action.saml.SamlLogoutAction;
 import org.elasticsearch.xpack.core.security.action.saml.SamlCompleteLogoutAction;
 import org.elasticsearch.xpack.core.security.action.saml.SamlPrepareAuthenticationAction;
+import org.elasticsearch.xpack.core.security.action.saml.SamlSpMetadataAction;
 import org.elasticsearch.xpack.core.security.action.token.CreateTokenAction;
 import org.elasticsearch.xpack.core.security.action.token.InvalidateTokenAction;
 import org.elasticsearch.xpack.core.security.action.token.RefreshTokenAction;
@@ -174,6 +175,7 @@ import org.elasticsearch.xpack.security.action.saml.TransportSamlInvalidateSessi
 import org.elasticsearch.xpack.security.action.saml.TransportSamlLogoutAction;
 import org.elasticsearch.xpack.security.action.saml.TransportSamlCompleteLogoutAction;
 import org.elasticsearch.xpack.security.action.saml.TransportSamlPrepareAuthenticationAction;
+import org.elasticsearch.xpack.security.action.saml.TransportSamlSpMetadataAction;
 import org.elasticsearch.xpack.security.action.token.TransportCreateTokenAction;
 import org.elasticsearch.xpack.security.action.token.TransportInvalidateTokenAction;
 import org.elasticsearch.xpack.security.action.token.TransportRefreshTokenAction;
@@ -243,6 +245,7 @@ import org.elasticsearch.xpack.security.rest.action.saml.RestSamlInvalidateSessi
 import org.elasticsearch.xpack.security.rest.action.saml.RestSamlLogoutAction;
 import org.elasticsearch.xpack.security.rest.action.saml.RestSamlCompleteLogoutAction;
 import org.elasticsearch.xpack.security.rest.action.saml.RestSamlPrepareAuthenticationAction;
+import org.elasticsearch.xpack.security.rest.action.saml.RestSamlSpMetadataAction;
 import org.elasticsearch.xpack.security.rest.action.user.RestChangePasswordAction;
 import org.elasticsearch.xpack.security.rest.action.user.RestDeleteUserAction;
 import org.elasticsearch.xpack.security.rest.action.user.RestGetUserPrivilegesAction;
@@ -781,6 +784,7 @@ public class Security extends Plugin implements SystemIndexPlugin, IngestPlugin,
                 new ActionHandler<>(SamlLogoutAction.INSTANCE, TransportSamlLogoutAction.class),
                 new ActionHandler<>(SamlInvalidateSessionAction.INSTANCE, TransportSamlInvalidateSessionAction.class),
                 new ActionHandler<>(SamlCompleteLogoutAction.INSTANCE, TransportSamlCompleteLogoutAction.class),
+                new ActionHandler<>(SamlSpMetadataAction.INSTANCE, TransportSamlSpMetadataAction.class),
                 new ActionHandler<>(OpenIdConnectPrepareAuthenticationAction.INSTANCE,
                     TransportOpenIdConnectPrepareAuthenticationAction.class),
                 new ActionHandler<>(OpenIdConnectAuthenticateAction.INSTANCE, TransportOpenIdConnectAuthenticateAction.class),
@@ -841,6 +845,7 @@ public class Security extends Plugin implements SystemIndexPlugin, IngestPlugin,
                 new RestSamlLogoutAction(settings, getLicenseState()),
                 new RestSamlInvalidateSessionAction(settings, getLicenseState()),
                 new RestSamlCompleteLogoutAction(settings, getLicenseState()),
+                new RestSamlSpMetadataAction(settings, getLicenseState()),
                 new RestOpenIdConnectPrepareAuthenticationAction(settings, getLicenseState()),
                 new RestOpenIdConnectAuthenticateAction(settings, getLicenseState()),
                 new RestOpenIdConnectLogoutAction(settings, getLicenseState()),

+ 88 - 0
x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/action/saml/TransportSamlSpMetadataAction.java

@@ -0,0 +1,88 @@
+/*
+ * 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.security.action.saml;
+
+import org.apache.logging.log4j.message.ParameterizedMessage;
+import org.elasticsearch.action.ActionListener;
+import org.elasticsearch.action.support.ActionFilters;
+import org.elasticsearch.action.support.HandledTransportAction;
+import org.elasticsearch.common.inject.Inject;
+import org.elasticsearch.tasks.Task;
+import org.elasticsearch.transport.TransportService;
+import org.elasticsearch.xpack.core.security.action.saml.SamlSpMetadataAction;
+import org.elasticsearch.xpack.core.security.action.saml.SamlSpMetadataRequest;
+import org.elasticsearch.xpack.core.security.action.saml.SamlSpMetadataResponse;
+import org.elasticsearch.xpack.security.authc.Realms;
+import org.elasticsearch.xpack.security.authc.saml.SamlRealm;
+import org.elasticsearch.xpack.security.authc.saml.SamlSpMetadataBuilder;
+import org.elasticsearch.xpack.security.authc.saml.SamlUtils;
+import org.elasticsearch.xpack.security.authc.saml.SpConfiguration;
+import org.opensaml.saml.saml2.core.AuthnRequest;
+import org.opensaml.saml.saml2.metadata.EntityDescriptor;
+import org.opensaml.saml.saml2.metadata.impl.EntityDescriptorMarshaller;
+import org.w3c.dom.Element;
+
+import javax.xml.transform.Transformer;
+import javax.xml.transform.dom.DOMSource;
+import javax.xml.transform.stream.StreamResult;
+import java.io.StringWriter;
+import java.util.List;
+import java.util.Locale;
+
+import static org.elasticsearch.xpack.security.authc.saml.SamlRealm.findSamlRealms;
+
+/**
+ * Transport action responsible for generating a SAML SP Metadata.
+ */
+public class TransportSamlSpMetadataAction
+    extends HandledTransportAction<SamlSpMetadataRequest, SamlSpMetadataResponse>  {
+
+    private final Realms realms;
+
+    @Inject
+    public TransportSamlSpMetadataAction(TransportService transportService, ActionFilters actionFilters, Realms realms) {
+        super(SamlSpMetadataAction.NAME, transportService, actionFilters, SamlSpMetadataRequest::new
+        );
+        this.realms = realms;
+    }
+
+    @Override
+    protected void doExecute(Task task, SamlSpMetadataRequest request,
+                             ActionListener<SamlSpMetadataResponse> listener) {
+        List<SamlRealm> realms = findSamlRealms(this.realms, request.getRealmName(), null);
+        if (realms.isEmpty()) {
+            listener.onFailure(SamlUtils.samlException("Cannot find any matching realm for [{}]", request.getRealmName()));
+        } else if (realms.size() > 1) {
+            listener.onFailure(SamlUtils.samlException("Found multiple matching realms [{}] for [{}]", realms, request.getRealmName()));
+        } else {
+            prepareMetadata(realms.get(0), listener);
+        }
+    }
+
+    private void prepareMetadata(SamlRealm realm, ActionListener<SamlSpMetadataResponse> listener) {
+        try {
+            final EntityDescriptorMarshaller marshaller = new EntityDescriptorMarshaller();
+            final SpConfiguration spConfig = realm.getServiceProvider();
+            final SamlSpMetadataBuilder builder = new SamlSpMetadataBuilder(Locale.getDefault(), spConfig.getEntityId())
+                .assertionConsumerServiceUrl(spConfig.getAscUrl())
+                .singleLogoutServiceUrl(spConfig.getLogoutUrl())
+                .encryptionCredentials(spConfig.getEncryptionCredentials())
+                .signingCredential(spConfig.getSigningConfiguration().getCredential())
+                .authnRequestsSigned(spConfig.getSigningConfiguration().shouldSign(AuthnRequest.DEFAULT_ELEMENT_LOCAL_NAME));
+            final EntityDescriptor descriptor = builder.build();
+            final Element element = marshaller.marshall(descriptor);
+            final StringWriter writer = new StringWriter();
+            final Transformer serializer = SamlUtils.getHardenedXMLTransformer();
+            serializer.transform(new DOMSource(element), new StreamResult(writer));
+            listener.onResponse(new SamlSpMetadataResponse(writer.toString()));
+        } catch (Exception e) {
+            logger.error(new ParameterizedMessage(
+                "Error during SAML SP metadata generation for realm [{}]", realm.name()), e);
+            listener.onFailure(e);
+        }
+    }
+}

+ 4 - 0
x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/saml/SamlRealm.java

@@ -208,6 +208,10 @@ public final class SamlRealm extends Realm implements Releasable {
         return realm;
     }
 
+    public SpConfiguration getServiceProvider() {
+        return serviceProvider;
+    }
+
     // For testing
     SamlRealm(
         RealmConfig config,

+ 3 - 1
x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/saml/SamlSpMetadataBuilder.java

@@ -240,7 +240,9 @@ public class SamlSpMetadataBuilder {
         if (organization != null) {
             descriptor.setOrganization(buildOrganization());
         }
-        contacts.forEach(c -> descriptor.getContactPersons().add(buildContact(c)));
+        if(contacts.size() > 0) {
+            contacts.forEach(c -> descriptor.getContactPersons().add(buildContact(c)));
+        }
 
         return descriptor;
     }

+ 3 - 3
x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/saml/SigningConfiguration.java

@@ -16,7 +16,7 @@ import org.opensaml.xmlsec.crypto.XMLSigningUtil;
 /**
  * Encapsulates the rules and credentials for how and when Elasticsearch should sign outgoing SAML messages.
  */
-class SigningConfiguration {
+public class SigningConfiguration {
 
     private final Set<String> messageTypes;
     private final X509Credential credential;
@@ -30,7 +30,7 @@ class SigningConfiguration {
         return shouldSign(object.getElementQName().getLocalPart());
     }
 
-    boolean shouldSign(String elementName) {
+    public boolean shouldSign(String elementName) {
         if (credential == null) {
             return false;
         }
@@ -45,7 +45,7 @@ class SigningConfiguration {
         return XMLSigningUtil.signWithURI(this.credential, algo, content);
     }
 
-    X509Credential getCredential() {
+    public X509Credential getCredential() {
         return credential;
     }
 }

+ 5 - 5
x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/saml/SpConfiguration.java

@@ -41,23 +41,23 @@ public class SpConfiguration {
     /**
      * The SAML identifier (as a URI) for the Sp
      */
-    String getEntityId() {
+    public String getEntityId() {
         return entityId;
     }
 
-    String getAscUrl() {
+    public String getAscUrl() {
         return ascUrl;
     }
 
-    String getLogoutUrl() {
+    public String getLogoutUrl() {
         return logoutUrl;
     }
 
-    List<X509Credential> getEncryptionCredentials() {
+    public List<X509Credential> getEncryptionCredentials() {
         return encryptionCredentials;
     }
 
-    SigningConfiguration getSigningConfiguration() {
+    public SigningConfiguration getSigningConfiguration() {
         return signingConfiguration;
     }
 

+ 59 - 0
x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/rest/action/saml/RestSamlSpMetadataAction.java

@@ -0,0 +1,59 @@
+/*
+ * 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.security.rest.action.saml;
+
+import org.elasticsearch.client.node.NodeClient;
+import org.elasticsearch.common.settings.Settings;
+import org.elasticsearch.common.xcontent.XContentBuilder;
+import org.elasticsearch.license.XPackLicenseState;
+import org.elasticsearch.rest.BytesRestResponse;
+import org.elasticsearch.rest.RestRequest;
+import org.elasticsearch.rest.RestResponse;
+import org.elasticsearch.rest.RestStatus;
+import org.elasticsearch.rest.action.RestBuilderListener;
+import org.elasticsearch.xpack.core.security.action.saml.SamlSpMetadataAction;
+import org.elasticsearch.xpack.core.security.action.saml.SamlSpMetadataRequest;
+import org.elasticsearch.xpack.core.security.action.saml.SamlSpMetadataResponse;
+
+import java.io.IOException;
+import java.util.Collections;
+import java.util.List;
+
+import static org.elasticsearch.rest.RestRequest.Method.GET;
+
+public class RestSamlSpMetadataAction extends SamlBaseRestHandler {
+
+    public RestSamlSpMetadataAction(Settings settings, XPackLicenseState licenseState) {
+        super(settings, licenseState);
+    }
+
+    @Override
+    public List<Route> routes() {
+        return Collections.singletonList(
+            new Route(GET, "/_security/saml/metadata/{realm}"));
+    }
+
+    @Override
+    public String getName() {
+        return "security_saml_metadata_action";
+    }
+
+    @Override
+    public RestChannelConsumer innerPrepareRequest(RestRequest request, NodeClient client) throws IOException {
+        final SamlSpMetadataRequest SamlSpMetadataRequest = new SamlSpMetadataRequest(request.param("realm"));
+        return channel -> client.execute(SamlSpMetadataAction.INSTANCE, SamlSpMetadataRequest,
+            new RestBuilderListener<SamlSpMetadataResponse>(channel) {
+            @Override
+            public RestResponse buildResponse(SamlSpMetadataResponse response, XContentBuilder builder) throws Exception {
+                builder.startObject();
+                builder.field("metadata", response.getXMLString());
+                builder.endObject();
+                return new BytesRestResponse(RestStatus.OK, builder);
+            }
+        });
+    }
+}