Using Zimbra LDAP as Spring-security backend and handling Linux/BSD MD5 encrypted password
LDAP has been one of the most common way of managing user credentials for most company. Email and other services may use LDAP to authenticate users. I have been using with Spring Security for quite sometime however recently I have a very challenging task of integrating Zimbra LDAP as Spring Security backend.
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:
However, in my case this scenario is becoming complicated because:
So here is what I have done:
Here is my springsecurityContext.xml configuration for that:
First we need to define the connection to LDAP which is capable of listing all uid.
Here is my implementation of CryptPasswordEncoder:
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.
The final Java class that I write which is SimpleLdapAuthoritiesPopulator.java is as following:
I hope this would help anyone who are facing the same trouble as I did.
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.
Comments