I'm developing a new web application using Spring MVC with Spring Security 3.1.1 via Spring Tools Suite 3.4. My application is being written in java 1.6, it is authenticating against an Active Directory system, and it will be deployed to a Tomcat 7 server.
I will be deploying the application via WAR file to three different environments: dev, qa, and prod. What I normally do for settings that are unique for each environment, such as database connection strings (there's a separate database for each environment), is configure the Tomcat server's context.xml file, read that via jndi lookup in my Spring application and inject those settings into my DAO classes. The challenge that I have now is to figure out how to do something similar for my Active Directory settings that need to be injected into my spring-security-context.xml file.
As of right now, I've got my Active Directory domain and url hardcoded in my spring-security-context.xml file, but I don't want to leave it like that as there is a different Active Directory system for each of my environments. I guess what's confusing me about this is that I'm injecting constructor and property values into my ActiveDirectoryLdapAuthenticationProvider class from the spring-security-context.xml file, but how do I inject those settings into the spring-security-context.xml file from my Tomcat server's context.xml file?
Here's my spring-security-context.xml file:
<?xml version="1.0" encoding="UTF-8"?>
<beans:beans xmlns:security="http://www.springframework.org/schema/security"
xmlns:beans="http://www.springframework.org/schema/beans" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.springframework.org/schema/beans
http://www.springframework.org/schema/beans/spring-beans-3.1.xsd
http://www.springframework.org/schema/security
http://www.springframework.org/schema/security/spring-security-3.1.xsd">
<security:http pattern="/login" security="none" />
<security:http pattern="/logerror" security="none" />
<security:http pattern="/resources/**" security="none" />
<!-- LDAP server details -->
<security:authentication-manager>
<security:authentication-provider
ref="ldapActiveDirectoryAuthProvider" />
</security:authentication-manager>
<beans:bean id="grantedAuthoritiesMapper"
class="com.mycompany.pima.security.ActiveDirectoryGrantedAuthoritiesMapper" />
<beans:bean id="ldapActiveDirectoryAuthProvider" class="com.mycompany.pima.security.ActiveDirectoryLdapAuthenticationProvider">
<beans:constructor-arg value="mydomain.mycompany.com" />
<beans:constructor-arg value="ldap://adserver.mydomain.mycompany.com:389/" />
<beans:property name="authoritiesMapper" ref="grantedAuthoritiesMapper" />
<beans:property name="useAuthenticationRequestCredentials"
value="true" />
<beans:property name="convertSubErrorCodesToExceptions"
value="true" />
</beans:bean>
<security:http auto-config="true" pattern="/**">
<!-- Login pages -->
<security:form-login login-page="/login"
default-target-url="/users" login-processing-url="/j_spring_security_check"
authentication-failure-url="/login?error=true" />
<security:logout logout-success-url="/login" />
<!-- Security zones -->
<security:intercept-url pattern="/**" access="ROLE_USERS" />
<security:intercept-url pattern="/admin/**"
access="ROLE_ADMIN" />
<security:session-management
invalid-session-url="/login">
<security:concurrency-control
max-sessions="1" expired-url="/login" />
</security:session-management>
</security:http>
</beans:beans>
Here's my custom ActiveDirectoryLdapAuthenticationProvider.java file:
package com.mycompany.pima.security;
imports...
public final class ActiveDirectoryLdapAuthenticationProvider extends AbstractLdapAuthenticationProvider {
private static final Pattern SUB_ERROR_CODE = Pattern.compile(".*data\\s([0-9a-f]{3,4}).*");
// Error codes
private static final int USERNAME_NOT_FOUND = 0x525;
private static final int INVALID_PASSWORD = 0x52e;
private static final int NOT_PERMITTED = 0x530;
private static final int PASSWORD_EXPIRED = 0x532;
private static final int ACCOUNT_DISABLED = 0x533;
private static final int ACCOUNT_EXPIRED = 0x701;
private static final int PASSWORD_NEEDS_RESET = 0x773;
private static final int ACCOUNT_LOCKED = 0x775;
private final String domain;
private final String rootDn;
private final String url;
private boolean convertSubErrorCodesToExceptions;
private static final Logger logger = LoggerFactory.getLogger(ActiveDirectoryLdapAuthenticationProvider.class);
// Only used to allow tests to substitute a mock LdapContext
ContextFactory contextFactory = new ContextFactory();
public ActiveDirectoryLdapAuthenticationProvider(String domain, String url) {
Assert.isTrue(StringUtils.hasText(url), "Url cannot be empty");
this.domain = StringUtils.hasText(domain) ? domain.toLowerCase() : null;
//this.url = StringUtils.hasText(url) ? url : null;
this.url = url;
rootDn = this.domain == null ? null : rootDnFromDomain(this.domain);
}
@Override
protected DirContextOperations doAuthentication(UsernamePasswordAuthenticationToken auth) {
String username = auth.getName();
String password = (String)auth.getCredentials();
DirContext ctx = bindAsUser(username, password);
try {
return searchForUser(ctx, username);
} catch (NamingException e) {
logger.error("Failed to locate directory entry for authenticated user: " + username, e);
throw badCredentials(e);
} finally {
LdapUtils.closeContext(ctx);
}
}
/**
* Creates the user authority list from the values of the {@code memberOf} attribute obtained from the user's
* Active Directory entry.
*/
@Override
protected Collection<? extends GrantedAuthority> loadUserAuthorities(DirContextOperations userData, String username, String password) {
String[] groups = userData.getStringAttributes("memberOf");
if (groups == null) {
logger.debug("No values for 'memberOf' attribute.");
return AuthorityUtils.NO_AUTHORITIES;
}
if (logger.isDebugEnabled()) {
logger.debug("'memberOf' attribute values: " + Arrays.asList(groups));
}
ArrayList<GrantedAuthority> authorities = new ArrayList<GrantedAuthority>(groups.length);
for (String group : groups) {
authorities.add(new SimpleGrantedAuthority(new DistinguishedName(group).removeLast().getValue()));
}
return authorities;
}
private DirContext bindAsUser(String username, String password) {
// TODO. add DNS lookup based on domain
final String bindUrl = url;
Hashtable<String,String> env = new Hashtable<String,String>();
env.put(Context.SECURITY_AUTHENTICATION, "simple");
String bindPrincipal = createBindPrincipal(username);
env.put(Context.SECURITY_PRINCIPAL, bindPrincipal);
env.put(Context.PROVIDER_URL, bindUrl);
env.put(Context.SECURITY_CREDENTIALS, password);
env.put(Context.INITIAL_CONTEXT_FACTORY, "com.sun.jndi.ldap.LdapCtxFactory");
env.put(Context.OBJECT_FACTORIES, DefaultDirObjectFactory.class.getName());
try {
// return new InitialDirContext(env);
return contextFactory.createContext(env);
} catch (NamingException e) {
if ((e instanceof AuthenticationException) || (e instanceof OperationNotSupportedException)) {
handleBindException(bindPrincipal, e);
throw badCredentials(e);
} else {
throw LdapUtils.convertLdapException(e);
}
}
}
void handleBindException(String bindPrincipal, NamingException exception) {
if (logger.isDebugEnabled()) {
logger.debug("Authentication for " + bindPrincipal + " failed:" + exception);
}
int subErrorCode = parseSubErrorCode(exception.getMessage());
if (subErrorCode > 0) {
logger.info("Active Directory authentication failed: " + subCodeToLogMessage(subErrorCode));
if (convertSubErrorCodesToExceptions) {
raiseExceptionForErrorCode(subErrorCode, exception);
}
} else {
logger.debug("Failed to locate AD-specific sub-error code in message");
}
}
int parseSubErrorCode(String message) {
logger.info("in parseSubErrorCode");
Matcher m = SUB_ERROR_CODE.matcher(message);
if (m.matches()) {
return Integer.parseInt(m.group(1), 16);
}
return -1;
}
void raiseExceptionForErrorCode(int code, NamingException exception) {
String hexString = Integer.toHexString(code);
Throwable cause = new ActiveDirectoryAuthenticationException(hexString, exception.getMessage(), exception);
switch (code) {
case PASSWORD_EXPIRED:
throw new CredentialsExpiredException(messages.getMessage("LdapAuthenticationProvider.credentialsExpired",
"User credentials have expired"), cause);
case ACCOUNT_DISABLED:
throw new DisabledException(messages.getMessage("LdapAuthenticationProvider.disabled",
"User is disabled"), cause);
case ACCOUNT_EXPIRED:
throw new AccountExpiredException(messages.getMessage("LdapAuthenticationProvider.expired",
"User account has expired"), cause);
case ACCOUNT_LOCKED:
throw new LockedException(messages.getMessage("LdapAuthenticationProvider.locked",
"User account is locked"), cause);
default:
throw badCredentials(cause);
}
}
String subCodeToLogMessage(int code) {
switch (code) {
case USERNAME_NOT_FOUND:
return "User was not found in directory";
case INVALID_PASSWORD:
return "Supplied password was invalid";
case NOT_PERMITTED:
return "User not permitted to logon at this time";
case PASSWORD_EXPIRED:
return "Password has expired";
case ACCOUNT_DISABLED:
return "Account is disabled";
case ACCOUNT_EXPIRED:
return "Account expired";
case PASSWORD_NEEDS_RESET:
return "User must reset password";
case ACCOUNT_LOCKED:
return "Account locked";
}
return "Unknown (error code " + Integer.toHexString(code) +")";
}
private BadCredentialsException badCredentials() {
return new BadCredentialsException(messages.getMessage(
"LdapAuthenticationProvider.badCredentials", "Bad credentials"));
}
private BadCredentialsException badCredentials(Throwable cause) {
return (BadCredentialsException) badCredentials().initCause(cause);
}
@SuppressWarnings("deprecation")
private DirContextOperations searchForUser(DirContext ctx, String username) throws NamingException {
SearchControls searchCtls = new SearchControls();
searchCtls.setSearchScope(SearchControls.SUBTREE_SCOPE);
String searchFilter = "(&(cn=" + username + "))";
final String bindPrincipal = createBindPrincipal(username);
String searchRoot = rootDn != null ? rootDn : searchRootFromPrincipal(bindPrincipal);
searchRoot = "ou=ExternalUsers," + searchRoot;
try {
return SpringSecurityLdapTemplate.searchForSingleEntryInternal(ctx, searchCtls, searchRoot, searchFilter,
new Object[]{bindPrincipal});
} catch (IncorrectResultSizeDataAccessException incorrectResults) {
if (incorrectResults.getActualSize() == 0) {
UsernameNotFoundException userNameNotFoundException = new UsernameNotFoundException("User " + username + " not found in directory.", username);
userNameNotFoundException.initCause(incorrectResults);
throw badCredentials(userNameNotFoundException);
}
// Search should never return multiple results if properly configured, so just rethrow
throw incorrectResults;
}
}
private String searchRootFromPrincipal(String bindPrincipal) {
int atChar = bindPrincipal.lastIndexOf('@');
if (atChar < 0) {
logger.debug("User principal '" + bindPrincipal + "' does not contain the domain, and no domain has been configured");
throw badCredentials();
}
return rootDnFromDomain(bindPrincipal.substring(atChar+ 1, bindPrincipal.length()));
}
private String rootDnFromDomain(String domain) {
String[] tokens = StringUtils.tokenizeToStringArray(domain, ".");
StringBuilder root = new StringBuilder();
for (String token : tokens) {
if (root.length() > 0) {
root.append(',');
}
root.append("dc=").append(token);
}
return root.toString();
}
String createBindPrincipal(String username) {
if (domain == null || username.toLowerCase().endsWith(domain)) {
logger.info("in createBindPrincipal: in the if, username = " + username);
return username;
}
// return username + "@" + domain;
return username;
}
/**
* By default, a failed authentication (LDAP error 49) will result in a {@code BadCredentialsException}.
* <p>
* If this property is set to {@code true}, the exception message from a failed bind attempt will be parsed
* for the AD-specific error code and a {@link CredentialsExpiredException}, {@link DisabledException},
* {@link AccountExpiredException} or {@link LockedException} will be thrown for the corresponding codes. All
* other codes will result in the default {@code BadCredentialsException}.
*
* @param convertSubErrorCodesToExceptions {@code true} to raise an exception based on the AD error code.
*/
public void setConvertSubErrorCodesToExceptions(boolean convertSubErrorCodesToExceptions) {
this.convertSubErrorCodesToExceptions = convertSubErrorCodesToExceptions;
}
static class ContextFactory {
DirContext createContext(Hashtable<?,?> env) throws NamingException {
return new InitialLdapContext(env, null);
}
}
}
Here's my local host Tomcat server (actually, it's the VMWare vFabric tc Server) context.xml file:
<?xml version="1.0" encoding="UTF-8"?>
<Context reloadable="true" docBase="myApp" path="/myApp"
source="org.eclipse.jst.jee.server:app">
<!-- Default set of monitored resources -->
<WatchedResource>WEB-INF/web.xml</WatchedResource>
<!-- Uncomment this to disable session persistence across Tomcat restarts -->
<!-- <Manager pathname="" /> -->
<!-- Uncomment this to enable Comet connection tacking (provides events
on session expiration as well as webapp lifecycle) -->
<!-- <Valve className="org.apache.catalina.valves.CometConnectionManagerValve"
/> -->
<Resource name="jdbc/MyDB" auth="Container" type="javax.sql.DataSource"
driverClassName="net.sourceforge.jtds.jdbc.Driver"
url="jdbc:jtds:sqlserver://dbserver:1433/MyInstance;instance=dev"
username="dbuserid" password="dbpassword" />
</Context>
Can anyone please help me understand how to inject the Active Directory domain and url settings from the context.xml file into the spring-security-context.xml file?
Edit
The domain and url are the two values that I need to inject from context.xml. However, if I'm going to be injecting values from the context.xml file, I imagine that I'd need to include all of the values required for the ldapActiveDirectoryAuthProvider class. I'm thinking the entry in the context.xml file that I would need to add would be something like this:
<Resource name="ldapAdAuthProviderSettings" auth="Container" type="com.mycompany.pima.ActiveDirectoryLdapAuthenticationProvider"
domain="mydomain.mycompany.com"
url="ldap://adserver.mydomain.mycompany.com:389/"
authoritiesMapper="com.mycompany.pima.security.ActiveDirectoryGrantedAuthoritiesMapper"
useAuthenticationRequestCredentials="true"
convertSubErrorCodesToExceptions="true"
/>
Then in my spring-security-context.xml file, I'd need to adjust my ldapActiveDirectoryAuthProvider entry to something like this:
<beans:bean id="ldapActiveDirectoryAuthProvider"
class="org.springframework.jndi.JndiObjectFactoryBean">
<beans:property name="jndiName" value="java:comp/env/ldapAdAuthProviderSettings"/>
</beans:bean>
When I've tried this configuration, I get the following error:
2014-05-22 13:23:39,219 ERROR: org.springframework.web.context.ContextLoader - Context initialization failed org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'org.springframework.security.filterChains': Cannot resolve reference to bean 'org.springframework.security.web.DefaultSecurityFilterChain#3'
Thanks!
-StephenS