Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

SEC-1823: User roles in AD should handle nested groups #2053

Open
spring-projects-issues opened this issue Sep 21, 2011 · 17 comments
Open

SEC-1823: User roles in AD should handle nested groups #2053

spring-projects-issues opened this issue Sep 21, 2011 · 17 comments
Labels
in: ldap An issue in spring-security-ldap status: ideal-for-contribution An issue that we actively are looking for someone to help us with type: enhancement A general enhancement type: jira An issue that was migrated from JIRA

Comments

@spring-projects-issues
Copy link

Rick Jensen (Migrated from SEC-1823) said:

With Active Directory (AD), groups can be nested within each other. In fact, in larger organizations, it is quite common to use nested groups to manage users.

The default AD authorities populator only looks at a user's memberOf property to populate the list of granted authorities, using the group name as the authority name. The memberOf property is only populated with groups that the user is directly a member of, and does not include groups that the user is a member of through nesting of groups.

For example, you have the following group structure:

MyApplicationAdmins (group)
Members: DomainAdmins
DomainAdmins (group)
Members: User1
User1 (user)

Currently, the ActiveDirectoryLdapAuthenticationProvider will only populate the user's GrantedAuthorities with DomainAdmins, since that is the only group the user is directly a member of. Instead, the user should have a GrantedAuthorities list that contains both DomainAdmins and MyApplicationAdmins, since the user is in both groups through nesting.

With generic LDAP, there is no way to get nested groups other than by walking the LDAP tree, which requires multiple calls to the LDAP server and is very expensive. With AD however, there is a special matching rule object identifier that will walk a chain of ancestry objects. This page (http://msdn.microsoft.com/en-us/library/aa746475%28VS.85%29.aspx) describes it in more detail.

An example of how to use the filter to get all groups a user is in would be this filter:

(&(objectClass=group)(member:1.2.840.113556.1.4.1941:={0}))

where {0} is the DN of the user.

To enable this, the loadUserAuthorities(...) method of the ActiveDirectoryLdapAuthenticationProvider needs to be modified. Since the full nested group information is not available in the DirContextOperations object, another AD server call is required, but it is only a single call and it fully populates the GrantedAuthorities with all of the groups a user is in, both directly and via nesting.

Here is an example of the updated method: (note: this was put together hastily and likely has issues, but it serves as a concrete starting point)

/**
 * Creates the user authority list from the values of the {@code memberOf} attribute obtained from the user's
 * Active Directory entry in a nested fashion.
 * @throws NamingException 
 */
public Collection<? extends GrantedAuthority> loadUserAuthorities(DirContextOperations userData, String username, String password) throws NamingException {
    SearchControls searchControls = new SearchControls();
    searchControls.setSearchScope(SearchControls.SUBTREE_SCOPE);

    String filter = "(&(objectClass=group)(member:1.2.840.113556.1.4.1941:={0}))";

    final String bindPrincipal = createBindPrincipal(username, domain);
    String searchRoot = rootDn != null ? rootDn : searchRootFromPrincipal(bindPrincipal);


    DirContext ctx = bindAsUser(username, password);

    final DistinguishedName ctxBaseDn = new DistinguishedName(ctx.getNameInNamespace());

    final DistinguishedName searchBaseDn = new DistinguishedName(searchRoot);

    final NamingEnumeration<SearchResult> resultsEnum = ctx.search(searchBaseDn, filter, new Object[]{userData.getDn()}, searchControls);

    if (logger.isDebugEnabled()) {
        logger.debug("Searching for entry under DN '" + ctxBaseDn
                + "', base = '" + searchBaseDn + "', filter = '" + filter + "'");
    }

    ArrayList<GrantedAuthority> authorities = new ArrayList<GrantedAuthority>();
    try {
        while (resultsEnum.hasMore()) {
            SearchResult searchResult = resultsEnum.next();
            // Work out the DN of the matched entry
            DistinguishedName dn = new DistinguishedName(new CompositeName(searchResult.getName()));

            if (searchRoot.length() > 0) {
                dn.prepend(searchBaseDn);
            }

            if (logger.isDebugEnabled()) {
                logger.debug("Found DN: " + dn);
            }

            authorities.add(new SimpleGrantedAuthority(dn.removeLast().getValue()));
        }
    } catch (PartialResultException e) {
        org.springframework.security.ldap.LdapUtils.closeEnumeration(resultsEnum);
        logger.info("Ignoring PartialResultException");
    }

    return authorities;
}
@spring-projects-issues
Copy link
Author

Maxime Noirjean said:

Yeah, I have same problem.
Your code works perfectly but your code should be integrated in "ActiveDirectoryLdapAuthenticationProvider" class.

After more 6 month, this should already be done.

@spring-projects-issues
Copy link
Author

Marcel Stör said:

Out customer has the same requirement as far as nested groups are concerned. However, we're not using the AD authentication provider as all requests are pre-authenticated (SSO). We only make calls to AD/LDAP to retrieve user details and authorities using the DefaultLdapAuthoritiesPopulator. Wondering if the approach detailed in this issue can somehow be integrated into DefaultLdapAuthoritiesPopulator...

@spring-projects-issues
Copy link
Author

Rick Jensen said:

@maxime - I haven't had time yet, but I intend to push a patch upstream for this at some point.

@marcel - Yes, covering the scenario where users have already been authenticated and only need permissions loaded is important. I believe this covers that, but I'm open to suggestions if it doesn't.

@spring-projects-issues
Copy link
Author

Marcel Stör said:

@rick - not sure if we have a misunderstanding or not ;-)
My point was that in our scenario we're not using the ActiveDirectoryLdapAuthenticationProvider that you patched above. We're using our own PreAuthenticationProvider because, much to our surprise, we weren't able to configure what we need with standard SS classes. I outlined the solution here: http://forum.springsource.org/showthread.php?115826-Combine-pre-authentication-with-LDAP-for-user-details-and-authorities. Hence, if I understand your proposal correctly we'd need to patch the DefaultLdapAuthoritiesPopulator to make it work for us (didn't have time yet to really dig into this)?

@spring-projects-issues
Copy link
Author

Yann Nicolas said:

This issue has 2 years but I have this exactly same problem. Is it planned to resolve it?
Thanks

@spring-projects-issues
Copy link
Author

Benne Otten said:

I've stumbled upon thesame problem as outlined in this issue, but considering this issue has been open for almost three years now I am not hopefull it has been resolved yet and might not be resolved anytime soon.
Does anyone have a workaround for this? Any help is greatly appreciated.

@spring-projects-issues
Copy link
Author

Jean-Pierre Bergamin said:

I use this class here: https://gist.github.com/ractive/258dd06c99d2939781c0
Put it in the package org.springframework.security.ldap.authentication.ad and you should be ready to go...

@spring-projects-issues
Copy link
Author

Benne Otten said:

@Jean-Pierre, I'm now using the class you mentioned, and it looks promising.
It works, but I haven't had the chance yet to test it thouroughly.

Thanks for the input. ;)

@spring-projects-issues spring-projects-issues added in: ldap An issue in spring-security-ldap Open type: enhancement A general enhancement type: jira An issue that was migrated from JIRA labels Feb 5, 2016
@fpmoles
Copy link

fpmoles commented Aug 10, 2016

what is the status on this issue (and the solutions documented) becoming part of the main line. We too have had to implement a work around for what is normal behavior in AD (nesting groups) and would prefer to use "official" code.

@rwinch rwinch added the status: ideal-for-contribution An issue that we actively are looking for someone to help us with label Aug 10, 2016
@rwinch
Copy link
Member

rwinch commented Aug 10, 2016

@fpmoles Thanks for reaching out.

The proposed changes cannot be made in Spring Security because they are not passive. Ideally, we would use a strategy pattern to obtain the roles with a default implementation using the same behavior of the existing method. An additional implementation could then be provided that supports nested groups.

NOTE Simply setting a flag is not a good approach. This makes the code more complex and less flexible than using a strategy pattern.

One interface to look at is the LdapAuthoritiesPopulator. It could add a default method of Collection<? extends GrantedAuthority> getGrantedAuthorities(DirContextOperations userData, String username, String password) that calls the existing method. Then, we could provide two implementations. One with the existing logic and one with the proposed logic.

If we get a Pull Request that:

  • Is passive
  • Documented
  • Tested
  • Uses a strategy pattern

I would be glad to consider this for Spring Security 4.2.0

@dparker-coder
Copy link

I had the same issue as @RickJensen, Spring was retrieving the groups assigned to the logged in user, but not any nested groups. I was able to add the following code in an override of getAdditionalRoles which solved the problem.

/**
 * This class extends a Spring class in order to override the getAdditionalRoles method. Spring's LDAP query
 * is only returning roles which are assigned to the user; it does not return any nested roles. The override
 * method uses a single AD LDAP query to retrieve all groups assigned to this user plus any nested groups.
 */
public class CustomLdapAuthoritiesPopulator extends DefaultLdapAuthoritiesPopulator {

    private static final Logger logger                      = LogManager.getLogger();
    private LdapTemplate        ldapTemplate;
    public static final String  SECURITY_GROUP_FLAG         = String.valueOf(1 << 31);
    public static final String  LDAP_MATCHING_RULE_BIT_AND  = ":1.2.840.113556.1.4.803:";
    public static final String  LDAP_MATCHING_RULE_IN_CHAIN = ":1.2.840.113556.1.4.1941:";
    public static final String  GROUP_SEARCH_BASE           = "OU=Groups,DC=mycomp,DC=com";
    public static final String  IS_GROUP                    = "(objectCategory=group)";
    /* Checks if logical AND of groupType bitfield and security bit is 1 */
    public static final String  IS_SECURITY_ENABLED         = "(groupType" + LDAP_MATCHING_RULE_BIT_AND
            + "=" + SECURITY_GROUP_FLAG + ")";

    public CustomLdapAuthoritiesPopulator(final ContextSource contextSource, final String groupSearchBase) {
        super(contextSource, groupSearchBase);
    }

    @Override
    protected Set<GrantedAuthority> getAdditionalRoles(final DirContextOperations userData,
                                                       final String username) {
        final Set<GrantedAuthority> authorities = new HashSet<>();
        // Build LDAP query filter for nested groups (roles). For ref, this is the filter string:
        // (&(objectCategory=group)(groupType:1.2.840.113556.1.4.803:=-2147483648)(member:1.2.840.113556.1.4.1941:={0}))
        final String filter = "(&" + IS_GROUP + IS_SECURITY_ENABLED
                + "(member" + LDAP_MATCHING_RULE_IN_CHAIN + "={0}))";

        final SearchControls searchControls = new SearchControls();
        searchControls.setSearchScope(SearchControls.SUBTREE_SCOPE);

        // Search LDAP for nested roles
        final LdapQuery ldapQuery = query().filter(filter, new Object[] { userData.getDn() });
        final List<String> roleList = ldapTemplate.search(GROUP_SEARCH_BASE,
                ldapQuery.filter().toString(),
                searchControls,
                new AttributesMapper<String>() {
                    @Override
                    public String mapFromAttributes(final Attributes attrs) throws NamingException {
                        return attrs.get("cn").get().toString();
                    }
                });

        for (final String role : roleList) {
            authorities.add(new SimpleGrantedAuthority(role));
        }

        logger.debug(" authorities after LDAP search of nested groups: {}", authorities);
        return authorities;
    }

    public void setLdapTemplate(final LdapTemplate ldapTemplate) {
        this.ldapTemplate = ldapTemplate;
    }
}

@fpmoles
Copy link

fpmoles commented Feb 8, 2018

@rwinch this is what I was discussing last night

@gpsmit
Copy link

gpsmit commented Mar 2, 2021

Has Spring Security offered any other solution on this topic aside from a custom override as @dparker-coder proposed?
It has been years and it seems like a very basic LDAP request/feature.
I am currently using the ActiveDirectoryLdapAuthenticationProvider - spring security ldap 5.3.4

@skinnydan72
Copy link

I'm using Spring Boot 2.7.x (Spring Security 5.7.7.) and I've attempted to wire in @dparker-coder 's promising CustomLdapAuthoritiesPopulator - i.e. I've @bean annotated a factory method to return an instance. My ActiveDirectoryLdapAuthenticationProvider still works but it is still not evaluating transitive membership via nested groups in AD. When I put a break point on the CustomLdapAuthoritiesPopulator.getAdditionalRoles - it is never executed so it seems there is more to getting spring to use CustomLdapAuthoritiesPopulator - can anyone help?

@rick
Copy link

rick commented Aug 1, 2023

ON IT MY DUDE

@Mettbrot
Copy link

Mettbrot commented Apr 9, 2024

I'm using Spring Boot 2.7.x (Spring Security 5.7.7.) and I've attempted to wire in @dparker-coder 's promising CustomLdapAuthoritiesPopulator - i.e. I've @bean annotated a factory method to return an instance. My ActiveDirectoryLdapAuthenticationProvider still works but it is still not evaluating transitive membership via nested groups in AD. When I put a break point on the CustomLdapAuthoritiesPopulator.getAdditionalRoles - it is never executed so it seems there is more to getting spring to use CustomLdapAuthoritiesPopulator - can anyone help?

Hi @skinnydan72 how did you get around the issue? We are experiencing this problem too.

@skinnydan72
Copy link

skinnydan72 commented Apr 9, 2024

Hi @Mettbrot

In the end what worked was a modified version of that provided by @rwinch - with further customisation to add only from a list of provided authorities if optionally provided. It's a little slow if your AD is big and deeply nested etc.

import java.io.Serializable;
import java.util.*;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import javax.naming.*;
import javax.naming.directory.DirContext;
import javax.naming.directory.SearchControls;
import javax.naming.directory.SearchResult;
import javax.naming.ldap.InitialLdapContext;

import org.springframework.core.log.LogMessage;
import org.springframework.dao.IncorrectResultSizeDataAccessException;
import org.springframework.ldap.CommunicationException;
import org.springframework.ldap.core.DirContextOperations;
import org.springframework.ldap.core.DistinguishedName;
import org.springframework.ldap.core.support.DefaultDirObjectFactory;
import org.springframework.ldap.support.LdapUtils;
import org.springframework.security.authentication.AccountExpiredException;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.CredentialsExpiredException;
import org.springframework.security.authentication.DisabledException;
import org.springframework.security.authentication.InternalAuthenticationServiceException;
import org.springframework.security.authentication.LockedException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.ldap.SpringSecurityLdapTemplate;
import org.springframework.security.ldap.authentication.AbstractLdapAuthenticationProvider;
import org.springframework.util.Assert;
import org.springframework.util.StringUtils;

/**
 * Specialized LDAP authentication provider which uses Active Directory configuration
 * conventions.
 * <p>
 * Adapted from Spring Source with edits as suggested by https://github.com/spring-projects/spring-security/issues/2053
 * to handle nested groups
 * </p>
 *
 * <p>
 * It will authenticate using the Active Directory
 * <a href="https://msdn.microsoft.com/en-us/library/ms680857%28VS.85%29.aspx">
 * {@code userPrincipalName}</a> or a custom {@link #setSearchFilter(String) searchFilter}
 * in the form {@code username@domain}. If the username does not already end with the
 * domain name, the {@code userPrincipalName} will be built by appending the configured
 * domain name to the username supplied in the authentication request. If no domain name
 * is configured, it is assumed that the username will always contain the domain name.
 * <p>
 * The user authorities are obtained from the data contained in the {@code memberOf}
 * attribute.
 *
 * <h3>Active Directory Sub-Error Codes</h3>
 * <p>
 * When an authentication fails, resulting in a standard LDAP 49 error code, Active
 * Directory also supplies its own sub-error codes within the error message. These will be
 * used to provide additional log information on why an authentication has failed. Typical
 * examples are
 *
 * <ul>
 * <li>525 - user not found</li>
 * <li>52e - invalid credentials</li>
 * <li>530 - not permitted to logon at this time</li>
 * <li>532 - password expired</li>
 * <li>533 - account disabled</li>
 * <li>701 - account expired</li>
 * <li>773 - user must reset password</li>
 * <li>775 - account locked</li>
 * </ul>
 * <p>
 * If you set the {@link #setConvertSubErrorCodesToExceptions(boolean)
 * convertSubErrorCodesToExceptions} property to {@code true}, the codes will also be used
 * to control the exception raised.
 *
 * @author Luke Taylor
 * @author Rob Winch
 * @since 3.1
 */
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 String searchFilter = "(&(objectClass=user)(sAMAccountName={0}))";

    private Map<String, Object> contextEnvironmentProperties = new HashMap<>();

    // Only used to allow tests to substitute a mock LdapContext
    ContextFactory contextFactory = new ContextFactory();

    /**
     * @param domain the domain name (may be null or empty)
     * @param url    an LDAP url (or multiple URLs)
     * @param rootDn the root DN (may be null or empty)
     */
    public ActiveDirectoryLdapAuthenticationProvider(String domain, String url, String rootDn) {
        Assert.isTrue(StringUtils.hasText(url), "Url cannot be empty");
        this.domain = StringUtils.hasText(domain) ? domain.toLowerCase() : null;
        this.url = url;
        this.rootDn = StringUtils.hasText(rootDn) ? rootDn.toLowerCase() : null;
    }

    /**
     * @param domain the domain name (may be null or empty)
     * @param url    an LDAP url (or multiple URLs)
     */
    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 = url;
        this.rootDn = (this.domain != null) ? rootDnFromDomain(this.domain) : null;
    }

    @Override
    protected DirContextOperations doAuthentication(UsernamePasswordAuthenticationToken auth) {
        String username = auth.getName();
        String password = (String) auth.getCredentials();
        DirContext ctx = null;
        try {
            ctx = bindAsUser(username, password);
            return searchForUser(ctx, username);
        } catch (CommunicationException ex) {
            throw badLdapConnection(ex);
        } catch (NamingException ex) {
            this.logger.error("Failed to locate directory entry for authenticated user: " + username, ex);
            throw badCredentials(ex);
        } catch (Exception ex){
            this.logger.error("Unexpected exception authenticating user: " + username, ex);
            throw ex;
        }
        finally {
            LdapUtils.closeContext(ctx);
        }
    }

    Set<String> grantedAuthorities;

    /**
     * Only ad discovered groups to the list of grantedAuthorities if they match one of the provided strings in the set
     * @param grantedAuthorities
     */
    public void limitGrantedAuthoritiesTo(Set<String> grantedAuthorities){
        this.grantedAuthorities = grantedAuthorities;
    }
    /**
     * Creates the user authority list from the values of the {@code memberOf} attribute
     * obtained from the user's Active Directory entry.
     */
    public Collection<? extends GrantedAuthority> loadUserAuthorities(DirContextOperations userData, String username, String password) {
        SearchControls searchControls = new SearchControls();
        searchControls.setSearchScope(SearchControls.SUBTREE_SCOPE);

        String filter = "(&(objectClass=group)(member:1.2.840.113556.1.4.1941:={0}))";

        final String bindPrincipal = createBindPrincipal(username);
        String searchRoot = rootDn != null ? rootDn : searchRootFromPrincipal(bindPrincipal);


        DirContext ctx = bindAsUser(username, password);

        final DistinguishedName ctxBaseDn;
        try {
            ctxBaseDn = new DistinguishedName(ctx.getNameInNamespace());


            final DistinguishedName searchBaseDn = new DistinguishedName(searchRoot);

            final NamingEnumeration<SearchResult> resultsEnum = ctx.search(searchBaseDn, filter, new Object[]{userData.getDn()}, searchControls);

            if (logger.isDebugEnabled()) {
                logger.debug("Searching for entry under DN '" + ctxBaseDn
                        + "', base = '" + searchBaseDn + "', filter = '" + filter + "'");
            }

            ArrayList<GrantedAuthority> authorities = new ArrayList<GrantedAuthority>();
            try {
                while (resultsEnum.hasMore()) {
                    SearchResult searchResult = resultsEnum.next();
                    // Work out the DN of the matched entry
                    DistinguishedName dn = new DistinguishedName(new CompositeName(searchResult.getName()));

                    if (searchRoot.length() > 0) {
                        dn.prepend(searchBaseDn);
                    }

                    if (logger.isTraceEnabled()) {
                        logger.trace("Found DN: " + dn);
                    }

                    String grantedAuth = dn.removeLast().getValue();

                    if(grantedAuthorities == null || grantedAuthorities.contains(grantedAuth))
                        authorities.add(new SimpleGrantedAuthority(grantedAuth));
                }
            } catch (PartialResultException e) {
                org.springframework.security.ldap.LdapUtils.closeEnumeration(resultsEnum);
                logger.info("Ignoring PartialResultException");
            } catch (InvalidNameException e) {
                throw new RuntimeException(e);
            } catch (NamingException e) {
                throw new RuntimeException(e);
            }

            return authorities;
        } catch (NamingException e) {
            throw new RuntimeException(e);
        }
    }



    private DirContext bindAsUser(String username, String password) {
        // TODO. add DNS lookup based on domain
        final String bindUrl = this.url;
        Hashtable<String, Object> env = new Hashtable<>();
        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());
        env.putAll(this.contextEnvironmentProperties);
        try {
            return this.contextFactory.createContext(env);
        } catch (NamingException ex) {
            if ((ex instanceof AuthenticationException) || (ex instanceof OperationNotSupportedException)) {
                handleBindException(bindPrincipal, ex);
                throw badCredentials(ex);
            }
            throw LdapUtils.convertLdapException(ex);
        }
    }

    private void handleBindException(String bindPrincipal, NamingException exception) {
        this.logger.debug(LogMessage.format("Authentication for %s failed:%s", bindPrincipal, exception));
        handleResolveObj(exception);
        int subErrorCode = parseSubErrorCode(exception.getMessage());
        if (subErrorCode <= 0) {
            this.logger.debug("Failed to locate AD-specific sub-error code in message");
            return;
        }
        this.logger.info(
                LogMessage.of(() -> "Active Directory authentication failed: " + subCodeToLogMessage(subErrorCode)));
        if (this.convertSubErrorCodesToExceptions) {
            raiseExceptionForErrorCode(subErrorCode, exception);
        }
    }

    private void handleResolveObj(NamingException exception) {
        Object resolvedObj = exception.getResolvedObj();
        boolean serializable = resolvedObj instanceof Serializable;
        if (resolvedObj != null && !serializable) {
            exception.setResolvedObj(null);
        }
    }

    private int parseSubErrorCode(String message) {
        Matcher matcher = SUB_ERROR_CODE.matcher(message);
        if (matcher.matches()) {
            return Integer.parseInt(matcher.group(1), 16);
        }
        return -1;
    }

    private 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(this.messages.getMessage(
                        "LdapAuthenticationProvider.credentialsExpired", "User credentials have expired"), cause);
            case ACCOUNT_DISABLED:
                throw new DisabledException(
                        this.messages.getMessage("LdapAuthenticationProvider.disabled", "User is disabled"), cause);
            case ACCOUNT_EXPIRED:
                throw new AccountExpiredException(
                        this.messages.getMessage("LdapAuthenticationProvider.expired", "User account has expired"), cause);
            case ACCOUNT_LOCKED:
                throw new LockedException(
                        this.messages.getMessage("LdapAuthenticationProvider.locked", "User account is locked"), cause);
            default:
                throw badCredentials(cause);
        }
    }

    private 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(
                this.messages.getMessage("LdapAuthenticationProvider.badCredentials", "Bad credentials"));
    }

    private BadCredentialsException badCredentials(Throwable cause) {
        return (BadCredentialsException) badCredentials().initCause(cause);
    }

    private InternalAuthenticationServiceException badLdapConnection(Throwable cause) {
        return new InternalAuthenticationServiceException(this.messages.getMessage(
                "LdapAuthenticationProvider.badLdapConnection", "Connection to LDAP server failed."), cause);
    }

    private DirContextOperations searchForUser(DirContext context, String username) throws NamingException {
        SearchControls searchControls = new SearchControls();
        searchControls.setSearchScope(SearchControls.SUBTREE_SCOPE);
        String bindPrincipal = createBindPrincipal(username);
        String searchRoot = (this.rootDn != null) ? this.rootDn : searchRootFromPrincipal(bindPrincipal);

        try {
            return SpringSecurityLdapTemplate.searchForSingleEntryInternal(context, searchControls, searchRoot,
                this.searchFilter, new Object[]{username});
        } catch (CommunicationException ex) {
            throw badLdapConnection(ex);
        } catch (IncorrectResultSizeDataAccessException ex) {
            // Search should never return multiple results if properly configured -
            if (ex.getActualSize() != 0) {
                throw ex;
            }
            // If we found no results, then the username/password did not match
            UsernameNotFoundException userNameNotFoundException = new UsernameNotFoundException(
                    "User " + username + " not found in directory.", ex);
            throw badCredentials(userNameNotFoundException);
        }
    }

    private String searchRootFromPrincipal(String bindPrincipal) {
        int atChar = bindPrincipal.lastIndexOf('@');
        if (atChar < 0) {
            this.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 (this.domain == null || username.toLowerCase().endsWith(this.domain)) {
            return username;
        }
        return username + "@" + this.domain;
    }

    /**
     * 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;
    }

    /**
     * The LDAP filter string to search for the user being authenticated. Occurrences of
     * {0} are replaced with the {@code username@domain}. Occurrences of {1} are replaced
     * with the {@code username} only.
     * <p>
     * Defaults to: {@code (&(objectClass=user)(userPrincipalName={0}))}
     * </p>
     *
     * @param searchFilter the filter string
     * @since 3.2.6
     */
    public void setSearchFilter(String searchFilter) {
        Assert.hasText(searchFilter, "searchFilter must have text");
        this.searchFilter = searchFilter;
    }

    /**
     * Allows a custom environment properties to be used to create initial LDAP context.
     *
     * @param environment the additional environment parameters to use when creating the
     *                    LDAP Context
     */
    public void setContextEnvironmentProperties(Map<String, Object> environment) {
        Assert.notEmpty(environment, "environment must not be empty");
        this.contextEnvironmentProperties = new Hashtable<>(environment);
    }

    static class ContextFactory {

        DirContext createContext(Hashtable<?, ?> env) throws NamingException {
            return new InitialLdapContext(env, null);
        }

    }

}

And config was simply to supply a Bean annotated factory method

@Bean
public AuthenticationProvider activeDirectoryLdapAuthenticationProvider() {
    return new ActiveDirectoryLdapAuthenticationProvider(domain, domainController);
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
in: ldap An issue in spring-security-ldap status: ideal-for-contribution An issue that we actively are looking for someone to help us with type: enhancement A general enhancement type: jira An issue that was migrated from JIRA
Projects
None yet
Development

No branches or pull requests

9 participants