Index: security-jboss-sx/jbosssx/src/main/java/org/jboss/security/auth/spi/LdapExtAdLoginModule.java =================================================================== --- security-jboss-sx/jbosssx/src/main/java/org/jboss/security/auth/spi/LdapExtAdLoginModule.java (revision 0) +++ security-jboss-sx/jbosssx/src/main/java/org/jboss/security/auth/spi/LdapExtAdLoginModule.java (working copy) @@ -0,0 +1,628 @@ +/* + * JBoss, Home of Professional Open Source. + * Copyright 2006, Red Hat Middleware LLC, and individual contributors + * as indicated by the @author tags. See the copyright.txt file in the + * distribution for a full listing of individual contributors. + * + * This is free software; you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as + * published by the Free Software Foundation; either version 2.1 of + * the License, or (at your option) any later version. + * + * This software is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this software; if not, write to the Free + * Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA + * 02110-1301 USA, or see the FSF site: http://www.fsf.org. + */ +package org.jboss.security.auth.spi; + +import java.security.Principal; +import java.security.acl.Group; +import java.util.HashSet; +import java.util.Iterator; +import java.util.Map; +import java.util.Properties; +import java.util.Set; +import java.util.StringTokenizer; +import java.util.Map.Entry; + +import javax.management.ObjectName; +import javax.naming.Context; +import javax.naming.NamingEnumeration; +import javax.naming.NamingException; +import javax.naming.directory.Attribute; +import javax.naming.directory.Attributes; +import javax.naming.directory.SearchControls; +import javax.naming.directory.SearchResult; +import javax.naming.ldap.InitialLdapContext; +import javax.security.auth.Subject; +import javax.security.auth.callback.CallbackHandler; +import javax.security.auth.login.LoginException; + +import org.jboss.security.PicketBoxLogger; +import org.jboss.security.PicketBoxMessages; +import org.jboss.security.SimpleGroup; +import org.jboss.security.Util; +import org.jboss.security.vault.SecurityVaultUtil; + +/** + The org.jboss.security.auth.spi.LdapExtAdLoginModule, is an + alternate ldap login module implementation that uses searches for locating both + the user to bind as for authentication as well as the associated roles. The + roles query will recursively follow distinguished names (DNs) to navigate a + hierarchical role structure. This module is based on the org.jboss.security.auth.spi.LdapExtLoginModule, + but it is better optimized of ActiveDirectory based authentication. + + The LoginModule options include whatever options your LDAP JNDI provider + supports. Examples of standard property names are: + + * Context.INITIAL_CONTEXT_FACTORY = "java.naming.factory.initial" + * Context.SECURITY_PROTOCOL = "java.naming.security.protocol" + * Context.PROVIDER_URL = "java.naming.provider.url" + * Context.SECURITY_AUTHENTICATION = "java.naming.security.authentication" + + The authentication happens in 2 steps: + # An initial bind to the ldap server is done using the __bindDN__ and + __bindCredential__ options. The __bindDN__ is some user with the ability to + search the __baseDN__ tree and the tree containing the user roles. The + user DN to authenticate against is queried using the filter specified by the + __baseFilter__ attribute (see the __baseFilter__ option description for its + syntax). + # The resulting user DN is then authenticated by binding to ldap server using + the user DN as the InitialLdapContext environment Context.SECURITY_PRINCIPAL. + + The Context.SECURITY_CREDENTIALS property is either set to the String password + obtained by the callback handler. + + If this is successful, the associated user roles are queried using the + __roleAttributeID__, __roleNameAttributeID__, and __parseRoleNameFromDN__ options. + + The full odule properties include: + * __baseCtxDN__ : The fixed DN of the context to start the user search from. + * __bindDN__ : The DN used to bind against the ldap server for the user and + roles queries. This is some DN with read/search permissions on the baseCtxDN and + rolesCtxDN values. + * __bindCredential__ : The password for the bindDN. This can be encrypted if the + jaasSecurityDomain is specified. + * __jaasSecurityDomain__ : The JMX ObjectName of the JaasSecurityDomain to use + to decrypt the java.naming.security.principal. The encrypted form of the + password is that returned by the JaasSecurityDomain#encrypt64(byte[]) method. + The org.jboss.security.plugins.PBEUtils can also be used to generate the + encrypted form. + * __baseFilter__ : A search filter used to locate the context of the user to + authenticate. The input username/userDN as obtained from the login module + callback will be substituted into the filter anywhere a "{0}" expression is + seen. This substituion behavior comes from the standard + __DirContext.search(Name, String, Object[], SearchControls cons)__ method. A + common example search filter for Active Directory is "(sAMAccountName={0})". + * __roleAttributeID__ : The name of the attribute of the user or role object which + corresponds to the role the user/role is a member of. This attribute is assumed the + contain the DN of the referenced role. + * __roleNameAttributeID__ : The name of the attribute of the role object which + corresponds to the name of the role. If the __parseRoleNameFromDN__ property is set, + then this attribute is not queried from LDAP, rather it is used to parse the role name + from the role DN. + * __roleRecursion__ : How deep the role search will go below a given matching + context. Disable depth checking with -1, which is the default, set to 0 to only + query the roles that are directly associated with the user. + * __searchTimeLimit__ : The timeout in milliseconds for the user/role searches. + Defaults to 10000 (10 seconds). + * __searchScope__ : Sets the search scope to one of the strings. The default is + SUBTREE_SCOPE. + ** OBJECT_SCOPE : only search the named roles context. + ** ONELEVEL_SCOPE : search directly under the named roles context. + ** SUBTREE_SCOPE : If the roles context is not a DirContext, search only the + object. If the roles context is a DirContext, search the subtree rooted at the + named object, including the named object itself + * __allowEmptyPasswords__ : A flag indicating if empty(length==0) passwords + should be passed to the ldap server. An empty password is treated as an + anonymous login by some ldap servers and this may not be a desirable feature. + Set this to false to reject empty passwords, true to have the ldap server + validate the empty password. The default is true. + + @author Andy Oliver + @author Scott.Stark@jboss.org + @author Radics Péter + @version $Revision: 359 $ */ +public class LdapExtAdLoginModule extends UsernamePasswordLoginModule +{ + // see AbstractServerLoginModule + private static final String ROLE_ATTRIBUTE_ID_OPT = "roleAttributeID"; + private static final String ROLE_NAME_ATTRIBUTE_ID_OPT = "roleNameAttributeID"; + private static final String PARSE_ROLE_NAME_FROM_DN_OPT = "parseRoleNameFromDN"; + private static final String BIND_DN = "bindDN"; + private static final String BIND_CREDENTIAL = "bindCredential"; + private static final String BASE_CTX_DN = "baseCtxDN"; + private static final String BASE_FILTER_OPT = "baseFilter"; + private static final String ROLE_RECURSION = "roleRecursion"; + private static final String DEFAULT_ROLE = "defaultRole"; + private static final String SEARCH_TIME_LIMIT_OPT = "searchTimeLimit"; + private static final String SEARCH_SCOPE_OPT = "searchScope"; + private static final String SECURITY_DOMAIN_OPT = "jaasSecurityDomain"; + private static final String DISTINGUISHED_NAME_ATTRIBUTE_OPT = "distinguishedNameAttribute"; + private static final String ALLOW_EMPTY_PASSWORDS = "allowEmptyPasswords"; + private static final String[] ALL_VALID_OPTIONS = + { + ROLE_ATTRIBUTE_ID_OPT, + ROLE_NAME_ATTRIBUTE_ID_OPT, + PARSE_ROLE_NAME_FROM_DN_OPT, + BIND_DN, + BIND_CREDENTIAL, + BASE_CTX_DN, + BASE_FILTER_OPT, + ROLE_RECURSION, + DEFAULT_ROLE, + SEARCH_TIME_LIMIT_OPT, + SEARCH_SCOPE_OPT, + SECURITY_DOMAIN_OPT, + DISTINGUISHED_NAME_ATTRIBUTE_OPT, + ALLOW_EMPTY_PASSWORDS, + + Context.INITIAL_CONTEXT_FACTORY, + Context.OBJECT_FACTORIES, + Context.STATE_FACTORIES, + Context.URL_PKG_PREFIXES, + Context.PROVIDER_URL, + Context.DNS_URL, + Context.AUTHORITATIVE, + Context.BATCHSIZE, + Context.REFERRAL, + Context.SECURITY_PROTOCOL, + Context.SECURITY_AUTHENTICATION, + Context.SECURITY_PRINCIPAL, + Context.SECURITY_CREDENTIALS, + Context.LANGUAGE, + Context.APPLET + }; + + protected String bindDN; + + protected String bindCredential; + + protected String baseDN; + + protected String baseFilter; + + protected String roleAttributeID; + + protected String roleNameAttributeID; + + protected boolean parseRoleNameFromDN; + + protected String[] attributeIDs; + + protected int recursion = 0; + + protected int searchTimeLimit = 10000; + + protected int searchScope = SearchControls.SUBTREE_SCOPE; + + protected String distinguishedNameAttribute; + + // simple flag to indicate is the validatePassword method was called + protected boolean isPasswordValidated = false; + + public LdapExtAdLoginModule() + { + } + + private transient SimpleGroup userRoles = new SimpleGroup("Roles"); + + @Override + public void initialize(Subject subject, CallbackHandler callbackHandler, Map sharedState, Map options) + { + addValidOptions(ALL_VALID_OPTIONS); + super.initialize(subject, callbackHandler, sharedState, options); + } + + /** + Overridden to return an empty password string as typically one cannot obtain a + user's password. We also override the validatePassword so this is ok. + @return and empty password String + */ + @Override + protected String getUsersPassword() throws LoginException + { + return ""; + } + + /** + Overridden by subclasses to return the Groups that correspond to the to the + role sets assigned to the user. Subclasses should create at least a Group + named "Roles" that contains the roles assigned to the user. A second common + group is "CallerPrincipal" that provides the application identity of the user + rather than the security domain identity. + @return Group[] containing the sets of roles + */ + @Override + protected Group[] getRoleSets() throws LoginException + { + // SECURITY-225: check if authentication was already done in a previous login module + // and perform role mapping + if (!isPasswordValidated && getIdentity() != unauthenticatedIdentity) + { + try + { + String username = getUsername(); + PicketBoxLogger.LOGGER.traceBindingLDAPUsername(username); + createLdapInitContext(username, null); + defaultRole(); + } + catch (Exception e) + { + LoginException le = new LoginException(); + le.initCause(e); + throw le; + } + } + + Group[] roleSets = {userRoles}; + return roleSets; + } + + /** + Validate the inputPassword by creating a LDAP InitialContext with the + SECURITY_CREDENTIALS set to the password. + @param inputPassword the password to validate. + @param expectedPassword ignored + */ + @Override + protected boolean validatePassword(String inputPassword, String expectedPassword) + { + isPasswordValidated = true; + boolean isValid = false; + if (inputPassword != null) + { + // See if this is an empty password that should be disallowed + if (inputPassword.length() == 0) + { + // Check for an allowEmptyPasswords option + boolean allowEmptyPasswords = true; + String flag = (String) options.get(ALLOW_EMPTY_PASSWORDS); + if (flag != null) + allowEmptyPasswords = Boolean.valueOf(flag).booleanValue(); + if (!allowEmptyPasswords) + { + PicketBoxLogger.LOGGER.traceRejectingEmptyPassword(); + return false; + } + } + + try + { + // Validate the password by trying to create an initial context + String username = getUsername(); + isValid = createLdapInitContext(username, inputPassword); + defaultRole(); + isValid = true; + } + catch (Throwable e) + { + super.setValidateError(e); + } + } + return isValid; + } + + /** + @todo move to a generic role mapping function at the base login module + */ + private void defaultRole() + { + String defaultRole = (String) options.get(DEFAULT_ROLE); + try + { + if (defaultRole == null || defaultRole.equals("")) + { + return; + } + Principal p = super.createIdentity(defaultRole); + PicketBoxLogger.LOGGER.traceAssignUserToRole(defaultRole); + userRoles.addMember(p); + } + catch (Exception e) + { + PicketBoxLogger.LOGGER.debugFailureToCreatePrincipal(defaultRole, e); + } + } + + /** + Bind to the LDAP server for authentication. + + @param username + @param credential + @return true if the bind for authentication succeeded + @throws NamingException + */ + private boolean createLdapInitContext(String username, Object credential) throws Exception + { + bindDN = (String) options.get(BIND_DN); + bindCredential = (String) options.get(BIND_CREDENTIAL); + if ((bindCredential != null) && bindCredential.startsWith("{EXT}")) + bindCredential = new String(Util.loadPassword(bindCredential)); + String securityDomain = (String) options.get(SECURITY_DOMAIN_OPT); + if (securityDomain != null) + { + ObjectName serviceName = new ObjectName(securityDomain); + char[] tmp = DecodeAction.decode(bindCredential, serviceName); + bindCredential = new String(tmp); + } + //Check if the credential is vaultified + if(bindCredential != null && SecurityVaultUtil.isVaultFormat(bindCredential)) + { + bindCredential = SecurityVaultUtil.getValueAsString(bindCredential); + } + + baseDN = (String) options.get(BASE_CTX_DN); + baseFilter = (String) options.get(BASE_FILTER_OPT); + if (baseFilter == null) + baseFilter = "(sAMAccountName={0})"; + roleAttributeID = (String) options.get(ROLE_ATTRIBUTE_ID_OPT); + if (roleAttributeID == null) + roleAttributeID = "memberOf"; + roleNameAttributeID = (String) options.get(ROLE_NAME_ATTRIBUTE_ID_OPT); + if (roleNameAttributeID == null) + roleNameAttributeID = "cn"; + + //JBAS-4619:Parse Role Name from DN + String parseRoleNameFromDNOption = (String) options.get(PARSE_ROLE_NAME_FROM_DN_OPT); + parseRoleNameFromDN = Boolean.valueOf(parseRoleNameFromDNOption).booleanValue(); + if (parseRoleNameFromDN) + attributeIDs = new String[] { roleAttributeID }; + else + attributeIDs = new String[] { roleNameAttributeID, roleAttributeID }; + + String strRecursion = (String) options.get(ROLE_RECURSION); + try + { + recursion = Integer.parseInt(strRecursion); + } + catch (NumberFormatException e) + { + PicketBoxLogger.LOGGER.debugFailureToParseNumberProperty(ROLE_RECURSION, -1); + // its okay for this to be -1 as this just disables recursion depth checking + recursion = -1; + } + String timeLimit = (String) options.get(SEARCH_TIME_LIMIT_OPT); + if (timeLimit != null) + { + try + { + searchTimeLimit = Integer.parseInt(timeLimit); + } + catch (NumberFormatException e) + { + PicketBoxLogger.LOGGER.debugFailureToParseNumberProperty(SEARCH_TIME_LIMIT_OPT, this.searchTimeLimit); + } + } + String scope = (String) options.get(SEARCH_SCOPE_OPT); + if ("OBJECT_SCOPE".equalsIgnoreCase(scope)) + searchScope = SearchControls.OBJECT_SCOPE; + else if ("ONELEVEL_SCOPE".equalsIgnoreCase(scope)) + searchScope = SearchControls.ONELEVEL_SCOPE; + if ("SUBTREE_SCOPE".equalsIgnoreCase(scope)) + searchScope = SearchControls.SUBTREE_SCOPE; + + distinguishedNameAttribute = (String) options.get(DISTINGUISHED_NAME_ATTRIBUTE_OPT); + if (distinguishedNameAttribute == null) + distinguishedNameAttribute = "distinguishedName"; + + // Get the admin context for searching + InitialLdapContext ctx = null; + ClassLoader currentTCCL = SecurityActions.getContextClassLoader(); + try + { + if (currentTCCL != null) + SecurityActions.setContextClassLoader(null); + ctx = constructInitialLdapContext(bindDN, bindCredential); + // Validate the user by binding against the userDN + String userDN = bindDNAuthentication(ctx, username, credential, baseDN, baseFilter); + + // Query for roles matching the role filter + rolesSearch(ctx, userDN); + } + catch(Exception e) + { + throw e; + } + finally + { + if (ctx != null) + ctx.close(); + if (currentTCCL != null) + SecurityActions.setContextClassLoader(currentTCCL); + } + return true; + } + + /** + @param ctx - the context to search from + @param user - the input username + @param credential - the bind credential + @param baseDN - base DN to search the ctx from + @param filter - the search filter string + @return the userDN string for the successful authentication + @throws NamingException + */ + protected String bindDNAuthentication(InitialLdapContext ctx, String user, Object credential, String baseDN, + String filter) throws NamingException + { + SearchControls constraints = new SearchControls(); + constraints.setSearchScope(SearchControls.SUBTREE_SCOPE); + constraints.setTimeLimit(searchTimeLimit); + String attrList[] = {distinguishedNameAttribute}; + constraints.setReturningAttributes(attrList); + + NamingEnumeration results = null; + + Object[] filterArgs = {user}; + results = ctx.search(baseDN, filter, filterArgs, constraints); + if (results.hasMore() == false) + { + results.close(); + throw PicketBoxMessages.MESSAGES.failedToFindBaseContextDN(baseDN); + } + + SearchResult sr = (SearchResult) results.next(); + String name = sr.getName(); + String userDN = null; + Attributes attrs = sr.getAttributes(); + if (attrs != null) + { + Attribute dn = attrs.get(distinguishedNameAttribute); + if (dn != null) + { + userDN = (String) dn.get(); + } + } + if (userDN == null) + { + if (sr.isRelative() == true) + userDN = name + ("".equals(baseDN) ? "" : "," + baseDN); + else + throw PicketBoxMessages.MESSAGES.unableToFollowReferralForAuth(name); + } + + results.close(); + results = null; + // SECURITY-225: don't need to authenticate again + if (isPasswordValidated) + { + // Bind as the user dn to authenticate the user + InitialLdapContext userCtx = constructInitialLdapContext(userDN, credential); + userCtx.close(); + } + + return userDN; + } + + /** + @param ctx + @param userDN + @throws NamingException + */ + protected void rolesSearch(InitialLdapContext ctx, String userDN) throws NamingException + { + Set processedDNs = new HashSet(); + Attributes attributes = ctx.getAttributes(userDN, new String[]{ roleAttributeID } ); + processRoleAttribute(ctx, attributes.get(roleAttributeID), processedDNs, 0); + } + + private void processRoleAttribute(InitialLdapContext ctx, Attribute roleAttribute, Set processedDNs, int depth) throws NamingException + { + if (roleAttribute == null) + return; + int numRoleAttributes = roleAttribute.size(); + for (int i = 0; i < numRoleAttributes; ++i) + { + String roleDN = (String)roleAttribute.get(i); + rolesSearch(ctx, roleDN, processedDNs, depth); + } + } + + private void rolesSearch(InitialLdapContext ctx, String roleDN, Set processedDNs, int depth) { + if (!processedDNs.add(roleDN)) + return; + try + { + Attributes attributes = ctx.getAttributes(roleDN, attributeIDs); + if (parseRoleNameFromDN) + parseRole(roleDN); + else + { + Attribute roleNameAttribute = attributes.get(roleNameAttributeID); + if (roleNameAttribute != null) + { + int numRoleNames = roleNameAttribute.size(); + for (int i = 0; i < numRoleNames; ++i) + { + String roleName = (String)roleNameAttribute.get(i); + addRole(roleName); + } + } + } + if (recursion < 0 || depth < recursion) + { + processRoleAttribute(ctx, attributes.get(roleAttributeID), processedDNs, depth + 1); + } + } + catch (Throwable e) + { + PicketBoxLogger.LOGGER.debugFailureToQueryLDAPAttribute(roleNameAttributeID, roleDN, e); + } + + } + + private InitialLdapContext constructInitialLdapContext(String dn, Object credential) throws NamingException + { + Properties env = new Properties(); + Iterator iter = options.entrySet().iterator(); + while (iter.hasNext()) + { + Entry entry = (Entry) iter.next(); + env.put(entry.getKey(), entry.getValue()); + } + + // Set defaults for key values if they are missing + String factoryName = env.getProperty(Context.INITIAL_CONTEXT_FACTORY); + if (factoryName == null) + { + factoryName = "com.sun.jndi.ldap.LdapCtxFactory"; + env.setProperty(Context.INITIAL_CONTEXT_FACTORY, factoryName); + } + String authType = env.getProperty(Context.SECURITY_AUTHENTICATION); + if (authType == null) + env.setProperty(Context.SECURITY_AUTHENTICATION, "simple"); + String protocol = env.getProperty(Context.SECURITY_PROTOCOL); + String providerURL = (String) options.get(Context.PROVIDER_URL); + if (providerURL == null) + providerURL = "ldap://localhost:" + ((protocol != null && protocol.equals("ssl")) ? "636" : "389"); + + env.setProperty(Context.PROVIDER_URL, providerURL); + // JBAS-3555, allow anonymous login with no bindDN and bindCredential + if (dn != null) + env.setProperty(Context.SECURITY_PRINCIPAL, dn); + if (credential != null) + env.put(Context.SECURITY_CREDENTIALS, credential); + PicketBoxLogger.LOGGER.traceLDAPConnectionEnv(env); + return new InitialLdapContext(env, null); + } + + private void addRole(String roleName) + { + if (roleName != null) + { + try + { + Principal p = super.createIdentity(roleName); + PicketBoxLogger.LOGGER.traceAssignUserToRole(roleName); + userRoles.addMember(p); + } + catch (Exception e) + { + PicketBoxLogger.LOGGER.debugFailureToCreatePrincipal(roleName, e); + } + } + } + + private void parseRole(String dn) + { + StringTokenizer st = new StringTokenizer(dn, ","); + while(st != null && st.hasMoreTokens()) + { + String keyVal = st.nextToken(); + StringTokenizer kst = new StringTokenizer(keyVal, "="); + String key = kst.nextToken(); + if (roleNameAttributeID.equalsIgnoreCase(key)) + { + addRole(kst.nextToken()); + } + } + } + +}