From 93b863d2e560473ba78bb4a4e07f7df15d388e8e Mon Sep 17 00:00:00 2001 From: Filip Hanik Date: Thu, 19 Jun 2014 11:39:56 -0700 Subject: [PATCH] SEC-2690: Support static nested groups in LDAP This refers to groups that have member: as an attribute - Add in a utility method in the SpringSecurityLdapTemplate to retrieve multiple attributes and their values from an LDAP record - Make the DefaultLdapAuthoritiesPopulator more extensible - Add an LdapAuthority object that holds the DN in addition to other group attributes - Add a NestedLdapAuthoritiesPopulator to search statically nested groups --- .../SpringSecurityLdapTemplateITests.java | 64 +++++ .../FilterBasedLdapUserSearchTests.java | 2 +- .../NestedLdapAuthoritiesPopulatorTests.java | 134 +++++++++ .../resources/test-server.ldif | 106 +++++++ .../ldap/SpringSecurityLdapTemplate.java | 106 ++++++- .../ldap/server/ApacheDSContainer.java | 3 +- .../DefaultLdapAuthoritiesPopulator.java | 91 +++++- .../ldap/userdetails/LdapAuthority.java | 142 ++++++++++ .../NestedLdapAuthoritiesPopulator.java | 258 ++++++++++++++++++ .../ldap/userdetails/LdapAuthorityTests.java | 52 ++++ 10 files changed, 945 insertions(+), 13 deletions(-) create mode 100644 ldap/src/integration-test/java/org/springframework/security/ldap/userdetails/NestedLdapAuthoritiesPopulatorTests.java create mode 100644 ldap/src/main/java/org/springframework/security/ldap/userdetails/LdapAuthority.java create mode 100644 ldap/src/main/java/org/springframework/security/ldap/userdetails/NestedLdapAuthoritiesPopulator.java create mode 100644 ldap/src/test/java/org/springframework/security/ldap/userdetails/LdapAuthorityTests.java diff --git a/ldap/src/integration-test/java/org/springframework/security/ldap/SpringSecurityLdapTemplateITests.java b/ldap/src/integration-test/java/org/springframework/security/ldap/SpringSecurityLdapTemplateITests.java index 3cc2d51614..b4c10e5bce 100644 --- a/ldap/src/integration-test/java/org/springframework/security/ldap/SpringSecurityLdapTemplateITests.java +++ b/ldap/src/integration-test/java/org/springframework/security/ldap/SpringSecurityLdapTemplateITests.java @@ -17,6 +17,7 @@ package org.springframework.security.ldap; import static org.junit.Assert.*; +import java.util.Map; import java.util.Set; import javax.naming.Context; @@ -99,6 +100,69 @@ public class SpringSecurityLdapTemplateITests extends AbstractLdapIntegrationTes assertTrue(values.contains("submanager")); } + @Test + public void testMultiAttributeRetrievalWithNullAttributeNames() { + Set> values = + template.searchForMultipleAttributeValues( + "ou=people", + "(uid={0})", + new String[] {"bob"}, + null); + assertEquals(1, values.size()); + Map record = (Map)values.toArray()[0]; + assertAttributeValue(record,"uid","bob"); + assertAttributeValue(record,"objectclass","top","person","organizationalPerson","inetOrgPerson"); + assertAttributeValue(record,"cn","Bob Hamilton"); + assertAttributeValue(record,"sn","Hamilton"); + assertFalse(record.containsKey("userPassword")); + } + + @Test + public void testMultiAttributeRetrievalWithZeroLengthAttributeNames() { + Set> values = + template.searchForMultipleAttributeValues( + "ou=people", + "(uid={0})", + new String[] {"bob"}, + new String[0]); + assertEquals(1, values.size()); + Map record = (Map)values.toArray()[0]; + assertAttributeValue(record,"uid","bob"); + assertAttributeValue(record,"objectclass","top","person","organizationalPerson","inetOrgPerson"); + assertAttributeValue(record,"cn","Bob Hamilton"); + assertAttributeValue(record,"sn","Hamilton"); + assertFalse(record.containsKey("userPassword")); + } + + @Test + public void testMultiAttributeRetrievalWithSpecifiedAttributeNames() { + Set> values = + template.searchForMultipleAttributeValues( + "ou=people", + "(uid={0})", + new String[] {"bob"}, + new String[] { + "uid", + "cn", + "sn" + }); + assertEquals(1, values.size()); + Map record = (Map)values.toArray()[0]; + assertAttributeValue(record,"uid","bob"); + assertAttributeValue(record,"cn","Bob Hamilton"); + assertAttributeValue(record,"sn","Hamilton"); + assertFalse(record.containsKey("userPassword")); + assertFalse(record.containsKey("objectclass")); + } + + protected void assertAttributeValue(Map record, String attributeName, String... values) { + assertTrue(record.containsKey(attributeName)); + assertEquals(values.length,record.get(attributeName).length); + for (int i=0; i authorities = populator.getGrantedAuthorities(ctx,"scaladude"); + assertEquals(5, authorities.size()); + assertEquals(Arrays.asList(javaDevelopers, scalaDevelopers, circularJavaDevelopers, jDevelopers, groovyDevelopers), authorities); + } + + @Test + public void testJavaDudeJDevelopersAuthorities() { + DirContextAdapter ctx = new DirContextAdapter("uid=javadude,ou=people,dc=springframework,dc=org"); + Collection authorities = populator.getGrantedAuthorities(ctx,"javadude"); + assertEquals(3, authorities.size()); + assertEquals(Arrays.asList(javaDevelopers, circularJavaDevelopers, jDevelopers), authorities); + } + + @Test + public void testScalaDudeJDevelopersAuthoritiesWithSearchLimit() { + populator.setMaxSearchDepth(1); + DirContextAdapter ctx = new DirContextAdapter("uid=scaladude,ou=people,dc=springframework,dc=org"); + Collection authorities = populator.getGrantedAuthorities(ctx,"scaladude"); + assertEquals(1, authorities.size()); + assertEquals(Arrays.asList(scalaDevelopers), authorities); + } + + @Test + public void testGroovyDudeJDevelopersAuthorities() { + DirContextAdapter ctx = new DirContextAdapter("uid=groovydude,ou=people,dc=springframework,dc=org"); + Collection authorities = populator.getGrantedAuthorities(ctx,"groovydude"); + assertEquals(4, authorities.size()); + assertEquals(Arrays.asList(javaDevelopers,circularJavaDevelopers,jDevelopers,groovyDevelopers), authorities); + } + + @Test + public void testClosureDudeJDevelopersWithMembershipAsAttributeValues() { + populator.setAttributeNames(new HashSet(Arrays.asList("member"))); + + DirContextAdapter ctx = new DirContextAdapter("uid=closuredude,ou=people,dc=springframework,dc=org"); + Collection authorities = populator.getGrantedAuthorities(ctx,"closuredude"); + assertEquals(5, authorities.size()); + assertEquals(Arrays.asList(closureDevelopers,javaDevelopers,circularJavaDevelopers,jDevelopers,groovyDevelopers), authorities); + + LdapAuthority[] ldapAuthorities = authorities.toArray(new LdapAuthority[0]); + assertEquals(5, ldapAuthorities.length); + //closure group + assertTrue(ldapAuthorities[0].getAttributes().containsKey("member")); + assertNotNull(ldapAuthorities[0].getAttributes().get("member")); + assertEquals(1, ldapAuthorities[0].getAttributes().get("member").length); + assertEquals("uid=closuredude,ou=people,dc=springframework,dc=org",ldapAuthorities[0].getFirstAttributeValue("member")); + + //java group + assertTrue(ldapAuthorities[1].getAttributes().containsKey("member")); + assertNotNull(ldapAuthorities[1].getAttributes().get("member")); + assertEquals(3,ldapAuthorities[1].getAttributes().get("member").length); + assertEquals(groovyDevelopers.getDn(),ldapAuthorities[1].getFirstAttributeValue("member")); + assertEquals( + new String[] { + groovyDevelopers.getDn(), + scalaDevelopers.getDn(), + "uid=javadude,ou=people,dc=springframework,dc=org" + }, + ldapAuthorities[1].getAttributes().get("member") + ); + + //test non existent attribute + assertNull(ldapAuthorities[2].getFirstAttributeValue("test")); + assertNotNull(ldapAuthorities[2].getAttributeValues("test")); + assertEquals(0, ldapAuthorities[2].getAttributeValues("test").length); + //test role name + assertEquals(jDevelopers.getAuthority(), ldapAuthorities[3].getAuthority()); + } +} diff --git a/ldap/src/integration-test/resources/test-server.ldif b/ldap/src/integration-test/resources/test-server.ldif index f27d3f1685..d923b80842 100644 --- a/ldap/src/integration-test/resources/test-server.ldif +++ b/ldap/src/integration-test/resources/test-server.ldif @@ -122,3 +122,109 @@ objectclass: groupOfNames cn: submanagers ou: submanager member: uid=ben,ou=people,dc=springframework,dc=org + +#Nested groups data +################### + +dn: ou=jdeveloper,dc=springframework,dc=org +objectclass: top +objectclass: organizationalUnit +ou: jdeveloper + + +# javadude is part of (in a nested search) +# circular-java-developers, java-developers, j-developers +dn: uid=javadude,ou=people,dc=springframework,dc=org +objectclass: top +objectclass: person +objectclass: organizationalPerson +objectclass: inetOrgPerson +cn: Java Dude +sn: Dude +uid: javadude +userPassword: javadudespassword + +# groovydude is part of (in a nested search) +# groovy-developers, java-developers, circular-java-developers, j-developers +dn: uid=groovydude,ou=people,dc=springframework,dc=org +objectclass: top +objectclass: person +objectclass: organizationalPerson +objectclass: inetOrgPerson +cn: Groovy Dude +sn: Dude +uid: groovydude +userPassword: groovydudespassword + +# closuredude is part of (in a nested search) +# closure-developers, groovy-developers, java-developers, circular-java-developers, j-developers +dn: uid=closuredude,ou=people,dc=springframework,dc=org +objectclass: top +objectclass: person +objectclass: organizationalPerson +objectclass: inetOrgPerson +cn: Closure Dude +sn: Dude +uid: closuredude +userPassword: closuredudespassword + +# scaladude is part of (in a nested search) +# scala-developers, groovy-developers, java-developers, circular-java-developers, j-developers +dn: uid=scaladude,ou=people,dc=springframework,dc=org +objectclass: top +objectclass: person +objectclass: organizationalPerson +objectclass: inetOrgPerson +cn: Scala Dude +sn: Dude +uid: scaladude +userPassword: scaladudespassword + +dn: cn=j-developers,ou=jdeveloper,dc=springframework,dc=org +objectclass: top +objectclass: groupOfNames +cn: j-developers +ou: jdeveloper +member: cn=java-developers,ou=jdeveloper,dc=springframework,dc=org + +dn: cn=java-developers,ou=jdeveloper,dc=springframework,dc=org +objectclass: top +objectclass: groupOfNames +cn: java-developers +ou: jdeveloper +member: cn=groovy-developers,ou=jdeveloper,dc=springframework,dc=org +member: cn=scala-developers,ou=jdeveloper,dc=springframework,dc=org +member: uid=javadude,ou=people,dc=springframework,dc=org + +dn: cn=circular-java-developers,ou=jdeveloper,dc=springframework,dc=org +objectclass: top +objectclass: groupOfNames +cn: circular-java-developers +ou: jdeveloper +member: cn=groovy-developers,ou=jdeveloper,dc=springframework,dc=org +member: cn=scala-developers,ou=jdeveloper,dc=springframework,dc=org +member: uid=javadude,ou=people,dc=springframework,dc=org + + +dn: cn=groovy-developers,ou=jdeveloper,dc=springframework,dc=org +objectclass: top +objectclass: groupOfNames +cn: groovy-developers +ou: jdeveloper +member: cn=closure-developers,ou=jdeveloper,dc=springframework,dc=org +member: uid=groovydude,ou=people,dc=springframework,dc=org +member: cn=circular-java-developers,ou=jdeveloper,dc=springframework,dc=org + +dn: cn=closure-developers,ou=jdeveloper,dc=springframework,dc=org +objectclass: top +objectclass: groupOfNames +cn: closure-developers +ou: jdeveloper +member: uid=closuredude,ou=people,dc=springframework,dc=org + +dn: cn=scala-developers,ou=jdeveloper,dc=springframework,dc=org +objectclass: top +objectclass: groupOfNames +cn: scala-developers +ou: jdeveloper +member: uid=scaladude,ou=people,dc=springframework,dc=org diff --git a/ldap/src/main/java/org/springframework/security/ldap/SpringSecurityLdapTemplate.java b/ldap/src/main/java/org/springframework/security/ldap/SpringSecurityLdapTemplate.java index e5fd840c1d..06635f0cde 100644 --- a/ldap/src/main/java/org/springframework/security/ldap/SpringSecurityLdapTemplate.java +++ b/ldap/src/main/java/org/springframework/security/ldap/SpringSecurityLdapTemplate.java @@ -30,13 +30,18 @@ import org.springframework.util.Assert; import javax.naming.NamingEnumeration; import javax.naming.NamingException; import javax.naming.PartialResultException; +import javax.naming.directory.Attribute; import javax.naming.directory.Attributes; import javax.naming.directory.DirContext; import javax.naming.directory.SearchControls; import javax.naming.directory.SearchResult; import java.text.MessageFormat; +import java.util.ArrayList; import java.util.Arrays; +import java.util.HashMap; import java.util.HashSet; +import java.util.List; +import java.util.Map; import java.util.Set; @@ -45,6 +50,7 @@ import java.util.Set; * * @author Ben Alex * @author Luke Taylor + * @author Filip Hanik * @since 2.0 */ public class SpringSecurityLdapTemplate extends LdapTemplate { @@ -52,6 +58,13 @@ public class SpringSecurityLdapTemplate extends LdapTemplate { private static final Log logger = LogFactory.getLog(SpringSecurityLdapTemplate.class); public static final String[] NO_ATTRS = new String[0]; + + /** + * Every search results where a record is defined by a Map<String,String[]> + * contains at least this key - the DN of the record itself. + */ + public static final String DN_KEY = "spring.security.ldap.dn"; + private static final boolean RETURN_OBJECT = true; //~ Instance fields ================================================================================================ @@ -139,6 +152,34 @@ public class SpringSecurityLdapTemplate extends LdapTemplate { */ public Set searchForSingleAttributeValues(final String base, final String filter, final Object[] params, final String attributeName) { + String[] attributeNames = new String[] {attributeName}; + Set> multipleAttributeValues = searchForMultipleAttributeValues(base,filter,params,attributeNames); + Set result = new HashSet(); + for (Map map : multipleAttributeValues) { + String[] values = map.get(attributeName); + if (values!=null && values.length>0) { + result.addAll(Arrays.asList(values)); + } + } + return result; + } + + /** + * Performs a search using the supplied filter and returns the values of each named attribute + * found in all entries matched by the search. Note that one directory entry may have several values for the + * attribute. Intended for role searches and similar scenarios. + * + * @param base the DN to search in + * @param filter search filter to use + * @param params the parameters to substitute in the search filter + * @param attributeNames the attributes' values that are to be retrieved. + * + * @return the set of String values for each attribute found in all the matching entries. + * The attribute name is the key for each set of values. In addition each map contains the DN as a String + * with the key predefined key {@link #DN_KEY}. + */ + public Set> searchForMultipleAttributeValues(final String base, final String filter, final Object[] params, + final String[] attributeNames) { // Escape the params acording to RFC2254 Object[] encodedParams = new String[params.length]; @@ -149,30 +190,83 @@ public class SpringSecurityLdapTemplate extends LdapTemplate { String formattedFilter = MessageFormat.format(filter, encodedParams); logger.debug("Using filter: " + formattedFilter); - final HashSet set = new HashSet(); + final HashSet> set = new HashSet>(); ContextMapper roleMapper = new ContextMapper() { public Object mapFromContext(Object ctx) { DirContextAdapter adapter = (DirContextAdapter) ctx; - String[] values = adapter.getStringAttributes(attributeName); - if (values == null || values.length == 0) { - logger.debug("No attribute value found for '" + attributeName + "'"); + Map record = new HashMap(); + if (attributeNames==null||attributeNames.length==0) { + try { + for (NamingEnumeration ae = adapter.getAttributes().getAll(); ae.hasMore(); ) { + Attribute attr = (Attribute) ae.next(); + extractStringAttributeValues(adapter, record, attr.getID()); + } + }catch (NamingException x) { + org.springframework.ldap.support.LdapUtils.convertLdapException(x); + } } else { - set.addAll(Arrays.asList(values)); + for (String attributeName : attributeNames) { + extractStringAttributeValues(adapter, record, attributeName); + } } + record.put(DN_KEY, new String[] {getAdapterDN(adapter)}); + set.add(record); return null; } }; SearchControls ctls = new SearchControls(); ctls.setSearchScope(searchControls.getSearchScope()); - ctls.setReturningAttributes(new String[] {attributeName}); + ctls.setReturningAttributes(attributeNames!=null&&attributeNames.length>0?attributeNames:null); search(base, formattedFilter, ctls, roleMapper); return set; } + /** + * Returns the DN for the context representing this LDAP record. + * By default this is using {@link javax.naming.Context#getNameInNamespace()} + * instead of {@link org.springframework.ldap.core.DirContextAdapter#getDn()} since the + * latter returns a partial DN if a base has been specified. + * @param adapter - the Context to extract the DN from + * @return - the String representing the full DN + */ + protected String getAdapterDN(DirContextAdapter adapter) { + //returns the full DN rather than the sub DN if a base is specified + return adapter.getNameInNamespace(); + } + + /** + * Extracts String values for a specified attribute name and places them in the map representing the ldap record + * If a value is not of type String, it will derive it's value from the {@link Object#toString()} + * @param adapter - the adapter that contains the values + * @param record - the map holding the attribute names and values + * @param attributeName - the name for which to fetch the values from + */ + protected void extractStringAttributeValues(DirContextAdapter adapter, Map record, String attributeName) { + Object[] values = adapter.getObjectAttributes(attributeName); + if (values == null || values.length == 0) { + logger.debug("No attribute value found for '" + attributeName + "'"); + return; + } + List svalues = new ArrayList(); + for (Object o : values) { + if (o!=null) { + if (String.class.isAssignableFrom(o.getClass())) { + svalues.add((String)o); + } else { + if (logger.isDebugEnabled()) { + logger.debug("Attribute:" + attributeName + " contains a non string value of type[" + o.getClass() + "]"); + } + svalues.add(o.toString()); + } + } + } + record.put(attributeName, svalues.toArray(new String[svalues.size()])); + } + /** * Performs a search, with the requirement that the search shall return a single directory entry, and uses * the supplied mapper to create the object from that entry. diff --git a/ldap/src/main/java/org/springframework/security/ldap/server/ApacheDSContainer.java b/ldap/src/main/java/org/springframework/security/ldap/server/ApacheDSContainer.java index 569f622d12..5e51290faa 100644 --- a/ldap/src/main/java/org/springframework/security/ldap/server/ApacheDSContainer.java +++ b/ldap/src/main/java/org/springframework/security/ldap/server/ApacheDSContainer.java @@ -127,7 +127,8 @@ public class ApacheDSContainer implements InitializingBean, DisposableBean, Life server = new LdapServer(); server.setDirectoryService(service); - server.setTransports(new TcpTransport(port)); + //AbstractLdapIntegrationTests assume IPv4, so we specify the same here + server.setTransports(new TcpTransport("127.0.0.1", port)); start(); } diff --git a/ldap/src/main/java/org/springframework/security/ldap/userdetails/DefaultLdapAuthoritiesPopulator.java b/ldap/src/main/java/org/springframework/security/ldap/userdetails/DefaultLdapAuthoritiesPopulator.java index faad03d503..318cdb9867 100644 --- a/ldap/src/main/java/org/springframework/security/ldap/userdetails/DefaultLdapAuthoritiesPopulator.java +++ b/ldap/src/main/java/org/springframework/security/ldap/userdetails/DefaultLdapAuthoritiesPopulator.java @@ -92,6 +92,7 @@ import java.util.Set; * a search of the entire subtree under groupSearchBase. * * @author Luke Taylor + * @author Filip Hanik */ public class DefaultLdapAuthoritiesPopulator implements LdapAuthoritiesPopulator { //~ Static fields/initializers ===================================================================================== @@ -105,6 +106,9 @@ public class DefaultLdapAuthoritiesPopulator implements LdapAuthoritiesPopulator */ private GrantedAuthority defaultRole; + /** + * Template that will be used for searching + */ private final SpringSecurityLdapTemplate ldapTemplate; /** @@ -127,7 +131,13 @@ public class DefaultLdapAuthoritiesPopulator implements LdapAuthoritiesPopulator * The pattern to be used for the user search. {0} is the user's DN */ private String groupSearchFilter = "(member={0})"; + /** + * The role prefix that will be prepended to each role name + */ private String rolePrefix = "ROLE_"; + /** + * Should we convert the role name to uppercase + */ private boolean convertToUpperCase = true; //~ Constructors =================================================================================================== @@ -143,7 +153,7 @@ public class DefaultLdapAuthoritiesPopulator implements LdapAuthoritiesPopulator public DefaultLdapAuthoritiesPopulator(ContextSource contextSource, String groupSearchBase) { Assert.notNull(contextSource, "contextSource must not be null"); ldapTemplate = new SpringSecurityLdapTemplate(contextSource); - ldapTemplate.setSearchControls(searchControls); + getLdapTemplate().setSearchControls(getSearchControls()); this.groupSearchBase = groupSearchBase; if (groupSearchBase == null) { @@ -212,8 +222,8 @@ public class DefaultLdapAuthoritiesPopulator implements LdapAuthoritiesPopulator + groupSearchFilter + " in search base '" + getGroupSearchBase() + "'"); } - Set userRoles = ldapTemplate.searchForSingleAttributeValues(getGroupSearchBase(), groupSearchFilter, - new String[]{userDn, username}, groupRoleAttribute); + Set userRoles = getLdapTemplate().searchForSingleAttributeValues(getGroupSearchBase(), groupSearchFilter, + new String[]{userDn, username}, groupRoleAttribute); if (logger.isDebugEnabled()) { logger.debug("Roles from search: " + userRoles); @@ -232,7 +242,7 @@ public class DefaultLdapAuthoritiesPopulator implements LdapAuthoritiesPopulator } protected ContextSource getContextSource() { - return ldapTemplate.getContextSource(); + return getLdapTemplate().getContextSource(); } protected String getGroupSearchBase() { @@ -297,6 +307,77 @@ public class DefaultLdapAuthoritiesPopulator implements LdapAuthoritiesPopulator * @see LdapTemplate#setIgnoreNameNotFoundException(boolean) */ public void setIgnorePartialResultException(boolean ignore) { - ldapTemplate.setIgnorePartialResultException(ignore); + getLdapTemplate().setIgnorePartialResultException(ignore); + } + + /** + * Returns the current LDAP template. + * Method available so that classes extending this can override the template used + * @return the LDAP template + * @see {@link org.springframework.security.ldap.SpringSecurityLdapTemplate} + */ + protected SpringSecurityLdapTemplate getLdapTemplate() { + return ldapTemplate; + } + + /** + * Returns the default role + * Method available so that classes extending this can override + * @return the default role used + * @see {@link #setDefaultRole(String)} + */ + protected GrantedAuthority getDefaultRole() { + return defaultRole; + } + + /** + * Returns the search controls + * Method available so that classes extending this can override the search controls used + * @return the search controls + */ + protected SearchControls getSearchControls() { + return searchControls; } + + /** + * Returns the attribute name of the LDAP attribute that will be mapped to the role name + * Method available so that classes extending this can override + * @return the attribute name used for role mapping + * @see {@link #setGroupRoleAttribute(String)} + */ + protected String getGroupRoleAttribute() { + return groupRoleAttribute; + } + + /** + * Returns the search filter configured for this populator + * Method available so that classes extending this can override + * @return the search filter + * @see {@link #setGroupSearchFilter(String)} + */ + protected String getGroupSearchFilter() { + return groupSearchFilter; + } + + /** + * Returns the role prefix used by this populator + * Method available so that classes extending this can override + * @return the role prefix + * @see {@link #setRolePrefix(String)} + */ + protected String getRolePrefix() { + return rolePrefix; + } + + /** + * Returns true if role names are converted to uppercase + * Method available so that classes extending this can override + * @return true if role names are converted to uppercase. + * @see {@link #setConvertToUpperCase(boolean)} + */ + protected boolean isConvertToUpperCase() { + return convertToUpperCase; + } + + } diff --git a/ldap/src/main/java/org/springframework/security/ldap/userdetails/LdapAuthority.java b/ldap/src/main/java/org/springframework/security/ldap/userdetails/LdapAuthority.java new file mode 100644 index 0000000000..16293bc6fb --- /dev/null +++ b/ldap/src/main/java/org/springframework/security/ldap/userdetails/LdapAuthority.java @@ -0,0 +1,142 @@ +/* + * Copyright 2002-2014 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.security.ldap.userdetails; + +import org.springframework.security.core.GrantedAuthority; + +import java.util.Map; + +/** + * An authority that contains at least a DN and a role name for an LDAP entry + * but can also contain other desired attributes to be fetched during an LDAP + * authority search. + * @author Filip Hanik + */ +public class LdapAuthority implements GrantedAuthority { + + + private String dn; + private String role; + private Map attributes; + + /** + * Constructs an LdapAuthority that has a role and a DN but no other attributes + * @param role + * @param dn + */ + public LdapAuthority(String role, String dn) { + this(role,dn,null); + } + + /** + * Constructs an LdapAuthority with the given role, DN and other LDAP attributes + * @param role + * @param dn + * @param attributes + */ + public LdapAuthority(String role, String dn, Map attributes) { + if (role==null) throw new NullPointerException("role can not be null"); + this.role = role; + this.dn = dn; + this.attributes = attributes; + } + + /** + * Returns the LDAP attributes + * @return the LDAP attributes, map can be null + */ + public Map getAttributes() { + return attributes; + } + + /** + * Returns the DN for this LDAP authority + * @return + */ + public String getDn() { + return dn; + } + + /** + * Returns the values for a specific attribute + * @param name the attribute name + * @return a String array, never null but may be zero length + */ + public String[] getAttributeValues(String name) { + String[] result = null; + if (attributes!=null) { + result = attributes.get(name); + } + if (result==null) { + result = new String[0]; + } + return result; + } + + /** + * Returns the first attribute value for a specified attribute + * @param name + * @return the first attribute value for a specified attribute, may be null + */ + public String getFirstAttributeValue(String name) { + String[] result = getAttributeValues(name); + if (result.length>0) { + return result[0]; + } else { + return null; + } + } + + /** + * {@inheritDoc} + */ + @Override + public String getAuthority() { + return role; + } + + /** + * Compares the LdapAuthority based on {@link #getAuthority()} and {@link #getDn()} values + * {@inheritDoc} + */ + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof LdapAuthority)) return false; + + LdapAuthority that = (LdapAuthority) o; + + if (!dn.equals(that.dn)) return false; + if (role != null ? !role.equals(that.role) : that.role != null) return false; + + return true; + } + + @Override + public int hashCode() { + int result = dn.hashCode(); + result = 31 * result + (role != null ? role.hashCode() : 0); + return result; + } + + @Override + public String toString() { + return "LdapAuthority{" + + "dn='" + dn + '\'' + + ", role='" + role + '\'' + + '}'; + } +} diff --git a/ldap/src/main/java/org/springframework/security/ldap/userdetails/NestedLdapAuthoritiesPopulator.java b/ldap/src/main/java/org/springframework/security/ldap/userdetails/NestedLdapAuthoritiesPopulator.java new file mode 100644 index 0000000000..51b229b96f --- /dev/null +++ b/ldap/src/main/java/org/springframework/security/ldap/userdetails/NestedLdapAuthoritiesPopulator.java @@ -0,0 +1,258 @@ +/* + * Copyright 2002-2014 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.security.ldap.userdetails; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.springframework.ldap.core.ContextSource; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.ldap.SpringSecurityLdapTemplate; +import org.springframework.util.StringUtils; + +import java.util.Arrays; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +/** + * A LDAP authority populator that can recursively search static nested groups. + *

An example of nested groups can be + *

+ *  #Nested groups data
+ *
+ *  dn: uid=javadude,ou=people,dc=springframework,dc=org
+ *  objectclass: top
+ *  objectclass: person
+ *  objectclass: organizationalPerson
+ *  objectclass: inetOrgPerson
+ *  cn: Java Dude
+ *  sn: Dude
+ *  uid: javadude
+ *  userPassword: javadudespassword
+ *
+ *  dn: uid=groovydude,ou=people,dc=springframework,dc=org
+ *  objectclass: top
+ *  objectclass: person
+ *  objectclass: organizationalPerson
+ *  objectclass: inetOrgPerson
+ *  cn: Groovy Dude
+ *  sn: Dude
+ *  uid: groovydude
+ *  userPassword: groovydudespassword
+ *
+ *  dn: uid=closuredude,ou=people,dc=springframework,dc=org
+ *  objectclass: top
+ *  objectclass: person
+ *  objectclass: organizationalPerson
+ *  objectclass: inetOrgPerson
+ *  cn: Closure Dude
+ *  sn: Dude
+ *  uid: closuredude
+ *  userPassword: closuredudespassword
+ *
+ *  dn: uid=scaladude,ou=people,dc=springframework,dc=org
+ *  objectclass: top
+ *  objectclass: person
+ *  objectclass: organizationalPerson
+ *  objectclass: inetOrgPerson
+ *  cn: Scala Dude
+ *  sn: Dude
+ *  uid: scaladude
+ *  userPassword: scaladudespassword
+ *
+ *  dn: cn=j-developers,ou=jdeveloper,dc=springframework,dc=org
+ *  objectclass: top
+ *  objectclass: groupOfNames
+ *  cn: j-developers
+ *  ou: jdeveloper
+ *  member: cn=java-developers,ou=groups,dc=springframework,dc=org
+ *
+ *  dn: cn=java-developers,ou=jdeveloper,dc=springframework,dc=org
+ *  objectclass: top
+ *  objectclass: groupOfNames
+ *  cn: java-developers
+ *  ou: jdeveloper
+ *  member: cn=groovy-developers,ou=groups,dc=springframework,dc=org
+ *  member: cn=scala-developers,ou=groups,dc=springframework,dc=org
+ *  member: uid=javadude,ou=people,dc=springframework,dc=org
+ *
+ *  dn: cn=groovy-developers,ou=jdeveloper,dc=springframework,dc=org
+ *  objectclass: top
+ *  objectclass: groupOfNames
+ *  cn: java-developers
+ *  ou: jdeveloper
+ *  member: cn=closure-developers,ou=groups,dc=springframework,dc=org
+ *  member: uid=groovydude,ou=people,dc=springframework,dc=org
+ *
+ *  dn: cn=closure-developers,ou=jdeveloper,dc=springframework,dc=org
+ *  objectclass: top
+ *  objectclass: groupOfNames
+ *  cn: java-developers
+ *  ou: jdeveloper
+ *  member: uid=closuredude,ou=people,dc=springframework,dc=org
+ *
+ *  dn: cn=scala-developers,ou=jdeveloper,dc=springframework,dc=org
+ *  objectclass: top
+ *  objectclass: groupOfNames
+ *  cn: java-developers
+ *  ou: jdeveloper
+ *  member: uid=scaladude,ou=people,dc=springframework,dc=org * 
+ * + *

+ * + * @author Filip Hanik + */ + +public class NestedLdapAuthoritiesPopulator extends DefaultLdapAuthoritiesPopulator { + private static final Log logger = LogFactory.getLog(NestedLdapAuthoritiesPopulator.class); + + /** + * The attribute names to retrieve for each LDAP group + */ + private Set attributeNames; + + /** + * Maximum search depth - represents the number of recursive searches performed + */ + private int maxSearchDepth = 10; + /** + * Constructor for group search scenarios. userRoleAttributes may still be + * set as a property. + * + * @param contextSource supplies the contexts used to search for user roles. + * @param groupSearchBase if this is an empty string the search will be performed from the root DN of the + */ + public NestedLdapAuthoritiesPopulator(ContextSource contextSource, String groupSearchBase) { + super(contextSource, groupSearchBase); + } + + /** + * {@inheritDoc} + */ + @Override + public Set getGroupMembershipRoles(String userDn, String username) { + if (getGroupSearchBase() == null) { + return new HashSet(); + } + + Set authorities = new HashSet(); + + performNestedSearch(userDn, username, authorities, getMaxSearchDepth()); + + return authorities; + } + + /** + * Performs the nested group search + * @param userDn - the userDN to search for, will become the group DN for subsequent searches + * @param username - the username of the user + * @param authorities - the authorities set that will be populated, must not be null + * @param depth - the depth remaining, when 0 recursion will end + */ + protected void performNestedSearch(String userDn, String username, Set authorities, int depth) { + if (depth==0) { + //back out of recursion + if (logger.isDebugEnabled()) { + logger.debug("Search aborted, max depth reached," + + " for roles for user '" + username + "', DN = " + "'" + userDn + "', with filter " + + getGroupSearchFilter() + " in search base '" + getGroupSearchBase() + "'"); + } + return; + } + + if (logger.isDebugEnabled()) { + logger.debug("Searching for roles for user '" + username + "', DN = " + "'" + userDn + "', with filter " + + getGroupSearchFilter() + " in search base '" + getGroupSearchBase() + "'"); + } + + if (getAttributeNames()==null) { + setAttributeNames(new HashSet()); + } + if (StringUtils.hasText(getGroupRoleAttribute()) && !getAttributeNames().contains(getGroupRoleAttribute())) { + getAttributeNames().add(getGroupRoleAttribute()); + } + + Set> userRoles = getLdapTemplate().searchForMultipleAttributeValues( + getGroupSearchBase(), + getGroupSearchFilter(), + new String[]{userDn, username}, + getAttributeNames().toArray(new String[getAttributeNames().size()])); + + if (logger.isDebugEnabled()) { + logger.debug("Roles from search: " + userRoles); + } + + for (Map record : userRoles) { + boolean circular = false; + String dn = record.get(SpringSecurityLdapTemplate.DN_KEY)[0]; + String[] roleValues = record.get(getGroupRoleAttribute()); + Set roles = new HashSet(); + roles.addAll(Arrays.asList(roleValues!=null?roleValues:new String[0])); + for (String role : roles) { + if (isConvertToUpperCase()) { + role = role.toUpperCase(); + } + role = getRolePrefix() + role; + //if the group already exist, we will not search for it's parents again. + //this prevents a forever loop for a misconfigured ldap directory + circular = circular | (!authorities.add(new LdapAuthority(role,dn,record))); + } + String roleName = roles.size()>0 ? roles.iterator().next() : dn; + if (!circular) { + performNestedSearch(dn, roleName, authorities, (depth - 1)); + } + + } + } + + /** + * Returns the attribute names that this populator has been configured to retrieve + * Value can be null, represents fetch all attributes + * @return the attribute names or null for all + */ + public Set getAttributeNames() { + return attributeNames; + } + + /** + * Sets the attribute names to retrieve for each ldap groups. Null means retrieve all + * @param attributeNames - the names of the LDAP attributes to retrieve + */ + public void setAttributeNames(Set attributeNames) { + this.attributeNames = attributeNames; + } + + /** + * How far should a nested search go. Depth is calculated in the number of levels we search up for + * parent groups. + * @return the max search depth, default is 10 + */ + public int getMaxSearchDepth() { + return maxSearchDepth; + } + + /** + * How far should a nested search go. Depth is calculated in the number of levels we search up for + * parent groups. + * @param maxSearchDepth the max search depth + */ + public void setMaxSearchDepth(int maxSearchDepth) { + this.maxSearchDepth = maxSearchDepth; + } + + + +} diff --git a/ldap/src/test/java/org/springframework/security/ldap/userdetails/LdapAuthorityTests.java b/ldap/src/test/java/org/springframework/security/ldap/userdetails/LdapAuthorityTests.java new file mode 100644 index 0000000000..4af17cfe4b --- /dev/null +++ b/ldap/src/test/java/org/springframework/security/ldap/userdetails/LdapAuthorityTests.java @@ -0,0 +1,52 @@ +package org.springframework.security.ldap.userdetails; + +import org.junit.Before; +import org.junit.Test; +import org.springframework.security.ldap.SpringSecurityLdapTemplate; + +import java.util.HashMap; +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; + +/** + * @author Filip Hanik + */ +public class LdapAuthorityTests { + + public static final String DN = "cn=filip,ou=Users,dc=test,dc=com"; + LdapAuthority authority; + + @Before + public void setUp() { + Map attributes = new HashMap(); + attributes.put(SpringSecurityLdapTemplate.DN_KEY,new String[] {DN}); + attributes.put("mail",new String[] {"filip@ldap.test.org", "filip@ldap.test2.org"}); + authority = new LdapAuthority("testRole", DN, attributes); + } + + @Test + public void testGetDn() throws Exception { + assertEquals(DN, authority.getDn()); + assertNotNull(authority.getAttributeValues(SpringSecurityLdapTemplate.DN_KEY)); + assertEquals(1, authority.getAttributeValues(SpringSecurityLdapTemplate.DN_KEY).length); + assertEquals(DN, authority.getFirstAttributeValue(SpringSecurityLdapTemplate.DN_KEY)); + } + + @Test + public void testGetAttributes() throws Exception { + assertNotNull(authority.getAttributes()); + assertNotNull(authority.getAttributeValues("mail")); + assertEquals(2, authority.getAttributeValues("mail").length); + assertEquals("filip@ldap.test.org", authority.getFirstAttributeValue("mail")); + assertEquals("filip@ldap.test.org", authority.getAttributeValues("mail")[0]); + assertEquals("filip@ldap.test2.org", authority.getAttributeValues("mail")[1]); + } + + @Test + public void testGetAuthority() throws Exception { + assertNotNull(authority.getAuthority()); + assertEquals("testRole",authority.getAuthority()); + } +} \ No newline at end of file