-
Notifications
You must be signed in to change notification settings - Fork 6k
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
Comments
Maxime Noirjean said: Yeah, I have same problem. After more 6 month, this should already be done. |
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... |
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. |
Marcel Stör said: @rick - not sure if we have a misunderstanding or not ;-) |
Yann Nicolas said: This issue has 2 years but I have this exactly same problem. Is it planned to resolve it? |
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. |
Jean-Pierre Bergamin said: I use this class here: https://gist.github.com/ractive/258dd06c99d2939781c0 |
Benne Otten said: @Jean-Pierre, I'm now using the class you mentioned, and it looks promising. Thanks for the input. ;) |
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. |
@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 If we get a Pull Request that:
I would be glad to consider this for Spring Security 4.2.0 |
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;
}
} |
@rwinch this is what I was discussing last night |
Has Spring Security offered any other solution on this topic aside from a custom override as @dparker-coder proposed? |
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. |
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);
} |
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)
The text was updated successfully, but these errors were encountered: