hudson.plugins.active_directory.ActiveDirectoryUnixAuthenticationProvider.java Source code

Java tutorial

Introduction

Here is the source code for hudson.plugins.active_directory.ActiveDirectoryUnixAuthenticationProvider.java

Source

/*
 * The MIT License
 *
 * Copyright (c) 2008-2014, Kohsuke Kawaguchi, CloudBees, Inc., and contributors
 *
 * Permission is hereby granted, free of charge, to any person obtaining a copy
 * of this software and associated documentation files (the "Software"), to deal
 * in the Software without restriction, including without limitation the rights
 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 * copies of the Software, and to permit persons to whom the Software is
 * furnished to do so, subject to the following conditions:
 *
 * The above copyright notice and this permission notice shall be included in
 * all copies or substantial portions of the Software.
 *
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
 * THE SOFTWARE.
 */
package hudson.plugins.active_directory;

import com.google.common.cache.Cache;
import com.google.common.util.concurrent.UncheckedExecutionException;
import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
import hudson.Util;
import hudson.security.GroupDetails;
import hudson.security.SecurityRealm;
import hudson.security.UserMayOrMayNotExistException;
import hudson.util.DaemonThreadFactory;
import hudson.util.NamingThreadFactory;
import hudson.util.Secret;

import javax.naming.NameNotFoundException;

import hudson.util.TimeUnit2;
import org.acegisecurity.AuthenticationException;
import org.acegisecurity.AuthenticationServiceException;
import org.acegisecurity.BadCredentialsException;
import org.acegisecurity.GrantedAuthority;
import org.acegisecurity.GrantedAuthorityImpl;
import org.acegisecurity.providers.AuthenticationProvider;
import org.acegisecurity.providers.UsernamePasswordAuthenticationToken;
import org.acegisecurity.userdetails.UserDetails;
import org.acegisecurity.userdetails.UsernameNotFoundException;
import org.apache.commons.codec.digest.DigestUtils;
import org.apache.commons.lang.StringUtils;

import javax.naming.NamingEnumeration;
import javax.naming.NamingException;
import javax.naming.PartialResultException;
import javax.naming.TimeLimitExceededException;
import javax.naming.directory.Attribute;
import javax.naming.directory.Attributes;
import javax.naming.directory.DirContext;
import javax.naming.directory.SearchResult;
import javax.naming.ldap.LdapName;
import java.util.ArrayList;
import java.util.Date;
import java.util.HashSet;
import java.util.Hashtable;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.Stack;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.logging.Level;
import java.util.logging.Logger;

/**
 * {@link AuthenticationProvider} with Active Directory, through LDAP.
 * 
 * @author Kohsuke Kawaguchi
 * @author James Nord
 */
public class ActiveDirectoryUnixAuthenticationProvider extends AbstractActiveDirectoryAuthenticationProvider {

    private final List<ActiveDirectoryDomain> domains;

    private final String site;

    private final ActiveDirectorySecurityRealm.DescriptorImpl descriptor;

    private GroupLookupStrategy groupLookupStrategy;

    protected static final String DN_FORMATTED = "distinguishedNameFormatted";

    /**
     * To specify the TTL and Size used for caching users and groups
     */
    private CacheConfiguration cache;

    /**
     * The {@link UserDetails} cache.
     */
    private final Cache<String, UserDetails> userCache;

    /**
     * The {@link ActiveDirectoryGroupDetails} cache.
     */
    private final Cache<String, ActiveDirectoryGroupDetails> groupCache;

    /**
     * The threadPool to update the cache on background
     */
    private final ExecutorService threadPoolExecutor;

    /**
     * Properties to be passed to the current LDAP context
     */
    private Hashtable<String, String> props = new Hashtable<String, String>();

    /**
     * Timeout if no connection after 30 seconds
     */
    private final static String DEFAULT_LDAP_CONNECTION_TIMEOUT = "30000";

    /**
     * Timeout if no response after 60 seconds
     */
    private final static String DEFAULT_LDAP_READ_TIMEOUT = "60000";

    /**
     * Represents com.sun.jndi.ldap.connect.timeout
     */
    private final static String LDAP_CONNECT_TIMEOUT = "com.sun.jndi.ldap.connect.timeout";

    /**
     * Represents com.sun.jndi.ldap.read.timeout
     */
    private final static String LDAP_READ_TIMEOUT = "com.sun.jndi.ldap.read.timeout";

    /**
     * The core pool size for the {@link ExecutorService}
     */
    private static final int corePoolSize = Integer
            .parseInt(System.getProperty("hudson.plugins.active_directory.threadPoolExecutor.corePoolSize", "4"));

    /**
     * The max pool size for the {@link ExecutorService}
     */
    private static final int maxPoolSize = Integer
            .parseInt(System.getProperty("hudson.plugins.active_directory.threadPoolExecutor.maxPoolSize", "8"));

    /**
     * The keep alive time for the {@link ExecutorService}
     */
    private static final long keepAliveTime = Long.parseLong(
            System.getProperty("hudson.plugins.active_directory.threadPoolExecutor.keepAliveTime", "10000"));

    /**
     * The queue size for the {@link ExecutorService}
     */
    private static final int queueSize = Integer
            .parseInt(System.getProperty("hudson.plugins.active_directory.threadPoolExecutor.queueSize", "25"));

    public ActiveDirectoryUnixAuthenticationProvider(ActiveDirectorySecurityRealm realm) {
        this.site = realm.site;
        this.domains = realm.domains;
        this.groupLookupStrategy = realm.getGroupLookupStrategy();
        this.descriptor = realm.getDescriptor();
        this.cache = realm.cache;

        if (cache == null) {
            this.cache = new CacheConfiguration(0, 0);
        }

        // On startup userCache and groupCache are not created and cache is different from null
        if (cache.getUserCache() == null || cache.getGroupCache() == null) {
            this.cache = new CacheConfiguration(cache.getSize(), cache.getTtl());
        }

        this.userCache = cache.getUserCache();
        this.groupCache = cache.getGroupCache();

        this.threadPoolExecutor = new ThreadPoolExecutor(corePoolSize, maxPoolSize, keepAliveTime,
                TimeUnit.MILLISECONDS, new ArrayBlockingQueue<Runnable>(queueSize),
                new NamingThreadFactory(new DaemonThreadFactory(), "ActiveDirectory.updateUserCache"),
                new ThreadPoolExecutor.DiscardPolicy());

        Map<String, String> extraEnvVarsMap = ActiveDirectorySecurityRealm.EnvironmentProperty
                .toMap(realm.environmentProperties);
        props.put(LDAP_CONNECT_TIMEOUT, System.getProperty(LDAP_CONNECT_TIMEOUT, DEFAULT_LDAP_CONNECTION_TIMEOUT));
        props.put(LDAP_READ_TIMEOUT, System.getProperty(LDAP_READ_TIMEOUT, DEFAULT_LDAP_READ_TIMEOUT));
        // put all the user defined properties into our context environment replacing any mappings that already exist.
        props.putAll(extraEnvVarsMap);
    }

    protected UserDetails retrieveUser(final String username,
            final UsernamePasswordAuthenticationToken authentication) throws AuthenticationException {
        try {
            // this is more seriously error, indicating a failure to search
            List<BadCredentialsException> errors = new ArrayList<BadCredentialsException>();

            // this is lesser error, in that we searched and the user was not found
            List<UsernameNotFoundException> notFound = new ArrayList<UsernameNotFoundException>();

            for (ActiveDirectoryDomain domain : domains) {
                try {
                    return retrieveUser(username, authentication, domain);
                } catch (UsernameNotFoundException e) {
                    notFound.add(e);
                } catch (BadCredentialsException bce) {
                    LOGGER.log(Level.WARNING,
                            String.format("Credential exception trying to authenticate against %s domain",
                                    domain.getName()),
                            bce);
                    errors.add(bce);
                }
            }

            switch (errors.size()) {
            case 0:
                break; // fall through
            case 1:
                throw errors.get(0); // preserve the original exception
            default:
                throw new MultiCauseBadCredentialsException(
                        "Either no such user '" + username + "' or incorrect password", errors);
            }

            if (notFound.size() == 1) {
                throw notFound.get(0); // preserve the original exception
            }

            if (!Util.filter(notFound, UserMayOrMayNotExistException.class).isEmpty()) {
                // if one domain responds with UserMayOrMayNotExistException, then it might actually exist there,
                // so our response will be "can't tell"
                throw new MultiCauseUserMayOrMayNotExistException(
                        "We can't tell if the user exists or not: " + username, notFound);
            }
            if (!notFound.isEmpty()) {
                throw new MultiCauseUserNotFoundException("No such user: " + username, notFound);
            }

            throw new AssertionError("No domain is configured");
        } catch (AuthenticationException e) {
            LOGGER.log(Level.FINE, String.format("Failed to retrieve user %s", username), e);
            throw e;
        }
    }

    @Override
    protected boolean canRetrieveUserByName(ActiveDirectoryDomain domain) {
        return domain.getBindName() != null;
    }

    /**
     *
     * @param authentication
     *      null if we are just retrieving the said user, instead of trying to authenticate.
     */
    private UserDetails retrieveUser(String username, UsernamePasswordAuthenticationToken authentication,
            ActiveDirectoryDomain domain) throws AuthenticationException {
        // when we use custom socket factory below, every LDAP operations result
        // in a classloading via context classloader, so we need it to resolve.
        ClassLoader ccl = Thread.currentThread().getContextClassLoader();
        Thread.currentThread().setContextClassLoader(getClass().getClassLoader());
        try {
            String password = NO_AUTHENTICATION;
            if (authentication != null)
                password = (String) authentication.getCredentials();

            return retrieveUser(username, password, domain, obtainLDAPServers(domain));
        } finally {
            Thread.currentThread().setContextClassLoader(ccl);
        }
    }

    /**
     * Obtains the list of the LDAP servers in the order we should talk to, given how this
     * {@link ActiveDirectoryUnixAuthenticationProvider} is configured.
     */
    private List<SocketInfo> obtainLDAPServers(ActiveDirectoryDomain domain) throws AuthenticationServiceException {
        try {
            return descriptor.obtainLDAPServer(domain);
        } catch (NamingException e) {
            LOGGER.log(Level.WARNING, "Failed to find the LDAP service", e);
            throw new AuthenticationServiceException(
                    "Failed to find the LDAP service for the domain " + domain.getName(), e);
        }
    }

    /**
     * Authenticates and retrieves the user by using the given list of available AD LDAP servers.
     * 
     * @param password
     *      If this is {@link #NO_AUTHENTICATION}, the authentication is not performed, and just the retrieval
     *      would happen.
     * @throws UsernameNotFoundException
     *      The user didn't exist.
     * @return never null
     */
    @SuppressFBWarnings(value = "ES_COMPARING_PARAMETER_STRING_WITH_EQ", justification = "Intentional instance check.")
    public UserDetails retrieveUser(final String username, final String password,
            final ActiveDirectoryDomain domain, final List<SocketInfo> ldapServers) {
        UserDetails userDetails;
        String hashKey = username + "@@" + DigestUtils.sha1Hex(password);
        final String bindName = domain.getBindName();
        final String bindPassword = Secret.toString(domain.getBindPassword());
        try {
            final ActiveDirectoryUserDetail[] cacheMiss = new ActiveDirectoryUserDetail[1];
            userDetails = userCache.get(hashKey, new Callable<UserDetails>() {
                public UserDetails call() throws AuthenticationException {
                    DirContext context;
                    boolean anonymousBind = false; // did we bind anonymously?

                    // LDAP treats empty password as anonymous bind, so we need to reject it
                    if (StringUtils.isEmpty(password)) {
                        throw new BadCredentialsException("Empty password");
                    }

                    String userPrincipalName = getPrincipalName(username, domain.getName());
                    String samAccountName = userPrincipalName.substring(0, userPrincipalName.indexOf('@'));

                    if (bindName != null) {
                        // two step approach. Use a special credential to obtain DN for the
                        // user trying to login, then authenticate.
                        try {
                            context = descriptor.bind(bindName, bindPassword, ldapServers, props);
                            anonymousBind = false;
                        } catch (BadCredentialsException e) {
                            throw new AuthenticationServiceException(
                                    "Failed to bind to LDAP server with the bind name/password", e);
                        }
                    } else {
                        if (password.equals(NO_AUTHENTICATION)) {
                            anonymousBind = true;
                        }

                        try {
                            // if we are just retrieving the user, try using anonymous bind by empty password (see RFC 2829 5.1)
                            // but if that fails, that's not BadCredentialException but UserMayOrMayNotExistException
                            context = descriptor.bind(userPrincipalName, anonymousBind ? "" : password, ldapServers,
                                    props);
                        } catch (BadCredentialsException e) {
                            if (anonymousBind)
                                // in my observation, if we attempt an anonymous bind and AD doesn't allow it, it still passes the bind method
                                // and only fail later when we actually do a query. So perhaps this is a dead path, but I'm leaving it here
                                // anyway as a precaution.
                                throw new UserMayOrMayNotExistException(
                                        "Unable to retrieve the user information without bind DN/password configured");
                            throw e;
                        }
                    }

                    try {
                        // locate this user's record
                        final String domainDN = toDC(domain.getName());

                        Attributes user = new LDAPSearchBuilder(context, domainDN).subTreeScope()
                                .searchOne("(& (userPrincipalName={0})(objectCategory=user))", userPrincipalName);
                        if (user == null) {
                            // failed to find it. Fall back to sAMAccountName.
                            // see http://www.nabble.com/Re%3A-Hudson-AD-plug-in-td21428668.html
                            LOGGER.log(Level.FINE, "Failed to find {0} in userPrincipalName. Trying sAMAccountName",
                                    userPrincipalName);
                            user = new LDAPSearchBuilder(context, domainDN).subTreeScope()
                                    .searchOne("(& (sAMAccountName={0})(objectCategory=user))", samAccountName);
                            if (user == null) {
                                throw new UsernameNotFoundException(
                                        "Authentication was successful but cannot locate the user information for "
                                                + username);
                            }
                        }
                        LOGGER.fine("Found user " + username + " : " + user);

                        Object dnObject = user.get(DN_FORMATTED).get();
                        if (dnObject == null) {
                            throw new AuthenticationServiceException("No distinguished name for " + username);
                        }

                        String dn = dnObject.toString();
                        LdapName ldapName = new LdapName(dn);
                        String dnFormatted = ldapName.toString();

                        if (bindName != null && !password.equals(NO_AUTHENTICATION)) {
                            // if we've used the credential specifically for the bind, we
                            // need to verify the provided password to do authentication
                            LOGGER.log(Level.FINE, "Attempting to validate password for DN={0}", dn);
                            DirContext test = descriptor.bind(dnFormatted, password, ldapServers, props);
                            // Binding alone is not enough to test the credential. Need to actually perform some query operation.
                            // but if the authentication fails this throws an exception
                            try {
                                new LDAPSearchBuilder(test, domainDN).searchOne(
                                        "(& (userPrincipalName={0})(objectCategory=user))", userPrincipalName);
                            } finally {
                                closeQuietly(test);
                            }
                        }

                        Set<GrantedAuthority> groups = resolveGroups(domainDN, dnFormatted, context);
                        groups.add(SecurityRealm.AUTHENTICATED_AUTHORITY);

                        cacheMiss[0] = new ActiveDirectoryUserDetail(username, password, true, true, true, true,
                                groups.toArray(new GrantedAuthority[groups.size()]),
                                getStringAttribute(user, "displayName"), getStringAttribute(user, "mail"),
                                getStringAttribute(user, "telephoneNumber"));
                        return cacheMiss[0];
                    } catch (NamingException e) {
                        if (anonymousBind && e.getMessage().contains("successful bind must be completed")
                                && e.getMessage().contains("000004DC")) {
                            // sometimes (or always?) anonymous bind itself will succeed but the actual query will fail.
                            // see JENKINS-12619. On my AD the error code is DSID-0C0906DC
                            throw new UserMayOrMayNotExistException(
                                    "Unable to retrieve the user information without bind DN/password configured");
                        }

                        LOGGER.log(Level.WARNING,
                                String.format("Failed to retrieve user information for %s", username), e);
                        throw new BadCredentialsException("Failed to retrieve user information for " + username, e);
                    } finally {
                        closeQuietly(context);
                    }
                }
            });
            if (cacheMiss[0] != null) {
                threadPoolExecutor.execute(new Runnable() {
                    @Override
                    public void run() {
                        final String threadName = Thread.currentThread().getName();
                        Thread.currentThread()
                                .setName(threadName + " updating-cache-for-user-" + cacheMiss[0].getUsername());
                        LOGGER.log(Level.FINEST, "Starting the cache update {0}", new Date());
                        try {
                            long t0 = System.currentTimeMillis();
                            cacheMiss[0].updateUserInfo();
                            LOGGER.log(Level.FINEST, "Finished the cache update {0}", new Date());
                            long t1 = System.currentTimeMillis();
                            LOGGER.log(Level.FINE, "The cache for user {0} took {1} msec",
                                    new Object[] { cacheMiss[0].getUsername(), String.valueOf(t1 - t0) });
                        } finally {
                            Thread.currentThread().setName(threadName);
                        }
                    }
                });

            }
        } catch (UncheckedExecutionException e) {
            Throwable t = e.getCause();
            if (t instanceof AuthenticationException) {
                AuthenticationException authenticationException = (AuthenticationException) t;
                throw authenticationException;
            } else {
                throw new CacheAuthenticationException(
                        "Authentication failed because there was a problem caching user " + username, e);
            }
        } catch (ExecutionException e) {
            LOGGER.log(Level.SEVERE, "There was a problem caching user " + username, e);
            throw new CacheAuthenticationException(
                    "Authentication failed because there was a problem caching user " + username, e);
        }
        // We need to check the password when the user is cached so it doesn't get automatically authenticated
        // without verifying the credentials
        if (password != null && !password.equals(NO_AUTHENTICATION) && userDetails != null
                && !password.equals(userDetails.getPassword())) {
            throw new BadCredentialsException("Failed to retrieve user information from the cache for " + username);
        }
        return userDetails;
    }

    public GroupDetails loadGroupByGroupname(final String groupname) {
        try {
            return groupCache.get(groupname, new Callable<ActiveDirectoryGroupDetails>() {
                public ActiveDirectoryGroupDetails call() {
                    for (ActiveDirectoryDomain domain : domains) {
                        if (domain == null) {
                            throw new UserMayOrMayNotExistException(
                                    "Unable to retrieve group information without bind DN/password configured");
                        }
                        // when we use custom socket factory below, every LDAP operations result
                        // in a classloading via context classloader, so we need it to resolve.
                        ClassLoader ccl = Thread.currentThread().getContextClassLoader();
                        Thread.currentThread().setContextClassLoader(getClass().getClassLoader());
                        try {
                            DirContext context = descriptor.bind(domain.getBindName(),
                                    domain.getBindPassword().getPlainText(), obtainLDAPServers(domain));

                            try {
                                final String domainDN = toDC(domain.getName());

                                Attributes group = new LDAPSearchBuilder(context, domainDN).subTreeScope()
                                        .searchOne("(& (cn={0})(objectCategory=group))", groupname);
                                if (group == null) {
                                    // failed to find it. Fall back to sAMAccountName.
                                    // see http://www.nabble.com/Re%3A-Hudson-AD-plug-in-td21428668.html
                                    LOGGER.log(Level.FINE, "Failed to find {0} in cn. Trying sAMAccountName",
                                            groupname);
                                    group = new LDAPSearchBuilder(context, domainDN).subTreeScope()
                                            .searchOne("(& (sAMAccountName={0})(objectCategory=group))", groupname);
                                    if (group == null) {
                                        // Group not found in this domain, try next
                                        continue;
                                    }
                                }
                                LOGGER.log(Level.FINE, "Found group {0} : {1}", new Object[] { groupname, group });
                                return new ActiveDirectoryGroupDetails(groupname);
                            } catch (NamingException e) {
                                LOGGER.log(Level.WARNING,
                                        String.format("Failed to retrieve user information for %s", groupname), e);
                                throw new BadCredentialsException(
                                        "Failed to retrieve user information for " + groupname, e);
                            } finally {
                                closeQuietly(context);
                            }
                        } catch (UsernameNotFoundException e) {
                            // everything worked OK but we just didn't find it. This could be just a typo in group name.
                            LOGGER.log(Level.WARNING, String.format("Failed to find the group %s in %s domain",
                                    groupname, domain.getName()), e);
                        } catch (AuthenticationException e) {
                            // something went wrong talking to the server. This should be reported
                            LOGGER.log(Level.WARNING, String.format("Failed to find the group %s in %s domain",
                                    groupname, domain.getName()), e);
                        } finally {
                            Thread.currentThread().setContextClassLoader(ccl);
                        }
                    }
                    LOGGER.log(Level.WARNING,
                            "Exhausted all configured domains and could not authenticate against any");
                    throw new UserMayOrMayNotExistException(groupname);
                }
            });
        } catch (UncheckedExecutionException e) {
            Throwable t = e.getCause();
            if (t instanceof AuthenticationException) {
                AuthenticationException authenticationException = (AuthenticationException) t;
                throw authenticationException;
            } else {
                throw new CacheAuthenticationException(
                        "Authentication failed because there was a problem caching group " + groupname, e);
            }
        } catch (ExecutionException e) {
            LOGGER.log(Level.SEVERE, String.format("There was a problem caching group %s", groupname), e);
            throw new CacheAuthenticationException(
                    "Authentication failed because there was a problem caching group " + groupname, e);
        }
    }

    private void closeQuietly(DirContext context) {
        try {
            if (context != null)
                context.close();
        } catch (NamingException e) {
            LOGGER.log(Level.INFO, "Failed to close DirContext: " + context, e);
        }
    }

    private String getStringAttribute(Attributes user, String name) throws NamingException {
        Attribute a = user.get(name);
        if (a == null)
            return null;
        Object v = a.get();
        if (v == null)
            return null;
        return v.toString();
    }

    /**
     * Returns the full user principal name of the form "joe@europe.contoso.com".
     * 
     * If people type in 'foo@bar' or 'bar\foo' or just 'foo', it should be treated as
     * 'foo@bar' (where 'bar' represents the given domain name)
     */
    private String getPrincipalName(String username, String domainName) {
        String principalName;
        int slash = username.indexOf('\\');
        if (slash > 0) {
            principalName = username.substring(slash + 1) + '@' + domainName;
        } else if (username.contains("@"))
            principalName = username;
        else
            principalName = username + '@' + domainName;
        return principalName;
    }

    /**
     * Resolves all the groups that the user is in.
     *
     * We now use <a href="http://msdn.microsoft.com/en-us/library/windows/desktop/ms680275(v=vs.85).aspx">tokenGroups</a>
     * attribute, which is a computed attribute that lists all the SIDs of the groups that the user is directly/indirectly in.
     * We then use that to retrieve all the groups in one query and resolve their canonical names.
     *
     * @param userDN
     *      User's distinguished name.
     * @param context Used for making queries.
     */
    private Set<GrantedAuthority> resolveGroups(String domainDN, String userDN, DirContext context)
            throws NamingException {
        if (userDN.contains("/")) {
            userDN = userDN.replace("/", "\\/");
        }
        Set<GrantedAuthority> groups = new HashSet<GrantedAuthority>();

        LOGGER.log(Level.FINER, "Looking up group of {0}", userDN);
        Attributes id = context.getAttributes(userDN, new String[] { "tokenGroups", "memberOf", "CN" });
        Attribute tga = id.get("tokenGroups");

        if (tga == null) {
            // tga will be null if you are not using a global catalogue
            // or if the user is not actually a member of any security groups.
            LOGGER.log(Level.FINE, "Failed to retrieve tokenGroups for {0}", userDN);
            // keep on trucking as we can still use memberOf for Distribution Groups.
        } else {
            // build up the query to retrieve all the groups
            StringBuilder query = new StringBuilder("(|");
            List<byte[]> sids = new ArrayList<byte[]>();

            NamingEnumeration<?> tokenGroups = tga.getAll();
            while (tokenGroups.hasMore()) {
                byte[] gsid = (byte[]) tokenGroups.next();
                query.append("(objectSid={" + sids.size() + "})");
                sids.add(gsid);
            }
            tokenGroups.close();

            query.append(")");

            NamingEnumeration<SearchResult> renum = new LDAPSearchBuilder(context, domainDN).subTreeScope()
                    .returns("cn").search(query.toString(), sids.toArray());
            parseMembers(userDN, groups, renum);
            renum.close();
        }

        {/*
         stage 2: use memberOf to find groups that aren't picked up by tokenGroups.
         This includes distribution groups
            */
            LOGGER.fine("Stage 2: looking up via memberOf");

            while (true) {
                switch (groupLookupStrategy) {
                case TOKENGROUPS:
                    // no extra lookup - ever.
                    return groups;
                case AUTO:
                    // try the accurate one first, and if it's too slow fall back to recursive in the hope that it's faster
                    long start = System.nanoTime();
                    boolean found = false;
                    long duration = 0;
                    try {
                        found = chainGroupLookup(domainDN, userDN, context, groups);
                        duration = TimeUnit2.NANOSECONDS.toSeconds(System.nanoTime() - start);
                    } catch (TimeLimitExceededException e) {
                        LOGGER.log(Level.WARNING,
                                "The LDAP request did not terminate within the specified time limit. AD will fall back to recursive lookup",
                                e);
                    } catch (NamingException e) {
                        if (e.getMessage().contains("LDAP response read timed out")) {
                            LOGGER.log(Level.WARNING,
                                    "LDAP response read time out. AD will fall back to recursive lookup", e);
                        } else {
                            throw e;
                        }
                    }
                    if (!found && duration >= 10) {
                        LOGGER.log(Level.WARNING,
                                "Group lookup via Active Directory's 'LDAP_MATCHING_RULE_IN_CHAIN' extension timed out after {0} seconds. Falling back to recursive group lookup strategy for this and future queries",
                                duration);
                        groupLookupStrategy = GroupLookupStrategy.RECURSIVE;
                        continue;
                    } else if (found && duration >= 10) {
                        LOGGER.log(Level.WARNING,
                                "Group lookup via Active Directory's 'LDAP_MATCHING_RULE_IN_CHAIN' extension matched user's groups but took {0} seconds to run. Switching to recursive lookup for future group lookup queries",
                                duration);
                        groupLookupStrategy = GroupLookupStrategy.RECURSIVE;
                        return groups;
                    } else if (!found) {
                        LOGGER.log(Level.WARNING,
                                "Group lookup via Active Directory's 'LDAP_MATCHING_RULE_IN_CHAIN' extension failed. Falling back to recursive group lookup strategy for this and future queries");
                        groupLookupStrategy = GroupLookupStrategy.RECURSIVE;
                        continue;
                    } else {
                        // it run fast enough, so let's stick to it
                        groupLookupStrategy = GroupLookupStrategy.CHAIN;
                        return groups;
                    }
                case RECURSIVE:
                    recursiveGroupLookup(context, id, groups);
                    return groups;
                case CHAIN:
                    chainGroupLookup(domainDN, userDN, context, groups);
                    return groups;
                }
            }
        }
    }

    /**
     * Performs AD-extension to LDAP query that performs recursive group lookup.
     * This Microsoft extension is explained in http://msdn.microsoft.com/en-us/library/aa746475(v=vs.85).aspx
     *
     * @return
     *      false if it appears that this search failed.
     * @see
     */
    private boolean chainGroupLookup(String domainDN, String userDN, DirContext context,
            Set<GrantedAuthority> groups) throws NamingException {
        NamingEnumeration<SearchResult> renum = new LDAPSearchBuilder(context, domainDN).subTreeScope()
                .returns("cn").search("(member:1.2.840.113556.1.4.1941:={0})", userDN);
        try {
            if (renum.hasMore()) {
                // http://ldapwiki.willeke.com/wiki/Active%20Directory%20Group%20Related%20Searches cites that
                // this filter search extension requires at least Win2K3 SP2. So if this didn't find anything,
                // fall back to the recursive search

                // TODO: this search alone might be producing the super set of the tokenGroups/objectSid based search in the stage 1.
                parseMembers(userDN, groups, renum);
                return true;
            } else {
                return false;
            }
        } finally {
            renum.close();
        }
    }

    /**
     * Performs recursive group membership lookup.
     *
     * This was how we did the lookup traditionally until we discovered 1.2.840.113556.1.4.1941.
     * But various people reported that it slows down the execution tremendously to the point that it is unusable,
     * while others seem to report that it runs faster than recursive search (http://social.technet.microsoft.com/Forums/fr-FR/f238d2b0-a1d7-48e8-8a60-542e7ccfa2e8/recursive-retrieval-of-all-ad-group-memberships-of-a-user?forum=ITCG)
     *
     * This implementation is kept for Windows 2003 that doesn't support 1.2.840.113556.1.4.1941, but it can be also
     * enabled for those who are seeing the performance problem.
     *
     * See JENKINS-22830
     */
    private void recursiveGroupLookup(DirContext context, Attributes id, Set<GrantedAuthority> groups)
            throws NamingException {
        Stack<Attributes> q = new Stack<Attributes>();
        q.push(id);
        while (!q.isEmpty()) {
            Attributes identity = q.pop();
            LOGGER.finer("Looking up group of " + identity);

            Attribute memberOf = identity.get("memberOf");
            if (memberOf == null)
                continue;

            for (int i = 0; i < memberOf.size(); i++) {
                try {
                    LOGGER.log(Level.FINE, "Trying to get the CN of {0}", memberOf.get(i));
                    Attributes group = context.getAttributes(new LdapName(memberOf.get(i).toString()),
                            new String[] { "CN", "memberOf" });
                    Attribute cn = group.get("CN");
                    if (cn == null) {
                        LOGGER.fine("Failed to obtain CN of " + memberOf.get(i));
                        continue;
                    }
                    if (LOGGER.isLoggable(Level.FINE))
                        LOGGER.fine(cn.get() + " is a member of " + memberOf.get(i));

                    if (groups.add(new GrantedAuthorityImpl(cn.get().toString()))) {
                        q.add(group); // recursively look for groups that this group is a member of.
                    }
                } catch (NameNotFoundException e) {
                    LOGGER.fine("Failed to obtain CN of " + memberOf.get(i));
                }
            }
        }
    }

    private void parseMembers(String userDN, Set<GrantedAuthority> groups, NamingEnumeration<SearchResult> renum)
            throws NamingException {
        try {
            while (renum.hasMore()) {
                Attributes a = renum.next().getAttributes();
                Attribute cn = a.get("cn");
                if (LOGGER.isLoggable(Level.FINE))
                    LOGGER.fine(userDN + " is a member of " + cn);
                groups.add(new GrantedAuthorityImpl(cn.get().toString()));
            }
        } catch (PartialResultException e) {
            // See JENKINS-42687. Just log the exception. Sometimes all the groups are correctly
            // retrieved but this Exception is launched as a last element of the NamingEnumeration
            // Even if it is really a PartialResultException, I don't see why this should be a blocker
            // I think a better approach is to log the Exception and continue
            LOGGER.log(Level.WARNING, String.format("JENKINS-42687 Might be more members for user  %s", userDN), e);
        }
    }

    /*package*/ static String toDC(String domainName) {
        StringBuilder buf = new StringBuilder();
        for (String token : domainName.split("\\.")) {
            if (token.length() == 0)
                continue; // defensive check
            if (buf.length() > 0)
                buf.append(",");
            buf.append("DC=").append(token);
        }
        return buf.toString();
    }

    private static final Logger LOGGER = Logger
            .getLogger(ActiveDirectoryUnixAuthenticationProvider.class.getName());

    /**
     * We use this as the password value if we are calling retrieveUser to retrieve the user information
     * without authentication.
     */
    private static final String NO_AUTHENTICATION = "\u0000\u0000\u0000\u0000\u0000\u0000";
}