diff --git a/x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/IdentityProviderPlugin.java b/x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/IdentityProviderPlugin.java index d592652e08480..b2cc5c3231d09 100644 --- a/x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/IdentityProviderPlugin.java +++ b/x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/IdentityProviderPlugin.java @@ -8,29 +8,43 @@ import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import org.elasticsearch.action.ActionRequest; +import org.elasticsearch.action.ActionResponse; import org.elasticsearch.client.Client; +import org.elasticsearch.cluster.metadata.IndexNameExpressionResolver; +import org.elasticsearch.cluster.node.DiscoveryNodes; import org.elasticsearch.cluster.service.ClusterService; import org.elasticsearch.common.io.stream.NamedWriteableRegistry; +import org.elasticsearch.common.settings.ClusterSettings; +import org.elasticsearch.common.settings.IndexScopedSettings; import org.elasticsearch.common.settings.Setting; import org.elasticsearch.common.settings.Settings; +import org.elasticsearch.common.settings.SettingsFilter; import org.elasticsearch.common.xcontent.NamedXContentRegistry; import org.elasticsearch.env.Environment; import org.elasticsearch.env.NodeEnvironment; import org.elasticsearch.plugins.ActionPlugin; import org.elasticsearch.plugins.Plugin; +import org.elasticsearch.rest.RestController; +import org.elasticsearch.rest.RestHandler; import org.elasticsearch.script.ScriptService; import org.elasticsearch.threadpool.ThreadPool; import org.elasticsearch.watcher.ResourceWatcherService; import org.elasticsearch.xpack.core.ssl.X509KeyPairSettings; +import org.elasticsearch.xpack.idp.action.SamlInitiateSingleSignOnAction; +import org.elasticsearch.xpack.idp.action.TransportSamlInitiateSingleSignOnAction; +import org.elasticsearch.xpack.idp.rest.action.RestSamlInitiateSingleSignOnAction; import org.elasticsearch.xpack.idp.saml.idp.CloudIdp; -import org.elasticsearch.xpack.idp.saml.support.SamlInit; +import org.elasticsearch.xpack.idp.saml.support.SamlUtils; import java.net.URI; import java.net.URISyntaxException; import java.util.ArrayList; + import java.util.Collection; import java.util.Collections; import java.util.List; +import java.util.function.Supplier; /** * This plugin provides the backend for an IdP built on top of Elasticsearch security features. @@ -73,23 +87,45 @@ public class IdentityProviderPlugin extends Plugin implements ActionPlugin { private final Logger logger = LogManager.getLogger(); private boolean enabled; + private Settings settings; @Override public Collection createComponents(Client client, ClusterService clusterService, ThreadPool threadPool, ResourceWatcherService resourceWatcherService, ScriptService scriptService, NamedXContentRegistry xContentRegistry, Environment environment, NodeEnvironment nodeEnvironment, NamedWriteableRegistry namedWriteableRegistry) { - final Settings settings = environment.settings(); + settings = environment.settings(); enabled = ENABLED_SETTING.get(settings); if (enabled == false) { return List.of(); } - SamlInit.initialize(); + SamlUtils.initialize(); CloudIdp idp = new CloudIdp(environment, settings); return List.of(); } + @Override + public List> getActions() { + if (enabled == false) { + return Collections.emptyList(); + } + return Collections.singletonList( + new ActionHandler<>(SamlInitiateSingleSignOnAction.INSTANCE, TransportSamlInitiateSingleSignOnAction.class) + ); + } + + @Override + public List getRestHandlers(Settings settings, RestController restController, ClusterSettings clusterSettings, + IndexScopedSettings indexScopedSettings, SettingsFilter settingsFilter, + IndexNameExpressionResolver indexNameExpressionResolver, + Supplier nodesInCluster) { + if (enabled == false) { + return Collections.emptyList(); + } + return Collections.singletonList(new RestSamlInitiateSingleSignOnAction()); + } + @Override public List> getSettings() { List> settings = new ArrayList<>(); diff --git a/x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/action/SamlInitiateSingleSignOnAction.java b/x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/action/SamlInitiateSingleSignOnAction.java new file mode 100644 index 0000000000000..92c492842ee34 --- /dev/null +++ b/x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/action/SamlInitiateSingleSignOnAction.java @@ -0,0 +1,21 @@ +/* + * 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.action; + +import org.elasticsearch.action.ActionType; + +/** + * ActionType to create a SAML Response in the context of IDP initiated SSO for a given SP + */ +public class SamlInitiateSingleSignOnAction extends ActionType { + + public static final String NAME = "cluster:admin/idp/saml/init"; + public static final SamlInitiateSingleSignOnAction INSTANCE = new SamlInitiateSingleSignOnAction(); + + private SamlInitiateSingleSignOnAction() { + super(NAME, SamlInitiateSingleSignOnResponse::new); + } +} diff --git a/x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/action/SamlInitiateSingleSignOnRequest.java b/x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/action/SamlInitiateSingleSignOnRequest.java new file mode 100644 index 0000000000000..179947f435b99 --- /dev/null +++ b/x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/action/SamlInitiateSingleSignOnRequest.java @@ -0,0 +1,58 @@ +/* + * 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.action; + + +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 static org.elasticsearch.action.ValidateActions.addValidationError; + +import java.io.IOException; + +public class SamlInitiateSingleSignOnRequest extends ActionRequest { + + private String spEntityId; + + public SamlInitiateSingleSignOnRequest(StreamInput in) throws IOException { + super(in); + spEntityId = in.readString(); + } + + public SamlInitiateSingleSignOnRequest() { + } + + @Override + public ActionRequestValidationException validate() { + ActionRequestValidationException validationException = null; + if (Strings.isNullOrEmpty(spEntityId)) { + validationException = addValidationError("sp_entity_id is missing", validationException); + } + return validationException; + } + + public String getSpEntityId() { + return spEntityId; + } + + public void setSpEntityId(String spEntityId) { + this.spEntityId = spEntityId; + } + + @Override + public void writeTo(StreamOutput out) throws IOException { + super.writeTo(out); + out.writeString(spEntityId); + } + + @Override + public String toString() { + return getClass().getSimpleName() + "{spEntityId='" + spEntityId + "'}"; + } +} diff --git a/x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/action/SamlInitiateSingleSignOnResponse.java b/x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/action/SamlInitiateSingleSignOnResponse.java new file mode 100644 index 0000000000000..6995f0ba58418 --- /dev/null +++ b/x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/action/SamlInitiateSingleSignOnResponse.java @@ -0,0 +1,51 @@ +/* + * 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.action; + +import org.elasticsearch.action.ActionResponse; +import org.elasticsearch.common.io.stream.StreamInput; +import org.elasticsearch.common.io.stream.StreamOutput; + +import java.io.IOException; + +public class SamlInitiateSingleSignOnResponse extends ActionResponse { + + private String redirectUrl; + private String responseBody; + private String spEntityId; + + public SamlInitiateSingleSignOnResponse(StreamInput in) throws IOException { + super(in); + this.redirectUrl = in.readString(); + this.responseBody = in.readString(); + this.spEntityId = in.readString(); + } + + public SamlInitiateSingleSignOnResponse(String redirectUrl, String responseBody, String spEntityId) { + this.redirectUrl = redirectUrl; + this.responseBody = responseBody; + this.spEntityId = spEntityId; + } + + public String getRedirectUrl() { + return redirectUrl; + } + + public String getResponseBody() { + return responseBody; + } + + public String getSpEntityId() { + return spEntityId; + } + + @Override + public void writeTo(StreamOutput out) throws IOException { + out.writeString(redirectUrl); + out.writeString(responseBody); + out.writeString(spEntityId); + } +} diff --git a/x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/action/TransportSamlInitiateSingleSignOnAction.java b/x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/action/TransportSamlInitiateSingleSignOnAction.java new file mode 100644 index 0000000000000..a9b87211967de --- /dev/null +++ b/x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/action/TransportSamlInitiateSingleSignOnAction.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.idp.action; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +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.common.util.concurrent.ThreadContext; +import org.elasticsearch.env.Environment; +import org.elasticsearch.tasks.Task; +import org.elasticsearch.threadpool.ThreadPool; +import org.elasticsearch.transport.TransportService; +import org.elasticsearch.xpack.core.security.authc.Authentication; +import org.elasticsearch.xpack.core.security.authc.support.AuthenticationContextSerializer; +import org.elasticsearch.xpack.core.security.user.User; +import org.elasticsearch.xpack.idp.saml.authn.SuccessfulAuthenticationResponseMessageBuilder; +import org.elasticsearch.xpack.idp.saml.authn.UserServiceAuthentication; +import org.elasticsearch.xpack.idp.saml.idp.CloudIdp; +import org.elasticsearch.xpack.idp.saml.idp.SamlIdentityProvider; +import org.elasticsearch.xpack.idp.saml.sp.SamlServiceProvider; +import org.elasticsearch.xpack.idp.saml.support.SamlFactory; +import org.elasticsearch.xpack.idp.saml.support.SamlUtils; +import org.opensaml.saml.saml2.core.Response; + +import java.io.IOException; +import java.time.Clock; +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; + +public class TransportSamlInitiateSingleSignOnAction + extends HandledTransportAction { + + private final ThreadPool threadPool; + private final Environment env; + private final Logger logger = LogManager.getLogger(TransportSamlInitiateSingleSignOnAction.class); + + @Inject + public TransportSamlInitiateSingleSignOnAction(ThreadPool threadPool, TransportService transportService, + ActionFilters actionFilters, Environment environment) { + super(SamlInitiateSingleSignOnAction.NAME, transportService, actionFilters, SamlInitiateSingleSignOnRequest::new); + this.threadPool = threadPool; + this.env = environment; + } + + @Override + protected void doExecute(Task task, SamlInitiateSingleSignOnRequest request, + ActionListener listener) { + final ThreadContext threadContext = threadPool.getThreadContext(); + final SamlFactory samlFactory = new SamlFactory(); + final SamlIdentityProvider idp = new CloudIdp(env, env.settings()); + try { + // TODO: Adjust this once secondary auth code is merged in master and use the authentication object of the user + // Authentication authentication = authenticationService.getSecondaryAuth(); + Authentication serviceAccountAuthentication = new AuthenticationContextSerializer().readFromContext(threadContext); + SamlServiceProvider sp = idp.getRegisteredServiceProvider(request.getSpEntityId()); + if (null == sp) { + final String message = + "Service Provider with Entity ID [" + request.getSpEntityId() + "] is not registered with this Identity Provider"; + logger.debug(message); + listener.onFailure(new IllegalArgumentException(message)); + return; + } + final UserServiceAuthentication user = buildUserFromAuthentication(serviceAccountAuthentication, sp); + final SuccessfulAuthenticationResponseMessageBuilder builder = new SuccessfulAuthenticationResponseMessageBuilder(samlFactory, + Clock.systemUTC(), idp); + final Response response = builder.build(user, null); + listener.onResponse(new SamlInitiateSingleSignOnResponse(user.getServiceProvider().getAssertionConsumerService().toString(), + SamlUtils.getXmlContent(response), + user.getServiceProvider().getEntityId())); + } catch (IOException e) { + listener.onFailure(new IllegalArgumentException(e.getMessage())); + } + } + + private UserServiceAuthentication buildUserFromAuthentication(Authentication authentication, SamlServiceProvider sp) { + final User authenticatedUser = authentication.getUser(); + //TBD Where we will be sourcing the information from, use roles for easier testing now + final Set groups = new HashSet<>(Arrays.asList(authenticatedUser.roles())); + return new UserServiceAuthentication(authenticatedUser.principal(), groups, sp); + } +} diff --git a/x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/rest/action/RestSamlInitiateSingleSignOnAction.java b/x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/rest/action/RestSamlInitiateSingleSignOnAction.java new file mode 100644 index 0000000000000..60b87951290d5 --- /dev/null +++ b/x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/rest/action/RestSamlInitiateSingleSignOnAction.java @@ -0,0 +1,70 @@ +/* + * 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.rest.action; + +import org.elasticsearch.client.node.NodeClient; +import org.elasticsearch.common.ParseField; +import org.elasticsearch.common.xcontent.ObjectParser; +import org.elasticsearch.common.xcontent.XContentBuilder; +import org.elasticsearch.common.xcontent.XContentParser; +import org.elasticsearch.rest.BaseRestHandler; +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.idp.action.SamlInitiateSingleSignOnAction; +import org.elasticsearch.xpack.idp.action.SamlInitiateSingleSignOnRequest; +import org.elasticsearch.xpack.idp.action.SamlInitiateSingleSignOnResponse; + +import java.io.IOException; +import java.util.Collections; +import java.util.List; + +import static org.elasticsearch.rest.RestRequest.Method.POST; + +public class RestSamlInitiateSingleSignOnAction extends BaseRestHandler { + static final ObjectParser PARSER = new ObjectParser<>("idp_init_sso", + SamlInitiateSingleSignOnRequest::new); + + static { + PARSER.declareString(SamlInitiateSingleSignOnRequest::setSpEntityId, new ParseField("sp_entity_id")); + } + + @Override + public List routes(){ + return Collections.singletonList( + new Route(POST, "/_idp/saml/init") + ); + } + + @Override + public String getName() { + return "saml_idp_init_sso_action"; + } + + @Override + protected RestChannelConsumer prepareRequest(RestRequest request, NodeClient client) throws IOException { + try (XContentParser parser = request.contentParser()) { + final SamlInitiateSingleSignOnRequest initRequest = PARSER.parse(parser, null); + return channel -> client.execute(SamlInitiateSingleSignOnAction.INSTANCE, initRequest, + new RestBuilderListener(channel) { + @Override + public RestResponse buildResponse(SamlInitiateSingleSignOnResponse response, XContentBuilder builder) throws Exception { + builder.startObject(); + builder.field("redirect_url", response.getRedirectUrl()); + builder.field("response_body", response.getResponseBody()); + builder.startObject("service_provider"); + builder.field("entity_id", response.getSpEntityId()); + builder.endObject(); + builder.endObject(); + return new BytesRestResponse(RestStatus.OK, builder); + } + }); + } + } +} diff --git a/x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/saml/authn/UserServiceAuthentication.java b/x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/saml/authn/UserServiceAuthentication.java index 1bfd199291fa9..ee1be6a09d7e5 100644 --- a/x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/saml/authn/UserServiceAuthentication.java +++ b/x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/saml/authn/UserServiceAuthentication.java @@ -15,12 +15,43 @@ /** * Lightweight representation of a user that has authenticated to the IdP in the context of a specific service provider */ -public interface UserServiceAuthentication { +public class UserServiceAuthentication { + private final String principal; + private final Set groups; + private final SamlServiceProvider serviceProvider; + private final Set authenticationMethods; + private final Set networkControls; - String getPrincipal(); - Set getGroups(); - SamlServiceProvider getServiceProvider(); + public UserServiceAuthentication(String principal, Set groups, SamlServiceProvider serviceProvider, + Set authenticationMethods, Set networkControls) { + this.principal = principal; + this.groups = groups; + this.serviceProvider = serviceProvider; + this.authenticationMethods = authenticationMethods; + this.networkControls = networkControls; + } - Set getAuthenticationMethods(); - Set getNetworkControls(); + public UserServiceAuthentication(String principal, Set groups, SamlServiceProvider serviceProvider) { + this(principal, groups, serviceProvider, Set.of(AuthenticationMethod.PASSWORD), Set.of(NetworkControl.TLS)); + } + + public String getPrincipal() { + return principal; + } + + public Set getGroups() { + return groups; + } + + public SamlServiceProvider getServiceProvider() { + return serviceProvider; + } + + public Set getAuthenticationMethods() { + return authenticationMethods; + } + + public Set getNetworkControls() { + return networkControls; + } } diff --git a/x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/saml/idp/CloudIdp.java b/x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/saml/idp/CloudIdp.java index a2ae1610ef90b..9c9e9ac41bf72 100644 --- a/x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/saml/idp/CloudIdp.java +++ b/x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/saml/idp/CloudIdp.java @@ -14,6 +14,8 @@ import org.elasticsearch.env.Environment; import org.elasticsearch.xpack.core.ssl.CertParsingUtils; import org.elasticsearch.xpack.core.ssl.X509KeyPairSettings; +import org.elasticsearch.xpack.idp.saml.sp.CloudServiceProvider; +import org.elasticsearch.xpack.idp.saml.sp.SamlServiceProvider; import org.opensaml.security.x509.X509Credential; import org.opensaml.security.x509.impl.X509KeyManagerX509CredentialAdapter; @@ -22,6 +24,7 @@ import java.util.Arrays; import java.util.HashMap; import java.util.HashSet; +import java.util.Map; import java.util.Set; import static org.elasticsearch.xpack.idp.IdentityProviderPlugin.IDP_ENTITY_ID; @@ -37,6 +40,7 @@ public class CloudIdp implements SamlIdentityProvider { private final HashMap ssoEndpoints = new HashMap<>(); private final HashMap sloEndpoints = new HashMap<>(); private final X509Credential signingCredential; + private Map registeredServiceProviders; public CloudIdp(Environment env, Settings settings) { this.entityId = require(settings, IDP_ENTITY_ID); @@ -51,6 +55,7 @@ public CloudIdp(Environment env, Settings settings) { this.sloEndpoints.put("redirect", settings.get(IDP_SLO_REDIRECT_ENDPOINT.getKey())); } this.signingCredential = buildSigningCredential(env, settings); + this.registeredServiceProviders = gatherRegisteredServiceProviders(); } @Override @@ -73,6 +78,13 @@ public X509Credential getSigningCredential() { return signingCredential; } + @Override + public SamlServiceProvider getRegisteredServiceProvider(String spEntityId) { + return registeredServiceProviders.get(spEntityId); + + } + + private static String require(Settings settings, Setting setting) { if (settings.hasValue(setting.getKey())) { return setting.get(settings); @@ -128,4 +140,14 @@ static X509Credential buildSigningCredential(Environment env, Settings settings) } + private Map gatherRegisteredServiceProviders() { + // TODO Fetch all the registered service providers from the index (?) they are persisted. + // For now hardcode something to use. + Map registeredSps = new HashMap<>(); + registeredSps.put("https://sp.some.org", + new CloudServiceProvider("https://sp.some.org", "https://sp.some.org/api/security/v1/saml")); + return registeredSps; + } + + } diff --git a/x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/saml/idp/SamlIdentityProvider.java b/x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/saml/idp/SamlIdentityProvider.java index c81548120732a..2fc0aed066c0b 100644 --- a/x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/saml/idp/SamlIdentityProvider.java +++ b/x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/saml/idp/SamlIdentityProvider.java @@ -7,6 +7,7 @@ package org.elasticsearch.xpack.idp.saml.idp; +import org.elasticsearch.xpack.idp.saml.sp.SamlServiceProvider; import org.opensaml.security.x509.X509Credential; /** @@ -21,4 +22,6 @@ public interface SamlIdentityProvider { String getSingleLogoutEndpoint(String binding); X509Credential getSigningCredential(); + + SamlServiceProvider getRegisteredServiceProvider(String spEntityId); } diff --git a/x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/saml/sp/CloudServiceProvider.java b/x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/saml/sp/CloudServiceProvider.java new file mode 100644 index 0000000000000..23006bb76c80b --- /dev/null +++ b/x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/saml/sp/CloudServiceProvider.java @@ -0,0 +1,54 @@ +/* + * 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.saml.sp; + +import org.elasticsearch.common.Strings; +import org.joda.time.Duration; +import org.joda.time.ReadableDuration; + +import java.net.URI; +import java.net.URISyntaxException; + +public class CloudServiceProvider implements SamlServiceProvider { + + private final String entityid; + private final URI assertionConsumerService; + private final ReadableDuration authnExpiry; + + public CloudServiceProvider(String entityId, String assertionConsumerService) { + if (Strings.isNullOrEmpty(entityId)) { + throw new IllegalArgumentException("Service Provider Entity ID cannot be null or empty"); + } + this.entityid = entityId; + try { + this.assertionConsumerService = new URI(assertionConsumerService); + } catch (URISyntaxException e) { + throw new IllegalArgumentException("Invalid URI for Assertion Consumer Service", e); + } + this.authnExpiry = Duration.standardMinutes(5); + } + + @Override + public String getEntityId() { + return entityid; + } + + @Override + public URI getAssertionConsumerService() { + return assertionConsumerService; + } + + @Override + public ReadableDuration getAuthnExpiry() { + return authnExpiry; + } + + @Override + public AttributeNames getAttributeNames() { + return new SamlServiceProvider.AttributeNames(); + } +} diff --git a/x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/saml/support/SamlFactory.java b/x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/saml/support/SamlFactory.java index 7ad3739e7b417..c4a3eb8d22e32 100644 --- a/x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/saml/support/SamlFactory.java +++ b/x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/saml/support/SamlFactory.java @@ -27,7 +27,7 @@ public class SamlFactory { private final SecureRandom random; public SamlFactory() { - SamlInit.initialize(); + SamlUtils.initialize(); builderFactory = XMLObjectProviderRegistrySupport.getBuilderFactory(); random = new SecureRandom(); } diff --git a/x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/saml/support/SamlInit.java b/x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/saml/support/SamlInit.java deleted file mode 100644 index b1d9c6879ced1..0000000000000 --- a/x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/saml/support/SamlInit.java +++ /dev/null @@ -1,55 +0,0 @@ -/* - * 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.saml.support; - -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; -import org.elasticsearch.ElasticsearchSecurityException; -import org.elasticsearch.SpecialPermission; -import org.elasticsearch.xpack.core.security.support.RestorableContextClassLoader; -import org.opensaml.core.config.InitializationService; -import org.slf4j.LoggerFactory; - -import java.security.AccessController; -import java.security.PrivilegedActionException; -import java.security.PrivilegedExceptionAction; -import java.util.concurrent.atomic.AtomicBoolean; - -public final class SamlInit { - - private static final AtomicBoolean INITIALISED = new AtomicBoolean(false); - private static final Logger LOGGER = LogManager.getLogger(); - - private SamlInit() { } - - /** - * This is needed in order to initialize the underlying OpenSAML library. - * It must be called before doing anything that potentially interacts with OpenSAML (whether in server code, or in tests). - * The initialization happens within do privileged block as the underlying Apache XML security library has a permission check. - * The initialization happens with a specific context classloader as OpenSAML loads resources from its jar file. - */ - public static void initialize() { - if (INITIALISED.compareAndSet(false, true)) { - // We want to force these classes to be loaded _before_ we fiddle with the context classloader - LoggerFactory.getLogger(InitializationService.class); - SpecialPermission.check(); - try { - AccessController.doPrivileged((PrivilegedExceptionAction) () -> { - LOGGER.debug("Initializing OpenSAML"); - try (RestorableContextClassLoader ignore = new RestorableContextClassLoader(InitializationService.class)) { - InitializationService.initialize(); - } - LOGGER.debug("Initialized OpenSAML"); - return null; - }); - } catch (PrivilegedActionException e) { - throw new ElasticsearchSecurityException("failed to set context classloader for SAML IdP", e); - } - } - } - -} diff --git a/x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/saml/support/SamlUtils.java b/x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/saml/support/SamlUtils.java new file mode 100644 index 0000000000000..7f73bac8fc8ed --- /dev/null +++ b/x-pack/plugin/identity-provider/src/main/java/org/elasticsearch/xpack/idp/saml/support/SamlUtils.java @@ -0,0 +1,170 @@ +/* + * 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.saml.support; + +import javax.xml.XMLConstants; +import javax.xml.transform.OutputKeys; +import javax.xml.transform.Transformer; +import javax.xml.transform.TransformerConfigurationException; +import javax.xml.transform.TransformerException; +import javax.xml.transform.TransformerFactory; +import javax.xml.transform.dom.DOMSource; +import javax.xml.transform.stream.StreamResult; +import java.io.StringWriter; +import java.io.Writer; +import java.security.AccessController; +import java.security.PrivilegedActionException; +import java.security.PrivilegedExceptionAction; +import java.util.concurrent.atomic.AtomicBoolean; + +import org.apache.logging.log4j.Logger; +import org.apache.logging.log4j.LogManager; +import org.elasticsearch.ElasticsearchSecurityException; +import org.elasticsearch.SpecialPermission; +import org.elasticsearch.common.SuppressForbidden; +import org.elasticsearch.xpack.core.security.support.RestorableContextClassLoader; +import org.opensaml.core.config.InitializationService; +import org.opensaml.core.xml.io.MarshallingException; +import org.opensaml.core.xml.util.XMLObjectSupport; +import org.opensaml.saml.common.SAMLObject; +import org.opensaml.saml.saml2.core.Assertion; +import org.opensaml.saml.saml2.core.Response; +import org.slf4j.LoggerFactory; +import org.w3c.dom.Element; + +public class SamlUtils { + + private static final String SAML_MARSHALLING_ERROR_STRING = "_unserializable_"; + + private static final AtomicBoolean INITIALISED = new AtomicBoolean(false); + + private static final Logger LOGGER = LogManager.getLogger(SamlUtils.class); + + /** + * This is needed in order to initialize the underlying OpenSAML library. + * It must be called before doing anything that potentially interacts with OpenSAML (whether in server code, or in tests). + * The initialization happens within do privileged block as the underlying Apache XML security library has a permission check. + * The initialization happens with a specific context classloader as OpenSAML loads resources from its jar file. + */ + public static void initialize() { + if (INITIALISED.compareAndSet(false, true)) { + // We want to force these classes to be loaded _before_ we fiddle with the context classloader + LoggerFactory.getLogger(InitializationService.class); + SpecialPermission.check(); + try { + AccessController.doPrivileged((PrivilegedExceptionAction) () -> { + LOGGER.debug("Initializing OpenSAML"); + try (RestorableContextClassLoader ignore = new RestorableContextClassLoader(InitializationService.class)) { + InitializationService.initialize(); + } + LOGGER.debug("Initialized OpenSAML"); + return null; + }); + } catch (PrivilegedActionException e) { + throw new ElasticsearchSecurityException("failed to set context classloader for SAML IdP", e); + } + } + } + + static String toString(Element element, boolean pretty) { + try { + StringWriter writer = new StringWriter(); + print(element, writer, pretty); + return writer.toString(); + } catch (TransformerException e) { + return "[" + element.getNamespaceURI() + "]" + element.getLocalName(); + } + } + + static void print(Element element, Writer writer, boolean pretty) throws TransformerException { + final Transformer serializer = getHardenedXMLTransformer(); + if (pretty) { + serializer.setOutputProperty(OutputKeys.INDENT, "yes"); + } + serializer.transform(new DOMSource(element), new StreamResult(writer)); + } + + public static String getXmlContent(SAMLObject object){ + return getXmlContent(object, false); + } + + public static String getXmlContent(SAMLObject object, boolean prettyPrint) { + try { + return toString(XMLObjectSupport.marshall(object), prettyPrint); + } catch (MarshallingException e) { + LOGGER.info("Error marshalling SAMLObject ", e); + return SAML_MARSHALLING_ERROR_STRING; + } + } + + static String describeSamlObject(SAMLObject object) { + if (Response.class.isInstance(object)) { + Response response = (Response) object; + StringBuilder sb = new StringBuilder(); + sb.append("SAML Response: [\n"); + sb.append(" Destination: ").append(response.getDestination()).append("\n"); + sb.append(" Response ID: ").append(response.getID()).append("\n"); + sb.append(" In response to: ").append(response.getInResponseTo()).append("\n"); + sb.append(" Response issued at:").append(response.getIssueInstant()).append("\n"); + if (response.getIssuer() != null) { + sb.append(" Issuer: ").append(response.getIssuer().getValue()).append("\n"); + } + sb.append(" Number of unencrypted Assertions: ").append(response.getAssertions().size()).append("\n"); + sb.append(" Number of encrypted Assertions: ").append(response.getEncryptedAssertions().size()).append("\n"); + sb.append("]"); + return sb.toString(); + + } else if (Assertion.class.isInstance(object)) { + Assertion assertion = (Assertion) object; + StringBuilder sb = new StringBuilder(); + sb.append("SAML Assertion: [\n"); + sb.append(" Response ID: ").append(assertion.getID()).append("\n"); + sb.append(" Response issued at: ").append(assertion.getIssueInstant()).append("\n"); + if (assertion.getIssuer() != null) { + sb.append(" Issuer: ").append(assertion.getIssuer().getValue()).append("\n"); + } + sb.append(" Number of attribute statements: ").append(assertion.getAttributeStatements().size()).append("\n"); + sb.append(" Number of authentication statements: ").append(assertion.getAuthnStatements().size()).append("\n"); + sb.append("]"); + return sb.toString(); + } + return getXmlContent(object); + } + + @SuppressForbidden(reason = "This is the only allowed way to construct a Transformer") + public static Transformer getHardenedXMLTransformer() throws TransformerConfigurationException { + final TransformerFactory tfactory = TransformerFactory.newInstance(); + tfactory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true); + tfactory.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, ""); + tfactory.setAttribute(XMLConstants.ACCESS_EXTERNAL_STYLESHEET, ""); + tfactory.setAttribute("indent-number", 2); + Transformer transformer = tfactory.newTransformer(); + transformer.setErrorListener(new ErrorListener()); + return transformer; + } + + private static class ErrorListener implements javax.xml.transform.ErrorListener { + + @Override + public void warning(TransformerException e) throws TransformerException { + LOGGER.debug("XML transformation error", e); + throw e; + } + + @Override + public void error(TransformerException e) throws TransformerException { + LOGGER.debug("XML transformation error", e); + throw e; + } + + @Override + public void fatalError(TransformerException e) throws TransformerException { + LOGGER.debug("XML transformation error", e); + throw e; + } + } +} diff --git a/x-pack/plugin/identity-provider/src/test/java/org/elasticsearch/xpack/idp/action/SamlInitiateSingleSignOnRequestTests.java b/x-pack/plugin/identity-provider/src/test/java/org/elasticsearch/xpack/idp/action/SamlInitiateSingleSignOnRequestTests.java new file mode 100644 index 0000000000000..b70ce093ee3c2 --- /dev/null +++ b/x-pack/plugin/identity-provider/src/test/java/org/elasticsearch/xpack/idp/action/SamlInitiateSingleSignOnRequestTests.java @@ -0,0 +1,37 @@ +/* + * 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.action; + +import org.elasticsearch.action.ActionRequestValidationException; +import org.elasticsearch.common.io.stream.BytesStreamOutput; +import org.elasticsearch.test.ESTestCase; + +import static org.hamcrest.CoreMatchers.containsString; +import static org.hamcrest.CoreMatchers.equalTo; + +public class SamlInitiateSingleSignOnRequestTests extends ESTestCase { + + public void testSerialization() throws Exception { + final SamlInitiateSingleSignOnRequest request = new SamlInitiateSingleSignOnRequest(); + request.setSpEntityId("https://kibana_url"); + final BytesStreamOutput out = new BytesStreamOutput(); + request.writeTo(out); + + final SamlInitiateSingleSignOnRequest request1 = new SamlInitiateSingleSignOnRequest(out.bytes().streamInput()); + assertThat(request1.getSpEntityId(), equalTo(request.getSpEntityId())); + final ActionRequestValidationException validationException = request1.validate(); + assertNull(validationException); + } + + public void testValidation() { + + final SamlInitiateSingleSignOnRequest request1 = new SamlInitiateSingleSignOnRequest(); + final ActionRequestValidationException validationException = request1.validate(); + assertNotNull(validationException); + assertThat(validationException.validationErrors().size(), equalTo(1)); + assertThat(validationException.validationErrors().get(0), containsString("sp_entity_id is missing")); + } +} diff --git a/x-pack/plugin/identity-provider/src/test/java/org/elasticsearch/xpack/idp/action/TransportSamlInitiateSingleSignOnRequestTests.java b/x-pack/plugin/identity-provider/src/test/java/org/elasticsearch/xpack/idp/action/TransportSamlInitiateSingleSignOnRequestTests.java new file mode 100644 index 0000000000000..5539b19322518 --- /dev/null +++ b/x-pack/plugin/identity-provider/src/test/java/org/elasticsearch/xpack/idp/action/TransportSamlInitiateSingleSignOnRequestTests.java @@ -0,0 +1,80 @@ +/* + * 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.action; + +import org.elasticsearch.action.support.ActionFilters; +import org.elasticsearch.action.support.PlainActionFuture; +import org.elasticsearch.common.settings.Settings; +import org.elasticsearch.common.util.concurrent.ThreadContext; +import org.elasticsearch.env.Environment; +import org.elasticsearch.env.TestEnvironment; +import org.elasticsearch.tasks.Task; +import org.elasticsearch.test.ESTestCase; +import org.elasticsearch.threadpool.ThreadPool; +import org.elasticsearch.transport.Transport; +import org.elasticsearch.transport.TransportService; +import org.elasticsearch.xpack.core.security.authc.Authentication; +import org.elasticsearch.xpack.core.security.user.User; +import org.junit.Before; + +import java.util.Collections; + +import static org.hamcrest.CoreMatchers.containsString; +import static org.hamcrest.CoreMatchers.equalTo; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class TransportSamlInitiateSingleSignOnRequestTests extends ESTestCase { + + private TransportSamlInitiateSingleSignOnAction action; + + @Before + public void setup() throws Exception { + final Settings settings = Settings.builder() + .put("path.home", createTempDir()) + .put("xpack.idp.enabled", true) + .put("xpack.idp.entity_id", "https://idp.cloud.elastic.co") + .put("xpack.idp.sso_endpoint.redirect", "https://idp.cloud.elastic.co/saml/init") + .build(); + final ThreadContext threadContext = new ThreadContext(settings); + final ThreadPool threadPool = mock(ThreadPool.class); + final TransportService transportService = new TransportService(Settings.EMPTY, mock(Transport.class), null, + TransportService.NOOP_TRANSPORT_INTERCEPTOR, x -> null, null, Collections.emptySet()); + final ActionFilters actionFilters = mock(ActionFilters.class); + final Environment env = TestEnvironment.newEnvironment(settings); + when(threadPool.getThreadContext()).thenReturn(threadContext); + new Authentication(new User("test_saml_user", "saml_idp_role"), + new Authentication.RealmRef("_es_api_key", "_es_api_key", "node_name"), + new Authentication.RealmRef("_es_api_key", "_es_api_key", "node_name")) + .writeToContext(threadContext); + action = new TransportSamlInitiateSingleSignOnAction(threadPool, transportService, actionFilters, env); + } + + public void testGetResponseForRegisteredSp() throws Exception { + final SamlInitiateSingleSignOnRequest request = new SamlInitiateSingleSignOnRequest(); + request.setSpEntityId("https://sp.some.org"); + + final PlainActionFuture future = new PlainActionFuture<>(); + action.doExecute(mock(Task.class), request, future); + + final SamlInitiateSingleSignOnResponse response = future.get(); + assertThat(response.getSpEntityId(), equalTo("https://sp.some.org")); + assertThat(response.getRedirectUrl(), equalTo("https://sp.some.org/api/security/v1/saml")); + assertThat(response.getResponseBody(), containsString("test_saml_user")); + } + + public void testGetResponseForNotRegisteredSp() throws Exception { + final SamlInitiateSingleSignOnRequest request = new SamlInitiateSingleSignOnRequest(); + request.setSpEntityId("https://sp2.other.org"); + + final PlainActionFuture future = new PlainActionFuture<>(); + action.doExecute(mock(Task.class), request, future); + + Exception e = expectThrows(Exception.class, () -> future.get()); + assertThat(e.getCause().getMessage(), containsString("https://sp2.other.org")); + assertThat(e.getCause().getMessage(), containsString("is not registered with this Identity Provider")); + } +} diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/saml/SamlAuthenticator.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/saml/SamlAuthenticator.java index 77fe28f870145..7f074c8282c4f 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/saml/SamlAuthenticator.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/saml/SamlAuthenticator.java @@ -230,7 +230,7 @@ private Assertion decrypt(EncryptedAssertion encrypted) { private List processAssertion(Assertion assertion, boolean requireSignature, Collection allowedSamlRequestIds) { if (logger.isTraceEnabled()) { - logger.trace("(Possibly decrypted) Assertion: {}", SamlUtils.samlObjectToString(assertion)); + logger.trace("(Possibly decrypted) Assertion: {}", SamlUtils.getXmlContent(assertion)); logger.trace(SamlUtils.describeSamlObject(assertion)); } // Do not further process unsigned Assertions @@ -253,7 +253,7 @@ private List processAssertion(Assertion assertion, boolean requireSig for (EncryptedAttribute enc : statement.getEncryptedAttributes()) { final Attribute attribute = decrypt(enc); if (attribute != null) { - logger.trace("Successfully decrypted attribute: {}" + SamlUtils.samlObjectToString(attribute)); + logger.trace("Successfully decrypted attribute: {}" + SamlUtils.getXmlContent(attribute)); attributes.add(attribute); } } 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 4f8677364dcf7..ca16bd4a9a095 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 @@ -656,7 +656,7 @@ public AuthnRequest buildAuthenticationRequest() { .forceAuthn(forceAuthn) .build(); if (logger.isTraceEnabled()) { - logger.trace("Constructed SAML Authentication Request: {}", SamlUtils.samlObjectToString(authnRequest)); + logger.trace("Constructed SAML Authentication Request: {}", SamlUtils.getXmlContent(authnRequest)); } return authnRequest; } @@ -672,7 +672,7 @@ public LogoutRequest buildLogoutRequest(NameID nameId, String session) { final LogoutRequest logoutRequest = new SamlLogoutRequestMessageBuilder( Clock.systemUTC(), serviceProvider, idpDescriptor.get(), nameId, session).build(); if (logoutRequest != null && logger.isTraceEnabled()) { - logger.trace("Constructed SAML Logout Request: {}", SamlUtils.samlObjectToString(logoutRequest)); + logger.trace("Constructed SAML Logout Request: {}", SamlUtils.getXmlContent(logoutRequest)); } return logoutRequest; } else { @@ -688,7 +688,7 @@ public LogoutResponse buildLogoutResponse(String inResponseTo) { final LogoutResponse logoutResponse = new SamlLogoutResponseBuilder( Clock.systemUTC(), serviceProvider, idpDescriptor.get(), inResponseTo, StatusCode.SUCCESS).build(); if (logoutResponse != null && logger.isTraceEnabled()) { - logger.trace("Constructed SAML Logout Response: {}", SamlUtils.samlObjectToString(logoutResponse)); + logger.trace("Constructed SAML Logout Response: {}", SamlUtils.getXmlContent(logoutResponse)); } return logoutResponse; } diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/saml/SamlUtils.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/saml/SamlUtils.java index 88e1e2727b079..7777229dd0fcd 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/saml/SamlUtils.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/saml/SamlUtils.java @@ -162,9 +162,9 @@ static void print(Element element, Writer writer, boolean pretty) throws Transfo serializer.transform(new DOMSource(element), new StreamResult(writer)); } - static String samlObjectToString(SAMLObject object) { + static String getXmlContent(SAMLObject object) { try { - return toString(XMLObjectSupport.marshall(object), true); + return toString(XMLObjectSupport.marshall(object), false); } catch (MarshallingException e) { LOGGER.info("Error marshalling SAMLObject ", e); return SAML_MARSHALLING_ERROR_STRING; @@ -202,7 +202,7 @@ static String describeSamlObject(SAMLObject object) { sb.append("]"); return sb.toString(); } - return samlObjectToString(object); + return getXmlContent(object); } @SuppressForbidden(reason = "This is the only allowed way to construct a Transformer")