Spring 3, Spring Web Services 2 & LDAP Security
Due to general time constraints I am not including too much “fluff” in this post, just the nitty gritty of creating and securing a Spring 3 , Spring WS 2 web service using multiple XSDs and LDAP security.
The Code:
The Service Endpoint: ExampleServiceEndpoint
This is the class that will be exposed as web service using the configuration later in the post.
01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 | package javaitzen.spring.ws; import org.springframework.ws.server.endpoint.annotation.Endpoint; import org.springframework.ws.server.endpoint.annotation.PayloadRoot; import org.springframework.ws.server.endpoint.annotation.RequestPayload; import org.springframework.ws.server.endpoint.annotation.ResponsePayload; import javax.annotation.Resource; @Endpoint public class ExampleServiceEndpoint { /** * Autowire a POJO to handle the business logic @Resource(name = "businessComponent") private ComponentInterface businessComponent; */ public ExampleServiceEndpoint() { System.out.println( ">> javaitzen.spring.ws.ExampleServiceEndpoint loaded." ); } @PayloadRoot (localPart = "ProcessExample1Request" , namespace = NAMESPACE_URI + "/example1" ) @ResponsePayload public Example1Response processExample1Request( @RequestPayload final Example1 request) { System.out.println( ">> process example request1 ran." ); return new Example1Response(); } @PayloadRoot (localPart = "ProcessExample2Request" , namespace = NAMESPACE_URI + "/example2" ) @ResponsePayload public Example2Response processExample2Request( @RequestPayload final Example2 request) { System.out.println( ">> process example request2 ran." ); return new Example2Response(); } } |
The Code: CustomValidationCallbackHandler
This was my bit of custom code I wrote to extend the AbstactCallbackHandler allowing us to use LDAP.
As per the comments in the CallbackHandler below, it’s probably a good idea to have a cache manager, something like Hazelcast or Ehcache to cache authenticated users, depending on security / performance considerations.
The Digest Validator below can just be used directly from the Sun library, I was just wanted to see how it worked.
001 002 003 004 005 006 007 008 009 010 011 012 013 014 015 016 017 018 019 020 021 022 023 024 025 026 027 028 029 030 031 032 033 034 035 036 037 038 039 040 041 042 043 044 045 046 047 048 049 050 051 052 053 054 055 056 057 058 059 060 061 062 063 064 065 066 067 068 069 070 071 072 073 074 075 076 077 078 079 080 081 082 083 084 085 086 087 088 089 090 091 092 093 094 095 096 097 098 099 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 | package javaitzen.spring.ws; import com.sun.org.apache.xml.internal.security.exceptions.Base64DecodingException; import com.sun.xml.wss.impl.callback.PasswordValidationCallback; import com.sun.xml.wss.impl.misc.Base64; import org.springframework.beans.factory.InitializingBean; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.util.Assert; import org.springframework.ws.soap.security.callback.AbstractCallbackHandler; import javax.security.auth.callback.Callback; import javax.security.auth.callback.UnsupportedCallbackException; import java.io.IOException; import java.io.UnsupportedEncodingException; import java.security.MessageDigest; import java.util.Properties; public class CustomValidationCallbackHandler extends AbstractCallbackHandler implements InitializingBean { private Properties users = new Properties(); private AuthenticationManager ldapAuthenticationManager; @Override protected void handleInternal( final Callback callback) throws IOException, UnsupportedCallbackException { if (callback instanceof PasswordValidationCallback) { final PasswordValidationCallback passwordCallback = (PasswordValidationCallback) callback; if (passwordCallback.getRequest() instanceof PasswordValidationCallback.DigestPasswordRequest) { final PasswordValidationCallback.DigestPasswordRequest digestPasswordRequest = (PasswordValidationCallback.DigestPasswordRequest) passwordCallback.getRequest(); final String password = users .getProperty(digestPasswordRequest .getUsername()); digestPasswordRequest.setPassword(password); passwordCallback .setValidator( new CustomDigestPasswordValidator()); } if (passwordCallback.getRequest() instanceof PasswordValidationCallback.PlainTextPasswordRequest) { passwordCallback .setValidator( new LDAPPlainTextPasswordValidator()); } } else { throw new UnsupportedCallbackException(callback); } } /** * Digest Validator. * This code is directly from the sun class, I was just curious how it worked. */ private class CustomDigestPasswordValidator implements PasswordValidationCallback.PasswordValidator { public boolean validate( final PasswordValidationCallback.Request request) throws PasswordValidationCallback.PasswordValidationException { final PasswordValidationCallback.DigestPasswordRequest req = (PasswordValidationCallback.DigestPasswordRequest) request; final String passwd = req.getPassword(); final String nonce = req.getNonce(); final String created = req.getCreated(); final String passwordDigest = req.getDigest(); final String username = req.getUsername(); if ( null == passwd) return false ; byte [] decodedNonce = null ; if ( null != nonce) { try { decodedNonce = Base64.decode(nonce); } catch ( final Base64DecodingException bde) { throw new PasswordValidationCallback.PasswordValidationException(bde); } } String utf8String = "" ; if (created != null ) { utf8String += created; } utf8String += passwd; final byte [] utf8Bytes; try { utf8Bytes = utf8String.getBytes( "utf-8" ); } catch ( final UnsupportedEncodingException uee) { throw new PasswordValidationCallback.PasswordValidationException(uee); } final byte [] bytesToHash; if (decodedNonce != null ) { bytesToHash = new byte [utf8Bytes.length + decodedNonce.length]; for ( int i = 0 ; i < decodedNonce.length; i++) bytesToHash[i] = decodedNonce[i]; for ( int i = decodedNonce.length; i < utf8Bytes.length + decodedNonce.length; i++) bytesToHash[i] = utf8Bytes[i - decodedNonce.length]; } else { bytesToHash = utf8Bytes; } final byte [] hash; try { final MessageDigest sha = MessageDigest.getInstance( "SHA-1" ); hash = sha.digest(bytesToHash); } catch ( final Exception e) { throw new PasswordValidationCallback.PasswordValidationException( "Password Digest could not be created" + e); } return (passwordDigest.equals(Base64.encode(hash))); } } /** * LDAP Plain Text validator. */ private class LDAPPlainTextPasswordValidator implements PasswordValidationCallback.PasswordValidator { /** * Validate the callback against the injected LDAP server. * Probably a good idea to have a cache manager - ehcache / hazelcast injected to cache authenticated users. * * @param request the callback request * @return true if login successful * @throws PasswordValidationCallback.PasswordValidationException * */ public boolean validate( final PasswordValidationCallback.Request request) throws PasswordValidationCallback.PasswordValidationException { final PasswordValidationCallback.PlainTextPasswordRequest plainTextPasswordRequest = (PasswordValidationCallback.PlainTextPasswordRequest) request; final String username = plainTextPasswordRequest.getUsername(); final Authentication authentication; final Authentication userPassAuth = new UsernamePasswordAuthenticationToken(username, plainTextPasswordRequest.getPassword()); authentication = ldapAuthenticationManager.authenticate(userPassAuth); return authentication.isAuthenticated(); } } /** * Assert users. * * @throws Exception error */ public void afterPropertiesSet() throws Exception { Assert.notNull(users, "Users is required." ); Assert.notNull( this .ldapAuthenticationManager, "A LDAP Authentication manager is required." ); } /** * Sets the users to validate against. Property names are usernames, property values are passwords. * * @param users the users */ public void setUsers( final Properties users) { this .users = users; } /** * The the authentication manager. * * @param ldapAuthenticationManager the provider */ public void setLdapAuthenticationManager( final AuthenticationManager ldapAuthenticationManager) { this .ldapAuthenticationManager = ldapAuthenticationManager; } } |
The service config:
The configuration for the Endpoint, CallbackHandler and the LDAP Authentication manager.
The Application Context – Server Side:
001 002 003 004 005 006 007 008 009 010 011 012 013 014 015 016 017 018 019 020 021 022 023 024 025 026 027 028 029 030 031 032 033 034 035 036 037 038 039 040 041 042 043 044 045 046 047 048 049 050 051 052 053 054 055 056 057 058 059 060 061 062 063 064 065 066 067 068 069 070 071 072 073 074 075 076 077 078 079 080 081 082 083 084 085 086 087 088 089 090 091 092 093 094 095 096 097 098 099 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 | <? xml version = "1.0" encoding = "UTF-8" ?> xsi:schemaLocation="http://www.springframework.org/schema/beans < sws:annotation-driven /> < context:component-scan base-package = "javaitzen.spring.ws" /> < sws:dynamic-wsdl id = "exampleService" portTypeName = "javaitzen.spring.ws.ExampleServiceEndpoint" locationUri = "/exampleService/" < sws:xsd location = "classpath:/xsd/Example1Request.xsd" /> < sws:xsd location = "classpath:/xsd/Example1Response.xsd" /> < sws:xsd location = "classpath:/xsd/Example2Request.xsd" /> < sws:xsd location = "classpath:/xsd/Example2Response.xsd" /> </ sws:dynamic-wsdl > < sws:interceptors > < bean id = "validatingInterceptor" class = "org.springframework.ws.soap.server.endpoint.interceptor.PayloadValidatingInterceptor" > < property name = "schema" value = "classpath:/xsd/Example1Request.xsd" /> < property name = "validateRequest" value = "true" /> < property name = "validateResponse" value = "true" /> </ bean > < bean id = "loggingInterceptor" class = "org.springframework.ws.server.endpoint.interceptor.PayloadLoggingInterceptor" /> < bean class = "org.springframework.ws.soap.security.xwss.XwsSecurityInterceptor" > < property name = "policyConfiguration" value = "/WEB-INF/securityPolicy.xml" /> < property name = "callbackHandlers" > < list > < ref bean = "callbackHandler" /> </ list > </ property > </ bean > </ sws:interceptors > < bean id = "callbackHandler" class = "javaitzen.spring.ws.CustomValidationCallbackHandler" > < property name = "ldapAuthenticationManager" ref = "authManager" /> </ bean > < s:authentication-manager alias = "authManager" > < s:ldap-authentication-provider user-search-filter = "(uid={0})" user-search-base = "ou=users" group-role-attribute = "cn" role-prefix = "ROLE_" > </ s:ldap-authentication-provider > </ s:authentication-manager > <!-- Example... (inmemory apache ldap service) --> < s:ldap-server id = "contextSource" root = "o=example" ldif = "classpath:example.ldif" /> <!-- If you want to connect to a real LDAP server it would look more like: <s:ldap-server id="contextSource" url="ldap://localhost:7001/o=example" manager-dn="uid=admin,ou=system" manager-password="secret"> </s:ldap-server>--> < bean id = "marshallingPayloadMethodProcessor" class = "org.springframework.ws.server.endpoint.adapter.method.MarshallingPayloadMethodProcessor" > < constructor-arg ref = "serviceMarshaller" /> < constructor-arg ref = "serviceMarshaller" /> </ bean > < bean id = "defaultMethodEndpointAdapter" class = "org.springframework.ws.server.endpoint.adapter.DefaultMethodEndpointAdapter" > < property name = "methodArgumentResolvers" > < list > < ref bean = "marshallingPayloadMethodProcessor" /> </ list > </ property > < property name = "methodReturnValueHandlers" > < list > < ref bean = "marshallingPayloadMethodProcessor" /> </ list > </ property > </ bean > < bean id = "serviceMarshaller" class = "org.springframework.oxm.jaxb.Jaxb2Marshaller" > < property name = "classesToBeBound" > < list > < value >javaitzen.spring.ws.Example1</ value > < value >javaitzen.spring.ws.Example1Response</ value > < value >javaitzen.spring.ws.Example2</ value > < value >javaitzen.spring.ws.Example2Response</ value > </ list > </ property > < property name = "marshallerProperties" > < map > < entry key = "jaxb.formatted.output" > < value type = "java.lang.Boolean" >true</ value > </ entry > </ map > </ property > </ bean > </ beans > |
The Security Context – Server Side:
1 2 3 4 5 6 7 8 | xwss:SecurityConfiguration xmlns:xwss="http://java.sun.com/xml/ns/xwss/config"> < xwss:RequireTimestamp maxClockSkew = "60" timestampFreshnessLimit = "300" /> <!-- Expect plain text tokens from the client --> < xwss:RequireUsernameToken passwordDigestRequired = "false" nonceRequired = "false" /> < xwss:Timestamp /> <!-- server side reply token --> < xwss:UsernameToken name = "server" password = "server1" digestPassword = "false" useNonce = "false" /> </ xwss:SecurityConfiguration > |
The Web XML:
Nothing really special here, just the Spring WS MessageDispatcherServlet.
01 02 03 04 05 06 07 08 09 10 11 | spring-ws org.springframework.ws.transport.http.MessageDispatcherServlet transformWsdlLocationstrue 1 spring-ws /* |
The client config:
To test or use the service you’ll need the following:
The Application Context – Client Side Test:
01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 | <? xml version = "1.0" encoding = "UTF-8" ?> xsi:schemaLocation = "http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd" > < bean id = "messageFactory" class = "org.springframework.ws.soap.saaj.SaajSoapMessageFactory" /> < bean id = "webServiceTemplate" class = "org.springframework.ws.client.core.WebServiceTemplate" > < constructor-arg ref = "messageFactory" /> < property name = "marshaller" ref = "serviceMarshaller" /> < property name = "unmarshaller" ref = "serviceMarshaller" /> < property name = "interceptors" > < list > < ref local = "xwsSecurityInterceptor" /> </ list > </ property > </ bean > < bean id = "xwsSecurityInterceptor" class = "org.springframework.ws.soap.security.xwss.XwsSecurityInterceptor" > < property name = "policyConfiguration" value = "testSecurityPolicy.xml" /> < property name = "callbackHandlers" > < list > < ref bean = "callbackHandler" /> </ list > </ property > </ bean > <!-- As a client the username and password generated by the server must match with the client! --> <!-- a simple callback handler to configure users and passwords with an in-memory Properties object. --> < bean id = "callbackHandler" class = "org.springframework.ws.soap.security.xwss.callback.SimplePasswordValidationCallbackHandler" > < property name = "users" > < props > < prop key = "server" >server1</ prop > </ props > </ property > </ bean > < bean id = "serviceMarshaller" class = "org.springframework.oxm.jaxb.Jaxb2Marshaller" > < property name = "classesToBeBound" > < list > < value >javaitzen.spring.ws.Example1</ value > < value >javaitzen.spring.ws.Example1Response</ value > < value >javaitzen.spring.ws.Example2</ value > < value >javaitzen.spring.ws.Example2Response</ value > </ list > </ property > < property name = "marshallerProperties" > < map > < entry key = "jaxb.formatted.output" > < value type = "java.lang.Boolean" >true</ value > </ entry > </ map > </ property > </ bean > |
The Security Context – Client Side:
1 2 3 4 5 6 7 8 | < xwss:RequireTimestamp maxClockSkew = "60" timestampFreshnessLimit = "300" /> <!-- Expect a plain text reply from the server --> < xwss:RequireUsernameToken passwordDigestRequired = "false" nonceRequired = "false" /> < xwss:Timestamp /> <!-- Client sending to server --> < xwss:UsernameToken name = "example" password = "pass" digestPassword = "false" useNonce = "false" /> </ xwss:SecurityConfiguration > |
As usual with Java there can be a couple little nuances when it comes to jars and versions so below is part of the pom I used.
The Dependencies:
01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 | 3.0.6.RELEASE 2.0.2.RELEASE org.apache.directory.server apacheds-all 1.5.5 jar compile org.springframework.ws spring-ws-core ${spring-ws-version} org.springframework spring-webmvc ${spring-version} org.springframework spring-web ${spring-version} org.springframework spring-context ${spring-version} org.springframework spring-core ${spring-version} org.springframework spring-beans ${spring-version} org.springframework spring-oxm ${spring-version} org.springframework.ws spring-ws-security ${spring-ws-version} org.springframework.security spring-security-core ${spring-version} org.springframework.security spring-security-ldap ${spring-version} org.springframework.ldap spring-ldap-core 1.3.0.RELEASE org.apache.ws.security wss4j 1.5.12 com.sun.xml.wss xws-security 3.0 org.apache.ws.commons.schema XmlSchema 1.4.2 </ project > |
Reference: Spring 3, Spring Web Services 2 & LDAP Security. from our JCG partner Brian Du Preez at the Zen in the art of IT blog.
Nice post. Is there a way to secure endpoint methods using RolesAllowed and see if the requesting user has specific permission then only allow invoking of that endpoint? Like we normally do with Spring service?
Dude, is something wrong with Spring, or you really need 20 pages of code and xml to run simple ldap auth?
Good simple example of integrating spring ws with ldap security.
Keep up the good work.