LDAP with Spring Security


Posted by Steven

I spend quite some time implementing a login using Active Directory via LDAP for our Spring Boot 2 application, using Spring Security. This article outlines the implementation options I faced. On my quest to solve the many problems I encountered with this, I learned that there is not much documentation available in the web. I hope this article is of some help for other developers.

I will use the term "LDAP" when refering to the Active Directory because that's what most developers do when they mean "authorization using LDAP".

Setup: Use an LDAP-tool

The first thing to do when working with LDAP is probably to install an LDAP tool to explore the structure of the directory and find the correct attributes. I use the free Ldapadmin.

Setup: Add Certificate to JDK

To be able to connect securly to LDAP via TLS, a certificate has to be used. For local development, adding this certificate to the used JDK is sufficient. This is the command that has to be executed from the bin-folder in the JDK directory:

  1. keytool.exe -importcert -file ..\ldap_cert.pem -keystore ..\lib\security\cacerts -alias "your-alias"

This approach is to cumbersome for running the application on test- or production stages. There, the certificate should be added to a key-store which is part of the source code. There are many formats for key-stores, for example p12 and jks. Here are both snippets; one of them has to be added to the application.yml:

  1. server:
  2. ssl:
  3. key-alias: your-alias
  4. key-store-type: PKCS12
  5. key-password: your-password
  6. key-store: classpath:your-file.p12
  1. server:
  2. ssl:
  3. key-alias: your-alias
  4. key-store-type: JKS
  5. key-password: your-password
  6. key-store: classpath:your-file.jks
  7. enabled: true

The p12- or jks-file have to be in the src\main\resources folder right next to the application.yml (if you have that in your resources-folder; sometimes it's in the root directory)

To create and change certificates in key-stores, KeyStore Explorer can be used.

SSL-Handshake-Exceptions and Connect-Exceptions

In trying to find the right configuration, I encountered many of those:

  1. javax.net.ssl.SSLHandshakeException: java.security.cert.CertificateException: No subject alternative DNS name matching int.root.company.ag

or those:

  1. javax.naming.CommunicationException: int.root.company.ag:636 [Root exception is java.net.ConnectException: Connection timed out: connect]

First, I thought that these exceptions appeared when I tried to use the wrong attribute for either logging in or searching for a user. Sometimes, this exception occurred when using the common name ("cn"), sometimes when using the SAM-Account name ("sAMAccountName"). Turns out that I tried to connect to a server that only forwards requests to different LDAP servers. Those servers obviously are configured differently, which sometimes caused perfectly fine working connections, sometimes handshake-exceptions and sometimes even time-outs. If you encounter such behavior, try to figure out if you connect to a specific LDAP server or if your request is being forwarded.

Low-hanging fruit: complete patterns!

I had a lot of unsuccessful test runs because I did not use fully qualified patterns. It's important to not only use for example this:

  1. String groupSearchBase = "OU=role";

This is the full string needed:

  1. String groupSearchBase = "OU=role,OU=companygroup groups,DC=int,DC=root,DC=company,DC=ag";

The following sections describe different approaches I used to connect to the LDAP server.

Approach 1: Getting the password from LDAP and comparing it server-side

The first examples I found in the web simply used the credentials provided by the user to establish a connection to LDAP. Then, the value of the attribute "userPassword" is requested. The encrypted password is send to the Spring server where it is compared with what the user entered in the login-form. If both are equal, the provided credentials are correct. Here's how my code looked with this approach:

  1. @Configuration
  2. @EnableWebSecurity
  3. @EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)
  4. @Profile("integration")
  5. public class SecurityConfigurationT extends WebSecurityConfigurerAdapter {
  6.  
  7. private final AuthenticationManagerBuilder authenticationManagerBuilder;
  8.  
  9. private final TokenProvider tokenProvider;
  10.  
  11. public SecurityConfigurationT(
  12. AuthenticationManagerBuilder authenticationManagerBuilder,
  13. TokenProvider tokenProvider) {
  14. this.authenticationManagerBuilder = authenticationManagerBuilder;
  15. this.tokenProvider = tokenProvider;
  16. }
  17.  
  18. @PostConstruct
  19. public void initIntegration() {
  20.  
  21. try {
  22. authenticationManagerBuilder
  23. .ldapAuthentication()
  24. .userDnPatterns("cn={0},OU=company,OU=companygroup users,DC=int,DC=root,DC=company,DC=ag")
  25. .contextSource()
  26. .url("ldaps://int.root.company.ag:636")
  27. .managerDn("CN=system_user,OU=companygroup svc accs,DC=int,DC=root,DC=company,DC=ag")
  28. .managerPassword("XXXXXX")
  29. .and()
  30. .passwordCompare()
  31. .passwordEncoder(new BCryptPasswordEncoder())
  32. .passwordAttribute("userPassword");
  33. } catch (Exception e) {
  34. throw new BeanInitializationException("Security configuration failed", e);
  35. }
  36. }
  37.  
  38. @Override
  39. @Bean
  40. public AuthenticationManager authenticationManagerBean() throws Exception {
  41. return super.authenticationManagerBean();
  42. }
  43. }

This is generally valid and can work. However, the LDAP server I wanted to use prohibits giving away (encrypted) passwords and produced this exception:

  1. org.springframework.security.authentication.InternalAuthenticationServiceException: [LDAP: error code 16 - 00002080: AtrErr: DSID-03080155, #1:
  2. 0: 00002080: DSID-03080155, problem 1001 (NO_ATTRIBUTE_OR_VAL), data 0, Att 23 (userPassword)
  3. ]; nested exception is javax.naming.directory.NoSuchAttributeException: [LDAP: error code 16 - 00002080: AtrErr: DSID-03080155, #1:
  4. 0: 00002080: DSID-03080155, problem 1001 (NO_ATTRIBUTE_OR_VAL), data 0, Att 23 (userPassword)

I could have seen this error coming because the attribute "userPassword" cannot be seen when I view the directory in Ldapadmin. What cannot be seen most likely cannot be read. That's why I try to use approach 2.

Approach 2: BindAuthenticator with login-user's credentials

A "bind" is simply a login on an LDAP server. However, the BindAuthenticator sends the credentials to the LDAP server instead of requesting the stored (encrypted) password. The comparison of the stored password and the provided string is done by the LDAP server, not the Spring server. Here's code:

  1. @Configuration
  2. @EnableWebSecurity
  3. @EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)
  4. @Profile("integration")
  5. public class SecurityConfigurationT extends WebSecurityConfigurerAdapter {
  6.  
  7. private final AuthenticationManagerBuilder authenticationManagerBuilder;
  8.  
  9. private final TokenProvider tokenProvider;
  10.  
  11. public SecurityConfigurationT(
  12. AuthenticationManagerBuilder authenticationManagerBuilder,
  13. TokenProvider tokenProvider) {
  14. this.authenticationManagerBuilder = authenticationManagerBuilder;
  15. this.tokenProvider = tokenProvider;
  16. }
  17.  
  18. @PostConstruct
  19. public void initIntegration() {
  20.  
  21. try {
  22. authenticationManagerBuilder
  23. .authenticationProvider(ldapAuthenticationProvider());
  24. } catch (Exception e) {
  25. throw new BeanInitializationException("Security configuration failed", e);
  26. }
  27. }
  28.  
  29. @Override
  30. @Bean
  31. public AuthenticationManager authenticationManagerBean() throws Exception {
  32. return super.authenticationManagerBean();
  33. }
  34.  
  35. @Bean
  36. public LdapAuthenticationProvider ldapAuthenticationProvider() throws Exception {
  37. LdapAuthenticationProvider lAP = new LdapAuthenticationProvider(ldapAuthenticator(), ldapAuthoritiesPopulator());
  38. return lAP;
  39. }
  40.  
  41. private LdapAuthoritiesPopulator ldapAuthoritiesPopulator() throws Exception {
  42. return new DefaultLdapAuthoritiesPopulator(ldapContextSource(), "OU=role,OU=companygroup groups,DC=int,DC=root,DC=company,DC=ag");
  43. }
  44.  
  45. @Bean
  46. public LdapContextSource ldapContextSource() throws Exception {
  47. DefaultSpringSecurityContextSource contextSource = new DefaultSpringSecurityContextSource("ldaps://int.root.company.ag:636");
  48.  
  49. return contextSource;
  50. }
  51.  
  52. @Bean
  53. public LdapAuthenticator ldapAuthenticator() throws Exception {
  54. BindAuthenticator authenticator = new BindAuthenticator(ldapContextSource());
  55. authenticator.setUserDnPatterns(new String[] {"CN={0},OU=company,OU=companygroup users,DC=int,DC=root,DC=company,DC=ag"});
  56. return authenticator;
  57. }
  58. }

This looked nice, however it also produced this:

  1. LDAP: error code 1 - 000004DC: LdapErr: DSID-0C090A4C, comment: In order to perform this operation a successful bind must be completed on the connection., data 0

Short story: I needed a separate, technical system-user. Long story: read approach 3. :)

Approach 3: BindAuthenticator with technical system-user

(Once again) from Stackoverflow (here, first answer, here and here, first answer) I learned that the best way to authenticate with LDAP is to use a technical account for the first bind. After successful connecting to the server, a search with the credentials from the "real" user can be executed. Here's the code:

  1. @Configuration
  2. @EnableWebSecurity
  3. @EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)
  4. @Profile("integration")
  5. public class SecurityConfigurationT extends WebSecurityConfigurerAdapter {
  6.  
  7. private final AuthenticationManagerBuilder authenticationManagerBuilder;
  8.  
  9. private final TokenProvider tokenProvider;
  10.  
  11. public SecurityConfigurationT(
  12. AuthenticationManagerBuilder authenticationManagerBuilder,
  13. TokenProvider tokenProvider) {
  14. this.authenticationManagerBuilder = authenticationManagerBuilder;
  15. this.tokenProvider = tokenProvider;
  16. }
  17.  
  18. @PostConstruct
  19. public void initIntegration() {
  20.  
  21. try {
  22. authenticationManagerBuilder
  23. .authenticationProvider(ldapAuthenticationProvider());
  24. } catch (Exception e) {
  25. throw new BeanInitializationException("Security configuration failed", e);
  26. }
  27. }
  28.  
  29. @Override
  30. @Bean
  31. public AuthenticationManager authenticationManagerBean() throws Exception {
  32. return super.authenticationManagerBean();
  33. }
  34.  
  35. @Bean
  36. public LdapAuthenticationProvider ldapAuthenticationProvider() throws Exception {
  37. LdapAuthenticationProvider lAP = new LdapAuthenticationProvider(ldapAuthenticator(), ldapAuthoritiesPopulator());
  38. return lAP;
  39. }
  40.  
  41. private LdapAuthoritiesPopulator ldapAuthoritiesPopulator() throws Exception {
  42. return new DefaultLdapAuthoritiesPopulator(ldapContextSource(), "OU=role,OU=companygroup groups,DC=int,DC=root,DC=company,DC=ag");
  43. }
  44.  
  45. @Bean
  46. public LdapContextSource ldapContextSource() throws Exception {
  47. PasswordPolicyAwareContextSource contextSource = new PasswordPolicyAwareContextSource("ldaps://int.root.company.ag:636");
  48. contextSource.setUserDn("CN=system_user,OU=companygroup svc accs,DC=int,DC=root,DC=company,DC=ag");
  49. contextSource.setPassword("XXXXXX");
  50. return contextSource;
  51. }
  52.  
  53. @Bean
  54. public LdapAuthenticator ldapAuthenticator() throws Exception {
  55. BindAuthenticator authenticator = new BindAuthenticator(ldapContextSource());
  56. authenticator.setUserDnPatterns(new String[] {"CN={0},OU=company,OU=companygroup users,DC=int,DC=root,DC=company,DC=ag"});
  57. return authenticator;
  58. }
  59. }

This code finally did it, I can now connect to LDAP in a technically clean way, using Spring infrastructure.

There's also a nice log statement that shows the correct combination of technical user and login-user:

  1. 2019-01-04 09:00:20.297 DEBUG 20284 --- [nio-8090-exec-1] o.s.s.l.a.LdapAuthenticationProvider : Processing authentication request for user: Steven Schwenke
  2. 2019-01-04 09:00:20.303 DEBUG 20284 --- [nio-8090-exec-1] o.s.s.l.a.BindAuthenticator : Attempting to bind as cn=Steven Schwenke,ou=company,ou=companygroup users,dc=int,dc=root,dc=company,dc=ag
  3. 2019-01-04 09:00:20.303 DEBUG 20284 --- [nio-8090-exec-1] s.s.l.p.PasswordPolicyAwareContextSource : Binding as 'CN=system_user,OU=companygroup svc accs,DC=int,DC=root,DC=company,DC=ag', prior to reconnect as user 'cn=Steven Schwenke,ou=company,ou=companygroup users,dc=int,dc=root,dc=company,dc=ag'
  4. 2019-01-04 09:00:20.845 DEBUG 20284 --- [nio-8090-exec-1] o.s.l.c.support.AbstractContextSource : Got Ldap context on server 'ldaps://int.root.company.ag:636'

Using sAMAccountName instead of CN

In the last code snippet above, the common name (CN) is used to identify the user in LDAP. This can be for example the first name and last name, in my case "Steven Schwenke". This may not be the best attribute to use because names can be long and there is always the danger of duplicates. An alternative attribute to use is SamAccountName or sAMAccountName. Here's some background information about that attribute that is often used as logon name and shorter than most names.

To use the SamAccountName in the setup of "Approach 3", only the LdapAuthenticator has to be changed to use a FilterBasedLdapUserSearch. This is the implementation of LdapUserSearch which states in its JavaDoc "Obtains a user's information from the LDAP directory given a login name. May be optionally used to configure the LDAP authentication implementation when a more sophisticated approach is required than just using a simple username->DN mapping." Here's the changed code (the rest of the code of "Approach 3" is the same):

  1. @Bean
  2. public LdapAuthenticator ldapAuthenticator() {
  3. BindAuthenticator authenticator = new BindAuthenticator(ldapContextSource());
  4. authenticator.setUserSearch(new FilterBasedLdapUserSearch("OU=company,OU=companygroup users,DC=int,DC=root,DC=company,DC=ag", "(sAMAccountName={0})", ldapContextSource()));
  5. return authenticator;
  6. }

Bonus-Approach: Plain Java

The formerly mentioned SO-article holds yet another solution using plain Java. This is the code, copy-pasted without change just to have all the information right here in this article. Credits for this one go to Atanu Sarkar:

  1. public static boolean authenticateJndi(String username, String password) throws Exception{
  2. Properties props = new Properties();
  3. props.put(Context.INITIAL_CONTEXT_FACTORY, "com.sun.jndi.ldap.LdapCtxFactory");
  4. props.put(Context.PROVIDER_URL, "ldap://LDAPSERVER:PORT");
  5. props.put(Context.SECURITY_PRINCIPAL, "uid=adminuser,ou=special users,o=xx.com");//adminuser - User with special priviledge, dn user
  6. props.put(Context.SECURITY_CREDENTIALS, "adminpassword");//dn user password
  7.  
  8.  
  9. InitialDirContext context = new InitialDirContext(props);
  10.  
  11. ctrls.setReturningAttributes(new String[] { "givenName", "sn","memberOf" });
  12. ctrls.setSearchScope(SearchControls.SUBTREE_SCOPE);
  13.  
  14. NamingEnumeration<javax.naming.directory.SearchResult> answers = context.search("o=xx.com", "(uid=" + username + ")", ctrls);
  15. javax.naming.directory.SearchResult result = answers.nextElement();
  16.  
  17. String user = result.getNameInNamespace();
  18.  
  19. try {
  20. props = new Properties();
  21. props.put(Context.INITIAL_CONTEXT_FACTORY, "com.sun.jndi.ldap.LdapCtxFactory");
  22. props.put(Context.PROVIDER_URL, "ldap://LDAPSERVER:PORT");
  23. props.put(Context.SECURITY_PRINCIPAL, user);
  24. props.put(Context.SECURITY_CREDENTIALS, password);
  25.  
  26. context = new InitialDirContext(props);
  27. } catch (Exception e) {
  28. return false;
  29. }
  30. return true;
  31. }

Special case: Connecting to Active Directory

When connecting to an Active Directory (AD), I found two additional aspects that could be useful.

First, the before-mentioned time-outs could have been exceptions caused by the AD. Following this SO-question, the following code would have been a solution (but was not, because the time-outs were caused by the fact that my requests have been forwarded - see above):

  1. @Bean
  2. public LdapTemplate ldapTemplate() {
  3. LdapTemplate ldapTemplate = new LdapTemplate(ldapContextSource());
  4. ldapTemplate.setIgnorePartialResultException(true);
  5. return ldapTemplate;
  6. }

The other potentially interesting thing I found is the class ActiveDirectoryLdapAuthenticationProvider which can be used instead of LdapAuthenticationProvider. However, it's not compatible to the configuration used in this article.

Spring Data LDAP

Yet another approach to accessing LDAP from a Spring application is Spring Data LDAP which uses the Spring repositories. I didn't look into this, but it should be mentioned here.

Update

I've been told that the mentioned SSLHandshakeException and ConnectExceptions are indeed caused by misconfiguration. Also, here's more information on why not to use the sAMAccountName. In my setting however, I was told to use it so I guess it's fine for my case. ;) Thanks for the input!

TL;DR

There are a ton of aspects that can make connecting to an LDAP hell. See above for some hints that hopefully help you save some time.

Category: 
Share: