četvrtak, 30. kolovoza 2007.

CXF, Spring and WS-Security putting it all togeather - part 1

Last few days I was playing with Apache CXF (an open source services framework). CXF is a continuation of the popular Codehaus project named: XFire and is considered to be XFire 2.0 (more on concerning this merge can be found here: XFire/Celtix merge FAQ). Anyway, although CXF is still in Apache incubator, project seems quite promising and has lots of very interesting features, so i gave it a try. Since the documentation concerning Ws-Security on the Cxf Web is very thin I have also deceided to share some of my experiences, so hence comes this post.

1. Defining a Goal
My goal was to create simple HelloWorld service, and secure it with WS-Security, all this of course done with CXF. Full specification of Ws-Security can be found at Oasis Web site but in short we can say that WS-Security provides following functionality to the WebServices:
  • Pass authentication tokens between services
  • Encrypt messages or parts of messages
  • Sign messages
  • Timestamp messages
WS-Security defines several authentication tokens that can be exchanged:
In this first part i will limit myself only to UsernameToken, other tokens will be focus of some of my next blogs.
Therefore, the goal of this excersise is to create HelloWorld WebService and protect it with WS-Secuirtiy, so that it accepts only SOAP calls which in their headers have timestamp, UsernameToken and are signed by known entity.

2. Service and Client Creation with Spring, CXF
For this excersise I took web service from here (Writing a Web Service with Spring) .
So after going through the steps describe above i had fully functional Web Service and Client.
Service has only one operation sayHi() which excepts String as an input param, and returns String as an result of the operation.
In order to be able test service and client I wrote one java main class: HelloClient.

package demo.spring;

import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.context.ApplicationContext;
import org.springframework.context.support.ClassPathXmlApplicationContext;

public class HelloClient {

protected static final Log logger = LogFactory.getLog(HelloClient.class);
/**
*
* @param args
*/
public static void main(String[] args) {
ApplicationContext context = new ClassPathXmlApplicationContext("/clientAppContext.xml");
HelloWorld client = (HelloWorld) context.getBean("client");
logger.debug("Client invoking WS:");
String text = client.sayHi("Domagoj");
logger.debug("Response: " + text);
}

}
[HelloClient.java]

As can be seen from the above we also need clientApplicationContext.xml on classpath so here it is.

<beans xmlns="http://www.springframework.org/schema/beans" xsi="http://www.w3.org/2001/XMLSchema-instance" jaxws="http://cxf.apache.org/jaxws" schemalocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans-2.0.xsd http://cxf.apache.org/jaxws http://cxf.apache.org/schema/jaxws.xsd">
<bean id="client" class="demo.spring.HelloWorld" bean="clientFactory" method="create">
<bean id="clientFactory" class="org.apache.cxf.jaxws.JaxWsProxyFactoryBean">
<property name="serviceClass" value="demo.spring.HelloWorld">
<property name="address" value="http://localhost:8080/SoaLab/HelloWorld">
</property>
</bean>
</beans>
[clientApplicationContext.xml]

Ok now we have all set up and Ws-Security can be added.

3. Securing the Service
Ws-Security is in cxf implemented through Interceptors. In order to protect WebService one must register necessary Interceptors. The first step is to add SAAJInInterceptor and WSS4JInInterceptor to your web Service. Code snippet bellow shows configuration of the service with added interceptors.

<jaxws:endpoint id="helloWorld" implementor="demo.spring.HelloWorldImpl" address="/HelloWorld">
<jaxws:features>
<bean class="org.apache.cxf.feature.LoggingFeature"/>
</jaxws:features><
<jaxws:ininterceptors>
<bean class="org.apache.cxf.binding.soap.saaj.SAAJInInterceptor"/>
<ref bean="wss4jInConfiguration"/>
</jaxws:ininterceptors>
/jaxws:endpoint>

<bean id="wss4jInConfiguration" class="org.apache.cxf.ws.security.wss4j.WSS4JInInterceptor">
</bean>
[beans.xml]

On the client side Out interceptors corresponding to the server side configuration must be added, here it is how now changed configuration of the client looks like.

<bean id="clientFactory" class="org.apache.cxf.jaxws.JaxWsProxyFactoryBean">
<property name="serviceClass" value="demo.spring.HelloWorld">
<property name="address" value="http://localhost:8080/SoaLab/HelloWorld">
<property name="outInterceptors">
<list>
<bean class="org.apache.cxf.binding.soap.saaj.SAAJOutInterceptor" />
<ref bean="wss4jOutConfiguration" />
</list>
</property>
</bean>

<bean id="wss4jOutConfiguration" class="org.apache.cxf.ws.security.wss4j.WSS4JOutInterceptor">
</bean>
[clientAppContext.xml]

Configuration above does nothing since there are no actions specified either for WSS4JInInterceptor or WSS4jOutInterceptor, so lets add timestamp to it.

3.1 Setting up Timestamp

Setting up timestamp is pretty easy, just add following lines to the both wss4jInConfiguration and wss4jOutConfiguration beans.

<property name="properties">
<map>
<entry key="action" value="Timestamp">
</entry></map>
</property>

So now WSS4JInInterceptor on the service side expect that request that he receives have timestamp in them, and on the client side WSS4JOutInterceptor is responsible for adding timestamp to every SOAP request.

3.2. Username Token

Second step is to add username token authentication to the service. UsernameToken contains credentials ( username and password) that client presents when he want s to invoke the service. On the service side modify configuration of the wss4jInConfiguration bean so that now it looks like this:

<bean id="wss4jInConfiguration" class="org.apache.cxf.ws.security.wss4j.WSS4JInInterceptor">
<property name="properties">
<map>
<entry key="action" value="UsernameToken Timestamp"/>
<entry key="passwordType" value="PasswordDigest" />
<entry>
<key>
<value>passwordCallbackRef</value>
</key>
<ref bean="passwordCallback"/>
</entry>
</map>
</property>
</bean>

<bean id="passwordCallback" class="demo.interceptors.server.PasswordCallbackHandler"/>

As can be seen key "action" now holds two values: UsernameToken and Timestamp! Rest is configuration necessary for the UsernameToken action.

<entry key="passwordType" value="PasswordDigest"/>

This specify's that password used in this token is not plain text but rather it should be hashed.
Finally the last peace of configuration is passwordCallback bean. PassworCallbackHandler class should implement javax.security.auth.callback.CallbackHandler interface, which specify only one method:

void handle(Callback[] callbacks) throws IOException, UnsupportedCallbackException;


This method is repsonsible to retrive password and return it so that Wss4JInterceptor can compare that password with one that came with token.
My PasswordCallbackHandler.class for this demo is very simple:


package demo.interceptors.server;

import java.io.IOException;
import javax.security.auth.callback.Callback;
import javax.security.auth.callback.CallbackHandler;
import javax.security.auth.callback.UnsupportedCallbackException;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.apache.ws.security.WSPasswordCallback;

public class PasswordCallbackHandler implements CallbackHandler {

protected final Log logger = LogFactory.getLog(getClass());

public void handle(Callback[] callbacks) throws IOException, UnsupportedCallbackException {

WSPasswordCallback pc = (WSPasswordCallback) callbacks[0];
logger.debug("identifier: " + pc.getIdentifer());

if (pc.getIdentifer().equals("ws-client")) {
// set the password on the callback. This will later be compared to the
// password which was sent from the client.
pc.setPassword("password");
}
}

}
In a real life case, this class should connect to some storage (LDAP, Database) and check for password there.
Now its time to configure the client to send all the necessary security information. Change clientAppContext.xml so it looks like this:

<bean id="wss4jOutConfiguration" class="org.apache.cxf.ws.security.wss4j.WSS4JOutInterceptor">
<property name="properties">
<map>
<entry key="action" value="UsernameToken Timestamp"/>
<entry key="user" value="ws-client" />
<entry key="passwordType" value="PasswordDigest" />
<entry>
<key>
<value>passwordCallbackRef</value>
</key>
<ref bean="passwordCallback"/>
</entry>
</map>
</property>
</bean>
<bean id="passwordCallback" class="demo.interceptors.client.PasswordCallback" />

[cllientAppContext.xml]

As can be seen this configuration is pretty similar to that of the server, only exception is following line:

<entry key="user" value="ws-client" />

Which tells to the wss4jOutInterceptor that it should set username in UsernameToken to the "ws-client" value.
So far client side password handler is basically the same as that on the server.

3.3. Signing of the request

Finally its time to sign request. In order to sign requests private and public keys for the client must be created and client's public key imported into server's keystore.
To do this follow the instructions at the CXF Website and create client keys for alias "ws-client." Store both client keys in client-keystore.jks protected with password: "keyStorePassword" and also import client's public key into server_publicstore.jks also protected with password: "keyStorePassword."
Now put both client-keystore.jks and server_publicstore.jks in the classpath and confiogure service so that it expects signed requests.
Change beans.xml configuration file of the service so that it finally looks like this:

<bean id="wss4jInConfiguration" class="org.apache.cxf.ws.security.wss4j.WSS4JInInterceptor">
<property name="properties">
<map>
<entry key="action" value="UsernameToken Timestamp Signature"/>
<entry key="passwordType" value="PasswordDigest" />
<entry>
<key>
<value>passwordCallbackRef</value>
</key>
<ref bean="passwordCallback"/>
</entry>
<entry key="signaturePropFile" value="server_sign.properties"></entry>
</map>
</property>
</bean>
[beans.xml]

Configuration properties for Signature action are stored in a separate file server_sign.properties:

org.apache.ws.security.crypto.provider=org.apache.ws.security.components.crypto.Merlin
org.apache.ws.security.crypto.merlin.keystore.type=jks
org.apache.ws.security.crypto.merlin.keystore.password=keyStorePassword
org.apache.ws.security.crypto.merlin.file=server_publicstore.jks
[server_sign.properties]

Similar changes needs to be done also on the client side. Here is how clientAppContext.xml should finally look like:

<bean id="wss4jOutConfiguration" class="org.apache.cxf.ws.security.wss4j.WSS4JOutInterceptor">
<property name="properties">
<map>
<entry key="action" value="UsernameToken Timestamp Signature"/>
<entry key="user" value="ws-client"/>
<entry key="passwordType" value="PasswordDigest"/>
<entry key="signaturePropFile" value="client_sign.properties"/>
<entry>
<key>
<value>passwordCallbackRef</value>
</key>
<ref bean="passwordCallback"/>
</entry>
</map>
</property>
</bean>
[clientAppContext.xml]
And here it is, content of the client_sign.properties file:
org.apache.ws.security.crypto.provider=org.apache.ws.security.components.crypto.Merlin
org.apache.ws.security.crypto.merlin.keystore.type=jks
org.apache.ws.security.crypto.merlin.keystore.password=keyStorePassword
org.apache.ws.security.crypto.merlin.keystore.alias=ws-client
org.apache.ws.security.crypto.merlin.file=client_keystore.jks

But, one more thing needs to be done: demo.interceptors.client.PasswordCallback should be change. Since this class will be called both by Signature action and UsernameToken action. UsernameToken action calls it in order to get password for ws-client user and Signature action calls it in order to retrive the password that was used when client key's were generated. Here is source for the client PasswordCallback.java

package demo.interceptors.client;

import ... (ommited)

public class PasswordCallback implements CallbackHandler {

protected final Log logger = LogFactory.getLog(getClass());

public void handle(Callback[] callbacks) throws IOException, UnsupportedCallbackException {

for (int i=0; i< callbacks.length; i++) {
WSPasswordCallback pc = (WSPasswordCallback) callbacks[0];

int usage = pc.getUsage();
logger.debug("identifier: " + pc.getIdentifer());
logger.debug("usage: " + pc.getUsage());

if (usage == WSPasswordCallback.USERNAME_TOKEN) {
// username token pwd...
pc.setPassword("password");
} else if (usage == WSPasswordCallback.SIGNATURE) {
// set the password for client's keystore.keyPassword
pc.setPassword("keyPassword");
}
}
}

}
[PasswordCallback.java]

As can be seen based on the call to the getUsage() method PasswordCallback handler can determine which action invoked him and return the appropriate key.

4. Summary
So, in this post we have configured WSS4JxxxInterceptors to put/read timestamp, UsernameToken and Signatrue in/from Security header. On the Server side WSS4JInterceptor will performe following checks:
  • If Header contains UsernameToken, if one exist call PasswordCallback handler to retrieve password for username found in that token, and finaly hash that password and compare it to one from Token.
  • Check if message has timestamp
  • Check for valid signature

On the client side, follwing actions are performed by WSS4jOutInterceptor:
  • UsernameToken is constructed and put into Security Header
  • Message is Timestamped
  • Message is Signed

And thats all folks.

Broj komentara: 6:

Unknown kaže...

Great article!

davidecr kaže...

Thanks a lot... this is the kind of write style we love to see on the net.

test kaže...

That was certainly a wonderful post. I tried doing the way you suggested. But in the end when I test it, I get this Exception:

08/09/2008 17:06:17 WARN org.apache.cxf.ws.security.wss4j.WSS4JInInterceptor - Security processing failed (actions mismatch)
08/09/2008 17:06:17 WARN org.apache.cxf.ws.security.wss4j.WSS4JInInterceptor -
org.apache.ws.security.WSSecurityException: An error was discovered processing the <wsse:Security> header
.....................

The rest of the steps are same as you suggest.
I am also posting my request message:
---------------------
<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/">
<soapenv:Header>
<wsse:Security xmlns:wsse="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd">
<wsse:UsernameToken>
<wsse:Username>abc</wsse:Username>
<wsse:Password Type="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-username-token-profile-1.0#PasswordText">Password</wsse:Password>
</wsse:UsernameToken>
</wsse:Security>
</soapenv:Header>
<soapenv:Body>
<FindProducts>
<id>3</id>
</FindProducts>
</soapenv:Body>
</soapenv:Envelope>
----------------------

I did some logging, and found that Password callback Handler is not getting invoked. I am sure there is something wrong I am doing. any direction wd be greatly appreciated.
Cheers Bye.

Manjeet Singh Wadhwa kaže...

Your article is very very good. It helped me in creating web service. Though I have a question. My service is now SSL enabled and I am not sure how to access it from CXF client you have demonstrated in your blog. If I just change the service URL in CXFClient xml to https://... Client application is unable to connect. Do you know why it is so. Do I need to make any changes in the client application to access SSL enabled service (up and running)

taichimaro kaže...

Great post ! Thanx a lot :)

Unknown kaže...

Great article!
http://www.techtipsntricks.com