Skip to content

Commit

Permalink
[NOID] Fixes #3775: apoc.load.ldap doesn't work with SSL (#4010)
Browse files Browse the repository at this point in the history
  • Loading branch information
vga91 committed Apr 2, 2024
1 parent 8ec6391 commit 865569e
Show file tree
Hide file tree
Showing 5 changed files with 154 additions and 117 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,6 @@
With 'apoc.load.ldap' you can execute queries on any LDAP v3 enabled directory, the results are turned into a streams of entries.
The entries can then be used to update or create graph structures.

Note this utility requires to have the link:https://mvnrepository.com/artifact/com.novell.ldap/jldap/2009-10-07[jldap] library to be placed the plugin directory.

[separator=¦,opts=header,cols="5,1m,1m"]
|===
¦Qualified Name¦Type¦Release
Expand All @@ -24,6 +22,7 @@ include::example$generated-documentation/apoc.load.ldap.adoc[]
|{connectionMap} | ldapHost | the ldapserver:port if port is omitted the default port 389 will be used
| | loginDN | This is the dn of the ldap server user who has read access on the ldap server
| | loginPW | This is the password used by the loginDN
| | ssl | Boolean which indicates whether to use SSL connection or not (default false)
|{searchMap} | searchBase | From this entry a search is executed
| | searchScope | SCOPE_ONE (one level) or
SCOPE_SUB (all sub levels) or
Expand Down
2 changes: 1 addition & 1 deletion full/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ dependencies {
// compile group: 'commons-codec', name: 'commons-codec', version: '1.14'
implementation group: 'org.hdrhistogram', name: 'HdrHistogram', version: '2.1.9'
compileOnly group: 'org.neo4j.driver', name: 'neo4j-java-driver', version: '4.4.14'
implementation group: 'com.novell.ldap', name: 'jldap', version: '2009-10-07'
implementation group: 'com.unboundid', name: 'unboundid-ldapsdk', version: '6.0.11'

// If updated check if the transitive dependency to org.antlr:ST4:4.1 has also updated
// and remove the manual licensing check for it in licenses-3rdparties.gradle
Expand Down
179 changes: 70 additions & 109 deletions full/src/main/java/apoc/load/LoadLdap.java
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,17 @@
import static apoc.ApocConfig.apocConfig;

import apoc.Extended;
import apoc.util.Util;
import com.novell.ldap.*;
import java.io.UnsupportedEncodingException;
import com.unboundid.ldap.sdk.*;
import com.unboundid.ldap.sdk.SearchResult;
import com.unboundid.ldap.sdk.SearchScope;
import com.unboundid.util.ssl.SSLUtil;
import com.unboundid.util.ssl.TrustAllTrustManager;
import java.security.GeneralSecurityException;
import java.util.*;
import java.util.stream.Stream;
import java.util.stream.StreamSupport;
import javax.net.ssl.SSLSocketFactory;
import org.neo4j.logging.Log;
import org.neo4j.procedure.Context;
import org.neo4j.procedure.Description;
Expand Down Expand Up @@ -95,6 +101,7 @@ public static class LDAPManager {
private static final String LDAP_HOST_P = "ldapHost";
private static final String LDAP_LOGIN_DN_P = "loginDN";
private static final String LDAP_LOGIN_PW_P = "loginPW";
private static final String LDAP_SSL = "ssl";
private static final String SEARCH_BASE_P = "searchBase";
private static final String SEARCH_SCOPE_P = "searchScope";
private static final String SEARCH_FILTER_P = "searchFilter";
Expand All @@ -105,10 +112,10 @@ public static class LDAPManager {
private static final String SCOPE_SUB = "SCOPE_SUB";

private int ldapPort;
private int ldapVersion = LDAPConnection.LDAP_V3;
private String ldapHost;
private String loginDN;
private String password;
private boolean ssl;
private LDAPConnection lc;
private List<String> attributeList;

Expand All @@ -125,51 +132,76 @@ public LDAPManager(Map<String, Object> connParms) {

this.loginDN = (String) connParms.get(LDAP_LOGIN_DN_P);
this.password = (String) connParms.get(LDAP_LOGIN_PW_P);
this.ssl = Util.toBoolean(connParms.get(LDAP_SSL));
}

public Stream<LDAPResult> executeSearch(Map<String, Object> search) {
try {
Iterator<Map<String, Object>> supplier = new SearchResultsIterator(doSearch(search), attributeList);
Spliterator<Map<String, Object>> spliterator =
Spliterators.spliteratorUnknownSize(supplier, Spliterator.ORDERED);
return StreamSupport.stream(spliterator, false)
return doSearch(search).getSearchEntries().stream()
.map(i -> getMapFromEntry(i, attributeList))
.map(LDAPResult::new)
.onClose(() -> closeIt(lc));
} catch (Exception e) {
throw new RuntimeException(e);
}
}

public LDAPSearchResults doSearch(Map<String, Object> search) {
private Map<String, Object> getMapFromEntry(SearchResultEntry entry, List<String> attributes) {
Map<String, Object> map = new LinkedHashMap<>(attributes.size() + 1);
map.put("dn", entry.getDN());

if (attributes.isEmpty()) {
entry.getAttributes().forEach(i -> {
Object value = readValue(i);
map.put(i.getName(), value);
});
} else {
for (String attribute : attributes) {
Object value = readValue(entry.getAttribute(attribute));
if (value != null) map.put(attribute, value);
}
}

return map;
}

private Object readValue(Attribute att) {
if (att == null) return null;
if (att.size() == 1) {
// single value
// for now everything is string
return att.getValue();
} else {
return att.getValues();
}
}

public SearchResult doSearch(Map<String, Object> search) {
// parse search parameters
String searchBase = (String) search.get(SEARCH_BASE_P);
String searchFilter = (String) search.get(SEARCH_FILTER_P);
String searchFilter = (String) search.getOrDefault(SEARCH_FILTER_P, "(objectClass=*)");
String sScope = (String) search.get(SEARCH_SCOPE_P);
attributeList = (List<String>) search.get(SEARCH_ATTRIBUTES_P);
if (attributeList == null) attributeList = new ArrayList<>();
int searchScope = LDAPConnection.SCOPE_SUB;
if (sScope.equals(SCOPE_BASE)) {
searchScope = LDAPConnection.SCOPE_BASE;
} else if (sScope.equals(SCOPE_ONE)) {
searchScope = LDAPConnection.SCOPE_ONE;
} else if (sScope.equals(SCOPE_SUB)) {
searchScope = LDAPConnection.SCOPE_SUB;
} else {
throw new RuntimeException(
"Invalid scope:" + sScope + ". value scopes are SCOPE_BASE, SCOPE_ONE and SCOPE_SUB");
}

int searchScope =
switch (sScope) {
case SCOPE_BASE -> SearchScope.BASE_INT_VALUE;
case SCOPE_ONE -> SearchScope.ONE_INT_VALUE;
case SCOPE_SUB -> SearchScope.SUB_INT_VALUE;
default -> throw new RuntimeException(
"Invalid scope:" + sScope + ". value scopes are SCOPE_BASE, SCOPE_ONE and SCOPE_SUB");
};
// getting an ldap connection
try {
lc = getConnection();
// execute query
LDAPSearchConstraints cons = new LDAPSearchConstraints();
cons.setMaxResults(0); // no limit
LDAPSearchResults searchResults = null;
if (attributeList == null || attributeList.size() == 0) {
searchResults = lc.search(searchBase, searchScope, searchFilter, null, false, cons);
SearchResult searchResults;
SearchScope scope = SearchScope.valueOf(searchScope);
if (attributeList.isEmpty()) {
searchResults = lc.search(searchBase, scope, searchFilter);
} else {
searchResults = lc.search(
searchBase, searchScope, searchFilter, attributeList.toArray(new String[0]), false, cons);
searchResults = lc.search(searchBase, scope, searchFilter, attributeList.toArray(new String[0]));
}
return searchResults;
} catch (Exception e) {
Expand All @@ -179,100 +211,29 @@ public LDAPSearchResults doSearch(Map<String, Object> search) {

public static void closeIt(LDAPConnection lc) {
try {
lc.disconnect();
lc.close();
} catch (Exception e) {
// ignore
}
}

private LDAPConnection getConnection() throws LDAPException, UnsupportedEncodingException {
// LDAPSocketFactory ssf;
// Security.addProvider(new com.sun.net.ssl.internal.ssl.Provider());
// String path ="C:\\j2sdk1.4.2_09\\jre\\lib\\security\\cacerts";
// op("the trustStore: " + System.getProperty("javax.net.ssl.trustStore"));
// System.setProperty("javax.net.ssl.trustStore", path);
// op(" reading the strustStore: " + System.getProperty("javax.net.ssl.trustStore"));
// ssf = new LDAPJSSESecureSocketFactory();
// LDAPConnection.setSocketFactory(ssf);
private LDAPConnection getConnection() throws GeneralSecurityException, LDAPException {

SSLSocketFactory socketFactory = getSocketFactory();
lc = new LDAPConnection(socketFactory);

LDAPConnection lc = new LDAPConnection();
lc.connect(ldapHost, ldapPort);
lc.bind(loginDN, password);

// bind to the server
lc.bind(ldapVersion, loginDN, password.getBytes("UTF8"));
// tbd
// LDAPConnection pooling here?
//
return lc;
}
}

private static class SearchResultsIterator implements Iterator<Map<String, Object>> {
private final LDAPSearchResults lsr;
private final List<String> attributes;
private Map<String, Object> map;

public SearchResultsIterator(LDAPSearchResults lsr, List<String> attributes) {
this.lsr = lsr;
this.attributes = attributes;
this.map = get();
}

@Override
public boolean hasNext() {
return this.map != null;
}

@Override
public Map<String, Object> next() {
Map<String, Object> current = this.map;
this.map = get();
return current;
}

public Map<String, Object> get() {
if (handleEndOfResults()) return null;
try {
Map<String, Object> entry = new LinkedHashMap<>(attributes.size() + 1);
LDAPEntry en = null;
en = lsr.next();
entry.put("dn", en.getDN());
if (attributes != null && attributes.size() > 0) {
for (int col = 0; col < attributes.size(); col++) {
Object val = readValue(en.getAttributeSet().getAttribute(attributes.get(col)));
if (val != null) entry.put(attributes.get(col), val);
}
} else {
// make it dynamic
Iterator<LDAPAttribute> iter = en.getAttributeSet().iterator();
while (iter.hasNext()) {
LDAPAttribute attr = iter.next();
Object val = readValue(attr);
if (val != null) entry.put(attr.getName(), readValue(attr));
}
}
return entry;

} catch (LDAPException e) {
throw new RuntimeException("Error getting next ldap entry " + e.getLDAPErrorMessage());
}
}

private boolean handleEndOfResults() {
if (!lsr.hasMore()) {
return true;
}
return false;
}

private Object readValue(LDAPAttribute att) {
if (att == null) return null;
if (att.size() == 1) {
// single value
// for now everything is string
return att.getStringValue();
private SSLSocketFactory getSocketFactory() throws GeneralSecurityException {
if (ssl || ldapPort == 636) {
SSLUtil sslUtil = new SSLUtil(new TrustAllTrustManager());
return sslUtil.createSSLSocketFactory();
} else {
return att.getStringValueArray();
return null;
}
}
}
Expand Down
75 changes: 75 additions & 0 deletions full/src/test/java/apoc/load/LoadLdapContainerTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package apoc.load;

import static apoc.util.TestUtil.testCall;
import static org.junit.Assert.assertArrayEquals;
import static org.junit.Assert.assertEquals;

import apoc.util.TestUtil;
import java.util.Map;
import org.junit.AfterClass;
import org.junit.BeforeClass;
import org.junit.ClassRule;
import org.junit.Test;
import org.neo4j.test.rule.DbmsRule;
import org.neo4j.test.rule.ImpermanentDbmsRule;
import org.testcontainers.containers.GenericContainer;

public class LoadLdapContainerTest {
private static final int LDAP_DEFAULT_PORT = 389;
private static final int LDAP_DEFAULT_SSL_PORT = 636;

private static GenericContainer ldap;

@ClassRule
public static DbmsRule db = new ImpermanentDbmsRule();

@BeforeClass
public static void beforeClass() {
ldap = new GenericContainer("osixia/openldap:1.5.0")
.withEnv("LDAP_TLS_VERIFY_CLIENT", "try")
.withExposedPorts(LDAP_DEFAULT_PORT, LDAP_DEFAULT_SSL_PORT);
ldap.start();
TestUtil.registerProcedure(db, LoadLdap.class);
}

@AfterClass
public static void tearDown() {
ldap.stop();
db.shutdown();
}

@Test
public void testLoadLDAPWithSSLPort() {
int port = ldap.getMappedPort(LDAP_DEFAULT_SSL_PORT);
testLoadLDAPCommon(port, true);
}

@Test
public void testLoadLDAPWithDefaultPort() {
int port = ldap.getMappedPort(LDAP_DEFAULT_PORT);
testLoadLDAPCommon(port, false);
}

private static void testLoadLDAPCommon(int port, boolean ssl) {
Map<String, Object> conn = Map.of(
"ldapHost",
"localhost:" + port,
"loginDN",
"cn=admin,dc=example,dc=org",
"loginPW",
"admin",
"ssl",
ssl);

Map<String, Object> searchBase = Map.of("searchBase", "dc=example,dc=org", "searchScope", "SCOPE_BASE");
testCall(db, "call apoc.load.ldap($conn, $search)", Map.of("conn", conn, "search", searchBase), r -> {
Map<String, Object> entry = (Map<String, Object>) r.get("entry");

String[] expectedObjectClass = {"top", "dcObject", "organization"};
assertArrayEquals(expectedObjectClass, (String[]) entry.get("objectClass"));
assertEquals("dc=example,dc=org", entry.get("dn"));
assertEquals("Example Inc.", entry.get("o"));
assertEquals("example", entry.get("dc"));
});
}
}
12 changes: 7 additions & 5 deletions full/src/test/java/apoc/load/LoadLdapTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,9 @@

import apoc.util.FileUtils;
import apoc.util.TestUtil;
import com.novell.ldap.LDAPEntry;
import com.novell.ldap.LDAPSearchResults;
import com.unboundid.ldap.sdk.LDAPConnection;
import com.unboundid.ldap.sdk.SearchResult;
import com.unboundid.ldap.sdk.SearchResultEntry;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
Expand Down Expand Up @@ -173,9 +173,11 @@ private void testLoadAssertionCommon(Map<String, Object> r) {
public void testLoadLDAPConfig() throws Exception {
LoadLdap.LDAPManager mgr = new LoadLdap.LDAPManager(LoadLdap.getConnectionMap(connParams, null));

LDAPSearchResults results = mgr.doSearch(searchParams);
LDAPEntry le = results.next();
SearchResult results = mgr.doSearch(searchParams);
List<SearchResultEntry> searchEntries = results.getSearchEntries();
assertEquals(1, searchEntries.size());
SearchResultEntry le = searchEntries.get(0);
assertEquals("uid=training,dc=example,dc=com", le.getDN());
assertEquals("training", le.getAttribute("uid").getStringValue());
assertEquals("training", le.getAttribute("uid").getValue());
}
}

0 comments on commit 865569e

Please sign in to comment.