diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/saml/SamlSpMetadataAction.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/saml/SamlSpMetadataAction.java new file mode 100644 index 0000000000000..17588ec8c6eaf --- /dev/null +++ b/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 { + public static final String NAME = "cluster:monitor/xpack/security/saml/metadata"; + public static final SamlSpMetadataAction INSTANCE = new SamlSpMetadataAction(); + + private SamlSpMetadataAction() { + super(NAME, SamlSpMetadataResponse::new); + } +} diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/saml/SamlSpMetadataRequest.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/saml/SamlSpMetadataRequest.java new file mode 100644 index 0000000000000..4b302ecc5173c --- /dev/null +++ b/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); + } +} diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/saml/SamlSpMetadataResponse.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/saml/SamlSpMetadataResponse.java new file mode 100644 index 0000000000000..6afa597e40f1d --- /dev/null +++ b/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); + } +} diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authz/privilege/ClusterPrivilegeResolver.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authz/privilege/ClusterPrivilegeResolver.java index b4515e0511f39..4d2b254f3d90d 100644 --- a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authz/privilege/ClusterPrivilegeResolver.java +++ b/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.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 ALL_SECURITY_PATTERN = Set.of("cluster:admin/xpack/security/*"); private static final Set MANAGE_SAML_PATTERN = Set.of("cluster:admin/xpack/security/saml/*", - InvalidateTokenAction.NAME, RefreshTokenAction.NAME); + InvalidateTokenAction.NAME, RefreshTokenAction.NAME, SamlSpMetadataAction.NAME); private static final Set MANAGE_OIDC_PATTERN = Set.of("cluster:admin/xpack/security/oidc/*"); private static final Set MANAGE_TOKEN_PATTERN = Set.of("cluster:admin/xpack/security/token/*"); private static final Set MANAGE_API_KEY_PATTERN = Set.of("cluster:admin/xpack/security/api_key/*"); diff --git a/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/saml/SamlSpMetadataRequestTests.java b/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/saml/SamlSpMetadataRequestTests.java new file mode 100644 index 0000000000000..4a9e935c1d50f --- /dev/null +++ b/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}")); + } +} diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/Security.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/Security.java index 4474e401ecbaf..870fb9faf3ed8 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/Security.java +++ b/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.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.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.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 void onIndexModule(IndexModule module) { 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 List getRestHandlers(Settings settings, RestController restC 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()), diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/action/saml/TransportSamlSpMetadataAction.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/action/saml/TransportSamlSpMetadataAction.java new file mode 100644 index 0000000000000..ba12571d5215c --- /dev/null +++ b/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 { + + 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 listener) { + List 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 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); + } + } +} diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/saml/SamlRealm.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/saml/SamlRealm.java index e5e5e9edde663..2390d820d5fce 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/saml/SamlRealm.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/saml/SamlRealm.java @@ -208,6 +208,10 @@ public static SamlRealm create(RealmConfig config, SSLService sslService, Resour return realm; } + public SpConfiguration getServiceProvider() { + return serviceProvider; + } + // For testing SamlRealm( RealmConfig config, diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/saml/SamlSpMetadataBuilder.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/saml/SamlSpMetadataBuilder.java index 0993878e76a87..ec0a65155d7af 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/saml/SamlSpMetadataBuilder.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/saml/SamlSpMetadataBuilder.java @@ -240,7 +240,9 @@ public EntityDescriptor build() throws Exception { 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; } diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/saml/SigningConfiguration.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/saml/SigningConfiguration.java index 349d72d5369e8..dd4fa5a06a865 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/saml/SigningConfiguration.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/saml/SigningConfiguration.java @@ -16,7 +16,7 @@ /** * Encapsulates the rules and credentials for how and when Elasticsearch should sign outgoing SAML messages. */ -class SigningConfiguration { +public class SigningConfiguration { private final Set messageTypes; private final X509Credential credential; @@ -30,7 +30,7 @@ boolean shouldSign(SAMLObject object) { return shouldSign(object.getElementQName().getLocalPart()); } - boolean shouldSign(String elementName) { + public boolean shouldSign(String elementName) { if (credential == null) { return false; } @@ -45,7 +45,7 @@ byte[] sign(byte[] content, String algo) throws SecurityException { return XMLSigningUtil.signWithURI(this.credential, algo, content); } - X509Credential getCredential() { + public X509Credential getCredential() { return credential; } } diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/saml/SpConfiguration.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/saml/SpConfiguration.java index bc1ac3999211b..95d819dac7fe7 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/saml/SpConfiguration.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/saml/SpConfiguration.java @@ -41,23 +41,23 @@ public SpConfiguration(final String entityId, final String ascUrl, final String /** * 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 getEncryptionCredentials() { + public List getEncryptionCredentials() { return encryptionCredentials; } - SigningConfiguration getSigningConfiguration() { + public SigningConfiguration getSigningConfiguration() { return signingConfiguration; } diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/rest/action/saml/RestSamlSpMetadataAction.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/rest/action/saml/RestSamlSpMetadataAction.java new file mode 100644 index 0000000000000..2f6d3357740c8 --- /dev/null +++ b/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 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(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); + } + }); + } +}