diff --git a/pom.xml b/pom.xml index f150c7ac35a..272cd9e0f6e 100644 --- a/pom.xml +++ b/pom.xml @@ -89,6 +89,24 @@ 17.0 test + + org.slf4j + slf4j-log4j12 + 1.7.7 + test + + + org.apache.directory.server + apacheds-all + 2.0.0-M17 + test + + + javax + javaee-api + 6.0 + test + org.mockito mockito-core diff --git a/src/main/java/org/elasticsearch/shield/authc/AuthenticationModule.java b/src/main/java/org/elasticsearch/shield/authc/AuthenticationModule.java index befa977ebf7..5614bf46434 100644 --- a/src/main/java/org/elasticsearch/shield/authc/AuthenticationModule.java +++ b/src/main/java/org/elasticsearch/shield/authc/AuthenticationModule.java @@ -33,7 +33,7 @@ public class AuthenticationModule extends AbstractModule implements SpawnModules modules.add(new ESUsersModule()); } if (LdapModule.enabled(settings)) { - modules.add(new LdapModule()); + modules.add(new LdapModule(settings)); } return modules.build(); } diff --git a/src/main/java/org/elasticsearch/shield/authc/ldap/ActiveDirectoryConnectionFactory.java b/src/main/java/org/elasticsearch/shield/authc/ldap/ActiveDirectoryConnectionFactory.java new file mode 100644 index 00000000000..20c83cfdd40 --- /dev/null +++ b/src/main/java/org/elasticsearch/shield/authc/ldap/ActiveDirectoryConnectionFactory.java @@ -0,0 +1,107 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +package org.elasticsearch.shield.authc.ldap; + +import org.elasticsearch.common.Strings; +import org.elasticsearch.common.collect.ImmutableMap; +import org.elasticsearch.common.component.AbstractComponent; +import org.elasticsearch.common.inject.Inject; +import org.elasticsearch.common.settings.Settings; + +import javax.naming.Context; +import javax.naming.NamingEnumeration; +import javax.naming.NamingException; +import javax.naming.directory.DirContext; +import javax.naming.directory.InitialDirContext; +import javax.naming.directory.SearchControls; +import javax.naming.directory.SearchResult; +import java.io.Serializable; +import java.util.Hashtable; + +/** + * This Class creates LdapConnections authenticating via the custom Active Directory protocol. (that being + * authenticating with a principal name, "username@domain", then searching through the directory to find the + * user entry in LDAP that matches the user name). This eliminates the need for user templates, and simplifies + * the configuration for windows admins that may not be familiar with LDAP concepts. + */ +public class ActiveDirectoryConnectionFactory extends AbstractComponent implements LdapConnectionFactory { + + public static final String AD_DOMAIN_NAME_SETTING = "domain_name"; + public static final String AD_PORT = "default_port"; + public static final String AD_USER_SEARCH_BASEDN_SETTING = "user_search_dn"; + + private final ImmutableMap sharedLdapEnv; + private final String userSearchDN; + private final String domainName; + + @Inject + public ActiveDirectoryConnectionFactory(Settings settings){ + super(settings); + domainName = componentSettings.get(AD_DOMAIN_NAME_SETTING); + if (domainName == null) { + throw new org.elasticsearch.shield.SecurityException("Missing [" + AD_DOMAIN_NAME_SETTING + "] setting for active directory"); + } + userSearchDN = componentSettings.get(AD_USER_SEARCH_BASEDN_SETTING, buildDnFromDomain(domainName)); + int port = componentSettings.getAsInt(AD_PORT, 389); + String[] ldapUrls = componentSettings.getAsArray(URLS_SETTING, new String[] { "ldap://" + domainName + ":" + port }); + + + sharedLdapEnv = ImmutableMap.builder() + .put(Context.INITIAL_CONTEXT_FACTORY, "com.sun.jndi.ldap.LdapCtxFactory") + .put(Context.PROVIDER_URL, Strings.arrayToCommaDelimitedString(ldapUrls)) + .put(Context.REFERRAL, "follow") + .build(); + } + + /** + * This is an active directory bind that looks up the user DN after binding with a windows principal. + * @param userName name of the windows user without the domain + * @return An authenticated + */ + @Override + public LdapConnection bind(String userName, char[] password) { + String userPrincipal = userName + "@" + this.domainName; + + Hashtable ldapEnv = new Hashtable<>(this.sharedLdapEnv); + ldapEnv.put(Context.SECURITY_AUTHENTICATION, "simple"); + ldapEnv.put(Context.SECURITY_PRINCIPAL, userPrincipal); + ldapEnv.put(Context.SECURITY_CREDENTIALS, password); + + try { + DirContext ctx = new InitialDirContext(ldapEnv); + SearchControls searchCtls = new SearchControls(); + searchCtls.setSearchScope(SearchControls.SUBTREE_SCOPE); + searchCtls.setReturningAttributes( new String[0] ); + + String searchFilter = "(&(objectClass=user)(userPrincipalName={0}))"; + NamingEnumeration results = ctx.search(userSearchDN, searchFilter, new Object[]{ userPrincipal }, searchCtls); + + if (results.hasMore()){ + SearchResult entry = results.next(); + String name = entry.getNameInNamespace(); + + if (!results.hasMore()) { + //searchByAttribute=true, group subtree search=false, groupSubtreeDN=null + return new LdapConnection(ctx, name, true, false, null); + } + throw new LdapException("Search for user [" + userName + "] by principle name yielded multiple results"); + } + + throw new LdapException("Search for user [" + userName + "] yielded no results"); + + } catch (NamingException e) { + throw new LdapException("Unable to authenticate user [" + userName + "] to active directory domain ["+ domainName +"]", e); + } + } + + /** + * @param domain active directory domain name + * @return LDAP DN, distinguished name, of the root of the domain + */ + String buildDnFromDomain(String domain) { + return "DC=" + domain.replace(".", ",DC="); + } +} diff --git a/src/main/java/org/elasticsearch/shield/authc/ldap/LdapConnection.java b/src/main/java/org/elasticsearch/shield/authc/ldap/LdapConnection.java new file mode 100644 index 00000000000..4a06274170e --- /dev/null +++ b/src/main/java/org/elasticsearch/shield/authc/ldap/LdapConnection.java @@ -0,0 +1,153 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +package org.elasticsearch.shield.authc.ldap; + +import javax.naming.NamingEnumeration; +import javax.naming.NamingException; +import javax.naming.directory.*; +import java.io.Closeable; +import java.util.HashMap; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; + +/** + * Encapsulates jndi/ldap functionality into one authenticated connection. The constructor is package scoped, assuming + * instances of this connection will be produced by the LdapConnectionFactory.bind() methods. + * + * A standard looking usage pattern could look like this: +
+     try (LdapConnection session = ldapFac.bindXXX(...);
+     ...do stuff with the session
+     }
+     
+ */ +public class LdapConnection implements Closeable { + + private final String bindDn; + private final DirContext ldapContext; + + private final String groupSearchDN; + private final boolean isGroupSubTreeSearch; + private final boolean isFindGroupsByAttribute; + + + /** + * This object is intended to be constructed by the LdapConnectionFactory + */ + LdapConnection(DirContext ctx, String boundName, boolean isFindGroupsByAttribute, boolean isGroupSubTreeSearch, String groupSearchDN) { + this.ldapContext = ctx; + this.bindDn = boundName; + this.isGroupSubTreeSearch = isGroupSubTreeSearch; + this.groupSearchDN = groupSearchDN; + this.isFindGroupsByAttribute = isFindGroupsByAttribute; + } + + /** + * LDAP connections should be closed to clean up resources. However, the jndi contexts have the finalize + * implemented properly so that it will clean up on garbage collection. + */ + public void close(){ + try { + ldapContext.close(); + } catch (NamingException e) { + throw new SecurityException("Could not close the LDAP connection", e); + } + } + + /** + * Fetches the groups that the user is a member of + * @return List of group membership + */ + public List getGroups(){ + return isFindGroupsByAttribute ? getGroupsFromUserAttrs(bindDn) : getGroupsFromSearch(bindDn); + } + + /** + * Fetches the groups of a user by doing a search. This could be abstracted out into a strategy class or through + * an inherited class (with getGroups as the template method). + * @param userDn user fully distinguished name to fetch group membership for + * @return fully distinguished names of the roles + */ + List getGroupsFromSearch(String userDn){ + List groups = new LinkedList<>(); + SearchControls search = new SearchControls(); + search.setReturningAttributes( new String[0] ); + search.setSearchScope( this.isGroupSubTreeSearch ? SearchControls.SUBTREE_SCOPE : SearchControls.ONELEVEL_SCOPE); + + //This could be made could be made configurable but it should cover all cases + String filter = "(&" + + "(|(objectclass=groupOfNames)(objectclass=groupOfUniqueNames)(objectclass=group)) " + + "(|(uniqueMember={0})(member={0})))"; + + try { + NamingEnumeration results = ldapContext.search( + groupSearchDN, filter, new Object[]{userDn}, search); + while (results.hasMoreElements()){ + groups.add(results.next().getNameInNamespace()); + } + } catch (NamingException e) { + throw new SecurityException("Could not search for an LDAP group for user [" + userDn + "]", e); + } + return groups; + } + + /** + * Fetches the groups from the user attributes (if supported). This method could later be abstracted out + * into a strategy class + * @param userDn User fully distinguished name to fetch group membership from + * @return list of groups the user is a member of. + */ + List getGroupsFromUserAttrs(String userDn) { + List groupDns = new LinkedList<>(); + try { + Attributes results = ldapContext.getAttributes(userDn, new String[]{"memberOf", "isMemberOf"}); + for(NamingEnumeration ae = results.getAll(); ae.hasMore();) { + Attribute attr = (Attribute)ae.next(); + for (NamingEnumeration attrEnum = attr.getAll(); attrEnum.hasMore();) { + Object val = attrEnum.next(); + if (val instanceof String) { + String stringVal = (String) val; + groupDns.add(stringVal); + } + } + } + } catch (NamingException e) { + throw new SecurityException("Could not look up group attributes for user [" + userDn + "]", e); + } + return groupDns; + } + + /** + * Fetches common user attributes from the user. Its a good way to ensure a connection works. + */ + public Map getUserAttrs(String userDn) { + Map userAttrs = new HashMap<>(); + try { + Attributes results = ldapContext.getAttributes(userDn, new String[]{"uid", "memberOf", "isMemberOf"}); + for(NamingEnumeration ae = results.getAll(); ae.hasMore();) { + Attribute attr = (Attribute)ae.next(); + LinkedList attrList = new LinkedList<>(); + for (NamingEnumeration attrEnum = attr.getAll(); attrEnum.hasMore();) { + Object val = attrEnum.next(); + if (val instanceof String) { + String stringVal = (String) val; + attrList.add(stringVal); + } + } + String[] attrArray = attrList.toArray(new String[attrList.size()]); + userAttrs.put(attr.getID(), attrArray); + } + } catch (NamingException e) { + throw new SecurityException("Could not look up attributes for user [" + userDn + "]", e); + } + return userAttrs; + } + + public String getAuthenticatedUserDn() { + return bindDn; + } +} diff --git a/src/main/java/org/elasticsearch/shield/authc/ldap/LdapConnectionFactory.java b/src/main/java/org/elasticsearch/shield/authc/ldap/LdapConnectionFactory.java new file mode 100644 index 00000000000..91c2a6278f4 --- /dev/null +++ b/src/main/java/org/elasticsearch/shield/authc/ldap/LdapConnectionFactory.java @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +package org.elasticsearch.shield.authc.ldap; + +/** + * This factory holds settings needed for authenticating to LDAP and creating LdapConnections. + * Each created LdapConnection needs to be closed or else connections will pill up consuming resources. + * + * A standard looking usage pattern could look like this: +
+    try (LdapConnection session = ldapFac.bindXXX(...);
+        ...do stuff with the session
+    }
+ 
+ */ +public interface LdapConnectionFactory { + + public static final String URLS_SETTING = "urls"; //comma separated + + /** + * Password authenticated bind + * @param user name of the user to authenticate the connection with. + */ + public LdapConnection bind(String user, char[] password) ; + +} diff --git a/src/main/java/org/elasticsearch/shield/authc/ldap/LdapException.java b/src/main/java/org/elasticsearch/shield/authc/ldap/LdapException.java new file mode 100644 index 00000000000..ce33df9739b --- /dev/null +++ b/src/main/java/org/elasticsearch/shield/authc/ldap/LdapException.java @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +package org.elasticsearch.shield.authc.ldap; + +/** + * LdapExceptions typically wrap jndi Naming exceptions, and have an additional + * parameter of DN attached to each message. + */ +public class LdapException extends SecurityException { + public LdapException(String msg){ + super(msg); + } + + public LdapException(String msg, Throwable cause){ + super(msg, cause); + } + public LdapException(String msg, String dn) { + this(msg, dn, null); + } + + public LdapException(String msg, String dn, Throwable cause) { + super( msg + "; LDAP DN=[" + dn + "]", cause); + } +} diff --git a/src/main/java/org/elasticsearch/shield/authc/ldap/LdapGroupToRoleMapper.java b/src/main/java/org/elasticsearch/shield/authc/ldap/LdapGroupToRoleMapper.java new file mode 100644 index 00000000000..84196ca21ed --- /dev/null +++ b/src/main/java/org/elasticsearch/shield/authc/ldap/LdapGroupToRoleMapper.java @@ -0,0 +1,153 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +package org.elasticsearch.shield.authc.ldap; + +import org.elasticsearch.ElasticsearchException; +import org.elasticsearch.common.collect.ImmutableMap; +import org.elasticsearch.common.component.AbstractComponent; +import org.elasticsearch.common.inject.Inject; +import org.elasticsearch.common.logging.ESLogger; +import org.elasticsearch.common.settings.ImmutableSettings; +import org.elasticsearch.common.settings.Settings; +import org.elasticsearch.env.Environment; +import org.elasticsearch.watcher.FileChangesListener; +import org.elasticsearch.watcher.FileWatcher; +import org.elasticsearch.watcher.ResourceWatcherService; + +import javax.naming.InvalidNameException; +import javax.naming.ldap.LdapName; +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.*; + +/** + * This class loads and monitors the file defining the mappings of LDAP Group DNs to internal ES Roles. + */ +public class LdapGroupToRoleMapper extends AbstractComponent { + + public static final String ROLE_MAPPING_DEFAULT_FILE_NAME = ".role_mapping"; + public static final String ROLE_MAPPING_FILE_SETTING = "files.role_mapping"; + public static final String USE_UNMAPPED_GROUPS_AS_ROLES_SETTING = "unmapped_groups_as_roles"; + + private final Path file; + private final Listener listener; + private final boolean useUnmappedGroupsAsRoles; + private volatile ImmutableMap> groupRoles; + + @Inject + public LdapGroupToRoleMapper(Settings settings, Environment env, ResourceWatcherService watcherService) { + this(settings, env, watcherService, Listener.NOOP); + } + + LdapGroupToRoleMapper(Settings settings, Environment env, ResourceWatcherService watcherService, Listener listener) { + super(settings); + useUnmappedGroupsAsRoles = componentSettings.getAsBoolean(USE_UNMAPPED_GROUPS_AS_ROLES_SETTING, false); + file = resolveFile(componentSettings, env); + groupRoles = parseFile(file, logger); + FileWatcher watcher = new FileWatcher(file.getParent().toFile()); + watcher.addListener(new FileListener()); + watcherService.add(watcher); + this.listener = listener; + } + + public static ImmutableMap> parseFile(Path path, ESLogger logger) { + if (!Files.exists(path)) { + return ImmutableMap.of(); + } + + try (FileInputStream in = new FileInputStream( path.toFile() )){ + Settings settings = ImmutableSettings.builder() + .loadFromStream(path.toString(), in) + .build(); + + Map> groupToRoles = new HashMap<>(); + Set roles = settings.names(); + for(String role: roles){ + for(String ldapDN: settings.getAsArray(role)){ + try { + LdapName group = new LdapName(ldapDN); + Set groupRoles = groupToRoles.get(group); + if (groupRoles == null){ + groupRoles = new HashSet<>(); + groupToRoles.put(group, groupRoles); + } + groupRoles.add(role); + } catch (InvalidNameException e) { + logger.error("Invalid group DN [{}] found in ldap group to role mappings [{}]. Skipping... ", e, ldapDN, path); + } + } + + } + return ImmutableMap.copyOf(groupToRoles); + + } catch (IOException e) { + throw new ElasticsearchException("unable to load ldap role mapper file [" + path.toAbsolutePath() + "]", e); + } + } + + public static Path resolveFile(Settings settings, Environment env) { + String location = settings.get(ROLE_MAPPING_FILE_SETTING); + if (location == null) { + return env.configFile().toPath().resolve(ROLE_MAPPING_DEFAULT_FILE_NAME); + } + return Paths.get(location); + } + + /** + * This will map the groupDN's to ES Roles + */ + public Set mapRoles(List groupDns) { + Setroles = new HashSet<>(); + for(String groupDn: groupDns){ + LdapName groupLdapName = LdapUtils.ldapName(groupDn); + if (this.groupRoles.containsKey(groupLdapName)) { + roles.addAll(this.groupRoles.get(groupLdapName)); + } else if (useUnmappedGroupsAsRoles) { + roles.add(getRelativeName(groupLdapName)); + } + } + return roles; + } + + String getRelativeName(LdapName groupLdapName) { + return (String) groupLdapName.getRdn(groupLdapName.size() - 1).getValue(); + } + + private class FileListener extends FileChangesListener { + @Override + public void onFileCreated(File file) { + onFileChanged(file); + } + + @Override + public void onFileDeleted(File file) { + onFileChanged(file); + } + + @Override + public void onFileChanged(File file) { + if (file.equals(LdapGroupToRoleMapper.this.file.toFile())) { + groupRoles = parseFile(file.toPath(), logger); + listener.onRefresh(); + } + } + } + + public static interface Listener { + + static final Listener NOOP = new Listener() { + @Override + public void onRefresh() { + } + }; + + void onRefresh(); + } +} diff --git a/src/main/java/org/elasticsearch/shield/authc/ldap/LdapModule.java b/src/main/java/org/elasticsearch/shield/authc/ldap/LdapModule.java index 54a23985844..1dc422e6025 100644 --- a/src/main/java/org/elasticsearch/shield/authc/ldap/LdapModule.java +++ b/src/main/java/org/elasticsearch/shield/authc/ldap/LdapModule.java @@ -9,9 +9,14 @@ import org.elasticsearch.common.inject.AbstractModule; import org.elasticsearch.common.settings.Settings; /** - * + * Configures Ldap object injections */ public class LdapModule extends AbstractModule { + private final Settings settings; + + public LdapModule(Settings settings) { + this.settings = settings; + } public static boolean enabled(Settings settings) { Settings ldapSettings = settings.getComponentSettings(LdapModule.class); @@ -20,6 +25,13 @@ public class LdapModule extends AbstractModule { @Override protected void configure() { - + bind(LdapRealm.class).asEagerSingleton(); + bind(LdapGroupToRoleMapper.class).asEagerSingleton(); + String mode = settings.getComponentSettings(LdapModule.class).get("mode", "ldap"); + if ("ldap".equals(mode)) { + bind(LdapConnectionFactory.class).to(StandardLdapConnectionFactory.class); + } else { + bind(LdapConnectionFactory.class).to(ActiveDirectoryConnectionFactory.class); + } } } diff --git a/src/main/java/org/elasticsearch/shield/authc/ldap/LdapRealm.java b/src/main/java/org/elasticsearch/shield/authc/ldap/LdapRealm.java index f76dbba0ebe..666dd165d97 100644 --- a/src/main/java/org/elasticsearch/shield/authc/ldap/LdapRealm.java +++ b/src/main/java/org/elasticsearch/shield/authc/ldap/LdapRealm.java @@ -5,25 +5,36 @@ */ package org.elasticsearch.shield.authc.ldap; -import org.elasticsearch.common.component.AbstractComponent; import org.elasticsearch.common.inject.Inject; import org.elasticsearch.common.settings.Settings; import org.elasticsearch.shield.User; import org.elasticsearch.shield.authc.AuthenticationToken; +import org.elasticsearch.shield.SecurityException; import org.elasticsearch.shield.authc.Realm; +import org.elasticsearch.shield.authc.support.CachingUsernamePasswordRealm; import org.elasticsearch.shield.authc.support.UsernamePasswordToken; import org.elasticsearch.transport.TransportMessage; +import java.util.Arrays; +import java.util.List; +import java.util.Set; + /** - * + * Authenticates username/password tokens against ldap, locates groups and maps them to roles. */ -public class LdapRealm extends AbstractComponent implements Realm { +public class LdapRealm extends CachingUsernamePasswordRealm implements Realm { private static final String TYPE = "ldap"; + private final LdapConnectionFactory connectionFactory; + private final LdapGroupToRoleMapper roleMapper; + @Inject - public LdapRealm(Settings settings) { + public LdapRealm(Settings settings, LdapConnectionFactory ldap, LdapGroupToRoleMapper roleMapper) { super(settings); + + this.connectionFactory = ldap; + this.roleMapper = roleMapper; } @Override @@ -36,13 +47,26 @@ public class LdapRealm extends AbstractComponent implements Realm groupDNs = session.getGroups(); + Set roles = roleMapper.mapRoles(groupDNs); + User.Simple user = new User.Simple(token.principal(), roles.toArray(new String[roles.size()])); + Arrays.fill(token.credentials(), '\0'); + return user; + } catch (SecurityException e){ + logger.info("Authentication Failed for user [{}]", e, token.principal()); + return null; + } } + } diff --git a/src/main/java/org/elasticsearch/shield/authc/ldap/LdapUtils.java b/src/main/java/org/elasticsearch/shield/authc/ldap/LdapUtils.java new file mode 100644 index 00000000000..b779369d29b --- /dev/null +++ b/src/main/java/org/elasticsearch/shield/authc/ldap/LdapUtils.java @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +package org.elasticsearch.shield.authc.ldap; + +import org.elasticsearch.ElasticsearchException; + +import javax.naming.InvalidNameException; +import javax.naming.ldap.LdapName; + +public class LdapUtils { + + private LdapUtils() { + } + + public static LdapName ldapName(String dn) { + try { + return new LdapName(dn); + } catch (InvalidNameException e) { + throw new ElasticsearchException("Invalid group DN is set [" + dn + "]", e); + } + } +} diff --git a/src/main/java/org/elasticsearch/shield/authc/ldap/StandardLdapConnectionFactory.java b/src/main/java/org/elasticsearch/shield/authc/ldap/StandardLdapConnectionFactory.java new file mode 100644 index 00000000000..537d822a4c5 --- /dev/null +++ b/src/main/java/org/elasticsearch/shield/authc/ldap/StandardLdapConnectionFactory.java @@ -0,0 +1,102 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +package org.elasticsearch.shield.authc.ldap; + +import org.elasticsearch.common.Strings; +import org.elasticsearch.common.collect.ImmutableMap; +import org.elasticsearch.common.component.AbstractComponent; +import org.elasticsearch.common.inject.Inject; +import org.elasticsearch.common.settings.Settings; + +import javax.naming.Context; +import javax.naming.NamingException; +import javax.naming.directory.DirContext; +import javax.naming.directory.InitialDirContext; +import javax.naming.ldap.Rdn; +import java.io.Serializable; +import java.text.MessageFormat; +import java.util.Hashtable; + +/** + * This factory creates LDAP connections via iterating through user templates. + * + * Note that even though there is a separate factory for Active Directory, this factory would work against AD. A template + * for each user context would need to be supplied. + */ +public class StandardLdapConnectionFactory extends AbstractComponent implements LdapConnectionFactory { + + public static final String USER_DN_TEMPLATES_SETTING = "user_dn_templates"; + public static final String GROUP_SEARCH_SUBTREE_SETTING = "group_search.subtree_search"; + public static final String GROUP_SEARCH_BASEDN_SETTING = "group_search.group_search_dn"; + + private final ImmutableMap sharedLdapEnv; + private final String[] userDnTemplates; + protected final String groupSearchDN; + protected final boolean groupSubTreeSearch; + protected final boolean findGroupsByAttribute; + + @Inject + public StandardLdapConnectionFactory(Settings settings) { + super(settings); + userDnTemplates = componentSettings.getAsArray(USER_DN_TEMPLATES_SETTING); + if (userDnTemplates == null) { + throw new org.elasticsearch.shield.SecurityException("Missing required ldap setting [" + USER_DN_TEMPLATES_SETTING + "]"); + } + String[] ldapUrls = componentSettings.getAsArray(URLS_SETTING); + if (ldapUrls == null) { + throw new org.elasticsearch.shield.SecurityException("Missing required ldap setting [" + URLS_SETTING + "]"); + } + sharedLdapEnv = ImmutableMap.builder() + .put(Context.INITIAL_CONTEXT_FACTORY, "com.sun.jndi.ldap.LdapCtxFactory") + .put(Context.PROVIDER_URL, Strings.arrayToCommaDelimitedString(ldapUrls)) + .put(Context.REFERRAL, "follow") + .build(); + + groupSearchDN = componentSettings.get(GROUP_SEARCH_BASEDN_SETTING); + findGroupsByAttribute = groupSearchDN == null; + groupSubTreeSearch = componentSettings.getAsBoolean(GROUP_SEARCH_SUBTREE_SETTING, false); + } + + /** + * This iterates through the configured user templates attempting to connect. If all attempts fail, all exceptions + * are combined into one Exception as nested exceptions. + * @param username a relative name, Not a distinguished name, that will be inserted into the template. + * @return authenticated exception + */ + @Override + public LdapConnection bind(String username, char[] password) { + //SASL, MD5, etc. all options here stink, we really need to go over ssl + simple authentication + Hashtable ldapEnv = new Hashtable<>(this.sharedLdapEnv); + ldapEnv.put(Context.SECURITY_AUTHENTICATION, "simple"); + ldapEnv.put(Context.SECURITY_CREDENTIALS, password); + + for (String template : userDnTemplates) { + String dn = buildDnFromTemplate(username, template); + ldapEnv.put(Context.SECURITY_PRINCIPAL, dn); + try { + DirContext ctx = new InitialDirContext(ldapEnv); + + //return the first good connection + return new LdapConnection(ctx, dn, findGroupsByAttribute, groupSubTreeSearch, groupSearchDN); + + } catch (NamingException e) { + logger.warn("Failed ldap authentication with user template [{}], dn [{}]", template, dn); + } + } + throw new LdapException("Failed ldap authentication"); + } + + /** + * Securely escapes the username and inserts it into the template using MessageFormat + * @param username username to insert into the DN template. Any commas, equals or plus will be escaped. + * @return DN (distinquished name) build from the template. + */ + String buildDnFromTemplate(String username, String template) { + //this value must be escaped to avoid manipulation of the template DN. + String escapedUsername = Rdn.escapeValue(username); + return MessageFormat.format(template, escapedUsername); + } +} diff --git a/src/main/java/org/elasticsearch/shield/authc/support/CachingUsernamePasswordRealm.java b/src/main/java/org/elasticsearch/shield/authc/support/CachingUsernamePasswordRealm.java index 79a69aa4c91..f126bf0f0d8 100644 --- a/src/main/java/org/elasticsearch/shield/authc/support/CachingUsernamePasswordRealm.java +++ b/src/main/java/org/elasticsearch/shield/authc/support/CachingUsernamePasswordRealm.java @@ -24,16 +24,18 @@ public abstract class CachingUsernamePasswordRealm extends AbstractComponent imp private static final TimeValue DEFAULT_TTL = TimeValue.timeValueHours(1); private static final int DEFAULT_MAX_USERS = 100000; //100k users + public static final String CACHE_TTL = "cache.ttl"; + public static final String CACHE_MAX_USERS = "cache.max_users"; private final Cache cache; protected CachingUsernamePasswordRealm(Settings settings) { super(settings); - TimeValue ttl = componentSettings.getAsTime("cache.ttl", DEFAULT_TTL); + TimeValue ttl = componentSettings.getAsTime(CACHE_TTL, DEFAULT_TTL); if (ttl.millis() > 0) { cache = CacheBuilder.newBuilder() .expireAfterWrite(ttl.getMillis(), TimeUnit.MILLISECONDS) - .maximumSize(settings.getAsInt("cache.max_users", DEFAULT_MAX_USERS)) + .maximumSize(settings.getAsInt(CACHE_MAX_USERS, DEFAULT_MAX_USERS)) .build(); } else { cache = null; diff --git a/src/test/java/org/elasticsearch/shield/authc/ldap/ActiveDirectoryFactoryTests.java b/src/test/java/org/elasticsearch/shield/authc/ldap/ActiveDirectoryFactoryTests.java new file mode 100644 index 00000000000..6ccd958c595 --- /dev/null +++ b/src/test/java/org/elasticsearch/shield/authc/ldap/ActiveDirectoryFactoryTests.java @@ -0,0 +1,69 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +package org.elasticsearch.shield.authc.ldap; + +import org.elasticsearch.common.settings.ImmutableSettings; +import org.elasticsearch.common.settings.Settings; +import org.elasticsearch.test.ElasticsearchTestCase; +import org.junit.Ignore; +import org.junit.Test; + +import java.util.List; + +import static org.hamcrest.Matchers.containsInAnyOrder; +import static org.hamcrest.Matchers.containsString; + +public class ActiveDirectoryFactoryTests extends ElasticsearchTestCase { + public static final String OPEN_LDAP_URL = "ldap://ad.es.com:389"; + public static final String AD_LDAP_URL = "ldap://54.213.145.20:389"; + public static final String PASSWORD = "4joD8LmWcrEfRa&p"; + + public static String SETTINGS_PREFIX = LdapRealm.class.getPackage().getName().substring("com.elasticsearch.".length()) + '.'; + + @Ignore + @Test + public void testAdAuth() { + ActiveDirectoryConnectionFactory connectionFactory = new ActiveDirectoryConnectionFactory( + buildAdSettings(AD_LDAP_URL, "ad.test.elasticsearch.com")); + + String userName = "ironman"; + + LdapConnection ldap = connectionFactory.bind(userName, PASSWORD.toCharArray()); + String userDN = ldap.getAuthenticatedUserDn(); + //System.out.println("userPassword check:"+ldap.checkPassword(userDn, userPass)); + + List groups = ldap.getGroupsFromUserAttrs(userDN); + System.out.println("groups: "+groups); + + } + + @Ignore + @Test + public void testAD_standardLdapConnection(){ + String groupSearchBase = "dc=ad,dc=test,dc=elasticsearch,dc=com"; + String userTemplate = "cn={0},cn=Users,dc=ad,dc=test,dc=elasticsearch,dc=com"; + boolean isSubTreeSearch = true; + StandardLdapConnectionFactory connectionFactory = new StandardLdapConnectionFactory( + LdapConnectionTests.buildLdapSettings(AD_LDAP_URL, userTemplate, groupSearchBase, isSubTreeSearch)); + + String user = "Tony Stark"; + LdapConnection ldap = connectionFactory.bind(user, PASSWORD.toCharArray()); + + List groups = ldap.getGroupsFromUserAttrs(ldap.getAuthenticatedUserDn()); + List groups2 = ldap.getGroupsFromSearch(ldap.getAuthenticatedUserDn()); + + assertThat(groups, containsInAnyOrder(containsString("upchuckers"), containsString("localDistribution"))); + assertThat(groups2, containsInAnyOrder(containsString("upchuckers"), containsString("localDistribution"))); + } + + + public static Settings buildAdSettings(String ldapUrl, String adDomainName) { + return ImmutableSettings.builder() + .putArray(SETTINGS_PREFIX + ActiveDirectoryConnectionFactory.URLS_SETTING, ldapUrl) + .put(SETTINGS_PREFIX + ActiveDirectoryConnectionFactory.AD_DOMAIN_NAME_SETTING, adDomainName) + .build(); + } +} diff --git a/src/test/java/org/elasticsearch/shield/authc/ldap/ApacheDsEmbedded.java b/src/test/java/org/elasticsearch/shield/authc/ldap/ApacheDsEmbedded.java new file mode 100644 index 00000000000..3fa8da1f83f --- /dev/null +++ b/src/test/java/org/elasticsearch/shield/authc/ldap/ApacheDsEmbedded.java @@ -0,0 +1,303 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +package org.elasticsearch.shield.authc.ldap; + +import com.carrotsearch.randomizedtesting.SysGlobals; +import org.apache.directory.api.ldap.model.entry.Entry; +import org.apache.directory.api.ldap.model.name.Dn; +import org.apache.directory.api.ldap.model.schema.SchemaManager; +import org.apache.directory.api.ldap.model.schema.registries.SchemaLoader; +import org.apache.directory.api.ldap.schemaextractor.SchemaLdifExtractor; +import org.apache.directory.api.ldap.schemaextractor.impl.DefaultSchemaLdifExtractor; +import org.apache.directory.api.ldap.schemaloader.LdifSchemaLoader; +import org.apache.directory.api.ldap.schemamanager.impl.DefaultSchemaManager; +import org.apache.directory.api.util.exception.Exceptions; +import org.apache.directory.server.constants.ServerDNConstants; +import org.apache.directory.server.core.DefaultDirectoryService; +import org.apache.directory.server.core.api.CacheService; +import org.apache.directory.server.core.api.DirectoryService; +import org.apache.directory.server.core.api.DnFactory; +import org.apache.directory.server.core.api.InstanceLayout; +import org.apache.directory.server.core.api.partition.Partition; +import org.apache.directory.server.core.api.schema.SchemaPartition; +import org.apache.directory.server.core.partition.impl.btree.jdbm.JdbmIndex; +import org.apache.directory.server.core.partition.impl.btree.jdbm.JdbmPartition; +import org.apache.directory.server.core.partition.ldif.LdifPartition; +import org.apache.directory.server.i18n.I18n; +import org.apache.directory.server.ldap.LdapServer; +import org.apache.directory.server.protocol.shared.store.LdifFileLoader; +import org.apache.directory.server.protocol.shared.transport.TcpTransport; + +import java.io.File; +import java.net.URISyntaxException; +import java.net.URL; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +/** + * Helper Class to start up an Apache DS LDAP server for testing. Here is a typical use example in tests: + *
+ * static ApacheDsEmbedded ldap = new ApacheDsEmbedded("o=sevenSeas", "seven-seas.ldif");
+ *
+ * @BeforeClass public static void startServer() throws Exception {
+ * ldap.startServer();
+ * }
+ * @AfterClass public static void stopServer() throws Exception {
+ * ldap.stopAndCleanup();
+ * }
+ * 
+ */ +public class ApacheDsEmbedded { + /** + * The child JVM ordinal of this JVM. Placed by the testing framework. Default is 0 Sequential number starting with 0 + */ + public static final int CHILD_JVM_ID = Integer.parseInt(System.getProperty(SysGlobals.CHILDVM_SYSPROP_JVM_ID, "0")); + + private final File workDir; + private final String baseDN; + private final String ldifFileName; + private final int port; + /** + * The directory service + */ + private DirectoryService service; + + /** + * The LDAP server + */ + private LdapServer server; + + + /** + * Creates a new instance of EmbeddedADS. It initializes the directory service. + * + * @throws Exception If something went wrong + */ + public ApacheDsEmbedded(String baseDN, String ldifFileName, String testName) { + this.workDir = new File(System.getProperty("java.io.tmpdir") + "/server-work/" + testName); + this.baseDN = baseDN; + this.ldifFileName = ldifFileName; + this.port = 10389 + CHILD_JVM_ID; + } + + public int getPort() { + return port; + } + + + public String getUrl() { + return "ldap://localhost:" + port; + } + + /** + * starts the LdapServer + * + * @throws Exception + */ + public void startServer() throws Exception { + initDirectoryService(workDir, baseDN, ldifFileName); + loadSchema(ldifFileName); + + server = new LdapServer(); + server.setTransports(new TcpTransport(port)); + server.setDirectoryService(service); + + server.start(); + } + + /** + * This will cleanup the junk left on the file system and shutdown the server + * + * @throws Exception + */ + public void stopAndCleanup() throws Exception { + if (server != null) server.stop(); + if (service != null) service.shutdown(); + workDir.delete(); + } + + + /** + * Add a new partition to the server + * + * @param partitionId The partition Id + * @param partitionDn The partition DN + * @param dnFactory the DN factory + * @return The newly added partition + * @throws Exception If the partition can't be added + */ + private Partition addPartition(String partitionId, String partitionDn, DnFactory dnFactory) throws Exception { + // Create a new partition with the given partition id + JdbmPartition partition = new JdbmPartition(service.getSchemaManager(), dnFactory); + partition.setId(partitionId); + partition.setPartitionPath(new File(service.getInstanceLayout().getPartitionsDirectory(), partitionId).toURI()); + partition.setSuffixDn(new Dn(partitionDn)); + service.addPartition(partition); + + return partition; + } + + + /** + * Add a new set of index on the given attributes + * + * @param partition The partition on which we want to add index + * @param attrs The list of attributes to index + */ + private void addIndex(Partition partition, String... attrs) { + // Index some attributes on the apache partition + Set indexedAttributes = new HashSet(); + + for (String attribute : attrs) { + indexedAttributes.add(new JdbmIndex(attribute, false)); + } + + ((JdbmPartition) partition).setIndexedAttributes(indexedAttributes); + } + + + /** + * initialize the schema manager and add the schema partition to diectory service + * + * @throws Exception if the schema LDIF files are not found on the classpath + */ + private void initSchemaPartition() throws Exception { + InstanceLayout instanceLayout = service.getInstanceLayout(); + + File schemaPartitionDirectory = new File(instanceLayout.getPartitionsDirectory(), "schema"); + + // Extract the schema on disk (a brand new one) and load the registries + if (schemaPartitionDirectory.exists()) { + System.out.println("schema partition already exists, skipping schema extraction"); + } else { + SchemaLdifExtractor extractor = new DefaultSchemaLdifExtractor(instanceLayout.getPartitionsDirectory()); + extractor.extractOrCopy(); + } + + SchemaLoader loader = new LdifSchemaLoader(schemaPartitionDirectory); + SchemaManager schemaManager = new DefaultSchemaManager(loader); + + // We have to load the schema now, otherwise we won't be able + // to initialize the Partitions, as we won't be able to parse + // and normalize their suffix Dn + schemaManager.loadAllEnabled(); + + List errors = schemaManager.getErrors(); + + if (errors.size() != 0) { + throw new Exception(I18n.err(I18n.ERR_317, Exceptions.printErrors(errors))); + } + + service.setSchemaManager(schemaManager); + + // Init the LdifPartition with schema + LdifPartition schemaLdifPartition = new LdifPartition(schemaManager, service.getDnFactory()); + schemaLdifPartition.setPartitionPath(schemaPartitionDirectory.toURI()); + + // The schema partition + SchemaPartition schemaPartition = new SchemaPartition(schemaManager); + schemaPartition.setWrappedPartition(schemaLdifPartition); + service.setSchemaPartition(schemaPartition); + } + + /** + * Initialize the server. It creates the partition, adds the index, and + * injects the context entries for the created partitions. + * + * @param workDir the directory to be used for storing the data + * @param baseDn + * @param ldifFileName @throws Exception if there were some problems while initializing the system + */ + private void initDirectoryService(File workDir, String baseDn, String ldifFileName) throws Exception { + // Initialize the LDAP service + service = new DefaultDirectoryService(); + service.setInstanceLayout(new InstanceLayout(workDir)); + + CacheService cacheService = new CacheService(); + cacheService.initialize(service.getInstanceLayout()); + + service.setCacheService(cacheService); + + // first load the schema + initSchemaPartition(); + + // then the system partition + // this is a MANDATORY partition + // DO NOT add this via addPartition() method, trunk code complains about duplicate partition + // while initializing + JdbmPartition systemPartition = new JdbmPartition(service.getSchemaManager(), service.getDnFactory()); + systemPartition.setId("system"); + systemPartition.setPartitionPath(new File(service.getInstanceLayout().getPartitionsDirectory(), systemPartition.getId()).toURI()); + systemPartition.setSuffixDn(new Dn(ServerDNConstants.SYSTEM_DN)); + systemPartition.setSchemaManager(service.getSchemaManager()); + + // mandatory to call this method to set the system partition + // Note: this system partition might be removed from trunk + service.setSystemPartition(systemPartition); + + // Disable the ChangeLog system + service.getChangeLog().setEnabled(false); + service.setDenormalizeOpAttrsEnabled(true); + + Partition apachePartition = addPartition("ldapTest", baseDn, service.getDnFactory()); + + + // Index some attributes on the apache partition + addIndex(apachePartition, "objectClass", "ou", "uid"); + + // And start the service + service.startup(); + + + // Inject the context entry for dc=Apache,dc=Org partition + if (!service.getAdminSession().exists(apachePartition.getSuffixDn())) { + Dn dnApache = new Dn(baseDn); + Entry entryApache = service.newEntry(dnApache); + entryApache.add("objectClass", "top", "domain", "extensibleObject"); + entryApache.add("dc", "Apache"); + service.getAdminSession().add(entryApache); + } + + // We are all done ! + } + + private void loadSchema(String ldifFileName) throws URISyntaxException { + // Load the directory as a resource + URL dir_url = this.getClass().getResource(ldifFileName); + if (dir_url == null) throw new NullPointerException("the LDIF file doesn't exist: " + ldifFileName); + File ldifFile = new File(dir_url.toURI()); + LdifFileLoader ldifLoader = new LdifFileLoader(service.getAdminSession(), ldifFile, Collections.EMPTY_LIST); + ldifLoader.execute(); + } + + /** + * Main class. + * + * @param args Not used. + */ + public static void main(String[] args) { + try { + String baseDir = "o=sevenSeas"; + String ldifImport = "seven-seas.ldif"; + // Create the server + ApacheDsEmbedded ads = new ApacheDsEmbedded(baseDir, ldifImport, "test"); + ads.startServer(); + // Read an entry + Entry result = ads.service.getAdminSession().lookup(new Dn(baseDir)); + + // And print it if available + System.out.println("Found entry : " + result); + + // optionally we can start a server too + ads.stopAndCleanup(); + } catch (Exception e) { + // Ok, we have something wrong going on ... + e.printStackTrace(); + } + } +} diff --git a/src/test/java/org/elasticsearch/shield/authc/ldap/LdapConnectionTests.java b/src/test/java/org/elasticsearch/shield/authc/ldap/LdapConnectionTests.java new file mode 100644 index 00000000000..1a52d2da23e --- /dev/null +++ b/src/test/java/org/elasticsearch/shield/authc/ldap/LdapConnectionTests.java @@ -0,0 +1,127 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +package org.elasticsearch.shield.authc.ldap; + +import org.elasticsearch.common.settings.ImmutableSettings; +import org.elasticsearch.common.settings.Settings; +import org.elasticsearch.test.ElasticsearchTestCase; +import org.junit.AfterClass; +import org.junit.BeforeClass; +import org.junit.Test; + +import java.util.List; +import java.util.Map; + +import static org.hamcrest.Matchers.*; + +public class LdapConnectionTests extends ElasticsearchTestCase { + public static String SETTINGS_PREFIX = LdapRealm.class.getPackage().getName().substring("com.elasticsearch.".length()) + '.'; + + static ApacheDsEmbedded ldap = new ApacheDsEmbedded("o=sevenSeas", "seven-seas.ldif", LdapConnectionTests.class.getName()); + + @BeforeClass + public static void startServer() throws Exception { + ldap.startServer(); + } + @AfterClass + public static void stopServer() throws Exception { + ldap.stopAndCleanup(); + } + + @Test + public void testBindWithTemplates() { + String[] ldapUrls = new String[]{ldap.getUrl()}; + String groupSearchBase = "o=sevenSeas"; + boolean isSubTreeSearch = true; + String[] userTemplates = new String[]{ + "cn={0},ou=something,ou=obviously,ou=incorrect,o=sevenSeas", + "wrongname={0},ou=people,o=sevenSeas", + "cn={0},ou=people,o=sevenSeas", //this last one should work + }; + StandardLdapConnectionFactory connectionFactory = new StandardLdapConnectionFactory( + buildLdapSettings(ldapUrls, userTemplates, groupSearchBase, isSubTreeSearch)); + + String user = "Horatio Hornblower"; + char[] userPass = "pass".toCharArray(); + + LdapConnection ldap = connectionFactory.bind(user, userPass); + Map attrs = ldap.getUserAttrs(ldap.getAuthenticatedUserDn()); + + assertThat( attrs, hasKey("uid")); + assertThat( attrs.get("uid"), arrayContaining("hhornblo")); + } + @Test + public void testBindWithBogusTemplates() { + String[] ldapUrl = new String[]{ldap.getUrl()}; + String groupSearchBase = "o=sevenSeas"; + boolean isSubTreeSearch = true; + String[] userTemplates = new String[]{ + "cn={0},ou=something,ou=obviously,ou=incorrect,o=sevenSeas", + "wrongname={0},ou=people,o=sevenSeas", + "asdf={0},ou=people,o=sevenSeas", //none of these should work + }; + StandardLdapConnectionFactory ldapFac = new StandardLdapConnectionFactory( + buildLdapSettings(ldapUrl, userTemplates, groupSearchBase, isSubTreeSearch)); + + String user = "Horatio Hornblower"; + char[] userPass = "pass".toCharArray(); + + try { + LdapConnection ldap = ldapFac.bind(user, userPass); + fail("bindWithUserTemplates should have failed"); + } catch (LdapException le) { + + } + + } + + @Test + public void testGroupLookup_Subtree() { + String groupSearchBase = "o=sevenSeas"; + String userTemplate = "cn={0},ou=people,o=sevenSeas"; + + boolean isSubTreeSearch = true; + StandardLdapConnectionFactory ldapFac = new StandardLdapConnectionFactory( + buildLdapSettings(ldap.getUrl(), userTemplate, groupSearchBase, isSubTreeSearch)); + + String user = "Horatio Hornblower"; + char[] userPass = "pass".toCharArray(); + + LdapConnection ldap = ldapFac.bind(user, userPass); + List groups = ldap.getGroupsFromSearch(ldap.getAuthenticatedUserDn()); + System.out.println("groups:"+groups); + assertThat(groups, contains("cn=HMS Lydia,ou=crews,ou=groups,o=sevenSeas")); + } + + @Test + public void testGroupLookup_OneLevel() { + String groupSearchBase = "ou=crews,ou=groups,o=sevenSeas"; + String userTemplate = "cn={0},ou=people,o=sevenSeas"; + boolean isSubTreeSearch = false; + StandardLdapConnectionFactory ldapFac = new StandardLdapConnectionFactory( + buildLdapSettings(ldap.getUrl(), userTemplate, groupSearchBase, isSubTreeSearch)); + + String user = "Horatio Hornblower"; + LdapConnection ldap = ldapFac.bind(user, "pass".toCharArray()); + + List groups = ldap.getGroupsFromSearch(ldap.getAuthenticatedUserDn()); + System.out.println("groups:"+groups); + assertThat(groups, contains("cn=HMS Lydia,ou=crews,ou=groups,o=sevenSeas")); + } + + public static Settings buildLdapSettings(String ldapUrl, String userTemplate, String groupSearchBase, boolean isSubTreeSearch) { + return buildLdapSettings( new String[]{ldapUrl}, new String[]{userTemplate}, groupSearchBase, isSubTreeSearch ); + } + + public static Settings buildLdapSettings(String[] ldapUrl, String[] userTemplate, String groupSearchBase, boolean isSubTreeSearch) { + return ImmutableSettings.builder() + .putArray(SETTINGS_PREFIX + StandardLdapConnectionFactory.URLS_SETTING, ldapUrl) + .putArray(SETTINGS_PREFIX + StandardLdapConnectionFactory.USER_DN_TEMPLATES_SETTING, userTemplate) + .put(SETTINGS_PREFIX + StandardLdapConnectionFactory.GROUP_SEARCH_BASEDN_SETTING, groupSearchBase) + .put(SETTINGS_PREFIX + StandardLdapConnectionFactory.GROUP_SEARCH_SUBTREE_SETTING, isSubTreeSearch).build(); + } + +} diff --git a/src/test/java/org/elasticsearch/shield/authc/ldap/LdapGroupToRoleMapperTest.java b/src/test/java/org/elasticsearch/shield/authc/ldap/LdapGroupToRoleMapperTest.java new file mode 100644 index 00000000000..7546f3c408d --- /dev/null +++ b/src/test/java/org/elasticsearch/shield/authc/ldap/LdapGroupToRoleMapperTest.java @@ -0,0 +1,70 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +package org.elasticsearch.shield.authc.ldap; + +import org.elasticsearch.common.settings.ImmutableSettings; +import org.elasticsearch.common.settings.Settings; +import org.elasticsearch.env.Environment; +import org.elasticsearch.test.ElasticsearchTestCase; +import org.elasticsearch.threadpool.ThreadPool; +import org.elasticsearch.watcher.ResourceWatcherService; +import org.junit.Test; + +import java.io.File; +import java.io.IOException; +import java.util.Arrays; +import java.util.Set; + +import static org.hamcrest.Matchers.hasItems; + +public class LdapGroupToRoleMapperTest extends ElasticsearchTestCase { + + private final String tonyStarkDN = "cn=tstark,ou=marvel,o=superheros"; + private final String[] starkGroupDns = new String[] { + //groups can be named by different attributes, depending on the directory, + //we don't care what it is named by + "cn=shield,ou=marvel,o=superheros", + "cn=avengers,ou=marvel,o=superheros", + "group=genius, dc=mit, dc=edu", + "groupName = billionaire , ou = acme", + "gid = playboy , dc = example , dc = com", + "groupid=philanthropist,ou=groups,dc=unitedway,dc=org" + }; + private final String roleShield = "shield"; + private final String roleAvenger = "avenger"; + + + @Test + public void testYaml() throws IOException { + File file = this.getResource("role_mapping.yml"); + Settings settings = ImmutableSettings.settingsBuilder() + .put("shield.authc.ldap." + LdapGroupToRoleMapper.ROLE_MAPPING_FILE_SETTING, file.getCanonicalPath()) + .build(); + + LdapGroupToRoleMapper mapper = new LdapGroupToRoleMapper(settings, + new Environment(settings), + new ResourceWatcherService(settings, new ThreadPool("test"))); + + Set roles = mapper.mapRoles( Arrays.asList(starkGroupDns) ); + + //verify + assertThat(roles, hasItems(roleShield, roleAvenger)); + } + + @Test + public void testRelativeDN() { + Settings settings = ImmutableSettings.builder() + .put("shield.authc.ldap." + LdapGroupToRoleMapper.USE_UNMAPPED_GROUPS_AS_ROLES_SETTING, true) + .build(); + + LdapGroupToRoleMapper mapper = new LdapGroupToRoleMapper(settings, + new Environment(settings), + new ResourceWatcherService(settings, new ThreadPool("test"))); + + Set roles = mapper.mapRoles(Arrays.asList(starkGroupDns)); + assertThat(roles, hasItems("genius", "billionaire", "playboy", "philanthropist", "shield", "avengers")); + } +} diff --git a/src/test/java/org/elasticsearch/shield/authc/ldap/LdapRealmTest.java b/src/test/java/org/elasticsearch/shield/authc/ldap/LdapRealmTest.java new file mode 100644 index 00000000000..3e1a9ff3f7d --- /dev/null +++ b/src/test/java/org/elasticsearch/shield/authc/ldap/LdapRealmTest.java @@ -0,0 +1,167 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +package org.elasticsearch.shield.authc.ldap; + +import org.elasticsearch.common.settings.ImmutableSettings; +import org.elasticsearch.common.settings.Settings; +import org.elasticsearch.env.Environment; +import org.elasticsearch.shield.User; +import org.elasticsearch.shield.authc.support.UsernamePasswordToken; +import org.elasticsearch.test.ElasticsearchTestCase; +import org.elasticsearch.threadpool.ThreadPool; +import org.elasticsearch.watcher.ResourceWatcherService; +import org.junit.AfterClass; +import org.junit.BeforeClass; +import org.junit.Ignore; +import org.junit.Test; + +import static org.hamcrest.Matchers.*; +import static org.mockito.Matchers.any; +import static org.mockito.Matchers.anyString; +import static org.mockito.Mockito.*; + +public class LdapRealmTest extends ElasticsearchTestCase { + static ApacheDsEmbedded ldap = new ApacheDsEmbedded("o=sevenSeas", "seven-seas.ldif", LdapRealmTest.class.getName()); + public static String AD_IP = "54.213.145.20"; + public static String AD_URL = "ldap://" + AD_IP + ":389"; + + public static final String VALID_USER_TEMPLATE = "cn={0},ou=people,o=sevenSeas"; + public static final String VALID_USERNAME = "Thomas Masterman Hardy"; + public static final String PASSWORD = "pass"; + + @BeforeClass + public static void startServer() throws Exception { + ldap.startServer(); + } + @AfterClass + public static void stopServer() throws Exception { + ldap.stopAndCleanup(); + } + + @Test + public void testAuthenticate_subTreeGroupSearch(){ + String groupSearchBase = "o=sevenSeas"; + boolean isSubTreeSearch = true; + String userTemplate = VALID_USER_TEMPLATE; + Settings settings = LdapConnectionTests.buildLdapSettings(ldap.getUrl(), userTemplate, groupSearchBase, isSubTreeSearch); + StandardLdapConnectionFactory ldapFactory = new StandardLdapConnectionFactory(settings); + LdapRealm ldap = new LdapRealm(buildNonCachingSettings(), ldapFactory, buildGroupAsRoleMapper()); + + User user = ldap.authenticate(new UsernamePasswordToken(VALID_USERNAME, PASSWORD.toCharArray())); + assertThat( user, notNullValue()); + assertThat(user.roles(), arrayContaining("HMS Victory")); + } + + @Test + public void testAuthenticate_oneLevelGroupSearch(){ + String groupSearchBase = "ou=crews,ou=groups,o=sevenSeas"; + boolean isSubTreeSearch = false; + String userTemplate = VALID_USER_TEMPLATE; + StandardLdapConnectionFactory ldapFactory = new StandardLdapConnectionFactory( + LdapConnectionTests.buildLdapSettings(ldap.getUrl(), userTemplate, groupSearchBase, isSubTreeSearch)); + + LdapRealm ldap = new LdapRealm(buildNonCachingSettings(), ldapFactory, buildGroupAsRoleMapper()); + + User user = ldap.authenticate(new UsernamePasswordToken(VALID_USERNAME, PASSWORD.toCharArray())); + assertThat( user, notNullValue()); + assertThat( user.roles(), arrayContaining("HMS Victory")); + } + + @Ignore //this is still failing. not sure why. + @Test + public void testAuthenticate_caching(){ + String groupSearchBase = "o=sevenSeas"; + boolean isSubTreeSearch = true; + String userTemplate = VALID_USER_TEMPLATE; + StandardLdapConnectionFactory ldapFactory = new StandardLdapConnectionFactory( + LdapConnectionTests.buildLdapSettings( ldap.getUrl(), userTemplate, groupSearchBase, isSubTreeSearch) ); + + ldapFactory = spy(ldapFactory); + LdapRealm ldap = new LdapRealm( buildCachingSettings(), ldapFactory, buildGroupAsRoleMapper()); + User user = ldap.authenticate( new UsernamePasswordToken(VALID_USERNAME, PASSWORD.toCharArray())); + user = ldap.authenticate( new UsernamePasswordToken(VALID_USERNAME, PASSWORD.toCharArray())); + + //verify one and only one bind -> caching is working + verify(ldapFactory, times(1)).bind(anyString(), any(char[].class)); + } + + @Test + public void testAuthenticate_noncaching(){ + String groupSearchBase = "o=sevenSeas"; + boolean isSubTreeSearch = true; + String userTemplate = VALID_USER_TEMPLATE; + StandardLdapConnectionFactory ldapFactory = new StandardLdapConnectionFactory( + LdapConnectionTests.buildLdapSettings(ldap.getUrl(), userTemplate, groupSearchBase, isSubTreeSearch) ); + + ldapFactory = spy(ldapFactory); + LdapRealm ldap = new LdapRealm( buildNonCachingSettings(), ldapFactory, buildGroupAsRoleMapper()); + User user = ldap.authenticate( new UsernamePasswordToken(VALID_USERNAME, PASSWORD.toCharArray())); + user = ldap.authenticate( new UsernamePasswordToken(VALID_USERNAME, PASSWORD.toCharArray())); + + //verify two and only two binds -> caching is disabled + verify(ldapFactory, times(2)).bind(anyString(), any(char[].class)); + } + + @Ignore + @Test + public void testAD() { + String adDomain = "ad.test.elasticsearch.com"; + String userSearchBaseDN = "dc=ad,dc=es,dc=com"; + + ActiveDirectoryConnectionFactory ldapFactory = new ActiveDirectoryConnectionFactory( + ActiveDirectoryFactoryTests.buildAdSettings(AD_URL, adDomain)); + + LdapRealm ldap = new LdapRealm( buildNonCachingSettings(), ldapFactory, buildGroupAsRoleMapper()); + + User user = ldap.authenticate( new UsernamePasswordToken("george", "R))Tr0x".toCharArray())); + + assertThat( user, notNullValue()); + assertThat( user.roles(), hasItemInArray("upchuckers")); + } + + @Ignore + @Test + public void testAD_defaults() { + //only set the adDomain, and see if it infers the rest correctly + String adDomain = AD_IP; + Settings settings = ImmutableSettings.builder() + .put(LdapConnectionTests.SETTINGS_PREFIX + ActiveDirectoryConnectionFactory.AD_DOMAIN_NAME_SETTING, adDomain) + .build(); + + ActiveDirectoryConnectionFactory ldapFactory = new ActiveDirectoryConnectionFactory( settings ); + LdapRealm ldap = new LdapRealm( buildNonCachingSettings(), ldapFactory, buildGroupAsRoleMapper()); + User user = ldap.authenticate( new UsernamePasswordToken("george", "R))Tr0x".toCharArray())); + + assertThat( user, notNullValue()); + assertThat( user.roles(), hasItemInArray("upchuckers")); + } + + + + private Settings buildNonCachingSettings() { + return ImmutableSettings.builder() + .put("shield.authc.ldap."+LdapRealm.CACHE_TTL, -1) + .build(); + } + + private Settings buildCachingSettings() { + return ImmutableSettings.builder() + .put("shield.authc.ldap."+LdapRealm.CACHE_TTL, 1) + .put("shield.authc.ldap."+LdapRealm.CACHE_MAX_USERS, 10) + .build(); + } + + private LdapGroupToRoleMapper buildGroupAsRoleMapper() { + Settings settings = ImmutableSettings.builder() + .put("shield.authc.ldap." + LdapGroupToRoleMapper.USE_UNMAPPED_GROUPS_AS_ROLES_SETTING, true) + .build(); + + return new LdapGroupToRoleMapper(settings, + new Environment(settings), + new ResourceWatcherService(settings, new ThreadPool("test"))); + + } +} diff --git a/src/test/resources/org/elasticsearch/shield/authc/ldap/ldapWithGroupSearch.yaml b/src/test/resources/org/elasticsearch/shield/authc/ldap/ldapWithGroupSearch.yaml new file mode 100644 index 00000000000..db70f28232a --- /dev/null +++ b/src/test/resources/org/elasticsearch/shield/authc/ldap/ldapWithGroupSearch.yaml @@ -0,0 +1,11 @@ +# This LDAP connection does group lookup by a subtree search, no role mapping +ldap: + urls: + - ldap://ldap.example.com:1389 + userDnTemplate: "cn={0},ou=people,o=superheros" + groupSearch: + isSubtreeSearch: "true" + baseDn: "ou=marvel,o=superheros" + # no dnToRoleMapping mapping implies that the group name will be used directly as the ES role name + + diff --git a/src/test/resources/org/elasticsearch/shield/authc/ldap/ldapWithRoleMapping.yaml b/src/test/resources/org/elasticsearch/shield/authc/ldap/ldapWithRoleMapping.yaml new file mode 100644 index 00000000000..d98f48e90bc --- /dev/null +++ b/src/test/resources/org/elasticsearch/shield/authc/ldap/ldapWithRoleMapping.yaml @@ -0,0 +1,10 @@ +# This LDAP connection does group lookup by attribute with group to role mapping +ldap: + urls: # these connections are not round-robin, but primary, secondary, etc. When the first fails the second is attempted + - ldap://ldap.example.com:1389 + userDnTemplate: "cn={0},ou=people,o=superheros" + + # dnToRoleMapping as true means that group will be mapped to + # local ES roles using + dnToRoleMapping: true + #roleMappingFile: /etc/es/role_mapping.yaml diff --git a/src/test/resources/org/elasticsearch/shield/authc/ldap/role_mapping.yml b/src/test/resources/org/elasticsearch/shield/authc/ldap/role_mapping.yml new file mode 100644 index 00000000000..67e8fc14d9c --- /dev/null +++ b/src/test/resources/org/elasticsearch/shield/authc/ldap/role_mapping.yml @@ -0,0 +1,5 @@ +shield: + - "cn=avengers,ou=marvel,o=superheros" + - "cn=shield,ou=marvel,o=superheros" +avenger: + - "cn=avengers,ou=marvel,o=superheros" \ No newline at end of file diff --git a/src/test/resources/org/elasticsearch/shield/authc/ldap/seven-seas.ldif b/src/test/resources/org/elasticsearch/shield/authc/ldap/seven-seas.ldif new file mode 100644 index 00000000000..f9462276e22 --- /dev/null +++ b/src/test/resources/org/elasticsearch/shield/authc/ldap/seven-seas.ldif @@ -0,0 +1,219 @@ +# Sample LDIF data for the ApacheDS v1.0 Basic User's Guide +# +# Some sailors and their ships +# userpassword for all persons is "pass" +# +version: 1 + +dn: ou=people,o=sevenSeas +objectclass: organizationalUnit +objectclass: top +description: Contains entries which describe persons (seamen) +ou: people + +dn: ou=groups,o=sevenSeas +objectclass: organizationalUnit +objectclass: top +description: Contains entries which describe groups (crews, for instance) +ou: groups + +dn: ou=crews,ou=groups,o=sevenSeas +objectclass: organizationalUnit +objectclass: top +description: Contains entries which describe ship crews +ou: crews + +dn: ou=ranks,ou=groups,o=sevenSeas +objectclass: organizationalUnit +objectclass: top +description: Contains entries which describe naval ranks (e.g. captain) +ou: ranks + +# HMS Lydia Crew +# -------------- + +dn: cn=Horatio Hornblower,ou=people,o=sevenSeas +objectclass: person +objectclass: organizationalPerson +objectclass: inetOrgPerson +objectclass: top +cn: Horatio Hornblower +description: Capt. Horatio Hornblower, R.N +givenname: Horatio +sn: Hornblower +uid: hhornblo +mail: hhornblo@royalnavy.mod.uk +userpassword: {SHA}nU4eI71bcnBGqeO0t9tXvY1u5oQ= + +dn: cn=William Bush,ou=people,o=sevenSeas +objectclass: person +objectclass: organizationalPerson +objectclass: inetOrgPerson +objectclass: top +cn: William Bush +description: Lt. William Bush +givenname: William +manager: cn=Horatio Hornblower,ou=people,o=sevenSeas +sn: Bush +uid: wbush +mail: wbush@royalnavy.mod.uk +userpassword: {SHA}nU4eI71bcnBGqeO0t9tXvY1u5oQ= + +dn: cn=Thomas Quist,ou=people,o=sevenSeas +objectclass: person +objectclass: organizationalPerson +objectclass: inetOrgPerson +objectclass: top +cn: Thomas Quist +description: Seaman Quist +givenname: Thomas +manager: cn=Horatio Hornblower,ou=people,o=sevenSeas +sn: Quist +uid: tquist +mail: tquist@royalnavy.mod.uk +userpassword: {SHA}nU4eI71bcnBGqeO0t9tXvY1u5oQ= + +dn: cn=Moultrie Crystal,ou=people,o=sevenSeas +objectclass: person +objectclass: organizationalPerson +objectclass: inetOrgPerson +objectclass: top +cn: Moultrie Crystal +description: Lt. Crystal +givenname: Moultrie +manager: cn=Horatio Hornblower,ou=people,o=sevenSeas +sn: Crystal +uid: mchrysta +mail: mchrysta@royalnavy.mod.uk +userpassword: {SHA}nU4eI71bcnBGqeO0t9tXvY1u5oQ= + +dn: cn=HMS Lydia,ou=crews,ou=groups,o=sevenSeas +objectclass: groupOfUniqueNames +objectclass: top +cn: HMS Lydia +uniquemember: cn=Horatio Hornblower,ou=people,o=sevenSeas +uniquemember: cn=William Bush,ou=people,o=sevenSeas +uniquemember: cn=Thomas Quist,ou=people,o=sevenSeas +uniquemember: cn=Moultrie Crystal,ou=people,o=sevenSeas + +# HMS Victory Crew +# ---------------- + +dn: cn=Horatio Nelson,ou=people,o=sevenSeas +objectclass: person +objectclass: organizationalPerson +objectclass: inetOrgPerson +objectclass: top +cn: Horatio Nelson +description: Lord Horatio Nelson +givenname: Horatio +sn: Nelson +uid: hnelson +mail: hnelson@royalnavy.mod.uk +userpassword: {SHA}nU4eI71bcnBGqeO0t9tXvY1u5oQ= + +dn: cn=Thomas Masterman Hardy,ou=people,o=sevenSeas +objectclass: person +objectclass: organizationalPerson +objectclass: inetOrgPerson +objectclass: top +cn: Thomas Masterman Hardy +description: Sir Thomas Masterman Hardy +givenname: Thomas +manager: cn=Horatio Nelson,ou=people,o=sevenSeas +sn: Hardy +uid: thardy +mail: thardy@royalnavy.mod.uk +userpassword: {SHA}nU4eI71bcnBGqeO0t9tXvY1u5oQ= + +dn: cn=Cornelius Buckley,ou=people,o=sevenSeas +objectclass: person +objectclass: organizationalPerson +objectclass: inetOrgPerson +objectclass: top +cn: Cornelius Buckley +description: LM Cornelius Buckley +givenname: Cornelius +manager: cn=Horatio Nelson,ou=people,o=sevenSeas +sn: Buckley +uid: cbuckley +mail: cbuckley@royalnavy.mod.uk +userpassword: {SHA}nU4eI71bcnBGqeO0t9tXvY1u5oQ= + +dn: cn=HMS Victory,ou=crews,ou=groups,o=sevenSeas +objectclass: groupOfUniqueNames +objectclass: top +cn: HMS Victory +uniquemember: cn=Horatio Nelson,ou=people,o=sevenSeas +uniquemember: cn=Thomas Masterman Hardy,ou=people,o=sevenSeas +uniquemember: cn=Cornelius Buckley,ou=people,o=sevenSeas + +# HMS Bounty Crew +# --------------- + +dn: cn=William Bligh,ou=people,o=sevenSeas +objectclass: person +objectclass: organizationalPerson +objectclass: inetOrgPerson +objectclass: top +cn: William Bligh +description: Captain William Bligh +givenname: William +sn: Bligh +uid: wbligh +mail: wbligh@royalnavy.mod.uk +userpassword: {SHA}nU4eI71bcnBGqeO0t9tXvY1u5oQ= + +dn: cn=Fletcher Christian,ou=people,o=sevenSeas +objectclass: person +objectclass: organizationalPerson +objectclass: inetOrgPerson +objectclass: top +cn: Fletcher Christian +description: Lieutenant Fletcher Christian +givenname: Fletcher +manager: cn=William Bligh,ou=people,o=sevenSeas +sn: Christian +uid: fchristi +mail: fchristi@royalnavy.mod.uk +userpassword: {SHA}nU4eI71bcnBGqeO0t9tXvY1u5oQ= + +dn: cn=John Fryer,ou=people,o=sevenSeas +objectclass: person +objectclass: organizationalPerson +objectclass: inetOrgPerson +objectclass: top +cn: John Fryer +description: Master John Fryer +givenname: John +manager: cn=William Bligh,ou=people,o=sevenSeas +sn: Fryer +uid: jfryer +mail: jfryer@royalnavy.mod.uk +userpassword: {SHA}nU4eI71bcnBGqeO0t9tXvY1u5oQ= + +dn: cn=John Hallett,ou=people,o=sevenSeas +objectclass: person +objectclass: organizationalPerson +objectclass: inetOrgPerson +objectclass: top +cn: John Hallett +description: Midshipman John Hallett +givenname: John +manager: cn=William Bligh,ou=people,o=sevenSeas +sn: Hallett +uid: jhallett +mail: jhallett@royalnavy.mod.uk +userpassword: {SHA}nU4eI71bcnBGqeO0t9tXvY1u5oQ= + +dn: cn=HMS Bounty,ou=crews,ou=groups,o=sevenSeas +objectclass: groupOfUniqueNames +objectclass: top +cn: HMS Bounty +uniquemember: cn=William Bligh,ou=people,o=sevenSeas +uniquemember: cn=Fletcher Christian,ou=people,o=sevenSeas +uniquemember: cn=John Fryer,ou=people,o=sevenSeas +uniquemember: cn=John Hallett,ou=people,o=sevenSeas + + + diff --git a/tests.policy b/tests.policy index d206149a6b6..ad06580d8b8 100644 --- a/tests.policy +++ b/tests.policy @@ -31,4 +31,12 @@ grant { // Needed for accept all ssl certs in tests permission javax.net.ssl.SSLPermission "setHostnameVerifier"; + // Needed to startup embedded apacheDS LDAP server for tests + permission java.security.SecurityPermission "putProviderProperty.BC"; + permission java.security.SecurityPermission "insertProvider.BC"; + permission java.security.SecurityPermission "getProperty.ssl.KeyManagerFactory.algorithm"; + + //this shouldn't be in a production environment, just to run tests: + permission java.lang.reflect.ReflectPermission "suppressAccessChecks"; + permission java.lang.RuntimePermission "setDefaultUncaughtExceptionHandler"; };