Use SimpleHttp for SOAP calls
Closes https://github.com/keycloak/keycloak/issues/17139
This commit is contained in:
parent
d210980988
commit
c6a1820a47
4 changed files with 329 additions and 3 deletions
|
@ -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();
|
||||
}
|
||||
|
|
|
@ -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)) {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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"));
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue