Spring Security LDAP and Remember Me

Maksim picture Maksim · Jul 14, 2014 · Viewed 13.7k times · Source

I'm building an app with Spring Boot that has integration with LDAP. I was able to connect successfully to LDAP server and authenticate user. Now I have a requirement to add remember-me functionality. I tried to look through different posts (this) but was not able to find an answer to my problem. Official Spring Security document states that

If you are using an authentication provider which doesn't use a UserDetailsService (for example, the LDAP provider) then it won't work unless you also have a UserDetailsService bean in your application context

Here the my working code with some initial thoughts to add remember-me functionality:

WebSecurityConfig

import com.ui.security.CustomUserDetailsServiceImpl;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.access.event.LoggerListener;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.ldap.authentication.ad.ActiveDirectoryLdapAuthenticationProvider;
import org.springframework.security.ldap.userdetails.UserDetailsContextMapper;
import org.springframework.security.web.authentication.RememberMeServices;
import org.springframework.security.web.authentication.rememberme.TokenBasedRememberMeServices;

@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    String DOMAIN = "ldap-server.com";
    String URL = "ldap://ds.ldap-server.com:389";


    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .csrf().disable()
                .authorizeRequests()
                .antMatchers("/ui/**").authenticated()
                .antMatchers("/", "/home", "/UIDL/**", "/ui/**").permitAll()
                .anyRequest().authenticated()
        ;
        http
                .formLogin()
                .loginPage("/login").failureUrl("/login?error=true").permitAll()
                .and().logout().permitAll()
        ;

        // Not sure how to implement this
        http.rememberMe().rememberMeServices(rememberMeServices()).key("password");

    }

    @Override
    protected void configure(AuthenticationManagerBuilder authManagerBuilder) throws Exception {

        authManagerBuilder
                .authenticationProvider(activeDirectoryLdapAuthenticationProvider())
                .userDetailsService(userDetailsService())
        ;
    }

    @Bean
    public ActiveDirectoryLdapAuthenticationProvider activeDirectoryLdapAuthenticationProvider() {

        ActiveDirectoryLdapAuthenticationProvider provider = new ActiveDirectoryLdapAuthenticationProvider(DOMAIN, URL);
        provider.setConvertSubErrorCodesToExceptions(true);
        provider.setUseAuthenticationRequestCredentials(true);
        provider.setUserDetailsContextMapper(userDetailsContextMapper());
        return provider;
    }

    @Bean
    public UserDetailsContextMapper userDetailsContextMapper() {
        UserDetailsContextMapper contextMapper = new CustomUserDetailsServiceImpl();
        return contextMapper;
    }

    /**
     * Impl of remember me service
     * @return
     */
    @Bean
    public RememberMeServices rememberMeServices() {
//        TokenBasedRememberMeServices rememberMeServices = new TokenBasedRememberMeServices("password", userService);
//        rememberMeServices.setCookieName("cookieName");
//        rememberMeServices.setParameter("rememberMe");
        return rememberMeServices;
    }

    @Bean
    public LoggerListener loggerListener() {
        return new LoggerListener();
    }
}

CustomUserDetailsServiceImpl

public class CustomUserDetailsServiceImpl implements UserDetailsContextMapper {

    @Autowired
    SecurityHelper securityHelper;
    Log ___log = LogFactory.getLog(this.getClass());

    @Override
    public LoggedInUserDetails mapUserFromContext(DirContextOperations ctx, String username, Collection<? extends GrantedAuthority> grantedAuthorities) {

        LoggedInUserDetails userDetails = null;
        try {
            userDetails = securityHelper.authenticateUser(ctx, username, grantedAuthorities);
        } catch (NamingException e) {
            e.printStackTrace();
        }

        return userDetails;
    }

    @Override
    public void mapUserToContext(UserDetails user, DirContextAdapter ctx) {

    }
}

I know that I need to implement UserService somehow, but not sure how that can be achieved.

Answer

Vladim&#237;r Sch&#228;fer picture Vladimír Schäfer · Jul 20, 2014

There are two issues to configuration of the RememberMe features with LDAP:

  • selection of the correct RememberMe implementation (Tokens vs. PersistentTokens)
  • its configuration using Spring's Java Configuration

I'll take these step by step.

The Token-based remember me feature (TokenBasedRememberMeServices) works in the following way during authentication:

  • user gets authenticated (agaisnt AD) and we currently know user's ID and password
  • we construct value username + expirationTime + password + staticKey and create an MD5 hash of it
  • we create a cookie which contains username + expiration + the calculated hash

When user wants to come back to the service and be authenticated using the remember me functionality we:

  • check whether the cookie exists and isn't expired
  • populate the user ID from the cookie and call the provided UserDetailsService which is expected to return information related to the user's ID, including the password
  • we then calculate the hash from the returned data and verify that the hash in the cookie matches with the value we calculated
  • if it matches we return the user's Authentication object

The hash checking process is required in order to make sure that nobody can create a "fake" remember me cookie, which would let them impersonate another user. The problem is that this process relies on possibility of loading password from our repository - but this is impossible with Active Directory - we cannot load plaintext password based on username.

This makes the Token-based implementation unsuitable for usage with AD (unless we start creating some local user store which contains the password or some other secret user-based credential and I'm not suggesting this approach as I don't know other details of your application, although it might be a good way to go).

The other remember me implementation is based on persistent tokens (PersistentTokenBasedRememberMeServices) and it works like this (in a bit simplified way):

  • when user authenticates we generate a random token
  • we store the token in storage together with information about user's ID associated with it
  • we create a cookie which includes the token ID

When user wants to authenticate we:

  • check whether we have the cookie with token ID available
  • verify whether the token ID exists in database
  • load user's data based on information in the database

As you can see, the password is no longer required, although we now need a token storage (typically database, we can use in-memory for testing) which is used instead of the password verification.

And that gets us to the configuration part. The basic configuration for persistent-token-based remember me looks like this:

@Override
protected void configure(HttpSecurity http) throws Exception {           
    ....
    String internalSecretKey = "internalSecretKey";
    http.rememberMe().rememberMeServices(rememberMeServices(internalSecretKey)).key(internalSecretKey);
}

 @Bean
 public RememberMeServices rememberMeServices(String internalSecretKey) {
     BasicRememberMeUserDetailsService rememberMeUserDetailsService = new BasicRememberMeUserDetailsService();
     InMemoryTokenRepositoryImpl rememberMeTokenRepository = new InMemoryTokenRepositoryImpl();
     PersistentTokenBasedRememberMeServices services = new PersistentTokenBasedRememberMeServices(staticKey, rememberMeUserDetailsService, rememberMeTokenRepository);
     services.setAlwaysRemember(true);
     return services;
 }

This implementation will use in-memory token storage which should be replaced with JdbcTokenRepositoryImpl for production. The provided UserDetailsService is responsible for loading of additional data for the user identified by the user ID loaded from the remember me cookie. The simpliest implementation can look like this:

public class BasicRememberMeUserDetailsService implements UserDetailsService {
     public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
         return new User(username, "", Collections.<GrantedAuthority>emptyList());
     }
}

You could also supply another UserDetailsService implementation which loads additional attributes or group memberships from your AD or internal database, depending on your needs. It could look like this:

@Bean
public RememberMeServices rememberMeServices(String internalSecretKey) {
    LdapContextSource ldapContext = getLdapContext();

    String searchBase = "OU=Users,DC=test,DC=company,DC=com";
    String searchFilter = "(&(objectClass=user)(sAMAccountName={0}))";
    FilterBasedLdapUserSearch search = new FilterBasedLdapUserSearch(searchBase, searchFilter, ldapContext);
    search.setSearchSubtree(true);

    LdapUserDetailsService rememberMeUserDetailsService = new LdapUserDetailsService(search);
    rememberMeUserDetailsService.setUserDetailsMapper(new CustomUserDetailsServiceImpl());

    InMemoryTokenRepositoryImpl rememberMeTokenRepository = new InMemoryTokenRepositoryImpl();

    PersistentTokenBasedRememberMeServices services = new PersistentTokenBasedRememberMeServices(internalSecretKey, rememberMeUserDetailsService, rememberMeTokenRepository);
    services.setAlwaysRemember(true);
    return services;
}

@Bean
public LdapContextSource getLdapContext() {
    LdapContextSource source = new LdapContextSource();
    source.setUserDn("user@"+DOMAIN);
    source.setPassword("password");
    source.setUrl(URL);
    return source;
}

This will get you remember me functionality which works with LDAP and provides the loaded data inside RememberMeAuthenticationToken which will be available in the SecurityContextHolder.getContext().getAuthentication(). It will also be able to re-use your existing logic for parsing of LDAP data into an User object (CustomUserDetailsServiceImpl).

As a separate subject, there's also one problem with the code posted in the question, you should replace the:

    authManagerBuilder
            .authenticationProvider(activeDirectoryLdapAuthenticationProvider())
            .userDetailsService(userDetailsService())
    ;

with:

    authManagerBuilder
            .authenticationProvider(activeDirectoryLdapAuthenticationProvider())
    ;

The call to userDetailsService should only be made in order to add DAO-based authentication (e.g. against database) and should be called with a real implementation of the user details service. Your current configuration can lead to infinite loops.