This is an automated email from the ASF dual-hosted git repository.
smolnar82 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/knox.git
The following commit(s) were added to refs/heads/master by this push:
new ad6c15958 KNOX-3377: KNOX-3378: KNOX-3379: Support secure LDAP (LDAPS)
for the Knox embedded LDAP service (#1305)
ad6c15958 is described below
commit ad6c15958ba2ba3e78a5fd5225a43327003fa288
Author: Sandor Molnar <[email protected]>
AuthorDate: Wed Jul 15 10:47:35 2026 +0200
KNOX-3377: KNOX-3378: KNOX-3379: Support secure LDAP (LDAPS) for the Knox
embedded LDAP service (#1305)
---
.../workflows/build/conf/topologies/knoxldap.xml | 2 +-
.github/workflows/build/gateway-site.xml | 22 +-
.github/workflows/build/gateway.sh | 34 ++-
.github/workflows/build/ldap.sh | 20 +-
.../workflows/tests/test_knox_ldap_proxy_search.py | 16 +-
gateway-demo-ldap/pom.xml | 7 +
.../security/ldap/SimpleLdapDirectoryServer.java | 18 ++
.../gateway/config/impl/GatewayConfigImpl.java | 21 ++
.../services/ldap/KnoxLDAPServerManager.java | 72 +++++-
.../knox/gateway/services/ldap/LdapMessages.java | 8 +
.../services/ldap/backend/LdapProxyBackend.java | 131 +++++++++-
.../services/ldap/KnoxLDAPServerManagerTest.java | 132 +++++++++++
.../gateway/services/ldap/KnoxLDAPServiceTest.java | 1 +
.../ldap/backend/LdapProxyBackendSslTest.java | 263 +++++++++++++++++++++
.../org/apache/knox/gateway/GatewayTestConfig.java | 20 ++
.../apache/knox/gateway/config/GatewayConfig.java | 30 +++
pom.xml | 77 +++++-
17 files changed, 863 insertions(+), 11 deletions(-)
diff --git a/.github/workflows/build/conf/topologies/knoxldap.xml
b/.github/workflows/build/conf/topologies/knoxldap.xml
index 884e8bc5e..3b886b777 100644
--- a/.github/workflows/build/conf/topologies/knoxldap.xml
+++ b/.github/workflows/build/conf/topologies/knoxldap.xml
@@ -35,7 +35,7 @@ limitations under the License.
</param>
<param>
<name>main.ldapRealm.contextFactory.url</name>
- <value>ldap://localhost:33390</value> <!--
Local Knox LDAP service which is configured to proxy to the demo LDAP as a
backend -->
+ <value>ldaps://localhost:33390</value> <!--
Local Knox LDAP service (LDAPS); configured to proxy to the demo LDAP as a
backend -->
</param>
<param>
<name>main.ldapRealm.contextFactory.authenticationMechanism</name>
diff --git a/.github/workflows/build/gateway-site.xml
b/.github/workflows/build/gateway-site.xml
index 8f35a939f..36e884a3e 100644
--- a/.github/workflows/build/gateway-site.xml
+++ b/.github/workflows/build/gateway-site.xml
@@ -153,6 +153,20 @@ limitations under the License.
<name>gateway.ldap.interceptor.names</name>
<value>demoldap</value>
</property>
+ <!-- Expose the embedded Knox LDAP service over LDAPS using the dev
keystore that
+ gateway.sh provisions; its password is resolved from the credential
store. -->
+ <property>
+ <name>gateway.ldap.ssl.enabled</name>
+ <value>true</value>
+ </property>
+ <property>
+ <name>gateway.ldap.ssl.keystore.path</name>
+ <value>/knox-runtime/conf/ldaps-keystore.p12</value>
+ </property>
+ <property>
+ <name>gateway.ldap.ssl.keystore.password.alias</name>
+ <value>gateway_ldap_ssl_keystore_password</value>
+ </property>
<!-- LDAP Backend specific configuration (proxying to demo ldap) -->
<property>
@@ -165,7 +179,13 @@ limitations under the License.
</property>
<property>
<name>gateway.ldap.interceptor.demoldap.url</name>
- <value>ldap://ldap:33389</value>
+ <value>ldaps://ldap:33389</value>
+ </property>
+ <!-- The demo LDAP presents a self-signed dev certificate; trust it rather
than
+ wiring a truststore for this dev/CI fixture. -->
+ <property>
+ <name>gateway.ldap.interceptor.demoldap.trustAllCertificates</name>
+ <value>true</value>
</property>
<property>
<name>gateway.ldap.interceptor.demoldap.remoteBaseDn</name>
diff --git a/.github/workflows/build/gateway.sh
b/.github/workflows/build/gateway.sh
index f31dd441c..e4927b7b2 100755
--- a/.github/workflows/build/gateway.sh
+++ b/.github/workflows/build/gateway.sh
@@ -13,7 +13,37 @@
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
+set -e
+# The embedded Knox LDAP service is exposed over LDAPS (see gateway-site.xml:
+# gateway.ldap.ssl.enabled). Provision a dev-only certificate for it and make
the
+# JVM trust that certificate so the knoxldap Shiro realm - which authenticates
over
+# ldaps://localhost:33390 via JNDI - can validate it.
+KEYSTORE=/knox-runtime/conf/ldaps-keystore.p12
+KEYSTORE_PASSWORD=knox-ldap-ssl-password
+KEYSTORE_PASSWORD_ALIAS=gateway_ldap_ssl_keystore_password
-# Start Knox
-java -jar /knox-runtime/bin/gateway.jar
\ No newline at end of file
+# 1) Self-signed dev certificate for the embedded LDAP service's secure
transport.
+# Store and key passwords must match (ApacheDS opens the store and recovers
the
+# key with a single password). PKCS12 matches the JVM default keystore type.
+keytool -genkeypair -alias ldaps -keyalg RSA -keysize 2048 \
+ -dname "CN=localhost" -ext "san=dns:localhost,dns:knox" -validity 3650 \
+ -keystore "$KEYSTORE" -storetype PKCS12 \
+ -storepass "$KEYSTORE_PASSWORD" -keypass "$KEYSTORE_PASSWORD"
+
+# 2) Store the keystore password under the alias the LDAP SSL config resolves.
+/knox-runtime/bin/knoxcli.sh create-alias "$KEYSTORE_PASSWORD_ALIAS" --value
"$KEYSTORE_PASSWORD"
+
+# 3) Trust that certificate in the JVM default truststore (cacerts) so the
JNDI-based
+# Shiro LDAP realm accepts it. This is additive - it does not remove the
default CAs.
+keytool -exportcert -alias ldaps -rfc \
+ -keystore "$KEYSTORE" -storepass "$KEYSTORE_PASSWORD" -file
/tmp/ldaps-cert.pem
+keytool -importcert -noprompt -alias knox-ldaps \
+ -keystore "${JAVA_HOME}/lib/security/cacerts" -storepass changeit \
+ -file /tmp/ldaps-cert.pem
+rm -f /tmp/ldaps-cert.pem
+
+# Start Knox. Endpoint (hostname) identification is disabled for the embedded
LDAPS
+# connection - the dev cert is self-signed - but trust is still enforced via
cacerts.
+java -Dcom.sun.jndi.ldap.object.disableEndpointIdentification=true \
+ -jar /knox-runtime/bin/gateway.jar
diff --git a/.github/workflows/build/ldap.sh b/.github/workflows/build/ldap.sh
index ad3d9c8e3..fda7eaa92 100755
--- a/.github/workflows/build/ldap.sh
+++ b/.github/workflows/build/ldap.sh
@@ -13,4 +13,22 @@
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
-java -jar /knox-runtime/bin/ldap.jar /knox-runtime/conf
\ No newline at end of file
+set -e
+
+# Expose the demo LDAP over a secure (LDAPS) transport so the Knox backend can
be
+# configured to proxy to it over TLS. Generate a self-signed, dev-only
certificate on
+# each start (the demo LDAP is a throwaway fixture, not a secret). The store
and key
+# passwords must match: ApacheDS opens the keystore and recovers the key with
a single
+# password. PKCS12 matches the JVM default keystore type ApacheDS loads it
with.
+KEYSTORE=/knox-runtime/conf/ldap-keystore.p12
+KEYSTORE_PASSWORD=ldap-keystore-password
+
+keytool -genkeypair -alias ldaps -keyalg RSA -keysize 2048 \
+ -dname "CN=ldap" -ext "san=dns:ldap,dns:localhost" -validity 3650 \
+ -keystore "$KEYSTORE" -storetype PKCS12 \
+ -storepass "$KEYSTORE_PASSWORD" -keypass "$KEYSTORE_PASSWORD"
+
+java -Dldap.ssl.enabled=true \
+ -Dldap.keystore.file="$KEYSTORE" \
+ -Dldap.keystore.password="$KEYSTORE_PASSWORD" \
+ -jar /knox-runtime/bin/ldap.jar /knox-runtime/conf
\ No newline at end of file
diff --git a/.github/workflows/tests/test_knox_ldap_proxy_search.py
b/.github/workflows/tests/test_knox_ldap_proxy_search.py
index 9d015551e..bb42336ad 100644
--- a/.github/workflows/tests/test_knox_ldap_proxy_search.py
+++ b/.github/workflows/tests/test_knox_ldap_proxy_search.py
@@ -18,17 +18,25 @@
These exercise the LdapProxyBackend.search() path (KNOX-3341): clients can
query
the embedded Knox LDAP service - which proxies to the demo LDAP backend - by
objectClass, cn and uid (including wildcards), not just by a single uid lookup.
+
+The connection is made over LDAPS: the embedded Knox LDAP service is configured
+with gateway.ldap.ssl.enabled=true, and it in turn proxies to the demo LDAP
+backend over LDAPS as well (gateway.ldap.interceptor.demoldap.url=ldaps://...).
+The gateway presents a self-signed dev certificate in CI, so certificate
+validation is disabled on the client side.
"""
from __future__ import annotations
import os
+import ssl
import unittest
from urllib.parse import urlparse
import ldap3
-# The embedded Knox LDAP service port (see gateway-site.xml:
gateway.ldap.port).
+# The embedded Knox LDAP service secure port (see gateway-site.xml:
gateway.ldap.port
+# with gateway.ldap.ssl.enabled=true).
KNOX_LDAP_PORT = 33390
BASE_DN = "dc=hadoop,dc=apache,dc=org"
@@ -50,7 +58,11 @@ class TestKnoxLdapProxySearch(unittest.TestCase):
"""Verify general search requests are proxied to the demo LDAP backend."""
def setUp(self) -> None:
- server = ldap3.Server(knox_host(), port=KNOX_LDAP_PORT,
get_info=ldap3.NONE)
+ # Self-signed dev certificate in CI: connect over TLS but skip
validation.
+ tls = ldap3.Tls(validate=ssl.CERT_NONE)
+ server = ldap3.Server(
+ knox_host(), port=KNOX_LDAP_PORT, use_ssl=True, tls=tls,
get_info=ldap3.NONE
+ )
self.connection = ldap3.Connection(
server, user=BIND_DN, password=BIND_PASSWORD, auto_bind=True
)
diff --git a/gateway-demo-ldap/pom.xml b/gateway-demo-ldap/pom.xml
index 96afc9980..55eea594f 100644
--- a/gateway-demo-ldap/pom.xml
+++ b/gateway-demo-ldap/pom.xml
@@ -90,6 +90,13 @@
<groupId>org.bouncycastle</groupId>
<artifactId>bcprov-jdk18on</artifactId>
</dependency>
+ <!-- ApacheDS 2.0.0.AM27's LdapServer.start() references bcpkix (cert
utilities)
+ even for a plaintext transport. The legacy jdk15on BouncyCastle
it would pull
+ transitively is excluded project-wide, so declare the jdk18on
artifact here. -->
+ <dependency>
+ <groupId>org.bouncycastle</groupId>
+ <artifactId>bcpkix-jdk18on</artifactId>
+ </dependency>
</dependencies>
<build>
<plugins>
diff --git
a/gateway-demo-ldap/src/main/java/org/apache/knox/gateway/security/ldap/SimpleLdapDirectoryServer.java
b/gateway-demo-ldap/src/main/java/org/apache/knox/gateway/security/ldap/SimpleLdapDirectoryServer.java
index 1561e325e..f3e562419 100644
---
a/gateway-demo-ldap/src/main/java/org/apache/knox/gateway/security/ldap/SimpleLdapDirectoryServer.java
+++
b/gateway-demo-ldap/src/main/java/org/apache/knox/gateway/security/ldap/SimpleLdapDirectoryServer.java
@@ -104,6 +104,24 @@ public class SimpleLdapDirectoryServer {
server = new LdapServer();
server.setTransports( transports );
server.setDirectoryService( service );
+
+ // Optionally expose a secure (LDAPS) transport. Controlled by system
properties so the
+ // demo server can be launched in either plaintext or secure mode without
code changes.
+ if ( Boolean.parseBoolean( System.getProperty( "ldap.ssl.enabled", "false"
) ) ) {
+ for ( Transport transport : transports ) {
+ if ( transport instanceof TcpTransport ) {
+ ( (TcpTransport) transport ).setEnableSSL( true );
+ }
+ }
+ String keystoreFile = System.getProperty( "ldap.keystore.file" );
+ if ( keystoreFile != null && !keystoreFile.isEmpty() ) {
+ server.setKeystoreFile( keystoreFile );
+ }
+ String keystorePassword = System.getProperty( "ldap.keystore.password" );
+ if ( keystorePassword != null ) {
+ server.setCertificatePassword( keystorePassword );
+ }
+ }
}
private static void enabledPosixSchema( DirectoryService service ) throws
LdapException {
diff --git
a/gateway-server/src/main/java/org/apache/knox/gateway/config/impl/GatewayConfigImpl.java
b/gateway-server/src/main/java/org/apache/knox/gateway/config/impl/GatewayConfigImpl.java
index d401f3080..061518537 100644
---
a/gateway-server/src/main/java/org/apache/knox/gateway/config/impl/GatewayConfigImpl.java
+++
b/gateway-server/src/main/java/org/apache/knox/gateway/config/impl/GatewayConfigImpl.java
@@ -1863,6 +1863,27 @@ public class GatewayConfigImpl extends Configuration
implements GatewayConfig {
return get(LDAP_ROLES_LOOKUP_FILE_PATH);
}
+ @Override
+ public boolean isLDAPSSLEnabled() {
+ return Boolean.parseBoolean(get(LDAP_SSL_ENABLED, "false"));
+ }
+
+ @Override
+ public String getLDAPSSLKeystorePath() {
+ return get(LDAP_SSL_KEYSTORE_PATH, null);
+ }
+
+ @Override
+ public String getLDAPSSLKeystorePasswordAlias() {
+ return get(LDAP_SSL_KEYSTORE_PASSWORD_ALIAS, null);
+ }
+
+ @Override
+ public List<String> getLDAPSSLEnabledCipherSuites() {
+ final List<String> cipherSuites =
splitConfigValueToList(LDAP_SSL_ENABLED_CIPHER_SUITES);
+ return cipherSuites == null ? Collections.emptyList() : cipherSuites;
+ }
+
@Override
public boolean getGroupUIServicesOnHomepage() {
return getBoolean(KNOX_HOMEPAGE_GROUP_UI_SERVICES,
DEFAULT_GROUP_UI_SERVICES);
diff --git
a/gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/KnoxLDAPServerManager.java
b/gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/KnoxLDAPServerManager.java
index 5d41d7710..4f30fa80c 100644
---
a/gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/KnoxLDAPServerManager.java
+++
b/gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/KnoxLDAPServerManager.java
@@ -79,6 +79,11 @@ public class KnoxLDAPServerManager {
private int port;
private String baseDn;
private String bindUser;
+ // Secure (LDAPS) transport configuration
+ private boolean sslEnabled;
+ private String sslKeystorePath;
+ private String sslKeystorePasswordAlias;
+ private List<String> sslEnabledCipherSuites;
// Collection of DNs for the proxied backend LDAP servers
private Set<String> baseDns;
@@ -108,6 +113,17 @@ public class KnoxLDAPServerManager {
this.baseDn = config.getLDAPBaseDN();
this.bindUser = config.getLDAPBindUser();
+ // Secure (LDAPS) transport configuration. When enabled but no
dedicated keystore is
+ // configured, fall back to the gateway identity keystore so the
embedded server can
+ // reuse the gateway's own TLS material out of the box.
+ this.sslEnabled = config.isLDAPSSLEnabled();
+ if (sslEnabled) {
+ final String configuredKeystore = config.getLDAPSSLKeystorePath();
+ this.sslKeystorePath = StringUtils.isNotBlank(configuredKeystore)
? configuredKeystore : config.getIdentityKeystorePath();
+ this.sslKeystorePasswordAlias =
config.getLDAPSSLKeystorePasswordAlias();
+ this.sslEnabledCipherSuites =
config.getLDAPSSLEnabledCipherSuites();
+ }
+
createInterceptors(config);
// Clean up previous run if it didn't shut down cleanly
@@ -215,7 +231,11 @@ public class KnoxLDAPServerManager {
// Create LDAP server on configured port
ldapServer = new LdapServer();
- ldapServer.setTransports(new TcpTransport(port));
+ final TcpTransport transport = new TcpTransport(port);
+ if (sslEnabled) {
+ configureSsl(transport);
+ }
+ ldapServer.setTransports(transport);
ldapServer.setDirectoryService(directoryService);
ldapServer.start();
@@ -280,6 +300,56 @@ public class KnoxLDAPServerManager {
.orElse(-1);
}
+ /**
+ * Enable the secure (LDAPS) transport on the embedded server. The
keystore holding the
+ * server certificate and its password are resolved from the gateway
configuration and
+ * credential store; the password defaults to the gateway identity
keystore password when
+ * no dedicated alias is configured. The keystore must be in the JVM
default format
+ * (see {@code java.security.KeyStore#getDefaultType()}), which is what
ApacheDS uses to
+ * load it.
+ */
+ private void configureSsl(final TcpTransport transport) throws Exception {
+ if (StringUtils.isBlank(sslKeystorePath)) {
+ final String reason = "no keystore configured; set " +
GatewayConfig.LDAP_SSL_KEYSTORE_PATH
+ + " or configure the gateway identity keystore";
+ LOG.ldapSslConfigInvalid(reason);
+ throw new IllegalStateException("Cannot enable LDAPS: " + reason);
+ }
+
+ final File keystore = new File(sslKeystorePath);
+ if (!keystore.exists()) {
+ final String reason = "keystore file not found: " +
keystore.getAbsolutePath();
+ LOG.ldapSslConfigInvalid(reason);
+ throw new IllegalStateException("Cannot enable LDAPS: " + reason);
+ }
+
+ transport.setEnableSSL(true);
+ ldapServer.setKeystoreFile(keystore.getAbsolutePath());
+
+ final char[] passwordChars = resolveSslKeystorePassword();
+ if (passwordChars != null) {
+ ldapServer.setCertificatePassword(new String(passwordChars));
+ }
+
+ if (sslEnabledCipherSuites != null &&
!sslEnabledCipherSuites.isEmpty()) {
+ ldapServer.setEnabledCipherSuites(new
ArrayList<>(sslEnabledCipherSuites));
+ }
+
+ LOG.ldapSslEnabled(keystore.getAbsolutePath());
+ }
+
+ /**
+ * Resolve the password protecting the LDAPS keystore from the gateway
credential store.
+ * Uses the configured alias when set, otherwise falls back to the gateway
identity
+ * keystore password (matching the keystore-path fallback in {@link
#initialize(GatewayConfig)}).
+ */
+ private char[] resolveSslKeystorePassword() throws Exception {
+ if (StringUtils.isNotBlank(sslKeystorePasswordAlias)) {
+ return
aliasService.getPasswordFromAliasForGateway(sslKeystorePasswordAlias);
+ }
+ return aliasService.getGatewayIdentityKeystorePassword();
+ }
+
/**
* Stop the LDAP server
*/
diff --git
a/gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/LdapMessages.java
b/gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/LdapMessages.java
index a69ad7cd8..816fef003 100644
---
a/gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/LdapMessages.java
+++
b/gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/LdapMessages.java
@@ -33,6 +33,14 @@ public interface LdapMessages {
text = "LDAP service started successfully on port {0}")
void ldapServiceStarted(int port);
+ @Message(level = MessageLevel.INFO,
+ text = "Enabling secure (LDAPS) transport for LDAP service using
keystore: {0}")
+ void ldapSslEnabled(String keystorePath);
+
+ @Message(level = MessageLevel.ERROR,
+ text = "Cannot enable secure (LDAPS) transport: {0}")
+ void ldapSslConfigInvalid(String reason);
+
@Message(level = MessageLevel.INFO,
text = "Anonymous access disabled; clients must bind as: {0}")
void ldapBindUserConfigured(String bindDn);
diff --git
a/gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/backend/LdapProxyBackend.java
b/gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/backend/LdapProxyBackend.java
index 300acff39..8466f5b12 100644
---
a/gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/backend/LdapProxyBackend.java
+++
b/gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/backend/LdapProxyBackend.java
@@ -36,8 +36,18 @@ import
org.apache.directory.ldap.client.api.ValidatingPoolableLdapConnectionFact
import org.apache.knox.gateway.i18n.messages.MessagesFactory;
import org.apache.knox.gateway.services.ldap.LdapMessages;
+import javax.net.ssl.TrustManager;
+import javax.net.ssl.TrustManagerFactory;
+import javax.net.ssl.X509TrustManager;
+
import java.io.IOException;
+import java.io.InputStream;
+import java.nio.file.Files;
+import java.nio.file.Paths;
+import java.security.KeyStore;
+import java.security.cert.X509Certificate;
import java.util.ArrayList;
+import java.util.Arrays;
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
@@ -87,6 +97,18 @@ public class LdapProxyBackend implements LdapBackend {
private final String proxyEntryGroupMembershipAttributeType = "memberOf";
+ // Secure transport configuration for the connection to the remote LDAP
server
+ private boolean useSsl; // LDAPS (implicit TLS)
+ private boolean trustAllCertificates; // skip certificate validation
(test/dev only)
+ private String trustStorePath;
+ private String trustStoreType;
+ private String trustStorePassword;
+ private String sslProtocol;
+ private String[] enabledProtocols;
+ private String[] enabledCipherSuites;
+ // LDAP response timeout in milliseconds; negative means use the client
default.
+ private long connectionTimeoutMillis = -1;
+
// Connection pool for efficient connection reuse
private LdapConnectionPool connectionPool;
@@ -160,13 +182,31 @@ public class LdapProxyBackend implements LdapBackend {
recursiveGroupResolution =
Boolean.parseBoolean(config.getOrDefault("recursiveGroupResolution", "false"));
recursiveGroupResolutionMaxDepth =
Integer.parseInt(config.getOrDefault("recursiveGroupResolutionMaxDepth", "3"));
+ // Configure secure transport (LDAPS) to the remote server. An
ldaps:// URL enables it
+ // by default; an explicit useSsl setting always wins.
+ final boolean ldapsFromUrl = ldapUrl != null &&
ldapUrl.toLowerCase(Locale.ROOT).startsWith("ldaps://");
+ useSsl = Boolean.parseBoolean(config.getOrDefault("useSsl",
String.valueOf(ldapsFromUrl)));
+ trustAllCertificates =
Boolean.parseBoolean(config.getOrDefault("trustAllCertificates", "false"));
+ trustStorePath = config.get("trustStore");
+ trustStoreType = config.getOrDefault("trustStoreType",
KeyStore.getDefaultType());
+ trustStorePassword = config.get("trustStorePassword");
+ sslProtocol = config.get("sslProtocol");
+ enabledProtocols = splitToArray(config.get("enabledProtocols"));
+ enabledCipherSuites = splitToArray(config.get("enabledCipherSuites"));
+ final String timeout = config.get("connectionTimeout");
+ if (timeout != null && !timeout.trim().isEmpty()) {
+ connectionTimeoutMillis = Long.parseLong(timeout.trim());
+ }
+
// Build search filter template
userSearchFilter = "(" + remoteUserIdentifierAttribute +
"={username})";
LOG.ldapBackendLoading(getName(), "Proxying " + proxyBaseDn + " to " +
ldapUrl + " (" + remoteBaseDn + ") with " +
remoteUserIdentifierAttribute + " attribute" +
(useMemberOf ? " using memberOf lookups" : "
using group searches") +
- (recursiveGroupResolution ? " with recursive
group resolution (max depth: " + recursiveGroupResolutionMaxDepth + ")" : ""));
+ (recursiveGroupResolution ? " with recursive
group resolution (max depth: " + recursiveGroupResolutionMaxDepth + ")" : "") +
+ (useSsl ? " over LDAPS" : "") +
+ ((useSsl && trustAllCertificates) ? "
(certificate validation disabled)" : ""));
// Initialize connection pool
initializeConnectionPool(config);
@@ -198,6 +238,8 @@ public class LdapProxyBackend implements LdapBackend {
LdapConnectionConfig connectionConfig = new LdapConnectionConfig();
connectionConfig.setLdapHost(host);
connectionConfig.setLdapPort(port);
+ applySslConfiguration(connectionConfig);
+ applyTimeout(connectionConfig);
if (bindDn != null && !bindDn.isEmpty()) {
connectionConfig.setName(bindDn);
@@ -254,6 +296,91 @@ public class LdapProxyBackend implements LdapBackend {
}
}
+ /**
+ * Apply the configured LDAPS settings to a connection configuration. This
is shared by the
+ * pooled search connections and the short-lived authentication (bind)
connection so both
+ * talk to the remote server over the same secure transport.
+ */
+ private void applySslConfiguration(LdapConnectionConfig connectionConfig) {
+ if (!useSsl) {
+ return;
+ }
+ connectionConfig.setUseSsl(true);
+ connectionConfig.setTrustManagers(buildTrustManagers());
+ if (sslProtocol != null && !sslProtocol.isEmpty()) {
+ connectionConfig.setSslProtocol(sslProtocol);
+ }
+ if (enabledProtocols != null) {
+ connectionConfig.setEnabledProtocols(enabledProtocols);
+ }
+ if (enabledCipherSuites != null) {
+ connectionConfig.setEnabledCipherSuites(enabledCipherSuites);
+ }
+ }
+
+ /** Apply the configured LDAP response timeout, when set, to a connection
configuration. */
+ private void applyTimeout(LdapConnectionConfig connectionConfig) {
+ if (connectionTimeoutMillis >= 0) {
+ connectionConfig.setTimeout(connectionTimeoutMillis);
+ }
+ }
+
+ /**
+ * Build the trust managers used to validate the remote server's
certificate:
+ * <ul>
+ * <li>{@code trustAllCertificates=true} disables validation (test/dev
only);</li>
+ * <li>a configured {@code trustStore} is loaded and used as the trust
anchor;</li>
+ * <li>otherwise the JVM default trust store is used.</li>
+ * </ul>
+ */
+ private TrustManager[] buildTrustManagers() {
+ try {
+ if (trustAllCertificates) {
+ return new TrustManager[] { trustAllTrustManager() };
+ }
+ KeyStore trustStore = null;
+ if (trustStorePath != null && !trustStorePath.isEmpty()) {
+ trustStore = KeyStore.getInstance(trustStoreType);
+ final char[] password = trustStorePassword == null ? null :
trustStorePassword.toCharArray();
+ try (InputStream is =
Files.newInputStream(Paths.get(trustStorePath))) {
+ trustStore.load(is, password);
+ }
+ }
+ // A null KeyStore makes the factory fall back to the JVM default
trust store.
+ TrustManagerFactory tmf =
TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
+ tmf.init(trustStore);
+ return tmf.getTrustManagers();
+ } catch (Exception e) {
+ throw new IllegalStateException("Failed to configure trust
material for LDAP backend " + getName(), e);
+ }
+ }
+
+ /** A trust manager that accepts any certificate; only used when
trustAllCertificates is set. */
+ private static X509TrustManager trustAllTrustManager() {
+ return new X509TrustManager() {
+ @Override
+ public void checkClientTrusted(X509Certificate[] chain, String
authType) { }
+
+ @Override
+ public void checkServerTrusted(X509Certificate[] chain, String
authType) { }
+
+ @Override
+ public X509Certificate[] getAcceptedIssuers() {
+ return new X509Certificate[0];
+ }
+ };
+ }
+
+ private static String[] splitToArray(String value) {
+ if (value == null || value.trim().isEmpty()) {
+ return null;
+ }
+ return Arrays.stream(value.split(","))
+ .map(String::trim)
+ .filter(part -> !part.isEmpty())
+ .toArray(String[]::new);
+ }
+
/**
* Gets a connection from the connection pool.
* Connections obtained from this method should be released back to the
pool
@@ -307,6 +434,8 @@ public class LdapProxyBackend implements LdapBackend {
final LdapConnectionConfig authConfig = new LdapConnectionConfig();
authConfig.setLdapHost(host);
authConfig.setLdapPort(port);
+ applySslConfiguration(authConfig);
+ applyTimeout(authConfig);
authConfig.setName(remoteUserDnText);
authConfig.setCredentials(password);
try (LdapConnection connection = new
LdapNetworkConnection(authConfig)){
diff --git
a/gateway-server/src/test/java/org/apache/knox/gateway/services/ldap/KnoxLDAPServerManagerTest.java
b/gateway-server/src/test/java/org/apache/knox/gateway/services/ldap/KnoxLDAPServerManagerTest.java
index b277cb835..78631552c 100644
---
a/gateway-server/src/test/java/org/apache/knox/gateway/services/ldap/KnoxLDAPServerManagerTest.java
+++
b/gateway-server/src/test/java/org/apache/knox/gateway/services/ldap/KnoxLDAPServerManagerTest.java
@@ -23,12 +23,14 @@ import
org.apache.directory.api.ldap.model.entry.DefaultEntry;
import org.apache.directory.api.ldap.model.entry.Entry;
import
org.apache.directory.api.ldap.model.exception.LdapAuthenticationException;
import org.apache.directory.api.ldap.model.exception.LdapException;
+import
org.apache.directory.api.ldap.model.exception.LdapProtocolErrorException;
import org.apache.directory.api.ldap.model.message.Control;
import org.apache.directory.api.ldap.model.message.SearchScope;
import org.apache.directory.api.ldap.model.schema.SchemaManager;
import org.apache.directory.ldap.client.api.LdapConnection;
import org.apache.directory.ldap.client.api.LdapNetworkConnection;
import org.apache.directory.server.core.api.interceptor.Interceptor;
+import org.apache.knox.gateway.util.X509CertificateUtil;
import org.apache.knox.gateway.config.GatewayConfig;
import org.apache.knox.gateway.services.security.AliasService;
import
org.apache.knox.gateway.services.ldap.control.RolesLookupBypassControlFactory;
@@ -42,8 +44,21 @@ import org.junit.Test;
import org.junit.Before;
import org.junit.After;
+import javax.net.ssl.SSLContext;
+import javax.net.ssl.SSLSocket;
+import javax.net.ssl.TrustManager;
+import javax.net.ssl.X509TrustManager;
+
import java.io.File;
+import java.io.OutputStream;
import java.net.ServerSocket;
+import java.nio.file.Files;
+import java.security.KeyPair;
+import java.security.KeyPairGenerator;
+import java.security.KeyStore;
+import java.security.SecureRandom;
+import java.security.cert.Certificate;
+import java.security.cert.X509Certificate;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
@@ -60,6 +75,7 @@ import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.fail;
/**
* Unit tests for KnoxLDAPServerManager.
@@ -68,10 +84,13 @@ public class KnoxLDAPServerManagerTest {
private static final String BIND_DN = "uid=knox,ou=system";
private static final String BIND_PASSWORD = "knox-password";
+ private static final String KEYSTORE_PASSWORD = "keystore-password";
+ private static final String SSL_KEYSTORE_ALIAS =
"gateway_ldap_ssl_keystore_password";
private KnoxLDAPServerManager serverManager;
private File tempWorkDir;
private File tempLdapFile;
+ private File tempKeystore;
private int port;
@Before
@@ -517,6 +536,116 @@ public class KnoxLDAPServerManagerTest {
}
}
+ @Test
+ public void testLdapsTransportPresentsConfiguredCertificate() throws
Exception {
+ useKeystorePassword(KEYSTORE_PASSWORD);
+ serverManager.initialize(createSslEnabledConfig(createTempKeystore()));
+ serverManager.start();
+
+ assertTrue("Server should be running", serverManager.isRunning());
+
+ // A TLS handshake must succeed on the configured port and present the
certificate
+ // loaded from our keystore, proving the transport is genuinely
secured (LDAPS).
+ SSLContext sslContext = SSLContext.getInstance("TLS");
+ sslContext.init(null, new TrustManager[] { trustAllManager() }, new
SecureRandom());
+ try (SSLSocket socket = (SSLSocket)
sslContext.getSocketFactory().createSocket("localhost", port)) {
+ socket.setSoTimeout(5000);
+ socket.startHandshake();
+ Certificate[] serverCerts =
socket.getSession().getPeerCertificates();
+ assertTrue("Server should present a certificate",
serverCerts.length > 0);
+ X509Certificate serverCert = (X509Certificate) serverCerts[0];
+ assertTrue("Server certificate should be the one from the
configured keystore",
+
serverCert.getSubjectX500Principal().getName().contains("CN=localhost"));
+ }
+ }
+
+ @Test(expected = LdapProtocolErrorException.class)
+ public void testLdapsTransportRejectsPlaintextConnection() throws
Exception {
+ useKeystorePassword(KEYSTORE_PASSWORD);
+ serverManager.initialize(createSslEnabledConfig(createTempKeystore()));
+ serverManager.start();
+
+ // A plaintext client talking to an SSL-only transport must not be
able to bind/search.
+ try (LdapNetworkConnection connection = new
LdapNetworkConnection("localhost", port)) {
+ connection.setTimeOut(5000);
+ connection.bind();
+ try (EntryCursor cursor = connection.search("dc=test,dc=com",
"(objectClass=*)", SearchScope.SUBTREE)) {
+ cursor.next();
+ }
+ fail("Plaintext access against an LDAPS-only transport should
fail");
+ }
+ }
+
+ @Test(expected = IllegalStateException.class)
+ public void testLdapsFailsWhenKeystoreMissing() throws Exception {
+ useKeystorePassword(KEYSTORE_PASSWORD);
+ File missing = new File(tempWorkDir, "does-not-exist.jks");
+ serverManager.initialize(createSslEnabledConfig(missing));
+ serverManager.start();
+ }
+
+ private GatewayConfig createSslEnabledConfig(File keystore) {
+ GatewayConfig mockConfig =
EasyMock.createNiceMock(GatewayConfig.class);
+
expect(mockConfig.getGatewayDataDir()).andReturn(tempWorkDir.getParent()).anyTimes();
+ expect(mockConfig.getLDAPPort()).andReturn(port).anyTimes();
+
expect(mockConfig.getLDAPBaseDN()).andReturn("dc=test,dc=com").anyTimes();
+
expect(mockConfig.getLDAPInterceptorNames()).andReturn(List.of("filebackend")).anyTimes();
+
expect(mockConfig.getLDAPBackendDataFile()).andReturn(tempLdapFile.getAbsolutePath()).anyTimes();
+
expect(mockConfig.getLDAPInterceptorConfig("filebackend")).andReturn(createFileBackendInterceptorConfig()).anyTimes();
+ expect(mockConfig.isLDAPSSLEnabled()).andReturn(true).anyTimes();
+
expect(mockConfig.getLDAPSSLKeystorePath()).andReturn(keystore.getAbsolutePath()).anyTimes();
+
expect(mockConfig.getLDAPSSLKeystorePasswordAlias()).andReturn(SSL_KEYSTORE_ALIAS).anyTimes();
+
expect(mockConfig.getLDAPSSLEnabledCipherSuites()).andReturn(Collections.emptyList()).anyTimes();
+ replay(mockConfig);
+ return mockConfig;
+ }
+
+ /** A trust manager that accepts any certificate; the test cert is
self-signed. */
+ private X509TrustManager trustAllManager() {
+ return new X509TrustManager() {
+ @Override
+ public void checkClientTrusted(X509Certificate[] chain, String
authType) { }
+
+ @Override
+ public void checkServerTrusted(X509Certificate[] chain, String
authType) { }
+
+ @Override
+ public X509Certificate[] getAcceptedIssuers() {
+ return new X509Certificate[0];
+ }
+ };
+ }
+
+ /**
+ * Build a keystore holding a self-signed certificate and its private key,
in the JVM
+ * default keystore format (which is what ApacheDS uses to load the server
certificate).
+ */
+ private File createTempKeystore() throws Exception {
+ KeyPairGenerator keyPairGenerator =
KeyPairGenerator.getInstance("RSA");
+ keyPairGenerator.initialize(2048);
+ KeyPair keyPair = keyPairGenerator.generateKeyPair();
+ X509Certificate cert = X509CertificateUtil.generateCertificate(
+ "CN=localhost, OU=Test, O=Knox, L=Test, ST=Test, C=US",
keyPair, 365, "SHA256withRSA");
+ assertNotNull("Failed to generate a self-signed test certificate",
cert);
+
+ KeyStore keyStore = KeyStore.getInstance(KeyStore.getDefaultType());
+ keyStore.load(null, null);
+ keyStore.setKeyEntry("knox-ldaps", keyPair.getPrivate(),
KEYSTORE_PASSWORD.toCharArray(),
+ new Certificate[] { cert });
+
+ tempKeystore = File.createTempFile("knox-ldaps-test", ".ks");
+ tempKeystore.deleteOnExit();
+ try (OutputStream os = Files.newOutputStream(tempKeystore.toPath())) {
+ keyStore.store(os, KEYSTORE_PASSWORD.toCharArray());
+ }
+ return tempKeystore;
+ }
+
+ /** Rebuild the server manager so its credential store resolves any
gateway alias to the given keystore password. */
+ private void useKeystorePassword(String password) throws Exception {
+ serverManager = new
KnoxLDAPServerManager(aliasServiceReturning(password));
+ }
+
private GatewayConfig createBindEnabledConfig() {
GatewayConfig mockConfig =
EasyMock.createNiceMock(GatewayConfig.class);
expect(mockConfig.getGatewayDataDir()).andReturn(tempWorkDir.getParent()).anyTimes();
@@ -569,6 +698,9 @@ public class KnoxLDAPServerManagerTest {
if (tempLdapFile != null && tempLdapFile.exists()) {
tempLdapFile.delete();
}
+ if (tempKeystore != null && tempKeystore.exists()) {
+ tempKeystore.delete();
+ }
if (tempWorkDir != null && tempWorkDir.exists()) {
// Clean up work directory recursively
deleteRecursively(tempWorkDir);
diff --git
a/gateway-server/src/test/java/org/apache/knox/gateway/services/ldap/KnoxLDAPServiceTest.java
b/gateway-server/src/test/java/org/apache/knox/gateway/services/ldap/KnoxLDAPServiceTest.java
index 88d215c76..ce681a561 100644
---
a/gateway-server/src/test/java/org/apache/knox/gateway/services/ldap/KnoxLDAPServiceTest.java
+++
b/gateway-server/src/test/java/org/apache/knox/gateway/services/ldap/KnoxLDAPServiceTest.java
@@ -171,6 +171,7 @@ public class KnoxLDAPServiceTest {
private void setupMockConfig(String backendType) throws Exception {
expect(mockConfig.isLDAPEnabled()).andReturn(true).atLeastOnce();
+ expect(mockConfig.isLDAPSSLEnabled()).andReturn(false).atLeastOnce();
expect(mockConfig.isLDAPRecursiveGroupResolutionEnabled()).andReturn(false).atLeastOnce();
expect(mockConfig.getLDAPRecursiveGroupResolutionMaxDepth()).andReturn(0).atLeastOnce();
expect(mockConfig.getGatewayDataDir()).andReturn(tempDataDir.getAbsolutePath()).atLeastOnce();
diff --git
a/gateway-server/src/test/java/org/apache/knox/gateway/services/ldap/backend/LdapProxyBackendSslTest.java
b/gateway-server/src/test/java/org/apache/knox/gateway/services/ldap/backend/LdapProxyBackendSslTest.java
new file mode 100644
index 000000000..96b8e5a8f
--- /dev/null
+++
b/gateway-server/src/test/java/org/apache/knox/gateway/services/ldap/backend/LdapProxyBackendSslTest.java
@@ -0,0 +1,263 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.knox.gateway.services.ldap.backend;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+import org.apache.directory.api.ldap.model.entry.Entry;
+import org.apache.directory.api.ldap.model.entry.Value;
+import org.apache.directory.api.ldap.model.exception.LdapTlsHandshakeException;
+import org.apache.directory.api.ldap.model.name.Dn;
+import org.apache.directory.api.ldap.model.schema.SchemaManager;
+import org.apache.directory.server.core.api.CoreSession;
+import org.apache.directory.server.core.api.DirectoryService;
+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.factory.JdbmPartitionFactory;
+import org.apache.directory.server.core.factory.PartitionFactory;
+import org.apache.directory.server.core.partition.ldif.LdifPartition;
+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 org.apache.knox.gateway.security.ldap.SimpleDirectoryService;
+import org.apache.knox.gateway.services.ldap.SchemaManagerFactory;
+import org.apache.knox.gateway.util.X509CertificateUtil;
+import org.junit.After;
+import org.junit.AfterClass;
+import org.junit.BeforeClass;
+import org.junit.Test;
+
+import java.io.File;
+import java.io.OutputStream;
+import java.nio.file.Files;
+import java.security.KeyPair;
+import java.security.KeyPairGenerator;
+import java.security.KeyStore;
+import java.security.cert.Certificate;
+import java.security.cert.X509Certificate;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.UUID;
+
+/**
+ * Verifies that {@link LdapProxyBackend} can talk to a remote LDAP server
over a secure
+ * (LDAPS) transport. An embedded ApacheDS instance is started with SSL
enabled and the backend
+ * connects to it over TLS for both searches (pooled connections) and
authentication binds.
+ */
+public class LdapProxyBackendSslTest {
+ private static final String KEYSTORE_PASSWORD = "keystore-password";
+
+ private static File keystore;
+ private static TcpTransport transport;
+ private static DirectoryService directoryService;
+ private static LdapServer ldapServer;
+ private static SchemaManager schemaManager;
+ private static int port;
+
+ private LdapProxyBackend ldapProxyBackend;
+
+ @BeforeClass
+ public static void setupBeforeClass() throws Exception {
+ keystore = createTempKeystore();
+
+ directoryService = new SimpleDirectoryService();
+ directoryService.setShutdownHookEnabled(false);
+ schemaManager = SchemaManagerFactory.createSchemaManager();
+ directoryService.setSchemaManager(schemaManager);
+
+ String instanceDirectory = System.getProperty("java.io.tmpdir",
"/tmp") + "/server-work-ssl-" + UUID.randomUUID();
+ directoryService.setInstanceLayout(new
InstanceLayout(instanceDirectory));
+
+ File workingDirectory =
directoryService.getInstanceLayout().getPartitionsDirectory();
+ LdifPartition ldifPartition = new LdifPartition(schemaManager,
directoryService.getDnFactory());
+ ldifPartition.setPartitionPath(new File(workingDirectory,
"schema").toURI());
+ SchemaPartition schemaPartition = new SchemaPartition(schemaManager);
+ schemaPartition.setWrappedPartition(ldifPartition);
+ directoryService.setSchemaPartition(schemaPartition);
+
+ PartitionFactory partitionFactory = new JdbmPartitionFactory();
+ Partition systemPartition = partitionFactory.createPartition(
+ schemaManager, directoryService.getDnFactory(),
+ "system", "ou=system", 500,
+ new
File(directoryService.getInstanceLayout().getPartitionsDirectory(), "system"));
+ partitionFactory.addIndex(systemPartition, "objectClass", 100);
+ directoryService.setSystemPartition(systemPartition);
+ Partition partition = partitionFactory.createPartition(
+ schemaManager, directoryService.getDnFactory(),
+ "people", "dc=hadoop,dc=apache,dc=org", 500,
+ directoryService.getInstanceLayout().getInstanceDirectory());
+ directoryService.addPartition(partition);
+
+ directoryService.startup();
+
+ CoreSession session = directoryService.getAdminSession();
+ File ldifFile = new
File(LdapProxyBackendSslTest.class.getResource("/ldap-proxy-backend-test.ldif").toURI());
+ new LdifFileLoader(session, ldifFile, null).execute();
+
+ // Start the embedded server with a secure (LDAPS) transport.
+ transport = new TcpTransport(0);
+ transport.setEnableSSL(true);
+ ldapServer = new LdapServer();
+ ldapServer.setKeystoreFile(keystore.getAbsolutePath());
+ ldapServer.setCertificatePassword(KEYSTORE_PASSWORD);
+ ldapServer.setTransports(transport);
+ ldapServer.setDirectoryService(directoryService);
+ ldapServer.start();
+ port = transport.getAcceptor().getLocalAddress().getPort();
+ }
+
+ @AfterClass
+ public static void tearDownAfterClass() throws Exception {
+ if (ldapServer != null) {
+ ldapServer.stop();
+ }
+ if (directoryService != null) {
+ directoryService.shutdown();
+ }
+ if (keystore != null) {
+ keystore.delete();
+ }
+ }
+
+ @After
+ public void tearDown() {
+ if (ldapProxyBackend != null) {
+ ldapProxyBackend.close();
+ }
+ }
+
+ @Test
+ public void testGetUserOverLdaps() throws Exception {
+ ldapProxyBackend = new LdapProxyBackend("sslbackend",
trustingConfig());
+
+ Entry entry = ldapProxyBackend.getUser("ldaptest1", schemaManager);
+ assertNotNull("User should be resolved over LDAPS", entry);
+ assertEquals("ldaptest1", entry.get("uid").getString());
+ validateMemberOf(entry, Set.of(
+ "cn=group1,ou=groups,dc=hadoop,dc=apache,dc=org",
+ "cn=group2,ou=groups,dc=hadoop,dc=apache,dc=org"));
+ }
+
+ @Test
+ public void testGetUserGroupsOverLdaps() throws Exception {
+ ldapProxyBackend = new LdapProxyBackend("sslbackend",
trustingConfig());
+
+ List<String> groups = ldapProxyBackend.getUserGroups("ldaptest1",
schemaManager);
+ assertTrue(groups.contains("group1"));
+ assertTrue(groups.contains("group2"));
+ }
+
+ @Test
+ public void testAuthenticateOverLdaps() throws Exception {
+ ldapProxyBackend = new LdapProxyBackend("sslbackend",
trustingConfig());
+
+ Dn dn = new Dn("uid=guest,ou=people,dc=hadoop,dc=apache,dc=org");
+ assertTrue("Bind over LDAPS should succeed",
ldapProxyBackend.authenticate(dn, "guest-password"));
+ assertFalse("Bind over LDAPS with wrong password should fail",
+ ldapProxyBackend.authenticate(dn, "wrong-password"));
+ }
+
+ @Test
+ public void testExplicitUseSslWithoutUrlScheme() throws Exception {
+ // Enable LDAPS via the useSsl flag rather than the ldaps:// scheme
(host/port style config).
+ Map<String, String> config = new HashMap<>(baseConfig());
+ config.remove("url");
+ config.put("host", "localhost");
+ config.put("port", String.valueOf(port));
+ config.put("useSsl", "true");
+ config.put("trustAllCertificates", "true");
+ ldapProxyBackend = new LdapProxyBackend("sslbackend", config);
+
+ Entry entry = ldapProxyBackend.getUser("ldaptest1", schemaManager);
+ assertNotNull("User should be resolved over LDAPS configured via
useSsl flag", entry);
+ }
+
+ @Test(expected = LdapTlsHandshakeException.class)
+ public void testUntrustedCertificateIsRejected() throws Exception {
+ // useSsl without trusting the self-signed cert: the JVM default trust
store must reject it,
+ // proving certificate validation is actually enforced.
+ Map<String, String> config = new HashMap<>(baseConfig());
+ config.put("trustAllCertificates", "false");
+ config.put("connectionTimeout", "5000");
+ ldapProxyBackend = new LdapProxyBackend("sslbackend", config);
+
+ ldapProxyBackend.getUser("ldaptest1", schemaManager);
+ fail("Connecting over LDAPS to a server with an untrusted certificate
should fail");
+ }
+
+ private Map<String, String> baseConfig() {
+ Map<String, String> config = new HashMap<>();
+ config.put("baseDn", "dc=hadoop,dc=apache,dc=org");
+ config.put("remoteBaseDn", "dc=hadoop,dc=apache,dc=org");
+ config.put("url", "ldaps://localhost:" + port);
+ config.put("systemUsername",
"uid=guest,ou=people,dc=hadoop,dc=apache,dc=org");
+ config.put("systemPassword", "guest-password");
+ config.put("userSearchBase", "ou=people,dc=hadoop,dc=apache,dc=org");
+ config.put("groupSearchBase", "ou=groups,dc=hadoop,dc=apache,dc=org");
+ return config;
+ }
+
+ private Map<String, String> trustingConfig() {
+ Map<String, String> config = new HashMap<>(baseConfig());
+ config.put("trustAllCertificates", "true");
+ return config;
+ }
+
+ private void validateMemberOf(Entry entry, Set<String> expectedGroups)
throws Exception {
+ assertNotNull(entry.get("memberOf"));
+ assertEquals(expectedGroups.size(), entry.get("memberOf").size());
+ Set<String> foundGroups = new HashSet<>();
+ for (Value value : entry.get("memberOf")) {
+ foundGroups.add(value.getString());
+ }
+ assertEquals(expectedGroups, foundGroups);
+ }
+
+ /**
+ * Build a keystore holding a self-signed certificate and its private key,
in the JVM default
+ * keystore format (which is what ApacheDS uses to load the server
certificate).
+ */
+ private static File createTempKeystore() throws Exception {
+ KeyPairGenerator keyPairGenerator =
KeyPairGenerator.getInstance("RSA");
+ keyPairGenerator.initialize(2048);
+ KeyPair keyPair = keyPairGenerator.generateKeyPair();
+ X509Certificate cert = X509CertificateUtil.generateCertificate(
+ "CN=localhost, OU=Test, O=Knox, L=Test, ST=Test, C=US",
keyPair, 365, "SHA256withRSA");
+ assertNotNull("Failed to generate a self-signed test certificate",
cert);
+
+ KeyStore keyStore = KeyStore.getInstance(KeyStore.getDefaultType());
+ keyStore.load(null, null);
+ keyStore.setKeyEntry("knox-ldaps", keyPair.getPrivate(),
KEYSTORE_PASSWORD.toCharArray(),
+ new Certificate[] { cert });
+
+ File file = File.createTempFile("knox-ldaps-backend", ".ks");
+ file.deleteOnExit();
+ try (OutputStream os = Files.newOutputStream(file.toPath())) {
+ keyStore.store(os, KEYSTORE_PASSWORD.toCharArray());
+ }
+ return file;
+ }
+}
diff --git
a/gateway-spi-common/src/main/java/org/apache/knox/gateway/GatewayTestConfig.java
b/gateway-spi-common/src/main/java/org/apache/knox/gateway/GatewayTestConfig.java
index 73fcf1bfd..a3b3d0d9a 100644
---
a/gateway-spi-common/src/main/java/org/apache/knox/gateway/GatewayTestConfig.java
+++
b/gateway-spi-common/src/main/java/org/apache/knox/gateway/GatewayTestConfig.java
@@ -1335,6 +1335,26 @@ public class GatewayTestConfig extends Configuration
implements GatewayConfig {
return "";
}
+ @Override
+ public boolean isLDAPSSLEnabled() {
+ return false;
+ }
+
+ @Override
+ public String getLDAPSSLKeystorePath() {
+ return null;
+ }
+
+ @Override
+ public String getLDAPSSLKeystorePasswordAlias() {
+ return null;
+ }
+
+ @Override
+ public List<String> getLDAPSSLEnabledCipherSuites() {
+ return Collections.emptyList();
+ }
+
@Override
public boolean getGroupUIServicesOnHomepage() {
return false;
diff --git
a/gateway-spi/src/main/java/org/apache/knox/gateway/config/GatewayConfig.java
b/gateway-spi/src/main/java/org/apache/knox/gateway/config/GatewayConfig.java
index 2fdea677e..6b36e729b 100644
---
a/gateway-spi/src/main/java/org/apache/knox/gateway/config/GatewayConfig.java
+++
b/gateway-spi/src/main/java/org/apache/knox/gateway/config/GatewayConfig.java
@@ -151,6 +151,10 @@ public interface GatewayConfig {
String LDAP_ROLES_LOOKUP_STRATEGY = "gateway.ldap.roles.lookup.strategy";
String LDAP_ROLES_LOOKUP_REST_API_ENDPOINT =
"gateway.ldap.roles.lookup.rest.api.endpoint";
String LDAP_ROLES_LOOKUP_FILE_PATH = "gateway.ldap.roles.lookup.file.path";
+ String LDAP_SSL_ENABLED = "gateway.ldap.ssl.enabled";
+ String LDAP_SSL_KEYSTORE_PATH = "gateway.ldap.ssl.keystore.path";
+ String LDAP_SSL_KEYSTORE_PASSWORD_ALIAS =
"gateway.ldap.ssl.keystore.password.alias";
+ String LDAP_SSL_ENABLED_CIPHER_SUITES =
"gateway.ldap.ssl.enabled.cipher.suites";
/**
* The location of the gateway configuration.
@@ -1181,6 +1185,32 @@ public interface GatewayConfig {
*/
String getLdapRolesLookupFilePath();
+ /**
+ * @return true if the embedded LDAP service should expose a secure (LDAPS)
transport;
+ * otherwise false
+ */
+ boolean isLDAPSSLEnabled();
+
+ /**
+ * @return the path to the keystore holding the certificate presented by the
embedded
+ * LDAP service on its secure transport. When null/blank the gateway
identity keystore
+ * ({@link #getIdentityKeystorePath()}) is used.
+ */
+ String getLDAPSSLKeystorePath();
+
+ /**
+ * @return the credential-store alias for the password protecting the
keystore returned by
+ * {@link #getLDAPSSLKeystorePath()}. When null/blank the gateway identity
keystore password
+ * is used.
+ */
+ String getLDAPSSLKeystorePasswordAlias();
+
+ /**
+ * @return the TLS cipher suites the embedded LDAP service secure transport
is restricted to,
+ * or an empty list to use the JVM defaults
+ */
+ List<String> getLDAPSSLEnabledCipherSuites();
+
/**
* @return set of all property names in the configuration
*/
diff --git a/pom.xml b/pom.xml
index ebe25eba7..94ccc793f 100644
--- a/pom.xml
+++ b/pom.xml
@@ -157,8 +157,8 @@
<maven.compiler.target>17</maven.compiler.target>
<!-- Dependencies sorted alphabetically -->
- <apacheds.directory.api.version>2.0.0</apacheds.directory.api.version>
-
<apacheds.directory.server.version>2.0.0.AM26</apacheds.directory.server.version>
+ <apacheds.directory.api.version>2.1.8</apacheds.directory.api.version>
+
<apacheds.directory.server.version>2.0.0.AM27</apacheds.directory.server.version>
<apacheds-jdbm.version>2.0.0-M5</apacheds-jdbm.version>
<apache-rat-plugin.version>0.13</apache-rat-plugin.version>
<ant-nodeps.version>1.8.1</ant-nodeps.version>
@@ -177,6 +177,7 @@
<commons-cli.version>1.4</commons-cli.version>
<commons-codec.version>1.15</commons-codec.version>
<commons-collections.version>3.2.2</commons-collections.version>
+ <commons-collections4.version>4.5.0</commons-collections4.version>
<commons-compress.version>1.26.0</commons-compress.version>
<commons-configuration2.version>2.15.0</commons-configuration2.version>
<commons-digester3.version>3.2</commons-digester3.version>
@@ -2068,6 +2069,13 @@
<artifactId>commons-collections</artifactId>
<version>${commons-collections.version}</version>
</dependency>
+ <dependency>
+ <!-- Converge commons-collections4: ApacheDS pulls 4.4
transitively while
+ directory-api 2.1.8 pulls 4.5.0. Pin to the newer,
backward-compatible 4.5.0. -->
+ <groupId>org.apache.commons</groupId>
+ <artifactId>commons-collections4</artifactId>
+ <version>${commons-collections4.version}</version>
+ </dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-compress</artifactId>
@@ -2165,6 +2173,16 @@
<groupId>org.bouncycastle</groupId>
<artifactId>bcprov-jdk15on</artifactId>
</exclusion>
+ <!-- ApacheDS 2.0.0.AM27 pulls the legacy jdk15on
BouncyCastle; Knox
+ standardizes on the jdk18on artifacts (same
packages). -->
+ <exclusion>
+ <groupId>org.bouncycastle</groupId>
+ <artifactId>bcpkix-jdk15on</artifactId>
+ </exclusion>
+ <exclusion>
+ <groupId>org.bouncycastle</groupId>
+ <artifactId>bcutil-jdk15on</artifactId>
+ </exclusion>
</exclusions>
</dependency>
<dependency>
@@ -2196,6 +2214,14 @@
<groupId>org.bouncycastle</groupId>
<artifactId>bcprov-jdk15on</artifactId>
</exclusion>
+ <exclusion>
+ <groupId>org.bouncycastle</groupId>
+ <artifactId>bcpkix-jdk15on</artifactId>
+ </exclusion>
+ <exclusion>
+ <groupId>org.bouncycastle</groupId>
+ <artifactId>bcutil-jdk15on</artifactId>
+ </exclusion>
</exclusions>
</dependency>
<dependency>
@@ -2244,6 +2270,53 @@
<artifactId>api-asn1-api</artifactId>
<version>${apacheds.directory.api.version}</version>
</dependency>
+ <!-- Pin the remaining directory-api artifacts (pulled
transitively by ApacheDS) to
+ the same version to avoid a mixed directory-api classpath. -->
+ <dependency>
+ <groupId>org.apache.directory.api</groupId>
+ <artifactId>api-asn1-ber</artifactId>
+ <version>${apacheds.directory.api.version}</version>
+ </dependency>
+ <dependency>
+ <groupId>org.apache.directory.api</groupId>
+ <artifactId>api-i18n</artifactId>
+ <version>${apacheds.directory.api.version}</version>
+ </dependency>
+ <dependency>
+ <groupId>org.apache.directory.api</groupId>
+ <artifactId>api-ldap-net-mina</artifactId>
+ <version>${apacheds.directory.api.version}</version>
+ </dependency>
+ <dependency>
+ <groupId>org.apache.directory.api</groupId>
+ <artifactId>api-ldap-extras-aci</artifactId>
+ <version>${apacheds.directory.api.version}</version>
+ </dependency>
+ <dependency>
+ <groupId>org.apache.directory.api</groupId>
+ <artifactId>api-ldap-extras-codec</artifactId>
+ <version>${apacheds.directory.api.version}</version>
+ </dependency>
+ <dependency>
+ <groupId>org.apache.directory.api</groupId>
+ <artifactId>api-ldap-extras-codec-api</artifactId>
+ <version>${apacheds.directory.api.version}</version>
+ </dependency>
+ <dependency>
+ <groupId>org.apache.directory.api</groupId>
+ <artifactId>api-ldap-extras-sp</artifactId>
+ <version>${apacheds.directory.api.version}</version>
+ </dependency>
+ <dependency>
+ <groupId>org.apache.directory.api</groupId>
+ <artifactId>api-ldap-extras-trigger</artifactId>
+ <version>${apacheds.directory.api.version}</version>
+ </dependency>
+ <dependency>
+ <groupId>org.apache.directory.api</groupId>
+ <artifactId>api-ldap-extras-util</artifactId>
+ <version>${apacheds.directory.api.version}</version>
+ </dependency>
<dependency>
<groupId>org.apache.mina</groupId>