Zimbra LDAP does not allow user to connect and authenticate directly (no binding authentication). The preferred way of authenticating user is by comparing password (see PasswordComparisonAuthenticator).
My scenario is simple:
- First, Use initialDirContextFactory to connect to LDAP using the supplied admin/manager credential
- Use an LDAPAuthenticationProvider to do username password comparison and then to supply a list of granted authorities to each authenticated user
However, in my case this scenario is becoming complicated because:
- First, Zimbra LDAP stores password as a BSD/Linux MD5 Crypt format. i.e. {crypt}$1$[salt]$[encrypted_passwd] because it was generated from the entries of /etc/password from our old mail system
- Second, the crypt compatible implementation of Spring Security PasswordEncoder does not exist and the default PasswordComparisonAuthenticator does not support salt
- Third, standard Zimbra LDAP does not store any group information. And what I need is just to grant a "ROLE_USER" to each authenticated user.
So here is what I have done:
- Implement a PasswordEncoder (CryptPasswordEncoder) that is compatible with Linux/BSD Crypt
- Implement a PasswordComparisonAuthenticator (CryptPasswordAuthenticator) that is capable of fetching salt from LDAP and then use that salt to encode the password for further PasswordComparison
- Implement a SimpleLdapAuthoritiesPopulator to fill in the desired granted authorities I wish to give for any authenticated user
Here is my springsecurityContext.xml configuration for that:
First we need to define the connection to LDAP which is capable of listing all uid.
<bean id="initialDirContextFactory" class="org.springframework.security.ldap.DefaultSpringSecurityContextSource"> <constructor-arg value="ldap://your_zimbra_server/ou=people,dc=your_domain,dc=com" /> <property name="userDn"> <value>uid=zimbra,cn=admins,cn=zimbra</value> </property> <property name="password"> <value>your_zimbra_ldap_password</value> </property> </bean> <bean id="passwordEncoder" class="jbs.CryptPasswordEncoder" /> <bean id="defaultga" class="org.springframework.security.core.authority.GrantedAuthorityImpl"> <constructor-arg value="ROLE_USER"/> </bean> <bean id="authenticationProvider" class="org.springframework.security.ldap.authentication.LdapAuthenticationProvider"> <constructor-arg> <bean class="jbs.CryptPasswordComparisonAuthenticator"> <constructor-arg ref="initialDirContextFactory" /> <property name="passwordAttributeName" value="userPassword"/> <property name="userDnPatterns" value="uid={0}"/> <property name="passwordEncoder" ref="passwordEncoder"/> </bean> </constructor-arg> <constructor-arg> <bean class="jbs.SimpleLdapAuthoritiesPopulator"> <property name="defaultGrantedAuthorities"> <list> <ref bean="defaultga"/> </list> </property> </bean> </constructor-arg> </bean> <security:authentication-manager> <security:authentication-provider ref="authenticationProvider"/> </security:authentication-manager>
Here is my implementation of CryptPasswordEncoder:
package jbs; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; import org.springframework.dao.DataAccessException; import org.springframework.security.authentication.encoding.PasswordEncoder; /** * * @author joko */ public class CryptPasswordEncoder implements PasswordEncoder { private static final String CRYPT_PREFIX = "{crypt}"; private Log log = LogFactory.getLog(this.getClass().getName()); @Override public String encodePassword(String rawPass, Object salt) throws DataAccessException { String ret = ""; try { ret = CRYPT_PREFIX + MD5Crypt.crypt(rawPass, (salt!=null)? salt.toString(): ""); } catch (Exception ex) { log.warn(ex.getMessage()); } return ret; } @Override public boolean isPasswordValid(String encPass, String rawPass, Object salt) throws DataAccessException { boolean ret = false; if (encPass.startsWith(CRYPT_PREFIX)) { //strip header String saltedhash = encPass.substring(CRYPT_PREFIX.length() + 3); log.debug("Salted hash is: " + saltedhash); // remove salt String[] arr = saltedhash.split("\\$"); if (arr.length > 1) { ret = encodePassword(rawPass, salt).endsWith(arr[1]); } else { ret = encodePassword(rawPass, salt).endsWith(saltedhash); } } return ret; } }
For class MD5Crypt and its dependencies, please have a look at Applied Research Laboratories GIT Server
My CryptPasswordAuthenticator.java is basically very similar to the original PasswordAuthenticator.java however I fetch the salt from the userPassword field of LDAP user entry. Then I use the salt to generate the encrypted password for further LDAP comparison.
package jbs; import java.lang.reflect.Method; import javax.naming.NamingEnumeration; import javax.naming.directory.Attribute; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; import org.springframework.ldap.NameNotFoundException; import org.springframework.ldap.NamingException; import org.springframework.ldap.core.DirContextOperations; import org.springframework.ldap.core.support.BaseLdapPathContextSource; import org.springframework.security.authentication.BadCredentialsException; import org.springframework.security.authentication.encoding.PasswordEncoder; import org.springframework.security.core.Authentication; import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.security.ldap.LdapUtils; import org.springframework.security.ldap.SpringSecurityLdapTemplate; import org.springframework.security.ldap.authentication.AbstractLdapAuthenticator; import org.springframework.util.Assert; /** * * @author joko */ public class CryptPasswordComparisonAuthenticator extends AbstractLdapAuthenticator { private static final Log logger = LogFactory.getLog(CryptPasswordComparisonAuthenticator.class); //~ Instance fields ================================================================================================ private PasswordEncoder passwordEncoder = new CryptPasswordEncoder(); private String passwordAttributeName = "userPassword"; //~ Constructors =================================================================================================== public CryptPasswordComparisonAuthenticator(BaseLdapPathContextSource contextSource) { super(contextSource); } @Override public DirContextOperations authenticate(final Authentication authentication) { DirContextOperations user = null; String username = authentication.getName(); String password = (String) authentication.getCredentials(); SpringSecurityLdapTemplate ldapTemplate = new SpringSecurityLdapTemplate(getContextSource()); for (String userDn : getUserDns(username)) { try { user = ldapTemplate.retrieveEntry(userDn, getUserAttributes()); } catch (NameNotFoundException ignore) { } if (user != null) { break; } } if (user == null && getUserSearch() != null) { user = getUserSearch().searchForUser(username); } if (user == null) { throw new UsernameNotFoundException("User not found: " + username, username); } if (logger.isDebugEnabled()) { logger.debug("Performing LDAP compare of password attribute '" + passwordAttributeName + "' for user '" + user.getDn() + "'"); } String storedPassword = new String((byte[]) (user.getObjectAttribute(passwordAttributeName))); // get the salt String[] arr = storedPassword.split("\\$"); String encodedPassword = passwordEncoder.encodePassword(password, arr.length > 1 ? arr[2] : null); byte[] passwordBytes = LdapUtils.getUtf8Bytes(encodedPassword); logger.debug(encodedPassword + ":" + storedPassword); if (!ldapTemplate.compare(user.getDn().toString(), passwordAttributeName, passwordBytes)) { throw new BadCredentialsException(messages.getMessage("PasswordComparisonAuthenticator.badCredentials", "Bad credentials")); } return user; } public void setPasswordAttributeName(String passwordAttribute) { Assert.hasLength(passwordAttribute, "passwordAttributeName must not be empty or null"); this.passwordAttributeName = passwordAttribute; } public void setPasswordEncoder(PasswordEncoder passwordEncoder) { Assert.notNull(passwordEncoder, "passwordEncoder must not be null."); this.passwordEncoder = passwordEncoder; } }
The final Java class that I write which is SimpleLdapAuthoritiesPopulator.java is as following:
package jbs; import java.util.ArrayList; import java.util.Collection; import java.util.List; import javax.naming.NamingEnumeration; import javax.naming.NamingException; import javax.naming.directory.Attribute; import org.apache.commons.logging.LogFactory; import org.springframework.ldap.core.DirContextOperations; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.ldap.userdetails.LdapAuthoritiesPopulator; /** * * @author joko */ public class SimpleLdapAuthoritiesPopulator implements LdapAuthoritiesPopulator{ private List<GrantedAuthority> defaultGrantedAuthorities = new ArrayList<GrantedAuthority>(); @Override public Collection<GrantedAuthority> getGrantedAuthorities(DirContextOperations userData, String username) { NamingEnumeration<? extends Attribute> nes = userData.getAttributes().getAll(); try { while (nes.hasMore()) { Attribute a = nes.next(); a.toString(); } } catch (NamingException ex) { LogFactory.getLog(SimpleLdapAuthoritiesPopulator.class.getName()).warn(ex.getMessage()); } return getDefaultGrantedAuthorities(); } /** * @return the defaultGrantedAuthorities */ public List<GrantedAuthority> getDefaultGrantedAuthorities() { return defaultGrantedAuthorities; } /** * @param defaultGrantedAuthorities the defaultGrantedAuthorities to set */ public void setDefaultGrantedAuthorities(List<GrantedAuthority> defaultGrantedAuthorities) { this.defaultGrantedAuthorities = defaultGrantedAuthorities; } }
I hope this would help anyone who are facing the same trouble as I did.
3 comments:
That article made this day a good day! EXACTLY what I needed, thank you!
Great to know that this is useful.
THANK YOU FOR THE CODE AND THE INFORMATION. IT HELPED ME A LOT.
Post a Comment