Sunday, July 10, 2011

Web Services and Spring Security Integration

Recently I had a chance to work on a project that required integration of Web Service security(WS-Security, WSS) and Spring security.

During my research I found several interesting articles about integrating Spring security and different frameworks for creating web services, like Apache CXF, Axis1,2. Most of the solutions were based on Apache WSS4J and framework specific implementation of integration code like for example interceptors for CXF.

This way works perfectly, still was wondering if there is some kind of common solution for any (or almost any :-)) SOAP frameworks based on pure Spring Security filters and not coupled with them. This approach would require manual parsing of WSS SOAP headers, authentication/validation/extraction message body and in case of success passing it to farther processing to the framework.

For this purpose we need to create HttpServletRequest wrapper that would have an ability to cache original request and replace it if needed (remove WSS SOAP security headers for example) with a new content.

Here how our wrapper might look like:

public class WsHttpPostServletRequestWrapper extends HttpServletRequestWrapper {
 
 //128K is the max size of request we can handle at this point
 static private int MAX_REQUEST_LENGTH = 131071;
 
 private String buffer;

 public WsHttpPostServletRequestWrapper(HttpServletRequest request) {
  super(request);
  readPostData();
 }
 
 public String getRequestBody() {
  return buffer.toString();
 }
 
 public void setRequestBody(String body) {
  this.buffer = body;
 }
 
 @Override
 public ServletInputStream getInputStream() throws IOException {
  ServletInputStream sis = new PostServletInputStream(buffer.getBytes());
  return sis;  
 }
 
 
 protected void readPostData() {
  HttpServletRequest request = (HttpServletRequest) super.getRequest();
  
  buffer = new String();
  try {
   BufferedInputStream bis = new BufferedInputStream(request.getInputStream());
   BufferedReader in = new BufferedReader(new InputStreamReader(bis));
         String line;
         while ((line = in.readLine()) != null) {
          buffer = buffer.concat(line);
          
          if(buffer.length() >= MAX_REQUEST_LENGTH)
           break;
         }
  }
  catch(Exception e) {
  }
 }

 public class PostServletInputStream extends ServletInputStream {
  private ByteArrayInputStream bais;
  
  public PostServletInputStream(byte[] input) {
   bais = new ByteArrayInputStream(input);
  }
  
  public int read() {
   return bais.read();
  }
 }
}

Next step we need to do is to create WS request interceptor that would take care of extracting WS security headers and removing them from original request.
Here is how this thing could look like:

// lets define interface first
public interface WsRequestInterceptor {
 public void interceptRequest(ApWsHttpPostServletRequestWrapper request, CallbackHandler handler);
}

@Component
public class WsUsernameTokenRequestInterceptor implements WsRequestInterceptor {
 // we are using Apache WSS4J
 private static WSSecurityEngine secEngine       = new WSSecurityEngine();
 private static DocumentBuilderFactory factory   = DocumentBuilderFactory.newInstance();
 
        //for WSS4J version 1.6.1 we need to extend UsernameTokenValidator
 private static WsUserTokenValidator wssUserTokenValidator = new WsUserTokenValidator();

 static {
  factory.setNamespaceAware(true);
  
  WSSConfig config = WSSConfig.getNewInstance();
  config.setValidator(WSSecurityEngine.USERNAME_TOKEN, wssUserTokenValidator);
  config.setPasswordsAreEncoded(false);
  
  secEngine.setWssConfig(config);
 }

 private static Element getDirectChildElement(Node parentNode, String localName, String namespace) {
        if (parentNode == null) {
            return null;
        }
        for (Node currentChild = parentNode.getFirstChild(); currentChild != null; currentChild = currentChild.getNextSibling() ) {
            if (Node.ELEMENT_NODE == currentChild.getNodeType()
                && localName.equals(currentChild.getLocalName())
                && namespace.equals(currentChild.getNamespaceURI())) {
                return (Element)currentChild;
            }
        }
        return null;

    }

 @Override
 public void interceptRequest(ApWsHttpPostServletRequestWrapper request, CallbackHandler handler) {

  try {
   DocumentBuilder builder = factory.newDocumentBuilder();
   Document message        = builder.parse(request.getInputStream());
   
 //let's process WSS Security header, engine will call our 
 //validator, validator - user defined callback handler with 
 //extracted username and password
   List<WSSecurityEngineResult> wsResult = secEngine.processSecurityHeader(message, null, handler, null);

   
   //lets remove header and set new request body
   String soapNamespace = WSSecurityUtil.getSOAPNamespace(message.getDocumentElement());
   Element e = getDirectChildElement(message.getDocumentElement(), WSConstants.ELEM_HEADER, soapNamespace);
   if(e != null) {
    message.getFirstChild().removeChild(e);
   }

   String res = XMLUtils.PrettyDocumentToString(message);
   request.setRequestBody(res);
  }
  catch(Exception e) {
   throw new UsernameNotFoundException("Invalid Credentials");
  }
 }
}

//Username and Password Validator
@Component
public class WsUserTokenValidator extends UsernameTokenValidator {
 @Override
 protected void verifyPlaintextPassword(UsernameToken usernameToken, RequestData data) throws WSSecurityException {
  if (data.getCallbackHandler() == null) {
            throw new WSSecurityException(WSSecurityException.FAILURE, "noCallback");
        }
        
        String user     = usernameToken.getName();
        String password = usernameToken.getPassword();
        String pwType   = usernameToken.getPasswordType();
        
        WSPasswordCallback pwCb = new WSPasswordCallback(user, password, pwType, WSPasswordCallback.USERNAME_TOKEN, data);
        try {
            data.getCallbackHandler().handle(new Callback[]{pwCb});
        } 
        catch (Exception e) {
            throw new WSSecurityException(WSSecurityException.FAILED_AUTHENTICATION, null, null, e);
        }
 }
}

And we can extend standard Spring Security UsernamePasswordAuthenticationFilter using Request insterceptor which would look like this:

public class WsUsernamePasswordAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
 
 private FilterChain chain;
 
 @Autowired
 private WsUsernameTokenRequestInterceptor wsUsernameTokenRequestInterceptor;
 
 @Override
 protected String obtainUsername(HttpServletRequest request) {
          //create our callback hander that would store username and password from WS Security request
  WsUsernameTokenCallbackHandler handler = new WsUsernameTokenCallbackHandler((WsHttpPostServletRequestWrapper)request);
  
  wsUsernameTokenRequestInterceptor.interceptRequest((wsHttpPostServletRequestWrapper)request, handler);
  
  String username = handler.getUsername() + "|" + handler.getPassword();
  
  return username;
 }
 
 protected String obtainPassword(HttpServletRequest request) {
  return "";
 }
 
 

 public void doFilter(final ServletRequest req, final ServletResponse res, final FilterChain chain) throws IOException, ServletException {
  this.chain = chain;
 //let's substitute orignal HttpRequest with our wrapper 
  WsHttpPostServletRequestWrapper request = new WsHttpPostServletRequestWrapper((HttpServletRequest) req); 
  super.doFilter(request, res, chain);
 }

 protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, Authentication authResult) throws java.io.IOException, ServletException {
                //we need to continue with standard filter chain insead of redirecting
  super.successfulAuthentication(request, response, authResult);
  chain.doFilter(request, response);
 }
 
 protected boolean requiresAuthentication(HttpServletRequest request, HttpServletResponse response) {
  return true;
 }

And the last member of this game would be our callback handler that just stores username and password from WS Security header.

public class WsUsernameTokenCallbackHandler implements CallbackHandler {
 static private Logger logger =  
 private WsHttpPostServletRequestWrapper wrapper;
 private String username;
 private String password;
 
 public WsUsernameTokenCallbackHandler(WsHttpPostServletRequestWrapper wrapper) {
  this.wrapper = wrapper;
 }
 
 @Override
 public void handle(Callback[] callbacks) throws IOException, UnsupportedCallbackException {
  WSPasswordCallback pc = null;
  for (Callback callback : callbacks) {
   if (callback instanceof WSPasswordCallback) {
    pc = (WSPasswordCallback)callback; break;
   }
  }

  if (pc != null && StringUtils.hasText(pc.getIdentifier())) {
   username = pc.getIdentifier();
   password = pc.getPassword();
  }
 }

 public String getUsername() {
  return username;
 }
 public void setUsername(String username) {
  this.username = username;
 }

 public String getPassword() {
  return password;
 }
 public void setPassword(String password) {
  this.password = password;
 }
}

The last thing left is to create Spring Security Authentication Provider with User Detail Service that would understand piped username and password and configure Spring Security for our web service to allow JSR 250 annotations.

< sec:global-method-security jsr250-annotations="enabled" />

Now we can use annotations to control access to service methods:

@Component
public class SimpleServiceSOAP implements SimpleService {
 @Override
 @RolesAllowed( {"ROLE_ADMIN", "ROLE_GUEST} )
 public String helloWorld(String data) {
  return "HELLO [" + data + "]";
 }
}