Use SimpleHttp for SOAP calls

Closes https://github.com/keycloak/keycloak/issues/17139
This commit is contained in:
rmartinc 2023-03-30 17:53:14 +02:00 committed by Pedro Igor
parent d210980988
commit c6a1820a47
4 changed files with 329 additions and 3 deletions

View file

@ -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();
}

View file

@ -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 <a href="mailto:bill@burkecentral.com">Bill Burke</a>
@ -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)) {

View file

@ -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 <a href="mailto:psilva@redhat.com">Pedro Igor</a>
@ -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<MimeHeader> 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;
}

View file

@ -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;
/**
* <p>Test class for Soap utility class.</p>
*
* @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<String, String> 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<String, String> 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"));
}
}