From c6a1820a4735a315d9317b0456afba3d712bf30a Mon Sep 17 00:00:00 2001 From: rmartinc Date: Thu, 30 Mar 2023 17:53:14 +0200 Subject: [PATCH] Use SimpleHttp for SOAP calls Closes https://github.com/keycloak/keycloak/issues/17139 --- .../broker/provider/util/SimpleHttp.java | 38 +++- .../keycloak/protocol/saml/SamlProtocol.java | 7 +- .../protocol/saml/profile/util/Soap.java | 86 ++++++++ .../protocol/saml/profile/util/SoapTest.java | 201 ++++++++++++++++++ 4 files changed, 329 insertions(+), 3 deletions(-) create mode 100644 services/src/test/java/org/keycloak/protocol/saml/profile/util/SoapTest.java diff --git a/server-spi-private/src/main/java/org/keycloak/broker/provider/util/SimpleHttp.java b/server-spi-private/src/main/java/org/keycloak/broker/provider/util/SimpleHttp.java index ed2ba84590..fe743fef20 100755 --- a/server-spi-private/src/main/java/org/keycloak/broker/provider/util/SimpleHttp.java +++ b/server-spi-private/src/main/java/org/keycloak/broker/provider/util/SimpleHttp.java @@ -138,11 +138,23 @@ public class SimpleHttp { return this; } + public String getHeader(String name) { + if (headers != null) { + return headers.get(name); + } + return null; + } + public SimpleHttp json(Object entity) { this.entity = entity; return this; } + public SimpleHttp entity(HttpEntity entity) { + this.entity = entity; + return this; + } + public SimpleHttp param(String name, String value) { if (params == null) { params = new HashMap<>(); @@ -243,6 +255,8 @@ public class SimpleHttp { if (httpRequest instanceof HttpPost || httpRequest instanceof HttpPut || httpRequest instanceof HttpPatch) { if (params != null) { ((HttpEntityEnclosingRequestBase) httpRequest).setEntity(getFormEntityFromParameter()); + } else if (entity instanceof HttpEntity) { + ((HttpEntityEnclosingRequestBase) httpRequest).setEntity((HttpEntity) entity); } else if (entity != null) { if (headers == null || !headers.containsKey(HttpHeaders.CONTENT_TYPE)) { header(HttpHeaders.CONTENT_TYPE, "application/json"); @@ -322,6 +336,7 @@ public class SimpleHttp { private final HttpResponse response; private int statusCode = -1; private String responseString; + private ContentType contentType; public Response(HttpResponse response) { this.response = response; @@ -335,7 +350,7 @@ public class SimpleHttp { HttpEntity entity = response.getEntity(); if (entity != null) { is = entity.getContent(); - ContentType contentType = ContentType.getOrDefault(entity); + contentType = ContentType.getOrDefault(entity); Charset charset = contentType.getCharset(); try { HeaderIterator it = response.headerIterator(); @@ -411,6 +426,27 @@ public class SimpleHttp { return null; } + public Header[] getAllHeaders() throws IOException { + readResponse(); + return response.getAllHeaders(); + } + + public ContentType getContentType() throws IOException { + readResponse(); + return contentType; + } + + public Charset getContentTypeCharset() throws IOException { + readResponse(); + if (contentType != null) { + Charset charset = contentType.getCharset(); + if (charset != null) { + return charset; + } + } + return StandardCharsets.UTF_8; + } + public void close() throws IOException { readResponse(); } diff --git a/services/src/main/java/org/keycloak/protocol/saml/SamlProtocol.java b/services/src/main/java/org/keycloak/protocol/saml/SamlProtocol.java index 1f9cf99c69..f733e45d62 100755 --- a/services/src/main/java/org/keycloak/protocol/saml/SamlProtocol.java +++ b/services/src/main/java/org/keycloak/protocol/saml/SamlProtocol.java @@ -21,6 +21,7 @@ import org.apache.http.NameValuePair; import org.apache.http.client.entity.UrlEncodedFormEntity; import org.apache.http.client.methods.HttpPost; import org.apache.http.message.BasicNameValuePair; +import org.apache.http.util.EntityUtils; import org.jboss.logging.Logger; import org.keycloak.broker.saml.SAMLDataMarshaller; import org.keycloak.common.VerificationException; @@ -107,7 +108,6 @@ import java.util.UUID; import java.util.concurrent.atomic.AtomicReference; import org.apache.http.client.methods.CloseableHttpResponse; import org.apache.http.impl.client.CloseableHttpClient; -import org.apache.http.util.EntityUtils; /** * @author Bill Burke @@ -772,7 +772,10 @@ public class SamlProtocol implements LoginProtocol { try { LogoutRequestType logoutRequest = createLogoutRequest(soapLogoutUrl, clientSession, client); Document samlLogoutRequest = createBindingBuilder(samlClient, false).soapBinding(SAML2Request.convert(logoutRequest)).getDocument(); - SOAPMessage soapResponse = Soap.createMessage().addToBody(samlLogoutRequest).call(soapLogoutUrl); + SOAPMessage soapResponse = Soap.createMessage() + .addMimeHeader("SOAPAction", "http://www.oasis-open.org/committees/security") // MAY in SOAP binding spec + .addToBody(samlLogoutRequest) + .call(soapLogoutUrl, session); Document logoutResponse = Soap.extractSoapMessage(soapResponse); SAMLDocumentHolder samlDocResponse = SAML2Response.getSAML2ObjectFromDocument(logoutResponse); if (!validateLogoutResponse(logoutRequest, samlDocResponse, client)) { diff --git a/services/src/main/java/org/keycloak/protocol/saml/profile/util/Soap.java b/services/src/main/java/org/keycloak/protocol/saml/profile/util/Soap.java index ed6dab516f..5a25e20b19 100755 --- a/services/src/main/java/org/keycloak/protocol/saml/profile/util/Soap.java +++ b/services/src/main/java/org/keycloak/protocol/saml/profile/util/Soap.java @@ -17,15 +17,20 @@ package org.keycloak.protocol.saml.profile.util; +import org.apache.http.Header; +import org.apache.http.HttpStatus; import org.apache.http.client.methods.HttpPost; import org.apache.http.entity.ByteArrayEntity; import org.apache.http.entity.ContentType; +import org.keycloak.broker.provider.util.SimpleHttp; +import org.keycloak.models.KeycloakSession; import org.keycloak.saml.processing.core.saml.v2.util.DocumentUtil; import org.keycloak.saml.processing.web.util.PostBindingUtil; import org.w3c.dom.Document; import org.w3c.dom.Element; import org.w3c.dom.Node; +import javax.ws.rs.core.HttpHeaders; import javax.ws.rs.core.MediaType; import javax.ws.rs.core.Response; import javax.ws.rs.core.Response.Status; @@ -37,11 +42,16 @@ import javax.xml.soap.SOAPConnectionFactory; import javax.xml.soap.SOAPEnvelope; import javax.xml.soap.SOAPException; import javax.xml.soap.SOAPFault; +import javax.xml.soap.MimeHeader; +import javax.xml.soap.MimeHeaders; import javax.xml.soap.SOAPHeaderElement; import javax.xml.soap.SOAPMessage; +import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; +import java.io.IOException; import java.io.InputStream; import java.net.URI; +import java.util.Iterator; /** * @author Pedro Igor @@ -166,6 +176,11 @@ public final class Soap { } } + public SoapMessageBuilder addMimeHeader(String name, String value) { + this.message.getMimeHeaders().addHeader(name, value); + return this; + } + public Name createName(String name) { try { return this.envelope.createName(name); @@ -214,7 +229,9 @@ public final class Soap { * @param url a SOAP endpoint url * @return the SOAPMessage returned by the contacted SOAP server * @throws SOAPException Raised if there's a problem performing the SOAP call + * @deprecated Use {@link #call(String,KeycloakSession)} to use SimpleHttp configuration */ + @Deprecated public SOAPMessage call(String url) throws SOAPException { SOAPMessage response; SOAPConnection soapConnection = null; @@ -230,6 +247,75 @@ public final class Soap { return response; } + /** + * Performs a synchronous call, sending the current message to the given url. + * SimpleHttp is retrieved using the session parameter. + * @param url The SOAP endpoint URL to connect + * @param session The session to use to locate the SimpleHttp sender + * @return the SOAPMessage returned by the contacted SOAP server + * @throws SOAPException Raised if there's a problem performing the SOAP call + */ + public SOAPMessage call(String url, KeycloakSession session) throws SOAPException { + // https://github.com/eclipse-ee4j/metro-saaj/blob/master/saaj-ri/src/main/java/com/sun/xml/messaging/saaj/client/p2p/HttpSOAPConnection.java + // save changes of the message, this adds content-type and content-length headers + if (message.saveRequired()) { + message.saveChanges(); + } + // use SimpleHttp from the session + SimpleHttp simpleHttp = SimpleHttp.doPost(url, session); + // add all the headers as HTTP headers except the ones needed for the HttpEntity + Iterator reqHeaders = message.getMimeHeaders().getAllHeaders(); + ContentType contentType = null; + int length = -1; + boolean hasCacheControl = false; + while (reqHeaders.hasNext()) { + MimeHeader mimeHeader = reqHeaders.next(); + if (HttpHeaders.CONTENT_TYPE.equalsIgnoreCase(mimeHeader.getName())) { + contentType = ContentType.parse(mimeHeader.getValue()); + } else if (HttpHeaders.CONTENT_LENGTH.equalsIgnoreCase(mimeHeader.getName())) { + length = Integer.parseInt(mimeHeader.getValue()); + } else { + if (HttpHeaders.CACHE_CONTROL.equalsIgnoreCase(mimeHeader.getName())) { + hasCacheControl = true; + } + String currentValue = simpleHttp.getHeader(mimeHeader.getName()); + simpleHttp.header(mimeHeader.getName(), currentValue == null + ? mimeHeader.getValue() : currentValue + "," + mimeHeader.getValue()); + } + } + if (!hasCacheControl) { + // set no cache if cache-control was not specified + simpleHttp.header(HttpHeaders.CACHE_CONTROL, "no-cache, no-store"); + } + // create the message and send to the parameter URL + try (ByteArrayOutputStream out = new ByteArrayOutputStream()) { + message.writeTo(out); + simpleHttp.entity(new ByteArrayEntity(out.toByteArray(), 0, length, contentType)); + try (SimpleHttp.Response res = simpleHttp.asResponse()) { + // HTTP_INTERNAL_ERROR (500) and HTTP_BAD_REQUEST (400) should be processed as SOAP faults + if (res.getStatus() == HttpStatus.SC_INTERNAL_SERVER_ERROR + || res.getStatus() == HttpStatus.SC_BAD_REQUEST + || res.getStatus() == HttpStatus.SC_OK) { + MimeHeaders resHeaders = new MimeHeaders(); + Header[] headers = res.getAllHeaders(); + for (Header header : headers) { + resHeaders.addHeader(header.getName(), header.getValue()); + } + String responseString = res.asString(); + if (responseString == null || responseString.isEmpty()) { + // return null if no reply message + return null; + } + return MessageFactory.newInstance().createMessage(resHeaders, new ByteArrayInputStream(responseString.getBytes(res.getContentTypeCharset()))); + } else { + throw new SOAPException("Bad response (" + res.getStatus() + ") :" + res.asString()); + } + } + } catch (IOException e) { + throw new SOAPException(e); + } + } + public SOAPMessage getMessage() { return this.message; } diff --git a/services/src/test/java/org/keycloak/protocol/saml/profile/util/SoapTest.java b/services/src/test/java/org/keycloak/protocol/saml/profile/util/SoapTest.java new file mode 100644 index 0000000000..e3a8dda48c --- /dev/null +++ b/services/src/test/java/org/keycloak/protocol/saml/profile/util/SoapTest.java @@ -0,0 +1,201 @@ +/* + * Copyright 2016 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.protocol.saml.profile.util; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpHandler; +import com.sun.net.httpserver.HttpServer; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.net.URI; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.Properties; +import javax.ws.rs.core.HttpHeaders; +import javax.xml.soap.SOAPException; +import javax.xml.soap.SOAPMessage; +import org.apache.commons.io.IOUtils; +import org.hamcrest.CoreMatchers; +import org.hamcrest.MatcherAssert; +import org.junit.AfterClass; +import org.junit.Assert; +import org.junit.BeforeClass; +import org.junit.FixMethodOrder; +import org.junit.Test; +import org.junit.runners.MethodSorters; +import org.keycloak.Config; +import org.keycloak.common.Profile; +import org.keycloak.common.crypto.CryptoIntegration; +import org.keycloak.common.crypto.CryptoProvider; +import org.keycloak.dom.saml.v2.SAML2Object; +import org.keycloak.dom.saml.v2.assertion.NameIDType; +import org.keycloak.dom.saml.v2.protocol.LogoutRequestType; +import org.keycloak.models.KeycloakSession; +import org.keycloak.saml.SAML2LogoutRequestBuilder; +import org.keycloak.saml.common.constants.JBossSAMLURIConstants; +import org.keycloak.saml.common.exceptions.ConfigurationException; +import org.keycloak.saml.processing.api.saml.v2.request.SAML2Request; +import org.keycloak.saml.processing.core.saml.v2.common.SAMLDocumentHolder; +import org.keycloak.services.DefaultKeycloakSession; +import org.keycloak.services.DefaultKeycloakSessionFactory; +import org.keycloak.services.util.JsonConfigProvider; +import org.keycloak.services.util.JsonConfigProvider.JsonScope; +import org.w3c.dom.Document; + +/** + *

Test class for Soap utility class.

+ * + * @author rmartinc + */ +@FixMethodOrder(MethodSorters.NAME_ASCENDING) +public class SoapTest { + + private static HttpServer server; + + private static class MyHandler implements HttpHandler { + + @Override + public void handle(HttpExchange exchange) throws IOException { + // just return the same data received, headers inclusive + if ("POST".equals(exchange.getRequestMethod())) { + exchange.getResponseHeaders().putAll(exchange.getRequestHeaders()); + try ( InputStream is = exchange.getRequestBody(); + OutputStream os = exchange.getResponseBody()) { + exchange.sendResponseHeaders(200, Long.parseLong(exchange.getRequestHeaders().getFirst(HttpHeaders.CONTENT_LENGTH))); + IOUtils.copy(is, os); + } + } + exchange.sendResponseHeaders(400, 0); + } + } + + @BeforeClass + public static void startHttpServer() throws IOException { + server = HttpServer.create(new InetSocketAddress(8280), 0); + server.createContext("/", new MyHandler()); + server.setExecutor(null); // creates a default executor + server.start(); + } + + @AfterClass + public static void stopHttpServer() { + server.stop(0); + } + + + private String param(String key, String value) { + return "\"" + key + "\"" + " : " + "\"" + value + "\""; + } + + private String json(Map properties) { + String[] params = properties.entrySet().stream().map(e -> param(e.getKey(), e.getValue())).toArray(String[]::new); + + StringBuilder sb = new StringBuilder(); + sb.append("{"); + sb.append(String.join(",", params)); + sb.append("}"); + + return sb.toString(); + } + + private JsonScope createScope(Map properties) { + ObjectMapper mapper = new ObjectMapper(); + try { + JsonNode config = mapper.readTree(json(properties)); + return new JsonConfigProvider(config, new Properties()).new JsonScope(config); + } catch (IOException e) { + Assert.fail("Could not parse json"); + } + return null; + } + + private LogoutRequestType createLogoutRequestType() throws ConfigurationException { + NameIDType nameId = new NameIDType(); + nameId.setFormat(URI.create(JBossSAMLURIConstants.NAMEID_FORMAT_TRANSIENT.get())); + nameId.setValue("user1"); + return new SAML2LogoutRequestBuilder().assertionExpiration(60).issuer("http://sample.com") + .nameId(nameId).destination("http://sample.com/logout") + .sessionIndex("idx") + .createLogoutRequest(); + } + + @Test + public void test1ResponseOK() throws Exception { + LogoutRequestType request = createLogoutRequestType(); + Document doc = SAML2Request.convert(request); + Profile.defaults(); + CryptoIntegration.init(CryptoProvider.class.getClassLoader()); + DefaultKeycloakSessionFactory sessionFactory = new DefaultKeycloakSessionFactory(); + sessionFactory.init(); + KeycloakSession session = new DefaultKeycloakSession(sessionFactory); + + SOAPMessage soapResponse = Soap.createMessage() + .addMimeHeader("SOAPAction", "http://www.oasis-open.org/committees/security") + .addMimeHeader("custom-header", "custom-value") + .addToBody(doc) + .call("http://localhost:8280", session); + // check the headers are set back + Assert.assertArrayEquals(new String[]{"no-cache, no-store"}, soapResponse.getMimeHeaders().getHeader(HttpHeaders.CACHE_CONTROL)); + Assert.assertArrayEquals(new String[]{"http://www.oasis-open.org/committees/security"}, soapResponse.getMimeHeaders().getHeader("SOAPAction")); + Assert.assertArrayEquals(new String[]{"custom-value"}, soapResponse.getMimeHeaders().getHeader("custom-header")); + // check response is the LogoutResponseType sent + Document responseDoc = Soap.extractSoapMessage(soapResponse); + SAMLDocumentHolder samlDocResponse = SAML2Request.getSAML2ObjectFromDocument(responseDoc); + SAML2Object samlObject = samlDocResponse.getSamlObject(); + MatcherAssert.assertThat(samlObject, CoreMatchers.instanceOf(LogoutRequestType.class)); + LogoutRequestType response = (LogoutRequestType) samlObject; + Assert.assertEquals(request.getNameID().getValue(), response.getNameID().getValue()); + } + + @Test + public void test2ConfigurationUsed() throws Exception { + LogoutRequestType request = createLogoutRequestType(); + Document doc = SAML2Request.convert(request); + Profile.defaults(); + CryptoIntegration.init(CryptoProvider.class.getClassLoader()); + Config.init(new Config.ConfigProvider() { + @Override + public String getProvider(String spi) { + return null; + } + + @Override + public Config.Scope scope(String... scope) { + if (scope.length == 2 && "connectionsHttpClient".equals(scope[0]) && "default".equals(scope[1])) { + return createScope(Collections.singletonMap("proxy-mappings", "localhost;http://localhost:8281")); + } + return createScope(new HashMap<>()); + } + }); + DefaultKeycloakSessionFactory sessionFactory = new DefaultKeycloakSessionFactory(); + sessionFactory.init(); + KeycloakSession session = new DefaultKeycloakSession(sessionFactory); + + SOAPException ex = Assert.assertThrows(SOAPException.class, () -> { + Soap.createMessage() + .addToBody(doc) + .call("http://localhost:8280", session); + }); + MatcherAssert.assertThat(ex.getMessage(), CoreMatchers.containsString("localhost:8281")); + MatcherAssert.assertThat(ex.getMessage(), CoreMatchers.containsString("Connection refused")); + } +}