Soap Client with Authentication in Liferay 7.3


​​​​​​​Spricht man heute über Schnittstellen, so ist man schnell bei REST, GraphQL oder gRPC. Dennoch gibt es einige Gründe sich mit SOAP auseinanderzusetzen, insbesondere wenn man wie wir im Enterprise Umfeld unterwegs ist. Einen imho guten Vergleich liefert etwa https://raygun.com/blog/soap-vs-rest-vs-json/ doch das ist vermutlich nicht der Grund warum Du hier gelandest bist, sondern die Frage:

Wie zum Teufel bekomme ich einen SOAP Client mit Authentifizierung in Liferay implementiert ???

Der Beantwortung dieser Frage wollen wir uns nun im Folgenden widmen.

Vorhandene Dokumentation und Beispiele

Sucht man nach entsprechender Doku findet man verhältnismäßig wenig. Die Liferay Dokumentation liefert hauptsächlich Ansätze, um Soap Services zur Verfügung zu stellen. Geht es doch um die Client-Seite, dann darum, wie sich Clients gegen die Liferay SOAP API verbinden können.

Sucht man fleißig genug findet sich das Repository von Antonio Mussara https://github.com/amusarra/liferay-72-soap-client-examples der nicht nur die Service Seite, sondern auch die Client Seite zur Verfügung stellt, inklusive einem Ansatz zur SSL/TLS Mutual Authentication. Eine ausführliche Beschreibung findet sich dann noch einmal hier: https://www.dontesta.it/2019/09/05/how-to-implement-a-soap-client-using-jax-ws-liferay-infrastructure/

Damit könnte dieser Blogbeitrag zu Ende sein, wenn wir in freier Wildbahn nicht SOAP Services vorfinden würden, die per Basic Authenticat/UsernameToken/..., kurz: Benutzername und Passwort, gesichert sind. Zu erkennen sind die in Eurer WSDL durch so ein Konstrukt (oder ähnliches):

<?xml version='1.0' encoding='UTF-8'?><definitions xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:wsu="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd" xmlns:wspp="http://java.sun.com/xml/ns/wsit/policy" xmlns:wsp="http://www.w3.org/ns/ws-policy" xmlns:wsam="http://www.w3.org/2007/05/addressing/metadata" xmlns:tcp="http://java.sun.com/xml/ns/wsit/2006/09/policy/soaptcp/service" xmlns:sp="http://docs.oasis-open.org/ws-sx/ws-securitypolicy/200702" xmlns:soap="http://schemas.xmlsoap.org/wsdl/soap/" xmlns:sc="http://schemas.sun.com/2006/03/wss/server" xmlns:occ="common.de.soluvia.opencat.middleware" xmlns:oc="users.de.soluvia.opencat.middleware" xmlns:ns="http://schemas.xmlsoap.org/soap/encoding/" xmlns:fi="http://java.sun.com/xml/ns/wsit/2006/09/policy/fastinfoset/service" xmlns="http://schemas.xmlsoap.org/wsdl/" name="UserSOAPServices" targetNamespace="loremIpsumNamespace">
​​​​​​​...
  <wsp:Policy wsu:Id="UserServicesPortPolicy">
      <wsp:ExactlyOne>
          <wsp:All>
              <sp:SupportingTokens>
                  <wsp:Policy>
                      <sp:UsernameToken sp:IncludeToken="http://docs.oasis-open.org/ws-sx/ws-securitypolicy/200702/IncludeToken/AlwaysToRecipient">
                          <wsp:Policy>
                              <sp:WssUsernameToken10/>
                          </wsp:Policy>
                      </sp:UsernameToken>
                  </wsp:Policy>
              </sp:SupportingTokens>
          </wsp:All>
      </wsp:ExactlyOne>
  </wsp:Policy>

In SOAP UI ist das ganz einfach: Basic Authentication einrichten, WSS-Password Type einstellen, fertig. Im Zusammenspiel mit Liferay braucht es etwas mehr Aufwand...

In Liferay 6.2 haben wir ganz gute Erfahrungen mit der JAX-WS Referenzimplentierung Metro gemacht. Für Liferay 7.X und mit ganzen Umstellung auf OSGI funktionierte das aber für uns nicht mehr. Zudem bringt Liferay eigentlich alle Libraries auf Basis von Apache CXF mit... eigentlich. Ohne die Authentifizierung findet man auch hier, mit genügend Suchenergie, genügend Beispiele wie der Client zu realisieren ist, ohne auch nur eine CXF Klasse direkt benutzt zu haben (so wie es sein sollte, denn man möchte sich ja eigentlich nicht von einem Framework abhängig machen). Die cxf-rt-ws-security Erweiterung ist leider nicht dabei (Quelle: https://github.com/liferay/liferay-portal/blob/master/lib/versions-complete.xml)

Einmal Dependency-Hölle und zurück

Und da fing die Herausforderung an, denn die cxf-rt-ws-security Erweiterung hat natürlich ganz viele Dependencies innerhalb des Apache CXF Systems. Viele sind in Liferay zwar vorhanden aber nicht direkt zugreifbar, so dass wir jeweils ClassNotFound Exceptions bekamen. Somit gab es zwei Möglichkeiten: Entweder alle Dependencies mit compileInclude in unser Client Artefakt ziehen (#badidea) oder dafür sorgen, dass alles über OSGI Module zur Verfügung gestellt wird. Ein erster Ansatz kam hier wieder von dem bereits oben genannten Antonio Mussara in einem Blogbeitrag aus 2016 (https://www.dontesta.it/en/2016/07/19/liferay-7-come-realizzare-un-client-soap-apache-cxf-osgi-style/)

Aber auch hier fehlte die Security Erweiterung. Also gab es für uns nur den harten Weg sich durch die Dependency Hölle zu kämpfen. Modul installieren, gucken wo fehlende Abhängigkeiten sind welche nicht-optional sind und das dann über die GogoShell installieren. Dabei stellt man aber fest, dass manche Abhängigkeiten nicht als OSGI-fähige Version vorliegen. Insbesondere die SAML Bibliotheken auf die man zwangsläufig dann stößt, gibt es nicht "einfach so". Hier schafften Bundles aus dem Apache Service Mix Abhilfe. Am Ende erhielten wir folgende Liste an zu installierenden Dependencies:

XmlSchema Core (2.2.1)
Apache CXF Core (3.2.14)
Apache CXF Runtime JAXB DataBinding (3.2.14)
Apache CXF Runtime SOAP Binding (3.2.14)
Apache Commons Codec (1.15.0)
Apache CXF Runtime Simple Frontend (3.2.14)
Apache CXF Runtime JAX-WS Frontend (3.2.14)
Apache CXF Runtime HTTP Transport (3.2.14)
Apache CXF Runtime WS Security (3.2.14)
Apache XML Security for Java (2.2.0)
Apache WSS4J DOM WS-Security (2.2.5)
Apache WSS4J WS-Security Common (2.2.5)
Apache WSS4J Streaming WS-Security (2.2.5)
Apache WSS4J WS-Security Bindings (2.2.5)
Apache CXF Runtime WS Policy (3.2.14)
Apache WSS4J Streaming WS-SecurityPolicy (2.2.5)
Guava: Google Core Libraries for Java (20.0.0)
Apache ServiceMix :: Bundles :: opensaml (3.3.0.2)
Apache ServiceMix :: Bundles :: jasypt (1.9.3.1)
Joda-Time (2.10.8)
Metrics Core (3.2.6)
Apache Neethi (3.1.1)
Apache WSS4J WS-SecurityPolicy model (2.2.5)
Apache CXF Runtime SAML Security functionality (3.2.14)
Apache CXF Runtime Security functionality (3.2.14)
Apache CXF Advanced Logging Feature (3.2.14)
Apache CXF Runtime WS Addressing (3.2.14)

Bei den Versionen haben wir uns zum einen von den von Liferay verwendeten Versionen leiten lassen (Liferay wird seinen Grund dafür haben, z.B. nicht Apache CXF 3.3.X genommen zu haben) und ansonsten die jeweils höchsten, kompatiblen Versionsnummern verwendet.

Serviceorientiert wie wir sind liefern wir natürlich auf direkt die URLs zum Kopieren ;-)

install https://repository.apache.org/content/repositories/releases/org/apache/ws/xmlschema/xmlschema-core/2.2.1/xmlschema-core-2.2.1.jar
install https://repository.apache.org/content/repositories/releases/org/apache/cxf/cxf-core/3.2.14/cxf-core-3.2.14.jar
install https://repository.apache.org/content/repositories/releases/org/apache/cxf/cxf-rt-databinding-jaxb/3.2.14/cxf-rt-databinding-jaxb-3.2.14.jar
install https://repository.apache.org/content/repositories/releases/org/apache/cxf/cxf-rt-bindings-soap/3.2.14/cxf-rt-bindings-soap-3.2.14.jar
install https://repo1.maven.org/maven2/commons-codec/commons-codec/1.15/commons-codec-1.15.jar
install https://repository.apache.org/content/repositories/releases/org/apache/cxf/cxf-rt-frontend-simple/3.2.14/cxf-rt-frontend-simple-3.2.14.jar
install https://repository.apache.org/content/repositories/releases/org/apache/cxf/cxf-rt-frontend-jaxws/3.2.14/cxf-rt-frontend-jaxws-3.2.14.jar
install https://repository.apache.org/content/repositories/releases/org/apache/cxf/cxf-rt-transports-http/3.2.14/cxf-rt-transports-http-3.2.14.jar
install https://repository.apache.org/content/repositories/releases/org/apache/cxf/cxf-rt-ws-security/3.2.14/cxf-rt-ws-security-3.2.14.jar
install https://repo1.maven.org/maven2/org/apache/santuario/xmlsec/2.2.0/xmlsec-2.2.0.jar
install https://repo1.maven.org/maven2/org/apache/wss4j/wss4j-ws-security-dom/2.2.5/wss4j-ws-security-dom-2.2.5.jar
install https://repo1.maven.org/maven2/org/apache/wss4j/wss4j-ws-security-common/2.2.5/wss4j-ws-security-common-2.2.5.jar
install https://repo1.maven.org/maven2/org/apache/wss4j/wss4j-ws-security-stax/2.2.5/wss4j-ws-security-stax-2.2.5.jar
install https://repo1.maven.org/maven2/org/apache/wss4j/wss4j-bindings/2.2.5/wss4j-bindings-2.2.5.jar
install https://repo1.maven.org/maven2/org/apache/cxf/cxf-rt-ws-policy/3.2.14/cxf-rt-ws-policy-3.2.14.jar
install https://repo1.maven.org/maven2/org/apache/wss4j/wss4j-ws-security-policy-stax/2.2.5/wss4j-ws-security-policy-stax-2.2.5.jar
install https://repo1.maven.org/maven2/com/google/guava/guava/20.0/guava-20.0.jar
install https://repo1.maven.org/maven2/org/apache/servicemix/bundles/org.apache.servicemix.bundles.opensaml/3.3.0_2/org.apache.servicemix.bundles.opensaml-3.3.0_2.jar
install https://repo1.maven.org/maven2/org/apache/servicemix/bundles/org.apache.servicemix.bundles.jasypt/1.9.3_1/org.apache.servicemix.bundles.jasypt-1.9.3_1.jar
install https://repo1.maven.org/maven2/joda-time/joda-time/2.10.8/joda-time-2.10.8.jar
install https://repo1.maven.org/maven2/io/dropwizard/metrics/metrics-core/3.2.6/metrics-core-3.2.6.jar
install https://repo1.maven.org/maven2/org/apache/neethi/neethi/3.1.1/neethi-3.1.1.jar
install https://repo1.maven.org/maven2/org/apache/wss4j/wss4j-policy/2.2.5/wss4j-policy-2.2.5.jar
install https://repo1.maven.org/maven2/org/apache/cxf/cxf-rt-security-saml/3.2.14/cxf-rt-security-saml-3.2.14.jar
install https://repo1.maven.org/maven2/org/apache/cxf/cxf-rt-security/3.2.14/cxf-rt-security-3.2.14.jar
install https://repo1.maven.org/maven2/org/apache/cxf/cxf-rt-features-logging/3.2.14/cxf-rt-features-logging-3.2.14.jar
install https://repo1.maven.org/maven2/org/apache/cxf/cxf-rt-ws-addr/3.2.14/cxf-rt-ws-addr-3.2.14.jar

Wenn Ihr alles richtig gemacht habt, solltet Ihr in Eurer Gogo Shell in etwa folgendes Bild sehen:
​​​​​​​

Ggf. müsst Ihr die Module über start <bundleID> starten.

Nun galt es die build.gradle anzupassen, damit wir die neu gewonnenen Funktionen auch nutzen können. Alle relevanten Abhängigkeiten sind nun compileOnly und unser Client wird angenehm klein.

apply plugin: "java"
apply plugin: "maven"
apply plugin: "cz.swsamuraj.jaxws"

group = 'de.ffit.liferay.soap'
version = '1.17-SNAPSHOT'

buildscript {
    repositories {
        mavenLocal()
        maven {
            url "https://plugins.gradle.org/m2/"
        }
        maven {
            url "https://repository-cdn.liferay.com/nexus/content/groups/public"
        }
    }
    dependencies {
        classpath group: "gradle.plugin.cz.swsamuraj", name: "gradle-jaxws-plugin", version: "0.6.1"
        classpath group: "com.liferay", name: "com.liferay.gradle.plugins", version: "3.2.29"
    }
}

repositories {
    mavenLocal()
}

dependencies {
    compileOnly group: 'com.liferay.portal', name: 'release.portal.api', version: '7.3.5-ga6'
    compileOnly 'org.apache.cxf:cxf-rt-ws-policy:3.2.14'
    compileOnly 'org.apache.cxf:cxf-rt-transports-http:3.2.14'
    compileOnly 'org.apache.cxf:cxf-rt-frontend-jaxws:3.2.14'
    compileOnly 'org.apache.cxf:cxf-core:3.2.14'
    compileOnly 'org.apache.cxf:cxf-rt-ws-security:3.2.14'

    testCompile 'org.mockito:mockito-core:2.23.0'
    testCompile 'junit:junit:4.12'
}

jaxws {
    wsdlDir = 'src/wsdl/localhost_8084/middleware'
    generatedSources = 'src/generated/java'
}

Während des Buildprozesses werden nun alle WSDLs die (in unserem Fall) in /src/wsdl/localhost_8084/middleware liegen durchgeparsed und die daraus entstehenden Java Klassen in src/generated/java gespeichert.

Der SOAP Client

Für den Client schauen wir uns einmal den UserClient ab, der Nutzerdetails aus einer Middleware holt.

public class UserServiceClient {

    private static UserServiceClient instance;
    private UserServices services;
    private String endpoint = "/UserServices?wsdl";

    public static UserServiceClient getInstance() throws Exception {
        if (instance == null) {
            instance = new UserServiceClient();
        }

        return instance;
    }

    private UserServiceClient() throws Exception {
        wsdlUrl = generateWsdlURLfromProperties();
    }

    public UserSOAPServices connect() {
        if (services == null) {
            services = new UserServices(wsdlUrl);
        }
        UserSOAPServices port = services.getUserPort();

        attachCXFSecurity(port);
        reInitializeEndpoint(port);

        return port;
    }
    
    private URL generateWsdlURLfromProperties(){
        middlewareLocation = PrefsPropsUtil.getString("oc.middleware.location");
        String portalExtEndpoint = PrefsPropsUtil.getString("ffit.middleware.ws.endpoint.users");
        if (portalExtEndpoint != null) {
            endpoint = portalExtEndpoint;
        }
        return new URL(middlewareLocation + endpoint);    
    }
    
    private void attachCXFSecurity(Object port){
        Client client = ClientProxy.getClient(port);
        Endpoint endpoint = client.getEndpoint();

        Map<String, Object> props = ((BindingProvider) port).getRequestContext();
        props.put(WSHandlerConstants.ACTION, WSHandlerConstants.USERNAME_TOKEN);
        props.put(WSHandlerConstants.PASSWORD_TYPE, WSConstants.PW_TEXT);
        props.put(WSHandlerConstants.PW_CALLBACK_CLASS, UsernameCallbackHandler.class.getName());
        props.put(WSHandlerConstants.USER, "<PUT_YOUR_USERNAME_HERE>");

        WSS4JOutInterceptor wssOut = new WSS4JOutInterceptor(props);
        endpoint.getOutInterceptors().add(wssOut);
    }
    
    private void reInitializeEndpoint(Object port){
        ((BindingProvider)port).getRequestContext().put(BindingProvider.ENDPOINT_ADDRESS_PROPERTY, wsdlUrl.toString().replace("?wsdl",""));
    }
}

Der "spannende" Teil steckt in der attachCXFSecurity Methode. Hier werden der Benutzername und ein CallbackHandler gesetzt. Der CallbackHandler selbst sieht dann so aus:

import org.apache.wss4j.common.ext.WSPasswordCallback;
import org.slf4j.LoggerFactory;

import javax.security.auth.callback.*;
import java.io.IOException;

public class UsernameCallbackHandler implements CallbackHandler {

    private String USERNAME = "put your username here... better write service";
    private String PASSWORD = "your password goes here";

    @Override
    public void handle(Callback[] callbacks) throws IOException,
            UnsupportedCallbackException {
        for (Callback callback : callbacks) {
​​​​​​​            if(callback instanceof WSPasswordCallback){
                WSPasswordCallback pc = (WSPasswordCallback) callback;
                pc.setPassword(PASSWORD);
            }else{
                throw new UnsupportedCallbackException(callback, "Unrecognized Callback:"+callback.getClass().getName());
            }
        }
    }
}

Nun sollte alles beisammen sein, damit Ihr Euren mit Benutzername und Passwort geschützten SOAP Webservice aus Liferay 7 heraus aufrufen könnt.

#keepITfast

Chris