0

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

Stephen Spalding
  • 231
  • 4
  • 11

2 Answers2

2

I have solved my problem, although the solution took me down a different route than what I was originally trying to implement. My end goal was to not hardcode the Active Directory server domain and URL in my spring-security-context.xml file but to read in and inject the values for those from an outside source on my Tomcat servers. I wanted to be able to create a war file for my Spring application and move it from one Tomcat server to another and have it connect to the appropriate Active Directory environment without any manual intervention.

I was originally trying to accomplish this goal by adding my Active Directory settings as a 'Resource' in Tomcat's context.xml file and then reading that using JNDI from my code. I took this approach because it's what I've successfully done previously for database connections and other settings that are unique to each individual Tomcat server. I tried several different combinations of settings in the spring-security-context.xml, servlet-context.xml, and context.xml files but was never able to get it to work.

I read about creating properties files containing my variables within the directory structure of my Spring project and then using property place holders in the code. The idea would be that once the war file is built, deployed, and exploded on the Tomcat server, I could then replace the contents of the properties file. The location of the properties file inside my project just needed to be included in the project's classpath. Some of the folders that could be used were WEB-INF/classes or WEB-INF/lib. Although this idea did work for me, I found it kind of a headache having to remember to log onto each individual Tomcat server after deploying/exploding the war file and replace the contents of the properties file with the proper settings.

What I finally did was create a new folder with in Tomcat's directory structure, include it in Tomcat's classpath, and then put my properties file there. I was able to successfully use property place holders in my spring-security-context.xml file have it update from my properties file. It took a little bit of work to accomplish this, as I had to modify the catalina.properties file and figure out where things needed to go.

In my Spring Tools Suite IDE, the location of the catalina.properties file is:

C:\eclipse\springsource\vfabric-tc-server-developer-2.9.3.RELEASE\base-instance\conf\catalina.properties

In this file, I changed the following line:

shared.loader=

to this:

shared.loader=\
${catalina.home}/shared/lib

I saved my changes to the catalina.properties file, then I created the '\shared\lib' folders (trying to stay true to convention) in the following folder:

C:\eclipse\springsource\vfabric-tc-server-developer-2.9.3.RELEASE\tomcat-7.0.42.A.RELEASE

I then put my properties file, called ExternaActiveDirectory.properties, in this folder. So the full path to my properties file is:

C:\eclipse\springsource\vfabric-tc-server-developer-2.9.3.RELEASE\tomcat-7.0.42.A.RELEASE\shared\lib\ExternalActiveDirectory.properties

The contents of my ExternalActiveDirectory.properties file are:

ldap.domain=mydomain.mycompany.com
ldap.url=ldap://adserver.mydomain.mycompany.com:389/

I changed the ldapActiveDirectoryAuthProvider bean of my spring-security-context.xml file to look like this:

<beans:bean id="ldapActiveDirectoryAuthProvider" class="com.graybar.pima.security.ActiveDirectoryLdapAuthenticationProvider">
    <beans:constructor-arg value="${ldap.domain}" />
    <beans:constructor-arg value="${ldap.url}" />
    <beans:property name="authoritiesMapper" ref="grantedAuthoritiesMapper" />
    <beans:property name="useAuthenticationRequestCredentials" value="true" />
    <beans:property name="convertSubErrorCodesToExceptions" value="true" />
</beans:bean>

I also included this extra bit of config in my spring-security-context.xml file:

<beans:bean id="activeDirectoryProperties"
    class="org.springframework.beans.factory.config.PropertyPlaceholderConfigurer">
    <beans:property name="location" value="classpath:ExternalActiveDirectory.properties" />
</beans:bean>

I stopped and restarted my localhost Tomcat server, and it worked! I switched out the contents of my ExternalActiveDirectory.properties file to a different AD server, stopped/restarted tomcat, and tried again, just to make sure that it really was working, and it continued to work.

When I implemented my changes on the Linux server, I made the same change to the shared.loader line in the catalina.properties file, but it was on a different line number than on my localhost copy of Tomcat. Also, since the $CATALINA_HOME location on the linux server is /opt/tomcat, my properties file has the following path:

/opt/tomcat/shared/lib/ExternalActiveDirectory.properties

Some links that I followed that helped me along with this are:

http://www.mulesoft.com/tcat/tomcat-classpath Where to place and how to read configuration resource files in servlet based application? Tomcat 6 vs 7 - lib vs shared/lib - jars only?

I hope this helps someone else out!

-StephenS

Community
  • 1
  • 1
Stephen Spalding
  • 231
  • 4
  • 11
0

Its just a normal injectable resource so inject like you would anything else, one way :

Resource(name="ldapActiveDirectoryAuthProvider")
ActiveDirectoryLdapAuthenticationProvider myProvider;

Or you could autowire. Either way you can access its properties and fields as normal.

NimChimpsky
  • 46,453
  • 60
  • 198
  • 311
  • Hello! I kind of gathered that it would be something like that, but I was hoping for some more specific information about how I should configure this. I'm still kind of new to Spring. – Stephen Spalding May 22 '14 at 18:44
  • I can't really make it more specific, you have everything you need, whats the problem? – NimChimpsky May 22 '14 at 19:30
  • thats a very different question and just means you need to define your security context correctly, specifically there is no defaultsecruitychain bean defined. Have a look at example security-context.xml and make sure you have everything ... – NimChimpsky May 23 '14 at 08:14
  • Spring security is working just fine for me when I've got things hardcoded in my spring-security-context.xml file. Again, I'm trying to get the Active Directory domain and url configured in my tomcat server's context.xml file and then inject those values into my spring-security-context.xml so that I don't have to have them hardcoded. I believe the error that I'm seeing has to do with the value not being injected properly. – Stephen Spalding May 23 '14 at 12:19