Multiple Sources for User Details in Spring Security

With Spring Security it’s easy to get started with a user details source to fetch users and stitch it with an authentication provider. However there are situations where you need to use multiple sources to fetch user details and have a different authentication provider for each source. For example you wish to have users authenticate either against an Active Directory being accessed over LDAP or against a file on the filesystem. This post strives to provide a solution to this common business requirement for securing application access. So let’s see how to add multiple sources for user details in Spring Security.

Spring Security

Every application needs a security module to ensure only authorized users are allowed to access protected areas. Luckily with Spring framework, we can leverage Spring Security for the purpose of authentication (authN) and authorization (authZ). Spring Security was released publicly in 2008 and since then it has been an integral part of every project built using Spring framework.

It is very easy to get started with Spring Security setup in any Spring framework based project. Stitching together a user details source with the default authentication provider is so simple that it has become second nature for developers. And for serious enterprise needs, Spring Security framework can be used to quickly setup a custom OAuth2 provider/consumer or SAML service provider.

Multiple sources for user details in Spring Security

Business needs often demand there be multiple sources of truth for fetching & building user details. Further, each source of truth may have its own independent authentication requirement. 

For the context of this post, let’s assume we need to fetch user details from an Active Directory which can be accessed over an LDAP connection. Such users need to be authenticated via standard LDAP bind. Moreover we also need to provision a facility to fetch user details from a file on the filesystem. Such users need to be authenticated by matching their password hash which is stored in the same file using BCrypt.

To make it simple to identify the source to which a user belongs, we check for the presence of @local which would signify the user is to be found in the local filesystem otherwise in Active Directory.

Let’s see how we can go about achieving this business need.

Solution

At the heart of the solution lies the incredible power of extending Spring Security. Being a framework, rather than a library, Spring Security allows us to plug-in any complex business needs with relative ease including adding multiple sources for user details.

UserDetailsService – multiple sources for user details

We begin by writing a MultiSourceUserDetailsService that implements the UserDetailsService interface to initialize our two distinct user details sources. One for fetching users from a file on the local filesystem and keeping it loaded in-memory and another for fetching users from Active Directory using LDAP. This makes our application ready to intercept any loadUserByUsername requests to provide a custom flow to the standard authentication process.

@Service
public class MultiSourceUserDetailsService implements UserDetailsService {

	private static final Logger LOGGER = LogManager.getLogger(MultiSourceUserDetailsService.class);

	private UserDetailsService inMemoryUserDetailsService;

	private UserDetailsService ldapUserDetailsService;

	@Autowired
	LdapContextSource ldapContextSource;

	@Autowired
	LdapConfig ldapConfig;

	@PostConstruct
	public void init() {
		this.inMemoryUserDetailsService = initInMemoryUserDetailsService();
		this.ldapUserDetailsService = initLdapUserDetailsService();
	}

	@Override
	public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
		if (username.equalsIgnoreCase("admin@local")) {
			return inMemoryUserDetailsService.loadUserByUsername(username);
		}

		return ldapUserDetailsService.loadUserByUsername(username);
	}

	private UserDetailsService initInMemoryUserDetailsService() {
		Properties users = null;
		try {
			LOGGER.debug("Looking up from in-memory userDetailsService");
			users = PropertiesLoaderUtils.loadAllProperties("passwd.dat");
		} catch (IOException e) {
			LOGGER.error("Error while loading user details from in-memory userDetailsService.", e);
		}
		return new InMemoryUserDetailsManager(users);
	}

	private UserDetailsService initLdapUserDetailsService() {
		LdapUserSearch userSearch = new FilterBasedLdapUserSearch("", buildUserSearchFilter(), ldapContextSource);
		return new LdapUserDetailsService(userSearch);
	}

	private String buildUserSearchFilter() {
		StringBuilder builder = new StringBuilder();
		builder.append("(& ");
		builder.append("(objectclass=organizationalPerson)");
		builder.append("(sAMAccountName={0})");
		builder.append("(memberOf=");
		builder.append(ldapConfig.getAuthorizedUsersSearchBase());
		builder.append(")");
		builder.append(")");

		return builder.toString();
	}

	public void updateUserSearchFilter() {
		LdapUserSearch userSearch = new FilterBasedLdapUserSearch("", buildUserSearchFilter(), ldapContextSource);
		this.ldapUserDetailsService = new LdapUserDetailsService(userSearch);
	}
	
	public UserDetailsService getInMemoryUserDetailsService() {
		return inMemoryUserDetailsService;
	}

	public UserDetailsService getLdapUserDetailsService() {
		return ldapUserDetailsService;
	}
}

By using @PostConstruct annotation we are hooking our class to Spring beans initialization life-cycle. We initialize two instances of UserDetailsService

First: inMemoryUserDetailsService 

It will hold all user details fetched from a file on the filesystem. We read and parse a properties file to construct an InMemoryUserDetailsManager.

Second: ldapUserDetailsService

It will fetch user details from an Active Directory using LDAP search filter. We filter users that have objectclass=organizationalPerson AND sAMAccountName={0} AND memberOf=<authorizedUsersSearchBase>. The {0} is replaced by Spring with the username provided in the authentication flow. We are restricting login privileges to only a particular OU/Security Group indicated by the authorizedUsersSearchBase in the configuration file.

AuthenticationProvider – multiple sources for user authentication details

Next we write a MultiSourceAuthenticationProvider that implements the AuthenticationProvider interface to initialize our two distinct authentication providers. One for simple password based authentication and another for authentication via an LDAP bind. This makes our application ready to intercept any authenticate requests to provide a custom flow to the standard authentication process.

@Service
public class MultiSourceAuthenticationProvider implements AuthenticationProvider {

	private LdapAuthenticationProvider ldapAuthenticationProvider;
	
	private BindAuthenticator bindAuthenticator;
	
	private DaoAuthenticationProvider daoAuthenticationProvider;
	
	@Autowired
	LdapContextSource ldapContextSource;
	
	@Autowired
	UserDetailsService userDetailsService;
	
	@Autowired
	LdapConfig ldapConfig;
	
	@PostConstruct
	public void init() {
		this.daoAuthenticationProvider = new DaoAuthenticationProvider();
		this.daoAuthenticationProvider.setUserDetailsService(userDetailsService);
		this.daoAuthenticationProvider.setPasswordEncoder(new BCryptPasswordEncoder());
		
		LdapUserSearch userSearch = new FilterBasedLdapUserSearch("", buildUserSearchFilter(), 
				ldapContextSource);
		
		this.bindAuthenticator = new BindAuthenticator(ldapContextSource);
		this.bindAuthenticator.setUserSearch(userSearch);
		
		DefaultLdapAuthoritiesPopulator authoritiesPopulator = new DefaultLdapAuthoritiesPopulator(ldapContextSource, "");
		authoritiesPopulator.setIgnorePartialResultException(true);
		authoritiesPopulator.setConvertToUpperCase(true);
		authoritiesPopulator.setGroupSearchFilter("(& (objectclass=group) (member={0}))");
		
		this.ldapAuthenticationProvider = new LdapAuthenticationProvider(this.bindAuthenticator, authoritiesPopulator);
	}
	
	private String buildUserSearchFilter() {
		StringBuilder builder = new StringBuilder();
		builder.append("(& ");
		builder.append("(objectclass=organizationalPerson)");
		builder.append("(sAMAccountName={0})");
		builder.append("(memberOf=");
		builder.append(ldapConfig.getAuthorizedUsersSearchBase());
		builder.append(")");
		builder.append(")");
		
		return builder.toString();
	}
	
	public void updateUserSearchFilter() {
		LdapUserSearch userSearch = new FilterBasedLdapUserSearch("", buildUserSearchFilter(), 
				ldapContextSource);
		this.bindAuthenticator.setUserSearch(userSearch);
	}
	
	@Override
	public Authentication authenticate(Authentication authentication) throws AuthenticationException {
		if(authentication.getPrincipal().toString().equalsIgnoreCase("admin@local")) {
			return this.daoAuthenticationProvider.authenticate(authentication); 
		}
		
		return this.ldapAuthenticationProvider.authenticate(authentication);
	}

	@Override
	public boolean supports(Class<?> authentication) {
		return true;
	}
}

By using @PostConstruct annotation we are hooking our class to Spring beans initialization life-cycle. We initialize two instances of AuthenticationProvider

First: daoAuthenticationProvider

It will authenticate users by matching their password against the one stored in the file on the filesystem. It will use BCryptPasswordEncoder for matching the password.

Second: ldapAuthenticationProvider

It will authenticate users by attempting an LDAP bind for the user by using the BindAuthenticator. BindAuthenticator uses an LDAP search filter to first fetch the user to build its DistinguishedName (dn) and then proceeds with the bind operation.  

WebSecurityConfig – stitching together multiple sources for user details and authentication

We can now proceed to extend WebSecurityConfigurerAdapter to inject our MultiSourceUserDetailsService and MultiSourceAuthenticationProvider into the standard Spring Security’s Web Security flows.

@EnableWebSecurity
@Configuration
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

	@Bean
	public PasswordEncoder encoder() {
	    return new BCryptPasswordEncoder(11);
	}
	
	@Autowired
	UserDetailsService multiSourceUserDetailsService;
	
	@Autowired
	AuthenticationProvider multiSourceAuthenticationProvider;
	
	@Override
	protected void configure(HttpSecurity http) throws Exception {
		http
			.authorizeRequests()
				.antMatchers("/", "/error").permitAll()
				.anyRequest().authenticated()
				.and()
			.formLogin()
				.loginPage("/login")
				.permitAll()
				.and()
			.logout()
				.permitAll();
	}
}

We are configuring HttpSecurity to require authenticated requests except for the "/" and "/error" ant matchers. We also configure a formLogin and a logout handler which are accessible to all users.

Now whenever any user tries to access our application they will be presented a login screen using which they will be able to authenticate either against the local filesystem or against the Active Directory.

Bonus handling

How about changing the password of a user that logged-in by authenticating against the local filesystem? As the user details were read from the filesystem and an InMemoryUserDetailsManager was constructed, any change to password will need to be affected in both the InMemoryUserDetailsManager as well as the source file on the filesystem.

@PostMapping("/set-password")
	public String postPasswordUpdate(
			@ModelAttribute(name = "appLocalCredentials") @Valid AppLocalCredentials appLocalCredentials,
			BindingResult bindingResult, Model model) {
		if (bindingResult.hasErrors()) {
			if (!appLocalCredentials.getPassword()
					.equals(appLocalCredentials.getConfirmPassword())) {
				bindingResult.rejectValue("confirmPassword", "", "Password confirmation failed.");
			}
			return "set-password";
		}

		if (!appLocalCredentials.getPassword().equals(appLocalCredentials.getConfirmPassword())) {
			bindingResult.rejectValue("confirmPassword", "", "Password confirmation failed.");
			return "set-password";
		}

		String username = SecurityContextHolder.getContext().getAuthentication().getName();
		if (userDetailsService instanceof MultiSourceUserDetailsService) {
			MultiSourceUserDetailsService msuds = (MultiSourceUserDetailsService) userDetailsService;
			InMemoryUserDetailsManager uds = (InMemoryUserDetailsManager) msuds.getInMemoryUserDetailsService();
			String encNewPassword = null;
			UserDetails userDetails = uds.loadUserByUsername(username);
			
			if (passwordEncoder.matches(appLocalCredentials.getExistingPassword(),
					userDetails.getPassword())) {
				encNewPassword = passwordEncoder.encode(appLocalCredentials.getPassword());
				uds.changePassword(appLocalCredentials.getExistingPassword(), encNewPassword);
			} else {
				LOGGER.error("Authentication failed while changing password.");
				bindingResult.rejectValue("existingPassword", "", "Existing password verification failed!");
				return "set-password";
			}

			File file = null;
			try {
				StringBuilder builder = new StringBuilder();
				Properties users = PropertiesLoaderUtils.loadProperties(loadPasswdResource());
				Enumeration<?> names = users.propertyNames();
				while (names.hasMoreElements()) {
					String name = (String) names.nextElement();
					builder.append(name).append("=");

					if (name.equalsIgnoreCase(username)) {
						String[] tokens = StringUtils.commaDelimitedListToStringArray(users.getProperty(name));
						builder.append(encNewPassword).append(",").append(tokens[1]);
					} else {
						builder.append(users.getProperty(name));
					}

					builder.append(System.lineSeparator());
				}
				
				file = loadPasswdResource().getFile();
				try (BufferedWriter bw = new BufferedWriter(new FileWriter(file, false))) {
					bw.write(builder.toString());
					bw.flush();
				}
			} catch (IOException e) {
				LOGGER.error("Error while working with passwd.dat file.", e);
				return "set-password";
			}
		}

		return "redirect:/home";
	}

How about managing the LDAP configuration parameters for connecting with Active Directory while the application is running? The configuration parameters are sourced from a properties file during application start-up. Any changes to the parameters at run-time would need to be affected in both the running instance of ldapContextSource as well as the source file on the filesystem.

@PostMapping("/ldap-creds")
	public String postLdapCreds(@Valid LdapParams ldapParams, BindingResult bindingResult, Model model) {
		LOGGER.debug("Received ldap-creds...");
		
		if (bindingResult.hasErrors()) {
			return "ldap-creds";
		}

		try {
			ldapService.validateLdapAdminUser(ldapParams);
		} catch (CommunicationException ce) {
			LOGGER.error("Error while reaching LDAP server.", ce);
			bindingResult.rejectValue("ldapHost", "", "Could not connect to LDAP. Please check the host.");
			bindingResult.rejectValue("ldapPort", "", "Could not connect to LDAP. Please check the port.");
			return "ldap-creds";
		} catch(AuthenticationException ae) {
			LOGGER.error("Error while binding with LDAP server.", ae);
			bindingResult.rejectValue("ldapAdminUser", "", "Could not authenticate with LDAP. Please check the username.");
			bindingResult.rejectValue("ldapAdminPass", "", "Could not authenticate with LDAP. Please check the password.");
			return "ldap-creds";
		} catch(PartialResultException pre) {
			LOGGER.error("Error while querying LDAP server.", pre);
			bindingResult.rejectValue("ldapGroupsSearchBase", "", "Could not query LDAP. Please ensure user search base is correct.");
			return "ldap-creds";
		} catch (Exception e) {
			LOGGER.error("Error while querying LDAP.", e);
			bindingResult.rejectValue("ldapAdminPass", "", "Connection to LDAP failed.");
			return "ldap-creds";
		}

		if(!ldapParamsManager.updateLdapParams(ldapParams)) {
			return "ldap-creds";
		}
		
		ldapConfigService.refresh();
		
		return "redirect:/ous-and-groups";
	}
public void validateLdapAdminUser(LdapParams ldapParams) {
		LdapQuery query = LdapQueryBuilder.query()
				.searchScope(SearchScope.SUBTREE)
				.attributes("distinguishedName")
				.where("objectclass").is("organizationalPerson")
				.and("isCriticalSystemObject").not().isPresent()
				.and("sAMAccountName").is(ldapParams.getLdapAdminUser());

		LdapTemplate ldapTemplate = ldapConfigManager.ldapTemplate(ldapParams);
		ldapTemplate.setIgnorePartialResultException(true);
		
		List<String> userDn = ldapTemplate.search(query, new AttributesMapper<String>() {
			public String mapFromAttributes(Attributes attrs) throws NamingException {
				return fetchAttrValue(attrs, "distinguishedName");
			}
		});

		if(null != userDn && userDn.size() == 1) {
			// No need to bind again. 
			// To fetch userDn a bind was already performed successfully using the supplied creds! 
			LOGGER.info("Admin user {} credentials verified successfully. Admin user DN={}", ldapParams.getLdapAdminUser(), userDn.get(0));
		}
	}
public boolean updateLdapParams(LdapParams ldapParams) {
		File file = null;
		try {
			StringBuilder builder = new StringBuilder();
			Properties ldapProperties = PropertiesLoaderUtils.loadProperties(fetchLdapConfigResource());
			Enumeration<?> names = ldapProperties.propertyNames();
			while (names.hasMoreElements()) {
				String name = (String) names.nextElement();
				builder.append(name).append("=");

				switch (name) {
				case "my-ldap.ldapProtocol":
					builder.append(ldapProperties.getProperty(name));
					ldapConfig.setLdapProtocol(ldapProperties.getProperty(name));
					break;
				case "my-ldap.ldapHost":
					builder.append(ldapParams.getLdapHost());
					ldapConfig.setLdapHost(ldapParams.getLdapHost());
					break;
				case "my-ldap.ldapPort":
					builder.append(ldapParams.getLdapPort());
					ldapConfig.setLdapPort(ldapParams.getLdapPort());
					break;
				case "my-ldap.ldapAuth":
					builder.append(ldapProperties.getProperty(name));
					ldapConfig.setLdapAuth(ldapProperties.getProperty(name));
					break;
				case "my-ldap.domainPrefix":
					builder.append(ldapParams.getDomainPrefix());
					ldapConfig.setDomainPrefix(ldapParams.getDomainPrefix());
					break;
				case "my-ldap.ldapAdminUser":
					builder.append(ldapParams.getLdapAdminUser());
					ldapConfig.setLdapAdminUser(ldapParams.getLdapAdminUser());
					break;
				case "my-ldap.ldapAdminPass":
					builder.append(ldapParams.getLdapAdminPass());
					ldapConfig.setLdapAdminPass(ldapParams.getLdapAdminPass());
					break;
				case "my-ldap.ldapSearchBase":
					builder.append(ldapParams.getLdapSearchBase());
					ldapConfig.setLdapSearchBase(ldapParams.getLdapSearchBase());
					break;
				case "my-ldap.authorizedUsersSearchBase":
					builder.append(ldapParams.getAuthorizedUsersSearchBase());
					ldapConfig.setAuthorizedUsersSearchBase(ldapParams.getAuthorizedUsersSearchBase());
					break;
				default:
					builder.delete(builder.length() - name.length(), builder.length());
				}

				builder.append(System.lineSeparator());
			}

			file = fetchLdapConfigResource().getFile();
			try (BufferedWriter bw = new BufferedWriter(new FileWriter(file, false))) {
				bw.write(builder.toString());
				bw.flush();
			}
		} catch (IOException e) {
			LOGGER.error("Error while working with ldap-config.properties file.", e);
			return false;
		}
		return true;
	}
@Service
public class LdapConfigManager {
	
	@Autowired
	LdapContextSource ldapContextSource;
	
	@Autowired
	LdapParamsManager ldapParamsManager;
	
	@Autowired
	MultiSourceUserDetailsService msuds;
	
	@Autowired
	MultiSourceAuthenticationProvider msap;
	
	public void refresh() {
		LdapParams ldapParams = ldapParamsManager.fetchLdapParams();
		buildLdapContextSource(ldapParams, this.ldapContextSource);
		msuds.updateUserSearchFilter();
		msap.updateUserSearchFilter();
	}
	
	public LdapContextSource getLdapContextSource(LdapParams ldapParams) {
		return buildLdapContextSource(ldapParams, null);
	}
	
	public LdapTemplate ldapTemplate(LdapParams ldapParams) {
		return new LdapTemplate(buildLdapContextSource(ldapParams, null));
	}

	private LdapContextSource buildLdapContextSource(LdapParams ldapParams, LdapContextSource ldapContextSource) {
		if (null == ldapContextSource) {
			ldapContextSource = new LdapContextSource();
		}

		ldapContextSource.setUrl(
				ldapParams.getLdapProtocol() + "://" + ldapParams.getLdapHost() + ":" + ldapParams.getLdapPort());
		ldapContextSource.setBase(ldapParams.getLdapSearchBase());
		ldapContextSource.setUserDn(ldapParams.getDomainPrefix() + "\\" + ldapParams.getLdapAdminUser());
		ldapContextSource.setPassword(ldapParams.getLdapAdminPass());
		ldapContextSource.setReferral("ignore");

		final Map<String, Object> envProps = new HashMap<>();
		envProps.put("java.naming.ldap.attributes.binary", "objectGUID");
		ldapContextSource.setBaseEnvironmentProperties(envProps);

		ldapContextSource.afterPropertiesSet();

		return ldapContextSource;
	}

}

Source code

The entire working Proof-of-Concept application can be accessed at: https://github.com/sanketdaru/spring-multi-source-authentication

Conclusion

This was a simple application that demonstrates how easy it is to modify the standard Spring Security authentication flows. We added multiple sources for user details in Spring Security to achieve our desired business objective of authenticating a user either against an Active Directory using LDAP or against a file on the filesystem.

Leave a Reply