diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f3affdac3f..fdae2d23d6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -54,6 +54,8 @@ spotBugs = "4.9.4" swagger-annotations = "2.2.34" swagger-jaxrs = "1.6.16" systemstubs = "2.1.8" +testcontainers = "1.21.0" +unboundid-ldap-sdk = "7.0.3" victools = "4.38.0" wiremock = "3.0.1" zeroallocationhashing = "0.27ea0" @@ -154,6 +156,9 @@ slf4j-jultoslf4j = { module = "org.slf4j:jul-to-slf4j", version.ref = "slf4j" } swagger-annotations = { module = "io.swagger.core.v3:swagger-annotations", version.ref = "swagger-annotations" } swagger-jaxrs = { module = "io.swagger:swagger-jaxrs", version.ref = "swagger-jaxrs" } systemstubs = { module = "uk.org.webcompere:system-stubs-jupiter", version.ref = "systemstubs" } +testcontainers = { module = "org.testcontainers:testcontainers", version.ref = "testcontainers" } +testcontainers-junit-jupiter = { module = "org.testcontainers:junit-jupiter", version.ref = "testcontainers" } +unboundid-ldap-sdk = { module = "com.unboundid:unboundid-ldapsdk", version.ref = "unboundid-ldap-sdk" } victools-jsonschema-generator = { module = "com.github.victools:jsonschema-generator", version.ref = "victools" } victools-jsonschema-jackson = { module = "com.github.victools:jsonschema-module-jackson", version.ref = "victools" } wiremock-jre8-standalone = { module = "com.github.tomakehurst:wiremock-jre8-standalone", version.ref = "wiremock" } diff --git a/hivemq-edge/build.gradle.kts b/hivemq-edge/build.gradle.kts index 445eff1fe1..0b4382c9b1 100644 --- a/hivemq-edge/build.gradle.kts +++ b/hivemq-edge/build.gradle.kts @@ -188,6 +188,9 @@ dependencies { //JWT implementation(libs.jose4j) + //LDAP + implementation(libs.unboundid.ldap.sdk) + //json schema implementation(libs.json.schema.validator) implementation(libs.victools.jsonschema.generator) @@ -234,6 +237,8 @@ dependencies { testImplementation(libs.awaitility) testImplementation(libs.assertj) testImplementation(libs.systemstubs) + testImplementation(libs.testcontainers) + testImplementation(libs.testcontainers.junit.jupiter) } tasks.test { diff --git a/hivemq-edge/src/distribution/conf/examples/configuration/api/config-sample-ldap-api.xml b/hivemq-edge/src/distribution/conf/examples/configuration/api/config-sample-ldap-api.xml new file mode 100644 index 0000000000..781e0b97fe --- /dev/null +++ b/hivemq-edge/src/distribution/conf/examples/configuration/api/config-sample-ldap-api.xml @@ -0,0 +1,77 @@ + + + + + + + + + true + + + + + + ldap.example.com + 636 + LDAPS + + + + + /path/to/truststore.jks + changeit + JKS + + + + 5000 + 10000 + + + uid={username},ou=people,{baseDn} + + + dc=example,dc=com + + + + + diff --git a/hivemq-edge/src/distribution/conf/examples/configuration/api/config-sample-ldap-system-cas-api.xml b/hivemq-edge/src/distribution/conf/examples/configuration/api/config-sample-ldap-system-cas-api.xml new file mode 100644 index 0000000000..ffc2b79b65 --- /dev/null +++ b/hivemq-edge/src/distribution/conf/examples/configuration/api/config-sample-ldap-system-cas-api.xml @@ -0,0 +1,52 @@ + + + + + + + + + true + + + + + ldap.example.com + 636 + LDAPS + + + + + cn={username},cn=Users,{baseDn} + + + dc=company,dc=local + + + + + diff --git a/hivemq-edge/src/main/java/com/hivemq/api/auth/handler/impl/BasicAuthenticationHandler.java b/hivemq-edge/src/main/java/com/hivemq/api/auth/handler/impl/BasicAuthenticationHandler.java index c0b033e569..b13a11a083 100644 --- a/hivemq-edge/src/main/java/com/hivemq/api/auth/handler/impl/BasicAuthenticationHandler.java +++ b/hivemq-edge/src/main/java/com/hivemq/api/auth/handler/impl/BasicAuthenticationHandler.java @@ -16,18 +16,18 @@ package com.hivemq.api.auth.handler.impl; import com.google.common.base.Preconditions; -import com.hivemq.api.auth.ApiPrincipal; import com.hivemq.api.auth.handler.AuthenticationResult; -import com.hivemq.api.auth.provider.IUsernamePasswordProvider; -import org.jetbrains.annotations.NotNull; +import com.hivemq.api.auth.provider.IUsernameRolesProvider; import com.hivemq.http.HttpConstants; import com.hivemq.http.core.UsernamePasswordRoles; - import jakarta.inject.Inject; import jakarta.inject.Singleton; import jakarta.ws.rs.container.ContainerRequestContext; import jakarta.ws.rs.core.Response; import jakarta.ws.rs.core.SecurityContext; +import org.jetbrains.annotations.NotNull; + +import java.nio.charset.StandardCharsets; import java.util.Base64; import java.util.Optional; @@ -39,27 +39,26 @@ public class BasicAuthenticationHandler extends AbstractHeaderAuthenticationHand static final String SEP = ":"; static final String METHOD = "Basic"; - private final IUsernamePasswordProvider provider; + private final IUsernameRolesProvider provider; @Inject - public BasicAuthenticationHandler(final @NotNull IUsernamePasswordProvider provider) { + public BasicAuthenticationHandler(final @NotNull IUsernameRolesProvider provider) { this.provider = provider; } @Override - protected AuthenticationResult authenticateInternal(final @NotNull ContainerRequestContext requestContext, String authValue) { - Optional usernamePassword = parseValue(authValue); - if(usernamePassword.isPresent()){ - UsernamePasswordRoles supplied = usernamePassword.get(); - Optional record = provider.findByUsername(supplied.getUserName()); - if(record.isPresent() && record.get().getPassword().equals(supplied.getPassword())){ - AuthenticationResult result = AuthenticationResult.allowed(this); - ApiPrincipal principal = new ApiPrincipal(supplied.getUserName(), record.get().getRoles()); - result.setPrincipal(principal); - return result; - } - } - return AuthenticationResult.denied(this); + protected AuthenticationResult authenticateInternal(final @NotNull ContainerRequestContext requestContext, + final @NotNull String authValue) { + return parseValue(authValue) + .flatMap(supplied -> + provider.findByUsernameAndPassword( + supplied.getUserName(), + supplied.getPassword())) + .map(record -> { + final var result = AuthenticationResult.allowed(this); + result.setPrincipal(record.toPrincipal()); + return result; + }).orElseGet(() -> AuthenticationResult.denied(this)); } @Override @@ -72,14 +71,13 @@ public void decorateResponse(final AuthenticationResult result, final Response.R protected static Optional parseValue(final @NotNull String headerValue){ Preconditions.checkNotNull(headerValue); - String userPass = headerValue.trim(); - userPass = new String(Base64.getDecoder().decode(userPass)); + final var userPass = new String(Base64.getDecoder().decode(headerValue.trim())); if(userPass.contains(SEP)){ - String[] userNamePassword = userPass.split(SEP); + final var userNamePassword = userPass.split(SEP); if(userNamePassword.length == 2){ - UsernamePasswordRoles usernamePassword = new UsernamePasswordRoles(); + final var usernamePassword = new UsernamePasswordRoles(); usernamePassword.setUserName(userNamePassword[0]); - usernamePassword.setPassword(userNamePassword[1]); + usernamePassword.setPassword(userNamePassword[1].getBytes(StandardCharsets.UTF_8)); return Optional.of(usernamePassword); } } diff --git a/hivemq-edge/src/main/java/com/hivemq/api/auth/provider/IUsernamePasswordProvider.java b/hivemq-edge/src/main/java/com/hivemq/api/auth/provider/IUsernameRolesProvider.java similarity index 58% rename from hivemq-edge/src/main/java/com/hivemq/api/auth/provider/IUsernamePasswordProvider.java rename to hivemq-edge/src/main/java/com/hivemq/api/auth/provider/IUsernameRolesProvider.java index 15776cfdbc..65813c4b28 100644 --- a/hivemq-edge/src/main/java/com/hivemq/api/auth/provider/IUsernamePasswordProvider.java +++ b/hivemq-edge/src/main/java/com/hivemq/api/auth/provider/IUsernameRolesProvider.java @@ -15,15 +15,20 @@ */ package com.hivemq.api.auth.provider; +import com.hivemq.api.auth.ApiPrincipal; import org.jetbrains.annotations.NotNull; -import com.hivemq.http.core.UsernamePasswordRoles; import java.util.Optional; +import java.util.Set; -/** - * @author Simon L Johnson - */ -public interface IUsernamePasswordProvider extends ICredentialsProvider { +public interface IUsernameRolesProvider extends ICredentialsProvider { + + record UsernameRoles(String username, Set roles){ + public ApiPrincipal toPrincipal(){ + //decouple the password from the principal for the API + return new ApiPrincipal(username(), Set.copyOf(roles())); + } + } - Optional findByUsername(final @NotNull String userName); + Optional findByUsernameAndPassword(final @NotNull String userName, final byte @NotNull [] password); } diff --git a/hivemq-edge/src/main/java/com/hivemq/api/auth/provider/impl/SimpleUsernamePasswordProviderImpl.java b/hivemq-edge/src/main/java/com/hivemq/api/auth/provider/impl/SimpleUsernamePasswordProviderImpl.java deleted file mode 100644 index 9ce7211819..0000000000 --- a/hivemq-edge/src/main/java/com/hivemq/api/auth/provider/impl/SimpleUsernamePasswordProviderImpl.java +++ /dev/null @@ -1,65 +0,0 @@ -/* - * Copyright 2019-present HiveMQ GmbH - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.hivemq.api.auth.provider.impl; - -import com.google.common.base.Preconditions; -import com.hivemq.api.auth.provider.IUsernamePasswordProvider; -import org.jetbrains.annotations.NotNull; -import com.hivemq.http.core.UsernamePasswordRoles; - -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Optional; - -/** - * @author Simon L Johnson - */ -public class SimpleUsernamePasswordProviderImpl implements IUsernamePasswordProvider { - - private final @NotNull Map usernamePasswordMap; - - public SimpleUsernamePasswordProviderImpl() { - this.usernamePasswordMap = Collections.synchronizedMap(new HashMap<>()); - } - - public SimpleUsernamePasswordProviderImpl add(final @NotNull UsernamePasswordRoles usernamePassword){ - Preconditions.checkNotNull(usernamePassword); - Preconditions.checkArgument(usernamePassword.getUserName() != null, "Username must not be "); - usernamePasswordMap.put(usernamePassword.getUserName(), usernamePassword); - return this; - } - - public SimpleUsernamePasswordProviderImpl remove(final @NotNull String userName){ - Preconditions.checkNotNull(userName); - usernamePasswordMap.remove(userName); - return this; - } - - @Override - public Optional findByUsername(final @NotNull String userName) { - Preconditions.checkNotNull(userName); - UsernamePasswordRoles up = usernamePasswordMap.get(userName); - return Optional.ofNullable(up); - } - - public static SimpleUsernamePasswordProviderImpl fromList(List userList){ - SimpleUsernamePasswordProviderImpl simpleUsernamePasswordProvider = new SimpleUsernamePasswordProviderImpl(); - userList.stream().forEach(simpleUsernamePasswordProvider::add); - return simpleUsernamePasswordProvider; - } -} diff --git a/hivemq-edge/src/main/java/com/hivemq/api/auth/provider/impl/ldap/LdapClient.java b/hivemq-edge/src/main/java/com/hivemq/api/auth/provider/impl/ldap/LdapClient.java new file mode 100644 index 0000000000..038081eb78 --- /dev/null +++ b/hivemq-edge/src/main/java/com/hivemq/api/auth/provider/impl/ldap/LdapClient.java @@ -0,0 +1,254 @@ +/* + * Copyright 2019-present HiveMQ GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.hivemq.api.auth.provider.impl.ldap; + +import com.google.common.collect.ImmutableList; +import com.unboundid.ldap.sdk.BindRequest; +import com.unboundid.ldap.sdk.BindResult; +import com.unboundid.ldap.sdk.DN; +import com.unboundid.ldap.sdk.LDAPConnection; +import com.unboundid.ldap.sdk.LDAPConnectionPool; +import com.unboundid.ldap.sdk.LDAPConnectionPoolHealthCheck; +import com.unboundid.ldap.sdk.LDAPException; +import com.unboundid.ldap.sdk.PruneUnneededConnectionsLDAPConnectionPoolHealthCheck; +import com.unboundid.ldap.sdk.RDN; +import com.unboundid.ldap.sdk.ResultCode; +import com.unboundid.ldap.sdk.RoundRobinServerSet; +import com.unboundid.ldap.sdk.SimpleBindRequest; +import com.unboundid.ldap.sdk.StartTLSPostConnectProcessor; +import org.jetbrains.annotations.NotNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.net.SocketFactory; +import javax.net.ssl.SSLContext; +import java.security.GeneralSecurityException; + +/** + * LDAP client that manages connections and provides authentication operations. + *

+ * This class wraps the UnboundID LDAP SDK and provides a simplified API for LDAP operations + * with proper lifecycle management and connection pooling. + *

+ * Usage: + *

{@code
+ * LdapClient client = new LdapClient(connectionProperties);
+ * client.start();
+ * try {
+ *     boolean authenticated = client.bindUser(userDn, password);
+ *     // ... use client
+ * } finally {
+ *     client.stop();
+ * }
+ * }
+ */ +public class LdapClient { + + private static final @NotNull Logger log = LoggerFactory.getLogger(LdapClient.class); + + private final @NotNull LdapConnectionProperties connectionProperties; + private final @NotNull UserDnResolver userDnResolver; + private volatile LDAPConnectionPool connectionPool; + private volatile boolean started = false; + + /** + * Creates a new LDAP client with the specified connection properties. + * + * @param connectionProperties The connection configuration + */ + public LdapClient(final @NotNull LdapConnectionProperties connectionProperties) { + this.connectionProperties = connectionProperties; + this.userDnResolver = connectionProperties.createUserDnResolver(); + } + + /** + * Starts the LDAP client and initializes the connection pool. + * + * @throws LDAPException if the connection pool cannot be created + * @throws GeneralSecurityException if there's an SSL/TLS configuration issue + * @throws IllegalStateException if the client is already started + */ + public synchronized void start() throws LDAPException, GeneralSecurityException { + if (started) { + throw new IllegalStateException("LDAP client is already started"); + } + + log.debug("Starting LDAP client, connecting to {}:{}", + connectionProperties.servers().hosts()[0], connectionProperties.servers().ports()[0]); + + final var connectionOptions = connectionProperties.createConnectionOptions(); + + SocketFactory socketFactory = null; + StartTLSPostConnectProcessor startTlsProcessor = null; + switch (connectionProperties.tlsMode()) { + case NONE -> {} //NOOP + case LDAPS -> { + final SSLContext sslContext = connectionProperties.createSSLContext(); + socketFactory = sslContext.getSocketFactory(); + } + case START_TLS -> { + final SSLContext sslContext = connectionProperties.createSSLContext(); + startTlsProcessor = new StartTLSPostConnectProcessor(sslContext); + } + }; + + final var simpleBindEntity = connectionProperties.ldapSimpleBind(); + final var baseDn = new DN(connectionProperties.baseDn()); + + + final var bindDn = new DN(ImmutableList.builder() + .add(new DN(simpleBindEntity.rdns()).getRDNs()) + .add(baseDn.getRDNs()) + .build()); + + final var bindRequest = new SimpleBindRequest(bindDn, simpleBindEntity.userPassword()); + + final int maxConnections = connectionProperties.maxConnections(); + final int minConnections = Math.min(1, maxConnections); + + final var serverSet = new RoundRobinServerSet( + connectionProperties.servers().hosts(), + connectionProperties.servers().ports(), + socketFactory, + connectionOptions, + bindRequest, + startTlsProcessor); + + final var ldapConnectionPoolHealthCheck = + new PruneUnneededConnectionsLDAPConnectionPoolHealthCheck( + minConnections, 1_000L); //TODO configurable?? + + try { + connectionPool = new LDAPConnectionPool( // + serverSet, + bindRequest, + minConnections, + maxConnections, + minConnections, + null, + false, + ldapConnectionPoolHealthCheck); + + started = true; + log.info("LDAP client started successfully, connected to {}:{}", + connectionProperties.servers().hosts()[0], connectionProperties.servers().ports()[0]); + } catch (final Exception e) { + // Close the connection if pool creation fails + if(connectionPool != null) { + connectionPool.close(); + } + throw e; + } + } + + /** + * Stops the LDAP client and closes all connections in the pool. + * + * @throws IllegalStateException if the client is not started + */ + public synchronized void stop() { + log.debug("Stopping LDAP client"); + + if (connectionPool != null) { + connectionPool.close(); + connectionPool = null; + } + + started = false; + log.info("LDAP client stopped successfully"); + } + + /** + * Authenticates a user by performing an LDAP bind operation. + * + * @param userDn The user's Distinguished Name + * @param password The user's password + * @return {@code true} if authentication was successful, {@code false} otherwise + * @throws LDAPException if there's an LDAP protocol error (not authentication failure) + * @throws IllegalStateException if the client is not started + */ + public boolean bindUser(final @NotNull String userDn, final byte @NotNull [] password) throws LDAPException { + ensureStarted(); + + log.debug("Attempting to bind user: {}", userDn); + + LDAPConnection connection = null; + try { + connection = connectionPool.getConnection(); + final var bindRequest = new SimpleBindRequest(userDn, password); + final var bindResult = connection.bind(bindRequest); + + final boolean success = bindResult.getResultCode() == ResultCode.SUCCESS; + if (success) { + log.debug("User bind successful: {}", userDn); + } else { + log.debug("User bind failed: {}, result code: {}", userDn, bindResult.getResultCode()); + } + + return success; + } catch (final LDAPException e) { + // INVALID_CREDENTIALS is expected for wrong password, return false + if (e.getResultCode() == ResultCode.INVALID_CREDENTIALS) { + log.debug("User bind failed due to invalid credentials: {}", userDn); + return false; + } + // Other errors are unexpected, throw them + log.error("LDAP error during bind operation for user {}: {}", userDn, e.getMessage()); + throw e; + } finally { + if (connection != null) { + connectionPool.releaseConnection(connection); + } + } + } + + /** + * Authenticates a user by username, resolving the DN using the configured DN resolver. + *

+ * This is the recommended method for authentication. The username will be resolved to a full DN + * using the configured DN template (e.g., "uid={username},ou=people,dc=example,dc=com"). + * + * @param username The username to authenticate (e.g., "jdoe") + * @param password The user's password + * @return {@code true} if authentication was successful, {@code false} otherwise + * @throws LDAPException if there's an LDAP protocol error (not authentication failure) + * @throws IllegalStateException if the client is not started + */ + public boolean authenticateUser(final @NotNull String username, final byte @NotNull [] password) + throws LDAPException { + return bindUser(userDnResolver.resolveDn(username), password); + } + + /** + * Checks if the LDAP client is currently started. + * + * @return {@code true} if the client is started, {@code false} otherwise + */ + public boolean isStarted() { + return started; + } + + /** + * Ensures that the client is started, throwing an exception if not. + * + * @throws IllegalStateException if the client is not started + */ + private void ensureStarted() { + if (!started) { + throw new IllegalStateException("LDAP client is not started. Call start() first."); + } + } +} diff --git a/hivemq-edge/src/main/java/com/hivemq/api/auth/provider/impl/ldap/LdapConnectionProperties.java b/hivemq-edge/src/main/java/com/hivemq/api/auth/provider/impl/ldap/LdapConnectionProperties.java new file mode 100644 index 0000000000..bf804fc46e --- /dev/null +++ b/hivemq-edge/src/main/java/com/hivemq/api/auth/provider/impl/ldap/LdapConnectionProperties.java @@ -0,0 +1,291 @@ +/* + * Copyright 2019-present HiveMQ GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.hivemq.api.auth.provider.impl.ldap; + +import com.hivemq.configuration.entity.api.ldap.LdapAuthenticationEntity; +import com.hivemq.configuration.entity.api.ldap.LdapServerEntity; +import com.hivemq.configuration.entity.api.ldap.LdapSimpleBindEntity; +import com.hivemq.configuration.entity.api.ldap.TrustStoreEntity; +import com.unboundid.ldap.sdk.LDAPConnectionOptions; +import com.unboundid.util.ssl.SSLUtil; +import com.unboundid.util.ssl.TrustAllTrustManager; +import com.unboundid.util.ssl.TrustStoreTrustManager; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import javax.net.ssl.SSLContext; +import java.security.GeneralSecurityException; +import java.util.List; + +import static java.util.Arrays.stream; + +/** + * Record representing LDAP connection properties. + *

+ * Encapsulates all the connection details needed to connect to an LDAP server, + * including TLS configuration, timeouts, truststore information, and DN resolution. + * + * @param servers List of LDapServers to connect to + * @param tlsMode The TLS/SSL mode to use + * @param trustStore An optional truststore for connecting to an LDAP server + * @param connectTimeoutMillis Connection timeout in milliseconds (0 = use default) + * @param responseTimeoutMillis Response timeout in milliseconds (0 = use default) + * @param maxConnections Maximum number of connections in the connection pool + * @param userDnTemplate The DN template for resolving usernames to DNs (e.g., "uid={username},ou=people,{baseDn}") + * @param baseDn The base DN of the LDAP directory (e.g., "dc=example,dc=com") + * @param acceptAnyCertificateForTesting ⚠️ TEST ONLY - When true, disables all certificate validation + * and accepts any certificate including self-signed and expired certificates. + * NEVER use in production! Only for integration tests with + * testcontainers. Default: false + */ +public record LdapConnectionProperties( + @NotNull LdapServers servers, + @NotNull TlsMode tlsMode, + @Nullable TrustStore trustStore, + int connectTimeoutMillis, + int responseTimeoutMillis, + int maxConnections, + @NotNull String userDnTemplate, + @NotNull String baseDn, + @NotNull String assignedRole, + boolean acceptAnyCertificateForTesting, + @NotNull LdapSimpleBind ldapSimpleBind) { + + /** + * This class represents the simple bind credentials for an LDAP connection. + */ + public record LdapSimpleBind (@NotNull String rdns, @NotNull String userPassword){ + public static LdapSimpleBind fromEntity(final @NotNull LdapSimpleBindEntity ldapSimpleBindEntity) { + return new LdapSimpleBind( + ldapSimpleBindEntity.getRdns(), + ldapSimpleBindEntity.getUserPassword()); + } + } + + /** + * This class represents the simple bind credentials for an LDAP connection. + */ + public record LdapServers (@NotNull String[] hosts, int @NotNull [] ports){ + public static LdapServers fromEntity(final @NotNull List ldapServerEntities) { + final String[] hosts = ldapServerEntities.stream().map(LdapServerEntity::getHost).toArray(String[]::new); + final int[] ports = ldapServerEntities.stream().mapToInt(LdapServerEntity::getPort).toArray(); + return new LdapServers(hosts, ports); + } + } + + /** + * This class represents the simple bind credentials for an LDAP connection. + */ + public record TrustStore (@NotNull String trustStorePath, @Nullable String trustStorePassword, @Nullable String trustStoreType){ + public static TrustStore fromEntity(final @NotNull TrustStoreEntity trustStoreEntity) { + return new TrustStore( + trustStoreEntity.getTrustStorePath(), + trustStoreEntity.getTrustStorePassword(), + trustStoreEntity.getTrustStoreType()); + } + } + + /** + * Creates connection properties with default timeouts and secure certificate validation. + * + * @param servers List of LDapServers to connect to + * @param tlsMode The TLS/SSL mode to use + * @param trustStore An optional truststore for connecting to an LDAP server + * @param userDnTemplate The DN template for resolving usernames + * @param baseDn The base DN of the LDAP directory + */ + public LdapConnectionProperties( + final @NotNull LdapServers servers, + final @NotNull TlsMode tlsMode, + final @Nullable TrustStore trustStore, + final @NotNull String userDnTemplate, + final @NotNull String baseDn, + final @NotNull String assignedRole, + final @NotNull LdapSimpleBind ldapSimpleBind) { + this(servers, tlsMode, trustStore, 0, 0, 10, userDnTemplate, baseDn, assignedRole, false, ldapSimpleBind); + } + + /** + * Creates connection properties with explicit timeouts and secure certificate validation. + * + * @param servers List of LDapServers to connect to + * @param tlsMode The TLS/SSL mode to use + * @param trustStore An optional truststore for connecting to an LDAP server + * @param connectTimeoutMillis Connection timeout in milliseconds (0 = use default) + * @param responseTimeoutMillis Response timeout in milliseconds (0 = use default) + * @param userDnTemplate The DN template for resolving usernames + * @param baseDn The base DN of the LDAP directory + * @param assignedRole The base DN of the LDAP directory + */ + public LdapConnectionProperties( + final @NotNull LdapServers servers, + final @NotNull TlsMode tlsMode, + final @Nullable TrustStore trustStore, + final int connectTimeoutMillis, + final int responseTimeoutMillis, + final int maxConnections, + final @NotNull String userDnTemplate, + final @NotNull String baseDn, + final @NotNull String assignedRole, + final @NotNull LdapSimpleBind ldapSimpleBind) { + this(servers, tlsMode, trustStore, connectTimeoutMillis, responseTimeoutMillis, maxConnections, userDnTemplate, baseDn, assignedRole, false, ldapSimpleBind); + } + + /** + * Creates LdapConnectionProperties from XML configuration entity. + *

+ * This factory method converts an {@link LdapAuthenticationEntity} + * (loaded from config.xml) into runtime connection properties. + * + * @param entity The LDAP authentication configuration from XML + * @return Configured LdapConnectionProperties for runtime use + * @throws IllegalArgumentException if the entity configuration is invalid + */ + public static @NotNull LdapConnectionProperties fromEntity( + final @NotNull LdapAuthenticationEntity entity) { + // Parse TLS mode from string + final TlsMode tlsMode; + try { + tlsMode = TlsMode.valueOf(entity.getTlsMode().toUpperCase()); + } catch (final IllegalArgumentException e) { + throw new IllegalArgumentException( + "Invalid TLS mode: " + entity.getTlsMode() + ". Must be one of: NONE, LDAPS, START_TLS", e); + } + + return new LdapConnectionProperties( + LdapServers.fromEntity(entity.getServers()), + tlsMode, + entity.getTrustStore() != null ? TrustStore.fromEntity(entity.getTrustStore()) : null, + entity.getConnectTimeoutMillis(), + entity.getResponseTimeoutMillis(), entity.getMaxConnections(), + entity.getUserDnTemplate(), + entity.getBaseDn(), + entity.getAssignedRole(), + false, // Never allow test-only certificate acceptance from XML config + LdapSimpleBind.fromEntity(entity.getSimpleBindEntity()) + ); + } + + /** + * Validates the connection properties. + * + * @throws IllegalArgumentException if the configuration is invalid + */ + public LdapConnectionProperties { + stream(servers.ports()).forEach(port -> { + if (port < 1 || port > 65535) { + throw new IllegalArgumentException("Port must be between 1 and 65535, got: " + port); + } + }); + if (connectTimeoutMillis < 0) { + throw new IllegalArgumentException("Connect timeout cannot be negative: " + connectTimeoutMillis); + } + if (responseTimeoutMillis < 0) { + throw new IllegalArgumentException("Response timeout cannot be negative: " + responseTimeoutMillis); + } + if (userDnTemplate.isBlank()) { + throw new IllegalArgumentException("User DN template cannot be empty"); + } + if (!userDnTemplate.contains("{username}")) { + throw new IllegalArgumentException("User DN template must contain {username} placeholder"); + } + if (baseDn.isBlank()) { + throw new IllegalArgumentException("Base DN cannot be empty"); + } + if (assignedRole.isBlank()) { + throw new IllegalArgumentException("Assigned Role cannot be empty"); + } + } + + /** + * Creates a DN resolver using the configured template and base DN. + * + * @return A UserDnResolver configured with the template from this properties object + */ + public @NotNull UserDnResolver createUserDnResolver() { + return new TemplateDnResolver(userDnTemplate, baseDn); + } + + /** + * Creates an SSLContext from the truststore configuration. + *

+ * Certificate validation behavior depends on configuration: + *

    + *
  • acceptAnyCertificateForTesting = true: ⚠️ Disables ALL certificate validation. + * Accepts any certificate including self-signed, expired, or invalid certificates. + * NEVER use in production! Only for integration tests.
  • + *
  • trustStorePath = null: Uses system's default CA certificates + * (e.g., Let's Encrypt, DigiCert). Suitable for production with properly signed certificates.
  • + *
  • trustStorePath provided: Uses custom truststore. + * Useful for self-signed certificates or internal CAs in production.
  • + *
+ * + * @return A configured SSLContext + * @throws GeneralSecurityException if there's an issue creating the SSLContext + * @throws IllegalStateException if called when TLS mode is NONE + */ + public @NotNull SSLContext createSSLContext() throws GeneralSecurityException { + if (tlsMode.equals(TlsMode.NONE)) { + throw new IllegalStateException("SSLContext is not needed for TLS mode: " + tlsMode); + } + + // TEST ONLY: Accept any certificate without validation + if (acceptAnyCertificateForTesting) { + // WARNING: This disables certificate validation - only for testing! + // Configure SSLUtil to accept any certificate and hostname + final SSLUtil sslUtil = new SSLUtil(new TrustAllTrustManager()); + // Set default SSLContext protocol to work with OpenLDAP container + return sslUtil.createSSLContext("TLS"); + } + + if (trustStore() == null || trustStore().trustStorePath().isBlank()) { + // Use system default CA certificates (Java's default truststore) + final SSLUtil sslUtil = new SSLUtil(); + return sslUtil.createSSLContext(); + } + + // Use custom truststore for self-signed certificates or internal CAs + final SSLUtil sslUtil = new SSLUtil(new TrustStoreTrustManager( + trustStore().trustStorePath(), + trustStore().trustStorePassword() != null ? trustStore().trustStorePassword().toCharArray() : null, + trustStore().trustStoreType(), + true)); + return sslUtil.createSSLContext(); + } + + /** + * Creates connection options with configured timeouts. + * + * @return Configured LDAPConnectionOptions + */ + public @NotNull LDAPConnectionOptions createConnectionOptions() { + final LDAPConnectionOptions options = new LDAPConnectionOptions(); + + if (connectTimeoutMillis() > 0) { + options.setConnectTimeoutMillis(connectTimeoutMillis()); + } + + if (connectTimeoutMillis() > 0) { + options.setResponseTimeoutMillis(connectTimeoutMillis()); + } + + if (responseTimeoutMillis() > 0) { + options.setResponseTimeoutMillis(responseTimeoutMillis()); + } + + return options; + } +} diff --git a/hivemq-edge/src/main/java/com/hivemq/api/auth/provider/impl/ldap/LdapUsernameRolesProvider.java b/hivemq-edge/src/main/java/com/hivemq/api/auth/provider/impl/ldap/LdapUsernameRolesProvider.java new file mode 100644 index 0000000000..3bb9f2c205 --- /dev/null +++ b/hivemq-edge/src/main/java/com/hivemq/api/auth/provider/impl/ldap/LdapUsernameRolesProvider.java @@ -0,0 +1,64 @@ +/* + * Copyright 2019-present HiveMQ GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.hivemq.api.auth.provider.impl.ldap; + +import com.hivemq.api.auth.provider.IUsernameRolesProvider; +import com.unboundid.ldap.sdk.LDAPException; +import org.jetbrains.annotations.NotNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.security.GeneralSecurityException; +import java.util.Optional; +import java.util.Set; + +public class LdapUsernameRolesProvider implements IUsernameRolesProvider { + + private static final @NotNull Logger log = LoggerFactory.getLogger(LdapUsernameRolesProvider.class); + + private final @NotNull LdapClient ldapClient; + private final @NotNull Set assignedRole; + + public LdapUsernameRolesProvider(final @NotNull LdapConnectionProperties ldapConnectionProperties) { + this.ldapClient = new LdapClient(ldapConnectionProperties); + this.assignedRole = Set.of(ldapConnectionProperties.assignedRole()); + try { + this.ldapClient.start(); + } catch (final LDAPException | GeneralSecurityException e) { + log.error("Failed to start LDAP client", e); + throw new RuntimeException("Failed to initialize LDAP authentication provider", e); + } + } + + @Override + public Optional findByUsernameAndPassword( + final @NotNull String userName, + final @NotNull byte @NotNull [] password) { + try { + if(ldapClient.authenticateUser(userName, password)) { + return Optional.of(new UsernameRoles(userName, assignedRole)); + } else { + return Optional.empty(); + } + } catch (final LDAPException e) { + log.error("Error during LDAP authentication for user {}", userName, e); + return Optional.empty(); + } catch (final IllegalArgumentException e) { + log.debug("Invalid username or password format for user {}: {}", userName, e.getMessage()); + return Optional.empty(); + } + } +} diff --git a/hivemq-edge/src/main/java/com/hivemq/api/auth/provider/impl/ldap/SearchFilterDnResolver.java b/hivemq-edge/src/main/java/com/hivemq/api/auth/provider/impl/ldap/SearchFilterDnResolver.java new file mode 100644 index 0000000000..e7e6457939 --- /dev/null +++ b/hivemq-edge/src/main/java/com/hivemq/api/auth/provider/impl/ldap/SearchFilterDnResolver.java @@ -0,0 +1,267 @@ +/* + * Copyright 2019-present HiveMQ GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.hivemq.api.auth.provider.impl.ldap; + +import com.unboundid.ldap.sdk.DereferencePolicy; +import com.unboundid.ldap.sdk.Filter; +import com.unboundid.ldap.sdk.LDAPConnectionPool; +import com.unboundid.ldap.sdk.LDAPException; +import com.unboundid.ldap.sdk.LDAPSearchException; +import com.unboundid.ldap.sdk.ResultCode; +import com.unboundid.ldap.sdk.SearchRequest; +import com.unboundid.ldap.sdk.SearchResult; +import com.unboundid.ldap.sdk.SearchResultEntry; +import com.unboundid.ldap.sdk.SearchScope; +import org.jetbrains.annotations.NotNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Search filter-based DN resolver that performs an LDAP search to find a user's Distinguished Name. + *

+ * Unlike {@link TemplateDnResolver} which constructs the DN using string templates, this resolver + * queries the LDAP directory to find the user's actual DN. This is useful when: + *

    + *
  • User DNs follow complex or unpredictable patterns
  • + *
  • Users are scattered across multiple organizational units
  • + *
  • The DN structure varies between different user types
  • + *
  • Integration with Active Directory where DNs can be complex
  • + *
+ *

+ * Example search filters: + *

+ * Simple UID search:     "(uid={username})"
+ * Email search:          "(mail={username})"
+ * Active Directory:      "(sAMAccountName={username})"
+ * Multiple attributes:   "(|(uid={username})(mail={username}))"
+ * Complex filter:        "(&(objectClass=inetOrgPerson)(uid={username}))"
+ * 
+ *

+ * Performance Note: This resolver performs an LDAP search for each DN resolution, + * which adds latency compared to template-based resolution. Consider using {@link TemplateDnResolver} + * if your LDAP structure is simple and predictable. + */ +public class SearchFilterDnResolver implements UserDnResolver { + + private static final @NotNull Logger log = LoggerFactory.getLogger(SearchFilterDnResolver.class); + private static final @NotNull String USERNAME_PLACEHOLDER = "{username}"; + + private final @NotNull LDAPConnectionPool connectionPool; + private final @NotNull String searchBase; + private final @NotNull String searchFilterTemplate; + private final @NotNull SearchScope searchScope; + private final int timeoutSeconds; + + /** + * Creates a new search filter-based DN resolver. + * + * @param connectionPool The LDAP connection pool to use for searches + * @param searchBase The base DN where the search should start (e.g., "ou=people,dc=example,dc=com") + * @param searchFilterTemplate The LDAP search filter with {username} placeholder (e.g., "(uid={username})") + * @param searchScope The search scope (ONE_LEVEL, SUBTREE, or BASE) + * @param timeoutSeconds Search timeout in seconds (0 = no timeout) + * @throws IllegalArgumentException if any parameter is invalid + */ + public SearchFilterDnResolver( + final @NotNull LDAPConnectionPool connectionPool, + final @NotNull String searchBase, + final @NotNull String searchFilterTemplate, + final @NotNull SearchScope searchScope, + final int timeoutSeconds) { + if (searchBase.isBlank()) { + throw new IllegalArgumentException("Search base cannot be empty"); + } + if (searchFilterTemplate.isBlank()) { + throw new IllegalArgumentException("Search filter template cannot be empty"); + } + if (!searchFilterTemplate.contains(USERNAME_PLACEHOLDER)) { + throw new IllegalArgumentException("Search filter template must contain {username} placeholder"); + } + if (timeoutSeconds < 0) { + throw new IllegalArgumentException("Timeout cannot be negative: " + timeoutSeconds); + } + + this.connectionPool = connectionPool; + this.searchBase = searchBase; + this.searchFilterTemplate = searchFilterTemplate; + this.searchScope = searchScope; + this.timeoutSeconds = timeoutSeconds; + } + + /** + * Creates a resolver with SUBTREE scope and 5 second timeout (common defaults). + * + * @param connectionPool The LDAP connection pool + * @param searchBase The base DN for searching + * @param searchFilterTemplate The search filter with {username} placeholder + */ + public SearchFilterDnResolver( + final @NotNull LDAPConnectionPool connectionPool, + final @NotNull String searchBase, + final @NotNull String searchFilterTemplate) { + this(connectionPool, searchBase, searchFilterTemplate, SearchScope.SUB, 5); + } + + @Override + public @NotNull String resolveDn(final @NotNull String username) { + if (username.isBlank()) { + throw new IllegalArgumentException("Username cannot be empty"); + } + + // Replace {username} placeholder with actual username and create LDAP filter + final var filterString = searchFilterTemplate.replace(USERNAME_PLACEHOLDER, escapeFilterValue(username)); + + try { + final var filter = Filter.create(filterString); + + final var searchRequest = new SearchRequest( + searchBase, + searchScope, + DereferencePolicy.NEVER, + 1, // Size limit - we only need one result + timeoutSeconds, + false, // Types only = false (we want the full entry) + filter, + "1.1" // Return no attributes, we only need the DN + ); + + log.debug("Searching for user DN with filter: {} in base: {}", filterString, searchBase); + + final var searchResult = connectionPool.search(searchRequest); + + if (searchResult.getResultCode() != ResultCode.SUCCESS) { + throw new DnResolutionException( + "LDAP search failed with result code: " + searchResult.getResultCode() + + ", diagnostic message: " + searchResult.getDiagnosticMessage(), + username); + } + + final var entryCount = searchResult.getEntryCount(); + if (entryCount == 0) { + log.debug("No LDAP entry found for username: {} with filter: {}", username, filterString); + throw new DnResolutionException( + "No LDAP entry found for username: " + username, + username); + } + + if (entryCount > 1) { + log.warn("Multiple LDAP entries ({}) found for username: {} with filter: {}. Using first result.", + entryCount, username, filterString); + } + + final var entry = searchResult.getSearchEntries().getFirst(); + final var dn = entry.getDN(); + + log.debug("Resolved username '{}' to DN: {}", username, dn); + return dn; + + } catch (final LDAPSearchException e) { + if (e.getResultCode() == ResultCode.TIME_LIMIT_EXCEEDED) { + log.error("LDAP search timed out after {} seconds for username: {}", timeoutSeconds, username); + throw new DnResolutionException( + "LDAP search timed out after " + timeoutSeconds + " seconds", + username, + e); + } + log.error("LDAP search failed for username: {}, error: {}", username, e.getMessage()); + throw new DnResolutionException( + "LDAP search failed: " + e.getMessage(), + username, + e); + } catch (final LDAPException e) { + log.error("Invalid LDAP filter: {}", filterString, e); + throw new DnResolutionException( + "Invalid LDAP filter: " + filterString, + username, + e); + } + } + + /** + * Escapes special characters in LDAP filter values according to RFC 4515. + *

+ * Escapes: * ( ) \ NUL + *

+ * This prevents LDAP injection attacks when the username contains special characters. + * + * @param value The value to escape + * @return The escaped value safe for use in LDAP filters + */ + private @NotNull String escapeFilterValue(final @NotNull String value) { + // UnboundID SDK provides built-in escaping + return Filter.encodeValue(value); + } + + /** + * Returns the search base used by this resolver. + * + * @return The search base DN + */ + public @NotNull String getSearchBase() { + return searchBase; + } + + /** + * Returns the search filter template used by this resolver. + * + * @return The search filter template with {username} placeholder + */ + public @NotNull String getSearchFilterTemplate() { + return searchFilterTemplate; + } + + /** + * Returns the search scope used by this resolver. + * + * @return The search scope + */ + public @NotNull SearchScope getSearchScope() { + return searchScope; + } + + /** + * Returns the timeout in seconds used by this resolver. + * + * @return The timeout in seconds + */ + public int getTimeoutSeconds() { + return timeoutSeconds; + } + + /** + * Exception thrown when a user's DN cannot be resolved through LDAP search. + */ + public static class DnResolutionException extends RuntimeException { + private final @NotNull String username; + + public DnResolutionException(final @NotNull String message, final @NotNull String username) { + super(message); + this.username = username; + } + + public DnResolutionException( + final @NotNull String message, + final @NotNull String username, + final @NotNull Throwable cause) { + super(message, cause); + this.username = username; + } + + public @NotNull String getUsername() { + return username; + } + } +} diff --git a/hivemq-edge/src/main/java/com/hivemq/api/auth/provider/impl/ldap/TemplateDnResolver.java b/hivemq-edge/src/main/java/com/hivemq/api/auth/provider/impl/ldap/TemplateDnResolver.java new file mode 100644 index 0000000000..997ca77ea8 --- /dev/null +++ b/hivemq-edge/src/main/java/com/hivemq/api/auth/provider/impl/ldap/TemplateDnResolver.java @@ -0,0 +1,100 @@ +/* + * Copyright 2019-present HiveMQ GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.hivemq.api.auth.provider.impl.ldap; + +import com.unboundid.ldap.sdk.DN; +import com.unboundid.util.ByteStringBuffer; +import org.jetbrains.annotations.NotNull; + +/** + * Template-based DN resolver that uses string substitution to construct Distinguished Names. + *

+ * This resolver replaces placeholders in a template string with actual values: + *

    + *
  • {@code {username}} - The provided username
  • + *
  • {@code {baseDn}} - The base DN of the LDAP directory
  • + *
+ *

+ * Example templates: + *

+ * OpenLDAP:         "uid={username},ou=people,{baseDn}"
+ * Active Directory: "cn={username},cn=Users,{baseDn}"
+ * Email-based:      "mail={username},ou=staff,{baseDn}"
+ * Custom attribute: "employeeNumber={username},ou=employees,{baseDn}"
+ * Multiple OUs:     "uid={username},ou=engineering,ou=staff,{baseDn}"
+ * 
+ */ +public class TemplateDnResolver implements UserDnResolver { + + private static final @NotNull String USERNAME_PLACEHOLDER = "{username}"; + private static final @NotNull String BASE_DN_PLACEHOLDER = "{baseDn}"; + + private final @NotNull String template; + private final @NotNull String baseDn; + + /** + * Creates a new template-based DN resolver. + * + * @param template The DN template with placeholders (e.g., "uid={username},ou=people,{baseDn}") + * @param baseDn The base DN of the LDAP directory (e.g., "dc=example,dc=com") + * @throws IllegalArgumentException if template or baseDn is null or empty + */ + public TemplateDnResolver(final @NotNull String template, final @NotNull String baseDn) { + if (template.isBlank()) { + throw new IllegalArgumentException("DN template cannot be empty"); + } + if (baseDn.isBlank()) { + throw new IllegalArgumentException("Base DN cannot be empty"); + } + if (!template.contains(USERNAME_PLACEHOLDER)) { + throw new IllegalArgumentException("DN template must contain {username} placeholder"); + } + + this.template = template; + this.baseDn = baseDn; + } + + @Override + public @NotNull String resolveDn(final @NotNull String username) { + if (username.isBlank()) { + throw new IllegalArgumentException("Username cannot be empty"); + } + final var escaped = new ByteStringBuffer(); + DN.getDNEscapingStrategy().escape(username, escaped); + // Replace placeholders in template + return template + .replace(USERNAME_PLACEHOLDER, escaped.toString()) + .replace(BASE_DN_PLACEHOLDER, baseDn); + } + + /** + * Returns the template used by this resolver. + * + * @return The DN template string + */ + public @NotNull String getTemplate() { + return template; + } + + /** + * Returns the base DN used by this resolver. + * + * @return The base DN string + */ + public @NotNull String getBaseDn() { + return baseDn; + } +} diff --git a/hivemq-edge/src/main/java/com/hivemq/api/auth/provider/impl/ldap/TlsMode.java b/hivemq-edge/src/main/java/com/hivemq/api/auth/provider/impl/ldap/TlsMode.java new file mode 100644 index 0000000000..512bd4498f --- /dev/null +++ b/hivemq-edge/src/main/java/com/hivemq/api/auth/provider/impl/ldap/TlsMode.java @@ -0,0 +1,51 @@ +/* + * Copyright 2019-present HiveMQ GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.hivemq.api.auth.provider.impl.ldap; + +/** + * TLS/SSL modes for LDAP connections. + */ +public enum TlsMode { + /** + * No encryption - plain LDAP connection. + *

+ * Uses port 389 by default. Not recommended for production use as credentials + * and data are transmitted in clear text. + */ + NONE(389), + + /** + * LDAPS - LDAP over TLS/SSL. + *

+ * Establishes TLS connection from the start. Uses port 636 by default. + * Most secure option as the entire connection is encrypted. + */ + LDAPS(636), + + /** + * StartTLS - Upgrade plain connection to TLS. + *

+ * Starts as plain LDAP on port 389, then upgrades to TLS using the StartTLS + * extended operation. Common in production environments. + */ + START_TLS(389); + + public final int defaultPort; + + TlsMode(final int defaultPort) { + this.defaultPort = defaultPort; + } +} diff --git a/hivemq-edge/src/main/java/com/hivemq/api/auth/provider/impl/ldap/UserDnResolver.java b/hivemq-edge/src/main/java/com/hivemq/api/auth/provider/impl/ldap/UserDnResolver.java new file mode 100644 index 0000000000..4bdff237f3 --- /dev/null +++ b/hivemq-edge/src/main/java/com/hivemq/api/auth/provider/impl/ldap/UserDnResolver.java @@ -0,0 +1,44 @@ +/* + * Copyright 2019-present HiveMQ GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.hivemq.api.auth.provider.impl.ldap; + +import org.jetbrains.annotations.NotNull; + +/** + * Interface for resolving usernames to Distinguished Names (DNs) in LDAP. + *

+ * Different LDAP servers use different DN structures and attribute names. + * This interface provides a strategy for converting a username to a full DN + * that can be used for LDAP bind operations. + *

+ * Example DN formats: + *

    + *
  • OpenLDAP: {@code uid=jdoe,ou=people,dc=example,dc=com}
  • + *
  • Active Directory: {@code cn=John Doe,cn=Users,dc=company,dc=com}
  • + *
  • Email-based: {@code mail=jdoe@company.com,ou=staff,dc=company,dc=com}
  • + *
+ */ +@FunctionalInterface +public interface UserDnResolver { + + /** + * Resolves a username to a Distinguished Name. + * + * @param username The username to resolve (e.g., "jdoe", "john.doe@company.com") + * @return The full Distinguished Name (e.g., "uid=jdoe,ou=people,dc=example,dc=com") + */ + @NotNull String resolveDn(final @NotNull String username); +} diff --git a/hivemq-edge/src/main/java/com/hivemq/api/auth/provider/impl/ldap/package-info.java b/hivemq-edge/src/main/java/com/hivemq/api/auth/provider/impl/ldap/package-info.java new file mode 100644 index 0000000000..a0b0ce4605 --- /dev/null +++ b/hivemq-edge/src/main/java/com/hivemq/api/auth/provider/impl/ldap/package-info.java @@ -0,0 +1,184 @@ +/* + * Copyright 2019-present HiveMQ GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * LDAP client library for HiveMQ Edge authentication. + *

+ * This package provides a comprehensive LDAP client implementation with support for: + *

    + *
  • TLS/SSL encryption (LDAPS, StartTLS, or plain LDAP)
  • + *
  • Connection pooling for high performance
  • + *
  • Multiple DN resolution strategies (template-based and search-based)
  • + *
  • Flexible authentication options
  • + *
+ * + *

Quick Start

+ * + *

Basic Usage with Template DN Resolution

+ *
{@code
+ * // 1. Configure connection properties
+ * LdapConnectionProperties props = new LdapConnectionProperties(
+ *     "ldap.example.com",                      // LDAP server host
+ *     636,                                      // Port (636 for LDAPS)
+ *     TlsMode.LDAPS,                           // TLS mode
+ *     "/path/to/truststore.jks",              // Truststore (or null for system CAs)
+ *     "changeit".toCharArray(),               // Truststore password
+ *     "JKS",                                   // Truststore type
+ *     10000,                                   // Connect timeout (ms)
+ *     30000,                                   // Response timeout (ms)
+ *     "uid={username},ou=people,{baseDn}",    // DN template
+ *     "dc=example,dc=com"                     // Base DN
+ * );
+ *
+ * // 2. Create and start LDAP client
+ * LdapClient client = new LdapClient(props);
+ * client.start();
+ *
+ * try {
+ *     // 3. Authenticate a user
+ *     boolean authenticated = client.authenticateUser("jdoe", "password123");
+ *     if (authenticated) {
+ *         System.out.println("Authentication successful!");
+ *     }
+ * } finally {
+ *     // 4. Always stop the client when done
+ *     client.stop();
+ * }
+ * }
+ * + *

Advanced Usage with Search Filter DN Resolution

+ *
{@code
+ * // Configure and start client (same as above)
+ * LdapClient client = new LdapClient(props);
+ * client.start();
+ *
+ * try {
+ *     // Create a search-based DN resolver
+ *     SearchFilterDnResolver resolver = new SearchFilterDnResolver(
+ *         client.getConnectionPool(),
+ *         "dc=example,dc=com",                // Search base
+ *         "(|(uid={username})(mail={username}))", // Search filter (uid OR mail)
+ *         SearchScope.SUB,                     // Search entire subtree
+ *         5                                    // Timeout (seconds)
+ *     );
+ *
+ *     // Resolve the DN and authenticate
+ *     String userDn = resolver.resolveDn("jdoe@example.com");
+ *     boolean authenticated = client.bindUser(userDn, "password123");
+ *
+ * } catch (SearchFilterDnResolver.DnResolutionException e) {
+ *     System.err.println("User not found: " + e.getUsername());
+ * } finally {
+ *     client.stop();
+ * }
+ * }
+ * + *

DN Resolution Strategies

+ * + *

Template-Based Resolution ({@link TemplateDnResolver})

+ *

+ * Best for: Simple, predictable LDAP structures + *
Performance: Fast (no LDAP query) + *
Flexibility: Limited + *

+ * Constructs DNs using string templates with placeholders: + *

    + *
  • OpenLDAP: {@code uid={username},ou=people,{baseDn}}
  • + *
  • Active Directory: {@code cn={username},cn=Users,{baseDn}}
  • + *
  • Email-based: {@code mail={username},ou=staff,{baseDn}}
  • + *
+ * + *

Search Filter-Based Resolution ({@link SearchFilterDnResolver})

+ *

+ * Best for: Complex LDAP structures, scattered users + *
Performance: Slower (requires LDAP query) + *
Flexibility: High + *

+ * Searches the LDAP directory to find the user's DN using filters: + *

    + *
  • Simple: {@code (uid={username})}
  • + *
  • Email: {@code (mail={username})}
  • + *
  • Multiple attributes: {@code (|(uid={username})(mail={username}))}
  • + *
  • Complex: {@code (&(objectClass=inetOrgPerson)(uid={username}))}
  • + *
+ * + *

TLS Configuration

+ * + *

LDAPS (Recommended)

+ *
{@code
+ * TlsMode.LDAPS  // TLS from connection start, usually port 636
+ * }
+ * + *

StartTLS

+ *
{@code
+ * TlsMode.START_TLS  // Upgrade plain connection to TLS, usually port 389
+ * }
+ * + *

Plain LDAP (Not Recommended for Production)

+ *
{@code
+ * TlsMode.NONE  // No encryption, credentials sent in clear text
+ * }
+ * + *

System CA Certificates

+ *

+ * Pass {@code null} for truststore path to use system's default CA certificates: + *

{@code
+ * new LdapConnectionProperties(
+ *     "ldap.example.com",
+ *     636,
+ *     TlsMode.LDAPS,
+ *     null,  // Use system CAs (Let's Encrypt, DigiCert, etc.)
+ *     null,
+ *     null,
+ *     "uid={username},ou=people,{baseDn}",
+ *     "dc=example,dc=com"
+ * );
+ * }
+ * + *

Error Handling

+ * + *
{@code
+ * try {
+ *     client.start();
+ *     boolean authenticated = client.authenticateUser("user", "pass");
+ *
+ * } catch (LDAPException e) {
+ *     // LDAP protocol errors (connection issues, invalid credentials, etc.)
+ *     if (e.getResultCode() == ResultCode.INVALID_CREDENTIALS) {
+ *         // Wrong password
+ *     } else if (e.getResultCode() == ResultCode.CONNECT_ERROR) {
+ *         // Cannot connect to LDAP server
+ *     }
+ * } catch (GeneralSecurityException e) {
+ *     // SSL/TLS errors (certificate issues, etc.)
+ * } catch (SearchFilterDnResolver.DnResolutionException e) {
+ *     // User DN could not be resolved (user not found, timeout, etc.)
+ *     String username = e.getUsername();
+ * }
+ * }
+ * + *

Thread Safety

+ *

+ * All classes in this package are thread-safe once initialized. The {@link LdapClient} uses + * connection pooling internally to handle concurrent authentication requests efficiently. + * + * @see LdapClient Main client for LDAP authentication + * @see LdapConnectionProperties Connection configuration + * @see TemplateDnResolver Fast, template-based DN resolution + * @see SearchFilterDnResolver Flexible, search-based DN resolution + * @see TlsMode TLS/SSL encryption modes + */ +package com.hivemq.api.auth.provider.impl.ldap; diff --git a/hivemq-edge/src/main/java/com/hivemq/api/auth/provider/impl/simple/SimpleUsernameRolesProviderImpl.java b/hivemq-edge/src/main/java/com/hivemq/api/auth/provider/impl/simple/SimpleUsernameRolesProviderImpl.java new file mode 100644 index 0000000000..9cc16363a0 --- /dev/null +++ b/hivemq-edge/src/main/java/com/hivemq/api/auth/provider/impl/simple/SimpleUsernameRolesProviderImpl.java @@ -0,0 +1,70 @@ +/* + * Copyright 2019-present HiveMQ GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.hivemq.api.auth.provider.impl.simple; + +import com.hivemq.api.auth.provider.IUsernameRolesProvider; +import com.hivemq.http.core.UsernamePasswordRoles; +import org.jetbrains.annotations.NotNull; + +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +import static com.google.common.base.Preconditions.checkArgument; +import static com.google.common.base.Preconditions.checkNotNull; + +/** + * @author Simon L Johnson + */ +public class SimpleUsernameRolesProviderImpl implements IUsernameRolesProvider { + + private final @NotNull Map usernamePasswordMap; + + public SimpleUsernameRolesProviderImpl() { + this.usernamePasswordMap = new ConcurrentHashMap<>(); + } + + public SimpleUsernameRolesProviderImpl add(final @NotNull UsernamePasswordRoles usernamePassword){ + checkNotNull(usernamePassword); + checkArgument(usernamePassword.getUserName() != null && !usernamePassword.getUserName().isBlank(), "Username must not be "); + usernamePasswordMap.put(usernamePassword.getUserName(), usernamePassword); + return this; + } + + public SimpleUsernameRolesProviderImpl remove(final @NotNull String userName){ + checkNotNull(userName); + usernamePasswordMap.remove(userName); + return this; + } + + @Override + public Optional findByUsernameAndPassword(final @NotNull String userName, final byte @NotNull [] password) { + checkNotNull(userName); + return Optional + .ofNullable(usernamePasswordMap.get(userName)) + .map(user -> { + if(!Arrays.equals(user.getPassword(), password)) { + return null; + } else { + return new UsernameRoles(user.getUserName(), Set.copyOf(user.getRoles())); + } + }); + } +} diff --git a/hivemq-edge/src/main/java/com/hivemq/api/ioc/ApiModule.java b/hivemq-edge/src/main/java/com/hivemq/api/ioc/ApiModule.java index b7e70fc151..ede10c2b70 100644 --- a/hivemq-edge/src/main/java/com/hivemq/api/ioc/ApiModule.java +++ b/hivemq-edge/src/main/java/com/hivemq/api/ioc/ApiModule.java @@ -22,8 +22,9 @@ import com.hivemq.api.auth.jwt.JwtAuthenticationProvider; import com.hivemq.api.auth.provider.ITokenGenerator; import com.hivemq.api.auth.provider.ITokenVerifier; -import com.hivemq.api.auth.provider.IUsernamePasswordProvider; -import com.hivemq.api.auth.provider.impl.SimpleUsernamePasswordProviderImpl; +import com.hivemq.api.auth.provider.IUsernameRolesProvider; +import com.hivemq.api.auth.provider.impl.ldap.LdapUsernameRolesProvider; +import com.hivemq.api.auth.provider.impl.simple.SimpleUsernameRolesProviderImpl; import com.hivemq.api.config.ApiListener; import com.hivemq.api.resources.impl.AuthenticationResourceImpl; import com.hivemq.api.resources.impl.BridgeResourceImpl; @@ -125,12 +126,17 @@ static Set provideAuthHandlers( @Provides @Singleton - static IUsernamePasswordProvider usernamePasswordProvider(final @NotNull ApiConfigurationService apiConfigurationService) { - //Generic Credentials used by Both Authentication Handler - final SimpleUsernamePasswordProviderImpl provider = new SimpleUsernamePasswordProviderImpl(); - log.trace("Applying {} users to API access list", apiConfigurationService.getUserList().size()); - apiConfigurationService.getUserList().forEach(provider::add); - return provider; + static IUsernameRolesProvider usernamePasswordProvider(final @NotNull ApiConfigurationService apiConfigurationService) { + final var ldap = apiConfigurationService.getLdapConnectionProperties(); + if(ldap != null) { + return new LdapUsernameRolesProvider(ldap); + } else { + //Generic Credentials used by Both Authentication Handler + final SimpleUsernameRolesProviderImpl provider = new SimpleUsernameRolesProviderImpl(); + log.trace("Applying {} users to API access list", apiConfigurationService.getUserList().size()); + apiConfigurationService.getUserList().forEach(provider::add); + return provider; + } } @Provides diff --git a/hivemq-edge/src/main/java/com/hivemq/api/resources/impl/AuthenticationResourceImpl.java b/hivemq-edge/src/main/java/com/hivemq/api/resources/impl/AuthenticationResourceImpl.java index b53b6dee62..8a2793a919 100644 --- a/hivemq-edge/src/main/java/com/hivemq/api/resources/impl/AuthenticationResourceImpl.java +++ b/hivemq-edge/src/main/java/com/hivemq/api/resources/impl/AuthenticationResourceImpl.java @@ -21,7 +21,7 @@ import com.hivemq.api.auth.AuthenticationException; import com.hivemq.api.auth.provider.ITokenGenerator; import com.hivemq.api.auth.provider.ITokenVerifier; -import com.hivemq.api.auth.provider.IUsernamePasswordProvider; +import com.hivemq.api.auth.provider.IUsernameRolesProvider; import com.hivemq.api.error.ApiException; import com.hivemq.api.errors.authentication.AuthenticationValidationError; import com.hivemq.api.errors.authentication.UnauthorizedError; @@ -30,14 +30,14 @@ import com.hivemq.edge.api.AuthenticationApi; import com.hivemq.edge.api.model.ApiBearerToken; import com.hivemq.edge.api.model.UsernamePasswordCredentials; -import com.hivemq.http.core.UsernamePasswordRoles; import com.hivemq.util.ErrorResponseUtil; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; - import jakarta.inject.Inject; import jakarta.inject.Singleton; import jakarta.ws.rs.core.Response; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.nio.charset.StandardCharsets; import java.util.Optional; /** @@ -48,11 +48,11 @@ public class AuthenticationResourceImpl extends AbstractApi implements Authentic private final @NotNull ITokenGenerator tokenGenerator; private final @NotNull ITokenVerifier tokenVerifier; - private final @NotNull IUsernamePasswordProvider usernamePasswordProvider; + private final @NotNull IUsernameRolesProvider usernamePasswordProvider; @Inject public AuthenticationResourceImpl( - final @NotNull IUsernamePasswordProvider usernamePasswordProvider, + final @NotNull IUsernameRolesProvider usernamePasswordProvider, final @NotNull ITokenGenerator tokenGenerator, final @NotNull ITokenVerifier tokenVerifier) { this.usernamePasswordProvider = usernamePasswordProvider; @@ -74,24 +74,22 @@ public AuthenticationResourceImpl( } else { final String userName = credentials.getUserName(); final String password = credentials.getPassword(); - final Optional usernamePasswordRoles = usernamePasswordProvider.findByUsername(userName); - if (usernamePasswordRoles.isPresent()) { - final UsernamePasswordRoles user = usernamePasswordRoles.get(); - if (user.getPassword().equals(password)) { - try { - final ApiBearerToken token = new ApiBearerToken().token(tokenGenerator.generateToken(user.toPrincipal())); - if (logger.isTraceEnabled()) { - logger.trace("Bearer authentication was success, token generated for {}", - user.getUserName()); + return usernamePasswordProvider + .findByUsernameAndPassword(userName, password.getBytes(StandardCharsets.UTF_8)) + .map(user -> { + try { + final ApiBearerToken token = new ApiBearerToken().token(tokenGenerator.generateToken(user.toPrincipal())); + if (logger.isTraceEnabled()) { + logger.trace("Bearer authentication was success, token generated for {}", + userName); + } + return Response.ok(token).build(); + } catch (final AuthenticationException e) { + logger.warn("Authentication failed with error", e); + throw new ApiException("error encountered during authentication", e); } - return Response.ok(token).build(); - } catch (final AuthenticationException e) { - logger.warn("Authentication failed with error", e); - throw new ApiException("error encountered during authentication", e); - } - } - } - return ErrorResponseUtil.errorResponse(new UnauthorizedError("Invalid username and/or password")); + }) + .orElse(ErrorResponseUtil.errorResponse(new UnauthorizedError("Invalid username and/or password"))); } } diff --git a/hivemq-edge/src/main/java/com/hivemq/configuration/entity/api/AdminApiEntity.java b/hivemq-edge/src/main/java/com/hivemq/configuration/entity/api/AdminApiEntity.java index 1ccd6b2e27..2322969341 100644 --- a/hivemq-edge/src/main/java/com/hivemq/configuration/entity/api/AdminApiEntity.java +++ b/hivemq-edge/src/main/java/com/hivemq/configuration/entity/api/AdminApiEntity.java @@ -16,6 +16,7 @@ package com.hivemq.configuration.entity.api; import com.hivemq.configuration.entity.EnabledEntity; +import com.hivemq.configuration.entity.api.ldap.LdapAuthenticationEntity; import jakarta.xml.bind.annotation.XmlAccessType; import jakarta.xml.bind.annotation.XmlAccessorType; import jakarta.xml.bind.annotation.XmlElementRef; @@ -31,7 +32,7 @@ @XmlRootElement(name = "admin-api") @XmlAccessorType(XmlAccessType.NONE) -@SuppressWarnings({"FieldMayBeFinal", "FieldCanBeLocal"}) +@SuppressWarnings({"FieldMayBeFinal", "FieldCanBeLocal", "deprecation"}) public class AdminApiEntity extends EnabledEntity { @XmlElementWrapper(name = "listeners") @@ -47,6 +48,9 @@ public class AdminApiEntity extends EnabledEntity { @XmlElementRef(required = false) private @NotNull List users; + @XmlElementRef(required = false) + private @Nullable LdapAuthenticationEntity ldapAuthentication; + @XmlElementRef(required = false) private @Nullable ApiTlsEntity tls; @@ -72,6 +76,10 @@ public AdminApiEntity() { return users; } + public @Nullable LdapAuthenticationEntity getLdap() { + return ldapAuthentication; + } + public @Nullable ApiTlsEntity getTls() { return tls; } @@ -93,6 +101,7 @@ public boolean equals(final @Nullable Object o) { Objects.equals(tls, that.tls) && Objects.equals(jws, that.jws) && Objects.equals(users, that.users) && + Objects.equals(ldapAuthentication, that.ldapAuthentication) && Objects.equals(preLoginNotice, that.preLoginNotice); } return false; @@ -100,6 +109,6 @@ public boolean equals(final @Nullable Object o) { @Override public int hashCode() { - return Objects.hash(super.hashCode(), listeners, tls, jws, users, preLoginNotice); + return Objects.hash(super.hashCode(), listeners, tls, jws, users, ldapAuthentication, preLoginNotice); } } diff --git a/hivemq-edge/src/main/java/com/hivemq/configuration/entity/api/UserEntity.java b/hivemq-edge/src/main/java/com/hivemq/configuration/entity/api/UserEntity.java index ac812963be..89eb1443de 100644 --- a/hivemq-edge/src/main/java/com/hivemq/configuration/entity/api/UserEntity.java +++ b/hivemq-edge/src/main/java/com/hivemq/configuration/entity/api/UserEntity.java @@ -46,7 +46,7 @@ public String getPassword() { return password; } - public List getRoles() { + public @NotNull List getRoles() { return roles; } diff --git a/hivemq-edge/src/main/java/com/hivemq/configuration/entity/api/ldap/LdapAuthenticationEntity.java b/hivemq-edge/src/main/java/com/hivemq/configuration/entity/api/ldap/LdapAuthenticationEntity.java new file mode 100644 index 0000000000..fff1a77a71 --- /dev/null +++ b/hivemq-edge/src/main/java/com/hivemq/configuration/entity/api/ldap/LdapAuthenticationEntity.java @@ -0,0 +1,158 @@ +/* + * Copyright 2019-present HiveMQ GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.hivemq.configuration.entity.api.ldap; + +import jakarta.xml.bind.annotation.XmlAccessType; +import jakarta.xml.bind.annotation.XmlAccessorType; +import jakarta.xml.bind.annotation.XmlElement; +import jakarta.xml.bind.annotation.XmlElementRef; +import jakarta.xml.bind.annotation.XmlElementWrapper; +import jakarta.xml.bind.annotation.XmlRootElement; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +/** + * XML entity for LDAP authentication configuration. + *

+ * Configures connection to an LDAP server for user authentication in the Admin API. + * Supports plain LDAP, LDAPS (LDAP over TLS), and START_TLS modes. + *

+ * Example configuration: + *

{@code
+ * 
+ *     ldap.example.com
+ *     636
+ *     LDAPS
+ *     
+ *         /path/to/truststore.jks
+ *         changeit
+ *         JKS
+ *     
+ *     uid={username},ou=people,{baseDn}
+ *     dc=example,dc=com
+ * 
+ * }
+ */ +@XmlRootElement(name = "ldap") +@XmlAccessorType(XmlAccessType.NONE) +@SuppressWarnings({"FieldMayBeFinal", "FieldCanBeLocal"}) +public class LdapAuthenticationEntity { + + @XmlElementWrapper(name = "servers", required = true) + @XmlElement(name = "ldap-server") + private @NotNull List servers = new ArrayList<>(); + + @XmlElement(name = "tls-mode") + private @NotNull String tlsMode = "NONE"; + + @XmlElementRef(required = false) + private @Nullable TrustStoreEntity trustStore = null; + + @XmlElement(name = "connect-timeout-millis") + private int connectTimeoutMillis = 0; + + @XmlElement(name = "response-timeout-millis") + private int responseTimeoutMillis = 0; + + @XmlElement(name = "max-connections", required = true, defaultValue = "1") + private int maxConnections = 1; + + @XmlElement(name = "user-dn-template", required = true) + private @NotNull String userDnTemplate = ""; + + @XmlElement(name = "base-dn", required = true) + private @NotNull String baseDn = ""; + + @XmlElement(name = "assigned-role", required = true, defaultValue = "ADMIN") + private @NotNull String assignedRole = "ADMIN"; + + @XmlElement(name = "simple-bind", required = true) + private @NotNull LdapSimpleBindEntity simpleBindEntity = new LdapSimpleBindEntity(); + + public @NotNull String getTlsMode() { + return tlsMode; + } + + public @Nullable TrustStoreEntity getTrustStore() { + return trustStore; + } + + public int getConnectTimeoutMillis() { + return connectTimeoutMillis; + } + + public int getResponseTimeoutMillis() { + return responseTimeoutMillis; + } + + public int getMaxConnections() { + return maxConnections; + } + + public @NotNull String getUserDnTemplate() { + return userDnTemplate; + } + + public @NotNull String getBaseDn() { + return baseDn; + } + + public @NotNull String getAssignedRole() { + return assignedRole; + } + + public @NotNull LdapSimpleBindEntity getSimpleBindEntity() { + return simpleBindEntity; + } + + public @NotNull List getServers() { + return servers; + } + + @Override + public boolean equals(final Object o) { + if (o == null || getClass() != o.getClass()) return false; + final LdapAuthenticationEntity that = (LdapAuthenticationEntity) o; + return getConnectTimeoutMillis() == that.getConnectTimeoutMillis() && + getResponseTimeoutMillis() == that.getResponseTimeoutMillis() && + getMaxConnections() == that.getMaxConnections() && + Objects.equals(servers, that.servers) && + Objects.equals(getTlsMode(), that.getTlsMode()) && + Objects.equals(getTrustStore(), that.getTrustStore()) && + Objects.equals(getUserDnTemplate(), that.getUserDnTemplate()) && + Objects.equals(getBaseDn(), that.getBaseDn()) && + Objects.equals(getAssignedRole(), that.getAssignedRole()) && + Objects.equals(getSimpleBindEntity(), that.getSimpleBindEntity()); + } + + @Override + public int hashCode() { + return Objects.hash(servers, + getTlsMode(), + getTrustStore(), + getConnectTimeoutMillis(), + getResponseTimeoutMillis(), + getMaxConnections(), + getUserDnTemplate(), + getBaseDn(), + getAssignedRole(), + getSimpleBindEntity()); + } +} diff --git a/hivemq-edge/src/main/java/com/hivemq/configuration/entity/api/ldap/LdapServerEntity.java b/hivemq-edge/src/main/java/com/hivemq/configuration/entity/api/ldap/LdapServerEntity.java new file mode 100644 index 0000000000..cba8578580 --- /dev/null +++ b/hivemq-edge/src/main/java/com/hivemq/configuration/entity/api/ldap/LdapServerEntity.java @@ -0,0 +1,38 @@ +package com.hivemq.configuration.entity.api.ldap; + +import jakarta.xml.bind.annotation.XmlAccessType; +import jakarta.xml.bind.annotation.XmlAccessorType; +import jakarta.xml.bind.annotation.XmlElement; +import org.jetbrains.annotations.NotNull; + +@XmlAccessorType(XmlAccessType.NONE) +@SuppressWarnings("NotNullFieldNotInitialized") +public class LdapServerEntity { + + /* + host + 389 + + */ + @XmlElement(name = "host", required = true) + private @NotNull String host; + + @XmlElement(name = "port", defaultValue = "389") + private int port = 389; + + public LdapServerEntity() { + } + + public LdapServerEntity(final @NotNull String host, final int port) { + this.host = host; + this.port = port; + } + + public @NotNull String getHost() { + return host; + } + + public int getPort() { + return port; + } +} diff --git a/hivemq-edge/src/main/java/com/hivemq/configuration/entity/api/ldap/LdapSimpleBindEntity.java b/hivemq-edge/src/main/java/com/hivemq/configuration/entity/api/ldap/LdapSimpleBindEntity.java new file mode 100644 index 0000000000..4eeca06487 --- /dev/null +++ b/hivemq-edge/src/main/java/com/hivemq/configuration/entity/api/ldap/LdapSimpleBindEntity.java @@ -0,0 +1,36 @@ +package com.hivemq.configuration.entity.api.ldap; + +import jakarta.xml.bind.annotation.XmlAccessType; +import jakarta.xml.bind.annotation.XmlAccessorType; +import jakarta.xml.bind.annotation.XmlElement; +import org.jetbrains.annotations.NotNull; + +/** + * This class represents the simple bind credentials for an LDAP connection. + */ +@XmlAccessorType(XmlAccessType.NONE) +@SuppressWarnings("NotNullFieldNotInitialized") +public class LdapSimpleBindEntity { + + @XmlElement(name = "rdns", required = true) + private @NotNull String rdns; + + @XmlElement(name = "userPassword", required = true) + private @NotNull String userPassword; + + public LdapSimpleBindEntity() { + } + + public LdapSimpleBindEntity(final @NotNull String rdns, final @NotNull String password) { + this.rdns = rdns; + this.userPassword = password; + } + + public @NotNull String getRdns() { + return rdns; + } + + public @NotNull String getUserPassword() { + return userPassword; + } +} diff --git a/hivemq-edge/src/main/java/com/hivemq/configuration/entity/api/ldap/TrustStoreEntity.java b/hivemq-edge/src/main/java/com/hivemq/configuration/entity/api/ldap/TrustStoreEntity.java new file mode 100644 index 0000000000..831f6739f6 --- /dev/null +++ b/hivemq-edge/src/main/java/com/hivemq/configuration/entity/api/ldap/TrustStoreEntity.java @@ -0,0 +1,77 @@ +/* + * Copyright 2019-present HiveMQ GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.hivemq.configuration.entity.api.ldap; + +import jakarta.xml.bind.annotation.XmlAccessType; +import jakarta.xml.bind.annotation.XmlAccessorType; +import jakarta.xml.bind.annotation.XmlElement; +import jakarta.xml.bind.annotation.XmlRootElement; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.Objects; + +/** + * XML entity for LDAP TLS/SSL configuration. + *

+ * Configures truststore settings for secure LDAP connections (LDAPS or START_TLS). + * If no truststore is configured, the system's default CA certificates will be used. + */ +@XmlRootElement(name = "tls") +@XmlAccessorType(XmlAccessType.NONE) +@SuppressWarnings({"FieldMayBeFinal", "FieldCanBeLocal"}) +public class TrustStoreEntity { + + @XmlElement(name = "truststore-path", required = true, defaultValue = "") + private @NotNull String trustStorePath = ""; + + @XmlElement(name = "truststore-password") + private @Nullable String trustStorePassword = null; + + @XmlElement(name = "truststore-type") + private @Nullable String trustStoreType = null; + + public @NotNull String getTrustStorePath() { + return trustStorePath; + } + + public @Nullable String getTrustStorePassword() { + return trustStorePassword; + } + + public @Nullable String getTrustStoreType() { + return trustStoreType; + } + + @Override + public boolean equals(final @Nullable Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + final TrustStoreEntity that = (TrustStoreEntity) o; + return Objects.equals(trustStorePath, that.trustStorePath) && + Objects.equals(trustStorePassword, that.trustStorePassword) && + Objects.equals(trustStoreType, that.trustStoreType); + } + + @Override + public int hashCode() { + return Objects.hash(trustStorePath, trustStorePassword, trustStoreType); + } +} diff --git a/hivemq-edge/src/main/java/com/hivemq/configuration/reader/ApiConfigurator.java b/hivemq-edge/src/main/java/com/hivemq/configuration/reader/ApiConfigurator.java index 3ebad68de5..c4dea53209 100644 --- a/hivemq-edge/src/main/java/com/hivemq/configuration/reader/ApiConfigurator.java +++ b/hivemq-edge/src/main/java/com/hivemq/configuration/reader/ApiConfigurator.java @@ -16,6 +16,7 @@ package com.hivemq.configuration.reader; import com.google.common.collect.ImmutableList; +import com.hivemq.api.auth.provider.impl.ldap.LdapConnectionProperties; import com.hivemq.api.config.ApiJwtConfiguration; import com.hivemq.api.config.ApiListener; import com.hivemq.api.config.HttpListener; @@ -40,6 +41,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.nio.charset.StandardCharsets; import java.util.List; import java.util.Set; @@ -51,7 +53,7 @@ public class ApiConfigurator implements Configurator { private static final @NotNull List DEFAULT_LISTENERS = List.of(new HttpListener(8080, "127.0.0.1")); private static final @NotNull Logger log = LoggerFactory.getLogger(ApiConfigurator.class); private static final @NotNull List DEFAULT_USERS = - List.of(new UsernamePasswordRoles(DEFAULT_USERNAME, DEFAULT_PASSWORD, Set.of("ADMIN"))); + List.of(new UsernamePasswordRoles(DEFAULT_USERNAME, DEFAULT_PASSWORD.getBytes(StandardCharsets.UTF_8), Set.of("ADMIN"))); private final @NotNull ApiConfigurationService apiCfgService; private volatile @Nullable AdminApiEntity configEntity; @@ -63,7 +65,7 @@ public ApiConfigurator(final @NotNull ApiConfigurationService apiCfgService) { private static @NotNull UsernamePasswordRoles fromModel(final @NotNull UserEntity userEntity) { return new UsernamePasswordRoles(userEntity.getUserName(), - userEntity.getPassword(), + userEntity.getPassword().getBytes(StandardCharsets.UTF_8), Set.copyOf(userEntity.getRoles())); } @@ -84,11 +86,17 @@ public boolean needsRestartWithConfig(final @NotNull HiveMQConfigEntity config) apiCfgService.setEnabled(entity.isEnabled()); // Users - final List users = entity.getUsers(); - if (!users.isEmpty()) { - apiCfgService.setUserList(users.stream().map(ApiConfigurator::fromModel).toList()); + if(entity.getLdap() != null) { + apiCfgService.setLdapConnectionProperties(LdapConnectionProperties.fromEntity(entity.getLdap())); } else { - apiCfgService.setUserList(DEFAULT_USERS); + final List users = entity.getUsers(); + if (!users.isEmpty()) { + log.warn("The element in the configuration is deprecated and will be removed in future versions. " + + "Please use the element instead."); + apiCfgService.setUserList(users.stream().map(ApiConfigurator::fromModel).toList()); + } else { + apiCfgService.setUserList(DEFAULT_USERS); + } } // JWT diff --git a/hivemq-edge/src/main/java/com/hivemq/configuration/service/ApiConfigurationService.java b/hivemq-edge/src/main/java/com/hivemq/configuration/service/ApiConfigurationService.java index c0d742784f..d3fa801440 100644 --- a/hivemq-edge/src/main/java/com/hivemq/configuration/service/ApiConfigurationService.java +++ b/hivemq-edge/src/main/java/com/hivemq/configuration/service/ApiConfigurationService.java @@ -15,6 +15,7 @@ */ package com.hivemq.configuration.service; +import com.hivemq.api.auth.provider.impl.ldap.LdapConnectionProperties; import com.hivemq.api.config.ApiJwtConfiguration; import com.hivemq.api.config.ApiListener; import com.hivemq.api.config.ApiStaticResourcePath; @@ -50,6 +51,10 @@ public interface ApiConfigurationService { void setUserList(final @NotNull List userList); + void setLdapConnectionProperties(final @NotNull LdapConnectionProperties connectionProperties); + + @Nullable LdapConnectionProperties getLdapConnectionProperties(); + @NotNull PreLoginNotice getPreLoginNotice(); void setPreLoginNotice(final @NotNull PreLoginNotice preLoginNotice); diff --git a/hivemq-edge/src/main/java/com/hivemq/configuration/service/impl/ApiConfigurationServiceImpl.java b/hivemq-edge/src/main/java/com/hivemq/configuration/service/impl/ApiConfigurationServiceImpl.java index 71ca1c400b..425fee95d9 100644 --- a/hivemq-edge/src/main/java/com/hivemq/configuration/service/impl/ApiConfigurationServiceImpl.java +++ b/hivemq-edge/src/main/java/com/hivemq/configuration/service/impl/ApiConfigurationServiceImpl.java @@ -15,6 +15,7 @@ */ package com.hivemq.configuration.service.impl; +import com.hivemq.api.auth.provider.impl.ldap.LdapConnectionProperties; import com.hivemq.api.config.ApiJwtConfiguration; import com.hivemq.api.config.ApiListener; import com.hivemq.api.config.ApiStaticResourcePath; @@ -37,6 +38,7 @@ public class ApiConfigurationServiceImpl implements ApiConfigurationService { private @NotNull List listeners = new CopyOnWriteArrayList<>(); private @Nullable ApiJwtConfiguration apiJwtConfiguration; private @NotNull PreLoginNotice preLoginNotice = new PreLoginNotice(); + private @Nullable LdapConnectionProperties ldapConnectionProperties; @Override public @NotNull List getListeners() { @@ -83,6 +85,16 @@ public void setUserList(final @NotNull List userList) { this.userList = userList; } + @Override + public void setLdapConnectionProperties(final @NotNull LdapConnectionProperties connectionProperties) { + this.ldapConnectionProperties = connectionProperties; + } + + @Override + public @Nullable LdapConnectionProperties getLdapConnectionProperties() { + return ldapConnectionProperties; + } + @Override public @NotNull PreLoginNotice getPreLoginNotice() { return preLoginNotice; diff --git a/hivemq-edge/src/main/java/com/hivemq/http/core/UsernamePasswordRoles.java b/hivemq-edge/src/main/java/com/hivemq/http/core/UsernamePasswordRoles.java index a42fe0bbba..152ac9708a 100644 --- a/hivemq-edge/src/main/java/com/hivemq/http/core/UsernamePasswordRoles.java +++ b/hivemq-edge/src/main/java/com/hivemq/http/core/UsernamePasswordRoles.java @@ -15,7 +15,6 @@ */ package com.hivemq.http.core; -import com.hivemq.api.auth.ApiPrincipal; import org.jetbrains.annotations.NotNull; import java.util.Set; @@ -26,7 +25,7 @@ public class UsernamePasswordRoles { public static final String DEFAULT_PASSWORD = "hivemq"; private String userName; - private String password; + private byte[] password; private String realm; private Set roles = Set.of(); @@ -34,7 +33,7 @@ public UsernamePasswordRoles() { } - public UsernamePasswordRoles(final @NotNull String userName, final @NotNull String password, Set roles) { + public UsernamePasswordRoles(final @NotNull String userName, final byte @NotNull [] password, final @NotNull Set roles) { this(); this.userName = userName; this.password = password; @@ -45,15 +44,15 @@ public String getUserName() { return userName; } - public void setUserName(String userName) { + public void setUserName(final @NotNull String userName) { this.userName = userName; } - public String getPassword() { + public byte[] getPassword() { return password; } - public void setPassword(String password) { + public void setPassword(final byte[] password) { this.password = password; } @@ -61,16 +60,11 @@ public String getRealm() { return realm; } - public void setRealm(String realm) { + public void setRealm(final @NotNull String realm) { this.realm = realm; } public Set getRoles() { return roles; } - - public ApiPrincipal toPrincipal(){ - //decouple the password from the principal for the API - return new ApiPrincipal(getUserName(), Set.copyOf(getRoles())); - } } diff --git a/hivemq-edge/src/main/java/com/hivemq/security/ssl/SslUtil.java b/hivemq-edge/src/main/java/com/hivemq/security/ssl/SslUtil.java index 8fe688a34d..976c3b380e 100644 --- a/hivemq-edge/src/main/java/com/hivemq/security/ssl/SslUtil.java +++ b/hivemq-edge/src/main/java/com/hivemq/security/ssl/SslUtil.java @@ -82,7 +82,9 @@ public final class SslUtil { } private static @NotNull KeyStore getKeyStore( - @NotNull String keyStoreType, @NotNull String keyStorePassword, String keyStorePath) + final @NotNull String keyStoreType, + final @NotNull String keyStorePassword, + final String keyStorePath) throws KeyStoreException, IOException, NoSuchAlgorithmException, CertificateException { //First try to load keystore as is try (final InputStream fileInputStream = new FileInputStream(keyStorePath)) { @@ -109,14 +111,16 @@ public final class SslUtil { } - private static byte[] loadFileContentAndConvertIfBase64Encoded(@NotNull String keyStoreType, @NotNull String keyStorePath) { + private static byte[] loadFileContentAndConvertIfBase64Encoded( + final @NotNull String keyStoreType, + final @NotNull String keyStorePath) { final byte[] keystoreContent; try { byte[] loaded = Files.readAllBytes(Path.of(keyStorePath)); //in containers the keystore might arrive base64 encoded try { loaded = Base64.getDecoder().decode(loaded); - } catch (IllegalArgumentException e) { + } catch (final IllegalArgumentException e) { //ignored, just means the content isn't base64 encoded } keystoreContent = loaded; @@ -162,6 +166,7 @@ private static byte[] loadFileContentAndConvertIfBase64Encoded(@NotNull String k } } + private SslUtil() { } } diff --git a/hivemq-edge/src/main/resources/config.xsd b/hivemq-edge/src/main/resources/config.xsd index efab080158..b60cab8fc1 100644 --- a/hivemq-edge/src/main/resources/config.xsd +++ b/hivemq-edge/src/main/resources/config.xsd @@ -1059,6 +1059,91 @@ + + + + + + + + + + + + + LDAP server port. If not specified, defaults to 389 for NONE/START_TLS or 636 for LDAPS. + + + + + + + + + + + TLS mode for LDAP connection. Default: NONE + + + + + + + + + + + + + + + Path to truststore file. If not specified, system default CA certificates are used. + + + + + Password for the truststore. + + + + + Type of truststore (e.g., JKS, PKCS12). + + + + + + + + + + + + + + + + Connection timeout in milliseconds. 0 means use SDK default. + + + + + Response timeout in milliseconds. 0 means use SDK default. + + + + + DN template for resolving usernames. Must contain {username} placeholder. Example: uid={username},ou=people,{baseDn} + + + + + Base DN of the LDAP directory. Example: dc=example,dc=com + + + + + diff --git a/hivemq-edge/src/test/java/com/hivemq/api/AuthTestUtils.java b/hivemq-edge/src/test/java/com/hivemq/api/AuthTestUtils.java index 9fa4fbebbc..5b6be9722a 100644 --- a/hivemq-edge/src/test/java/com/hivemq/api/AuthTestUtils.java +++ b/hivemq-edge/src/test/java/com/hivemq/api/AuthTestUtils.java @@ -15,10 +15,11 @@ */ package com.hivemq.api; -import com.hivemq.api.auth.provider.IUsernamePasswordProvider; -import com.hivemq.api.auth.provider.impl.SimpleUsernamePasswordProviderImpl; +import com.hivemq.api.auth.provider.IUsernameRolesProvider; +import com.hivemq.api.auth.provider.impl.simple.SimpleUsernameRolesProviderImpl; import com.hivemq.http.core.UsernamePasswordRoles; +import java.nio.charset.StandardCharsets; import java.util.Set; /** @@ -26,12 +27,12 @@ */ public class AuthTestUtils { - public static IUsernamePasswordProvider createTestUsernamePasswordProvider(){ + public static IUsernameRolesProvider createTestUsernamePasswordProvider(){ - return new SimpleUsernamePasswordProviderImpl(). - add(new UsernamePasswordRoles("testadmin", "test", Set.of("ADMIN"))). - add(new UsernamePasswordRoles("testuser", "test", Set.of("USER"))). - add(new UsernamePasswordRoles("testnorole", "test", Set.of())); + return new SimpleUsernameRolesProviderImpl(). + add(new UsernamePasswordRoles("testadmin", "test".getBytes(StandardCharsets.UTF_8), Set.of("ADMIN"))). + add(new UsernamePasswordRoles("testuser", "test".getBytes(StandardCharsets.UTF_8), Set.of("USER"))). + add(new UsernamePasswordRoles("testnorole", "test".getBytes(StandardCharsets.UTF_8), Set.of())); } } diff --git a/hivemq-edge/src/test/java/com/hivemq/api/auth/ChainedAuthTests.java b/hivemq-edge/src/test/java/com/hivemq/api/auth/ChainedAuthTests.java index 50b72ff0e9..962e5f6a0b 100644 --- a/hivemq-edge/src/test/java/com/hivemq/api/auth/ChainedAuthTests.java +++ b/hivemq-edge/src/test/java/com/hivemq/api/auth/ChainedAuthTests.java @@ -24,7 +24,7 @@ import com.hivemq.api.auth.handler.impl.BasicAuthenticationHandler; import com.hivemq.api.auth.handler.impl.BearerTokenAuthenticationHandler; import com.hivemq.api.auth.jwt.JwtAuthenticationProvider; -import com.hivemq.api.auth.provider.IUsernamePasswordProvider; +import com.hivemq.api.auth.provider.IUsernameRolesProvider; import com.hivemq.api.config.ApiJwtConfiguration; import com.hivemq.api.resources.impl.AuthenticationResourceImpl; import com.hivemq.edge.api.model.ApiBearerToken; @@ -78,7 +78,7 @@ public static void setUp() throws Exception { final ApiJwtConfiguration configuration = new ApiJwtConfiguration(2048, "Test-Issuer", "Test-Audience", 10, 2); final JwtAuthenticationProvider jwtAuthenticationProvider = new JwtAuthenticationProvider(configuration); - final IUsernamePasswordProvider usernamePasswordProvider = AuthTestUtils.createTestUsernamePasswordProvider(); + final IUsernameRolesProvider usernamePasswordProvider = AuthTestUtils.createTestUsernamePasswordProvider(); final Set authenticationHandlers = new HashSet<>(); authenticationHandlers.add(new BearerTokenAuthenticationHandler(jwtAuthenticationProvider)); authenticationHandlers.add(new BasicAuthenticationHandler(usernamePasswordProvider)); diff --git a/hivemq-edge/src/test/java/com/hivemq/api/auth/LdapAuthenticationTests.java b/hivemq-edge/src/test/java/com/hivemq/api/auth/LdapAuthenticationTests.java new file mode 100644 index 0000000000..2b551969da --- /dev/null +++ b/hivemq-edge/src/test/java/com/hivemq/api/auth/LdapAuthenticationTests.java @@ -0,0 +1,177 @@ +/* + * Copyright 2019-present HiveMQ GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.hivemq.api.auth; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.hivemq.api.TestApiResource; +import com.hivemq.api.TestPermitAllApiResource; +import com.hivemq.api.TestResourceLevelRolesApiResource; +import com.hivemq.api.auth.handler.IAuthenticationHandler; +import com.hivemq.api.auth.handler.impl.BasicAuthenticationHandler; +import com.hivemq.api.auth.provider.impl.ldap.LdapConnectionProperties; +import com.hivemq.api.auth.provider.impl.ldap.LdapUsernameRolesProvider; +import com.hivemq.api.auth.provider.impl.ldap.TlsMode; +import com.hivemq.api.auth.provider.impl.ldap.testcontainer.LdapTestConnection; +import com.hivemq.api.auth.provider.impl.ldap.testcontainer.LldapContainer; +import com.hivemq.bootstrap.ioc.Injector; +import com.hivemq.extension.sdk.api.annotations.NotNull; +import com.hivemq.http.HttpConstants; +import com.hivemq.http.JaxrsHttpServer; +import com.hivemq.http.config.JaxrsHttpServerConfiguration; +import com.hivemq.http.core.HttpUrlConnectionClient; +import com.hivemq.http.core.HttpUtils; +import org.glassfish.jersey.server.ResourceConfig; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import java.io.IOException; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static com.hivemq.api.auth.provider.impl.ldap.testcontainer.LdapTestConnection.TEST_PASSWORD; +import static com.hivemq.api.auth.provider.impl.ldap.testcontainer.LdapTestConnection.TEST_USERNAME; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +@Testcontainers +public class LdapAuthenticationTests { + + @Container + private static final LldapContainer LLDAP_CONTAINER = new LldapContainer(); + + private static final String LDAP_DN_TEMPLATE = "uid={username},ou=people,{baseDn}"; + + protected final Logger logger = LoggerFactory.getLogger(LdapAuthenticationTests.class); + + static final int TEST_HTTP_PORT = 8088; + static final int CONNECT_TIMEOUT = 1000; + static final int READ_TIMEOUT = 1000; + static final String HTTP = "http"; + + protected static JaxrsHttpServer server; + + @Mock + private static Injector injector; + + @BeforeAll + static void setUp() throws Exception { + // Get the dynamically mapped port from the container + final var host = LLDAP_CONTAINER.getHost(); + final var port = LLDAP_CONTAINER.getLdapPort(); + + // Create LdapSimpleBind for LLDAP admin authentication + final var ldapSimpleBind = + new LdapConnectionProperties.LdapSimpleBind( + "uid=" + LLDAP_CONTAINER.getAdminUsername() + ",ou=people", + LLDAP_CONTAINER.getAdminPassword()); + + // Create connection properties for plain LDAP (no TLS for simplicity) + // 5 second connect timeout + // 10 second response timeout + final var ldapConnectionProperties = + new LdapConnectionProperties(new LdapConnectionProperties.LdapServers(new String[]{host}, + new int[]{port}), TlsMode.NONE, null, 5000, // 5 second connect timeout + 10000, // 10 second response timeout + 1, LDAP_DN_TEMPLATE, LLDAP_CONTAINER.getBaseDn(), "ADMIN", ldapSimpleBind); + + // Create test user in LLDAP + new LdapTestConnection(ldapConnectionProperties).createTestUser( + LLDAP_CONTAINER.getAdminDn(), + LLDAP_CONTAINER.getAdminPassword(), + LLDAP_CONTAINER.getBaseDn()); + + final var config = new JaxrsHttpServerConfiguration(); + config.setPort(TEST_HTTP_PORT); + + final Set authenticationHandlers = new HashSet<>(); + authenticationHandlers.add(new BasicAuthenticationHandler(new LdapUsernameRolesProvider(ldapConnectionProperties))); + final ResourceConfig conf = new ResourceConfig(){{ + register(new ApiAuthenticationFeature(authenticationHandlers)); + } + }; + conf.register(TestApiResource.class); + conf.register(TestPermitAllApiResource.class); + conf.register(TestResourceLevelRolesApiResource.class); + //-- ensure we supplied our own test mapper as this can effect output + final var mapper = new ObjectMapper(); + config.setObjectMapper(mapper); + server = new JaxrsHttpServer(mock(), List.of(config), conf); + server.startServer(); + } + + @AfterAll + public static void tearDown(){ + server.stopServer(); + } + + protected static String getTestServerAddress(final @NotNull String protocol, final @NotNull int port, final @NotNull String uri){ + return String.format("%s://%s:%s/%s", protocol, "localhost", port, uri); + } + + @Test + public void testGetSecuredResourceWithoutCreds() throws IOException { + final var response = + HttpUrlConnectionClient.get(null, + getTestServerAddress(HTTP, TEST_HTTP_PORT, "test/get/auth/admin"), CONNECT_TIMEOUT, READ_TIMEOUT); + assertThat(response.getStatusCode()) + .as("Resource should be denied") + .isEqualTo(401); + } + + @Test + public void testGetSecuredResourceWithInvalidUsername() throws IOException { + final var headers = Map.of(HttpConstants.AUTH_HEADER, + HttpUtils.getBasicAuthenticationHeaderValue("testaWRONG", TEST_PASSWORD)); + final var response = + HttpUrlConnectionClient.get(headers, + getTestServerAddress(HTTP, TEST_HTTP_PORT, "test/get/auth/admin"), CONNECT_TIMEOUT, READ_TIMEOUT); + assertThat(response.getStatusCode()) + .as("Resource should be denied") + .isEqualTo(401); + } + + @Test + public void testGetSecuredResourceWithInvalidPassword() throws IOException { + final var headers = Map.of(HttpConstants.AUTH_HEADER, + HttpUtils.getBasicAuthenticationHeaderValue(TEST_USERNAME, "incorrect")); + final var response = + HttpUrlConnectionClient.get(headers, + getTestServerAddress(HTTP, TEST_HTTP_PORT, "test/get/auth/admin"), CONNECT_TIMEOUT, READ_TIMEOUT); + assertThat(response.getStatusCode()) + .as("Resource should be denied") + .isEqualTo(401); + } + + @Test + public void testGetSecuredResourceWithValidCreds() throws IOException { + final var headers = Map.of(HttpConstants.AUTH_HEADER, + HttpUtils.getBasicAuthenticationHeaderValue(TEST_USERNAME, TEST_PASSWORD)); + final var response = + HttpUrlConnectionClient.get(headers, + getTestServerAddress(HTTP, TEST_HTTP_PORT, "test/get/auth/admin"), CONNECT_TIMEOUT, READ_TIMEOUT); + assertThat(response.getStatusCode()) + .as("Resource should be accepted") + .isEqualTo(200); + } +} diff --git a/hivemq-edge/src/test/java/com/hivemq/api/auth/LdapUsernameRolesProviderIntegrationTest.java b/hivemq-edge/src/test/java/com/hivemq/api/auth/LdapUsernameRolesProviderIntegrationTest.java new file mode 100644 index 0000000000..f9d369ec47 --- /dev/null +++ b/hivemq-edge/src/test/java/com/hivemq/api/auth/LdapUsernameRolesProviderIntegrationTest.java @@ -0,0 +1,228 @@ +/* + * Copyright 2019-present HiveMQ GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.hivemq.api.auth; + +import com.hivemq.api.auth.provider.IUsernameRolesProvider; +import com.hivemq.api.auth.provider.impl.ldap.LdapConnectionProperties; +import com.hivemq.api.auth.provider.impl.ldap.LdapUsernameRolesProvider; +import com.hivemq.api.auth.provider.impl.ldap.TlsMode; +import com.hivemq.api.auth.provider.impl.ldap.testcontainer.LdapTestConnection; +import com.hivemq.api.auth.provider.impl.ldap.testcontainer.LldapContainer; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import java.nio.charset.StandardCharsets; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Integration test for {@link LdapUsernameRolesProvider} using LLDAP testcontainer. + *

+ * Tests the high-level API for LDAP authentication that returns usernames and roles. + * Uses plain LDAP (no TLS) for simplicity in testing. + */ +@Testcontainers +class LdapUsernameRolesProviderIntegrationTest { + + private static final String LDAP_DN_TEMPLATE = "uid={username},ou=people,{baseDn}"; + + @Container + private static final LldapContainer LLDAP_CONTAINER = new LldapContainer(); + + private static LdapUsernameRolesProvider provider; + + @BeforeAll + static void setUp() throws Exception { + // Get the dynamically mapped port from the container + final var host = LLDAP_CONTAINER.getHost(); + final var port = LLDAP_CONTAINER.getLdapPort(); + + // Create LdapSimpleBind for LLDAP admin authentication + // LLDAP admin DN: uid=admin,ou=people,{baseDn} + final var ldapSimpleBind = + new LdapConnectionProperties.LdapSimpleBind( + "uid=" + LLDAP_CONTAINER.getAdminUsername() + ",ou=people", + LLDAP_CONTAINER.getAdminPassword()); + + // Create connection properties for plain LDAP (no TLS for simplicity) + // 5 second connect timeout + // 10 second response timeout + final var ldapConnectionProperties = + new LdapConnectionProperties(new LdapConnectionProperties.LdapServers(new String[]{host}, + new int[]{port}), TlsMode.NONE, null, 5000, // 5 second connect timeout + 10000, // 10 second response timeout + 1, LDAP_DN_TEMPLATE, LLDAP_CONTAINER.getBaseDn(), "ADMIN", ldapSimpleBind); + + // Create test user in LLDAP + new LdapTestConnection(ldapConnectionProperties).createTestUser( + LLDAP_CONTAINER.getAdminDn(), + LLDAP_CONTAINER.getAdminPassword(), + LLDAP_CONTAINER.getBaseDn()); + + // Create the LdapUsernameRolesProvider + provider = new LdapUsernameRolesProvider(ldapConnectionProperties); + } + + @AfterAll + static void tearDown() { + LLDAP_CONTAINER.stop(); + } + + /** + * Tests successful authentication with correct credentials. + *

+ * Verifies that: + * - findByUsernameAndPassword returns an Optional with UsernameRoles + * - The username matches the authenticated user + * - Roles are assigned (currently hardcoded to "ADMIN") + */ + @Test + void testSuccessfulAuthentication() { + // Act + final Optional result = + provider.findByUsernameAndPassword(LdapTestConnection.TEST_USERNAME, LdapTestConnection.TEST_PASSWORD.getBytes(StandardCharsets.UTF_8)); + + // Assert + assertThat(result) + .as("Authentication should succeed with correct credentials") + .isPresent(); + + assertThat(result.get().username()) + .as("Username should match the authenticated user") + .isEqualTo(LdapTestConnection.TEST_USERNAME); + + assertThat(result.get().roles()) + .as("User should have ADMIN role (hardcoded for now)") + .contains("ADMIN") + .hasSize(1); + } + + /** + * Tests authentication failure with incorrect password. + *

+ * Verifies that: + * - findByUsernameAndPassword returns an empty Optional + * - No exception is thrown (errors are logged internally) + */ + @Test + void testFailedAuthenticationWithWrongPassword() { + // Arrange + final String wrongPassword = "wrongpassword"; + + // Act + final Optional result = + provider.findByUsernameAndPassword(LdapTestConnection.TEST_USERNAME, wrongPassword.getBytes(StandardCharsets.UTF_8)); + + // Assert + assertThat(result) + .as("Authentication should fail with wrong password") + .isEmpty(); + } + + /** + * Tests authentication failure with non-existent user. + *

+ * Verifies that: + * - findByUsernameAndPassword returns an empty Optional + * - No exception is thrown (errors are logged internally) + */ + @Test + void testFailedAuthenticationWithNonExistentUser() { + // Arrange + final String nonExistentUser = "nonexistent"; + final String somePassword = "somepassword"; + + // Act + final Optional result = + provider.findByUsernameAndPassword(nonExistentUser, somePassword.getBytes(StandardCharsets.UTF_8)); + + // Assert + assertThat(result) + .as("Authentication should fail with non-existent user") + .isEmpty(); + } + + /** + * Tests that UsernameRoles can be converted to ApiPrincipal. + *

+ * Verifies the toPrincipal() method works correctly. + */ + @Test + void testUsernameRolesToPrincipalConversion() { + // Arrange & Act + final Optional result = + provider.findByUsernameAndPassword(LdapTestConnection.TEST_USERNAME, LdapTestConnection.TEST_PASSWORD.getBytes(StandardCharsets.UTF_8)); + + assertThat(result).isPresent(); + + // Convert to ApiPrincipal + final var principal = result.get().toPrincipal(); + + // Assert + assertThat(principal.getName()) + .as("Principal name should match username") + .isEqualTo(LdapTestConnection.TEST_USERNAME); + + assertThat(principal.getRoles()) + .as("Principal roles should match user roles") + .contains("ADMIN") + .hasSize(1); + } + + /** + * Tests authentication with empty password. + *

+ * Verifies that empty passwords are rejected. + */ + @Test + void testFailedAuthenticationWithEmptyPassword() { + // Arrange + final String emptyPassword = ""; + + // Act + final Optional result = + provider.findByUsernameAndPassword(LdapTestConnection.TEST_USERNAME, emptyPassword.getBytes(StandardCharsets.UTF_8)); + + // Assert + assertThat(result) + .as("Authentication should fail with empty password") + .isEmpty(); + } + + /** + * Tests authentication with empty username. + *

+ * Verifies that empty usernames are rejected. + */ + @Test + void testFailedAuthenticationWithEmptyUsername() { + // Arrange + final String emptyUsername = ""; + + // Act + final Optional result = + provider.findByUsernameAndPassword(emptyUsername, LdapTestConnection.TEST_PASSWORD.getBytes(StandardCharsets.UTF_8)); + + // Assert + assertThat(result) + .as("Authentication should fail with empty username") + .isEmpty(); + } +} diff --git a/hivemq-edge/src/test/java/com/hivemq/api/auth/provider/impl/ldap/LdapConnectionPropertiesTest.java b/hivemq-edge/src/test/java/com/hivemq/api/auth/provider/impl/ldap/LdapConnectionPropertiesTest.java new file mode 100644 index 0000000000..cdde504566 --- /dev/null +++ b/hivemq-edge/src/test/java/com/hivemq/api/auth/provider/impl/ldap/LdapConnectionPropertiesTest.java @@ -0,0 +1,287 @@ +/* + * Copyright 2019-present HiveMQ GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.hivemq.api.auth.provider.impl.ldap; + +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Unit tests for {@link LdapConnectionProperties}. + */ +class LdapConnectionPropertiesTest { + + private static final String DEFAULT_DN_TEMPLATE = "uid={username},ou=people,{baseDn}"; + private static final String DEFAULT_BASE_DN = "dc=example,dc=com"; + private static final LdapConnectionProperties.LdapSimpleBind DEFAULT_SIMPLE_BIND = + new LdapConnectionProperties.LdapSimpleBind("cn=admin", "admin"); + + @Test + void testTlsModeDefaults() { + Assertions.assertThat(TlsMode.NONE.defaultPort).isEqualTo(389); + assertThat(TlsMode.START_TLS.defaultPort).isEqualTo(389); + assertThat(TlsMode.LDAPS.defaultPort).isEqualTo(636); + } + + @Test + void testValidationRejectsInvalidPort() { + assertThatThrownBy(() -> new LdapConnectionProperties( + new LdapConnectionProperties.LdapServers(new String[]{"localhost"}, new int[]{0}), + TlsMode.NONE, + null, + 0, + 0, + 1, + DEFAULT_DN_TEMPLATE, + DEFAULT_BASE_DN, + "ADMIN", + DEFAULT_SIMPLE_BIND)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Port must be between 1 and 65535"); + + assertThatThrownBy(() -> new LdapConnectionProperties( + new LdapConnectionProperties.LdapServers(new String[]{"localhost"}, new int[]{65536}), + TlsMode.NONE, + null, + 0, + 0, + 1, + DEFAULT_DN_TEMPLATE, + DEFAULT_BASE_DN, + "ADMIN", + DEFAULT_SIMPLE_BIND)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Port must be between 1 and 65535"); + } + + @Test + void testValidationRejectsNegativeTimeouts() { + assertThatThrownBy(() -> new LdapConnectionProperties( + new LdapConnectionProperties.LdapServers(new String[]{"localhost"}, new int[]{389}), + TlsMode.NONE, + null, + -1, + 0, + 1, + DEFAULT_DN_TEMPLATE, + DEFAULT_BASE_DN, + "ADMIN", + DEFAULT_SIMPLE_BIND)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Connect timeout cannot be negative"); + + assertThatThrownBy(() -> new LdapConnectionProperties( + new LdapConnectionProperties.LdapServers(new String[]{"localhost"}, new int[]{389}), + TlsMode.NONE, + null, + 0, + -1, + 1, + DEFAULT_DN_TEMPLATE, + DEFAULT_BASE_DN, + "ADMIN", + DEFAULT_SIMPLE_BIND)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Response timeout cannot be negative"); + } + + @Test + void testTlsModesAllowNullTruststore() { + // LDAPS allows null truststore (will use system CAs) + final LdapConnectionProperties ldapsProps = new LdapConnectionProperties( + new LdapConnectionProperties.LdapServers(new String[]{"localhost"}, new int[]{636}), + TlsMode.LDAPS, + null, // no custom truststore - will use system CAs + 0, + 0, + 1, + DEFAULT_DN_TEMPLATE, + DEFAULT_BASE_DN, + "ADMIN", + DEFAULT_SIMPLE_BIND); + assertThat(ldapsProps.trustStore()).isNull(); + + // START_TLS allows null truststore (will use system CAs) + final LdapConnectionProperties startTlsProps = new LdapConnectionProperties( + new LdapConnectionProperties.LdapServers(new String[]{"localhost"}, new int[]{389}), + TlsMode.START_TLS, + null, // no custom truststore - will use system CAs + 0, + 0, + 1, + DEFAULT_DN_TEMPLATE, + DEFAULT_BASE_DN, + "ADMIN", + DEFAULT_SIMPLE_BIND); + assertThat(startTlsProps.trustStore()).isNull(); + + // NONE doesn't need truststore + final LdapConnectionProperties noneProps = new LdapConnectionProperties( + new LdapConnectionProperties.LdapServers(new String[]{"localhost"}, new int[]{389}), + TlsMode.NONE, + null, + 0, + 0, + 1, + DEFAULT_DN_TEMPLATE, + DEFAULT_BASE_DN, + "ADMIN", + DEFAULT_SIMPLE_BIND); + assertThat(noneProps.trustStore()).isNull(); + } + + @Test + void testConvenienceConstructorUsesDefaultTimeouts() { + final LdapConnectionProperties props = new LdapConnectionProperties( + new LdapConnectionProperties.LdapServers(new String[]{"localhost"}, new int[]{636}), + TlsMode.LDAPS, + new LdapConnectionProperties.TrustStore("/path/to/truststore", "password", "JKS"), + DEFAULT_DN_TEMPLATE, + DEFAULT_BASE_DN, + "ADMIN", + DEFAULT_SIMPLE_BIND); + + assertThat(props.connectTimeoutMillis()).isEqualTo(0); + assertThat(props.responseTimeoutMillis()).isEqualTo(0); + } + + @Test + void testCreateSSLContextThrowsForNoneTlsMode() { + final LdapConnectionProperties props = new LdapConnectionProperties( + new LdapConnectionProperties.LdapServers(new String[]{"localhost"}, new int[]{389}), + TlsMode.NONE, + null, + 0, + 0, + 1, + DEFAULT_DN_TEMPLATE, + DEFAULT_BASE_DN, + "ADMIN", + DEFAULT_SIMPLE_BIND); + + assertThatThrownBy(props::createSSLContext) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("SSLContext is not needed for TLS mode: NONE"); + } + + @Test + void testCreateSSLContextWithSystemCAs() throws Exception { + // LDAPS with null truststore should use system CAs + final LdapConnectionProperties ldapsProps = new LdapConnectionProperties( + new LdapConnectionProperties.LdapServers(new String[]{"localhost"}, new int[]{636}), + TlsMode.LDAPS, + null, // Use system default CAs + 0, + 0, + 1, + DEFAULT_DN_TEMPLATE, + DEFAULT_BASE_DN, + "ADMIN", + DEFAULT_SIMPLE_BIND); + + final javax.net.ssl.SSLContext sslContext = ldapsProps.createSSLContext(); + assertThat(sslContext) + .as("SSLContext should be created with system CAs") + .isNotNull(); + + // START_TLS with null truststore should use system CAs + final LdapConnectionProperties startTlsProps = new LdapConnectionProperties( + new LdapConnectionProperties.LdapServers(new String[]{"localhost"}, new int[]{389}), + TlsMode.START_TLS, + null, // Use system default CAs + 0, + 0, + 1, + DEFAULT_DN_TEMPLATE, + DEFAULT_BASE_DN, + "ADMIN", + DEFAULT_SIMPLE_BIND); + + final javax.net.ssl.SSLContext startTlsSslContext = startTlsProps.createSSLContext(); + assertThat(startTlsSslContext) + .as("SSLContext should be created with system CAs") + .isNotNull(); + } + + @Test + void testCreateSSLContextWithCustomTruststore() { + // Verify that custom truststore path is stored correctly + final LdapConnectionProperties props = new LdapConnectionProperties( + new LdapConnectionProperties.LdapServers(new String[]{"localhost"}, new int[]{636}), + TlsMode.LDAPS, + new LdapConnectionProperties.TrustStore("/path/to/custom/truststore.jks", "password", "JKS"), + 0, + 0, + 1, + DEFAULT_DN_TEMPLATE, + DEFAULT_BASE_DN, + "ADMIN", + DEFAULT_SIMPLE_BIND); + + assertThat(props.trustStore().trustStorePath()) + .as("Custom truststore path should be stored") + .isEqualTo("/path/to/custom/truststore.jks"); + assertThat(props.trustStore().trustStorePassword()) + .as("Truststore password should be stored") + .isNotNull(); + assertThat(props.trustStore().trustStoreType()) + .as("Truststore type should be stored") + .isEqualTo("JKS"); + + // Note: We can't easily test createSSLContext() with a non-existent file + // because UnboundID SDK creates the SSLContext successfully and only fails + // when actually using it to connect. Integration tests cover this scenario. + } + + @Test + void testValidationRejectsDnTemplateWithoutPlaceholder() { + assertThatThrownBy(() -> new LdapConnectionProperties( + new LdapConnectionProperties.LdapServers(new String[]{"localhost"}, new int[]{389}), + TlsMode.NONE, + null, + 0, + 0, + 1, + "uid=fixed,ou=people,{baseDn}", // missing {username} + DEFAULT_BASE_DN, + "ADMIN", + DEFAULT_SIMPLE_BIND)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("User DN template must contain {username} placeholder"); + } + + @Test + void testCreateUserDnResolver() { + final LdapConnectionProperties props = new LdapConnectionProperties( + new LdapConnectionProperties.LdapServers(new String[]{"localhost"}, new int[]{389}), + TlsMode.NONE, + null, + 0, + 0, + 1, + "uid={username},ou=people,{baseDn}", + "dc=example,dc=com", + "ADMIN", + DEFAULT_SIMPLE_BIND); + + final var resolver = props.createUserDnResolver(); + assertThat(resolver).isNotNull(); + assertThat(resolver.resolveDn("jdoe")) + .isEqualTo("uid=jdoe,ou=people,dc=example,dc=com"); + } +} diff --git a/hivemq-edge/src/test/java/com/hivemq/api/auth/provider/impl/ldap/LdapIntegrationTest.java b/hivemq-edge/src/test/java/com/hivemq/api/auth/provider/impl/ldap/LdapIntegrationTest.java new file mode 100644 index 0000000000..74fbfdb5f7 --- /dev/null +++ b/hivemq-edge/src/test/java/com/hivemq/api/auth/provider/impl/ldap/LdapIntegrationTest.java @@ -0,0 +1,245 @@ +/* + * Copyright 2019-present HiveMQ GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.hivemq.api.auth.provider.impl.ldap; + +import com.hivemq.api.auth.provider.impl.ldap.testcontainer.LdapTestConnection; +import com.hivemq.api.auth.provider.impl.ldap.testcontainer.LldapContainer; +import com.unboundid.ldap.sdk.BindRequest; +import com.unboundid.ldap.sdk.BindResult; +import com.unboundid.ldap.sdk.LDAPConnection; +import com.unboundid.ldap.sdk.LDAPConnectionPool; +import com.unboundid.ldap.sdk.LDAPException; +import com.unboundid.ldap.sdk.ResultCode; +import com.unboundid.ldap.sdk.SearchScope; +import com.unboundid.ldap.sdk.SimpleBindRequest; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.security.KeyStore; + +import static com.hivemq.api.auth.provider.impl.ldap.testcontainer.LdapTestConnection.TEST_PASSWORD; +import static com.hivemq.api.auth.provider.impl.ldap.testcontainer.LdapTestConnection.TEST_USERNAME; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Integration test for secure LDAP authentication using LLDAP testcontainer with TLS. + *

+ * This test demonstrates: + * - Setting up an LLDAP server in a Docker container with TLS/SSL enabled + * - Generating a self-signed certificate for secure connections + * - Configuring LDAPS (LDAP over TLS) for encrypted communication + * - Creating a test user programmatically + * - Performing successful authentication (bind) with correct credentials over secure connection + * - Verifying authentication failure with incorrect credentials over secure connection + *

+ * LLDAP (Light LDAP) is a lightweight LDAP server implementation perfect for testing. + * The test uses UnboundID LDAP SDK for all LDAP operations and demonstrates proper + * TLS/SSL certificate handling in a test environment. + */ +@Testcontainers +class LdapIntegrationTest { + + private static final String LDAP_DN_TEMPLATE = "uid={username},ou=people,{baseDn}"; + + @Container + private static final LldapContainer LLDAP_CONTAINER = LldapContainer.builder() + .withLdaps() + .build(); + + private static LdapClient ldapClient; + private static LdapConnectionProperties ldapConnectionProperties; + + @BeforeAll + static void setUp() throws Exception { + // Get the dynamically mapped LDAPS port and host from the container + final String host = LLDAP_CONTAINER.getHost(); + final int port = LLDAP_CONTAINER.getLdapsPort(); + + // Create LdapSimpleBind for LLDAP admin authentication + // LLDAP admin DN: uid=admin,ou=people,{baseDn} + final LdapConnectionProperties.LdapSimpleBind ldapSimpleBind = + new LdapConnectionProperties.LdapSimpleBind( + "uid=" + LLDAP_CONTAINER.getAdminUsername() + ",ou=people", + LLDAP_CONTAINER.getAdminPassword()); + + // Create connection properties for LDAPS + ldapConnectionProperties = new LdapConnectionProperties( + new LdapConnectionProperties.LdapServers(new String[]{host}, new int[]{port}), + TlsMode.LDAPS, + new LdapConnectionProperties.TrustStore(LLDAP_CONTAINER.getTrustStoreFile().getAbsolutePath(), LldapContainer.KEYSTORE_PASSWORD, KeyStore.getDefaultType()), + 10000, // 10 second connect timeout + 30000, // 30 second response timeout + 1, + LDAP_DN_TEMPLATE, + LLDAP_CONTAINER.getBaseDn(), + "ADMIN", + ldapSimpleBind); + + // Create and start LDAP client + ldapClient = new LdapClient(ldapConnectionProperties); + ldapClient.start(); + + // Create test user in LLDAP (using direct connection for admin operations) + new LdapTestConnection(ldapConnectionProperties).createTestUser( + LLDAP_CONTAINER.getAdminDn(), + LLDAP_CONTAINER.getAdminPassword(), + LLDAP_CONTAINER.getBaseDn()); + } + + @AfterAll + static void tearDown() { + // Stop LDAP client + if (ldapClient != null && ldapClient.isStarted()) { + ldapClient.stop(); + } + + LLDAP_CONTAINER.stop(); + } + + + /** + * Tests successful LDAP bind with correct credentials over secure TLS connection. + *

+ * This demonstrates the typical authentication flow with TLS using the LdapClient: + * 1. Client uses connection pool to get a connection + * 2. Attempt to bind with user DN and password + * 3. Verify the bind was successful + */ + @Test + void testSuccessfulBind() throws LDAPException { + // Act + final boolean authenticated = ldapClient.authenticateUser(TEST_USERNAME, LdapTestConnection.TEST_PASSWORD.getBytes(StandardCharsets.UTF_8)); + + // Assert + assertThat(authenticated) + .as("Bind should succeed with correct credentials over secure TLS connection") + .isTrue(); + } + + /** + * Tests failed LDAP bind with incorrect password over secure TLS connection. + *

+ * This demonstrates authentication failure handling with TLS using the LdapClient: + * 1. Client uses connection pool to get a connection + * 2. Attempt to bind with user DN and WRONG password + * 3. Verify the bind failed (returns false) + */ + @Test + void testFailedBindWithWrongPassword() throws LDAPException { + // Arrange + final String wrongPassword = "wrong_password"; + + // Act + final boolean authenticated = ldapClient.authenticateUser(TEST_USERNAME, wrongPassword.getBytes(StandardCharsets.UTF_8)); + + // Assert + assertThat(authenticated) + .as("Bind should fail with wrong password even over secure TLS connection") + .isFalse(); + } + + /** + * Tests SearchFilterDnResolver with an authenticated connection pool. + *

+ * Note: SearchFilterDnResolver requires an authenticated connection pool to perform searches. + * Most LDAP servers (including LLDAP) require authentication for search operations. + *

+ * This test demonstrates: + * - Creating an authenticated connection pool with a service account + * - Using SearchFilterDnResolver to find user DNs + * - Authenticating with the resolved DN + */ + @Test + void testSearchFilterDnResolver_withAuthenticatedPool() throws Exception { + // Create an authenticated connection pool using the admin account + // In production, you would use a dedicated service account with read-only permissions + final var testconnection = new LdapTestConnection(ldapConnectionProperties); + final var ldapClient = new LdapClient(ldapConnectionProperties); + ldapClient.start(); + try (final LDAPConnection adminConnection = testconnection.createConnection()) { + // Bind as admin to create authenticated pool using LldapContainer's convenience methods + final BindRequest bindRequest = new SimpleBindRequest( + LLDAP_CONTAINER.getAdminDn(), + LLDAP_CONTAINER.getAdminPassword()); + final BindResult bindResult = adminConnection.bind(bindRequest); + assertThat(bindResult.getResultCode()).isEqualTo(ResultCode.SUCCESS); + + // Create an authenticated connection pool + + try (final var authenticatedPool = new LDAPConnectionPool(adminConnection, 1, 5)) { + // Test 1: Simple UID search + final SearchFilterDnResolver uidResolver = new SearchFilterDnResolver(authenticatedPool, + "ou=people," + LLDAP_CONTAINER.getBaseDn(), + "(uid={username})", + SearchScope.ONE, + 5); + + String resolvedDn = uidResolver.resolveDn(TEST_USERNAME); + assertThat(resolvedDn).as("Should resolve DN by UID") + .isEqualTo("uid=" + TEST_USERNAME + ",ou=people," + LLDAP_CONTAINER.getBaseDn()); + + // Verify we can authenticate with the resolved DN + final boolean authenticated = + ldapClient.bindUser(resolvedDn, TEST_PASSWORD.getBytes(StandardCharsets.UTF_8)); + assertThat(authenticated).as("Should authenticate with resolved DN").isTrue(); + + // Test 2: Email search + final SearchFilterDnResolver emailResolver = new SearchFilterDnResolver(authenticatedPool, + LLDAP_CONTAINER.getBaseDn(), + "(mail={username})", + SearchScope.SUB, + 5); + + resolvedDn = emailResolver.resolveDn(TEST_USERNAME + "@example.com"); + assertThat(resolvedDn).as("Should resolve DN by email") + .isEqualTo("uid=" + TEST_USERNAME + ",ou=people," + LLDAP_CONTAINER.getBaseDn()); + + // Test 3: Complex filter + final SearchFilterDnResolver complexResolver = new SearchFilterDnResolver(authenticatedPool, + LLDAP_CONTAINER.getBaseDn(), + "(&(objectClass=inetOrgPerson)(uid={username}))", + SearchScope.SUB, + 5); + + resolvedDn = complexResolver.resolveDn(TEST_USERNAME); + assertThat(resolvedDn).as("Should resolve DN with complex filter") + .isEqualTo("uid=" + TEST_USERNAME + ",ou=people," + LLDAP_CONTAINER.getBaseDn()); + + // Test 4: OR filter + final SearchFilterDnResolver orResolver = new SearchFilterDnResolver(authenticatedPool, + LLDAP_CONTAINER.getBaseDn(), + "(|(uid={username})(mail={username}))", + SearchScope.SUB, + 5); + + resolvedDn = orResolver.resolveDn(TEST_USERNAME); + assertThat(resolvedDn).as("Should resolve DN with OR filter") + .isEqualTo("uid=" + TEST_USERNAME + ",ou=people," + LLDAP_CONTAINER.getBaseDn()); + + // Test 5: User not found + assertThatThrownBy(() -> uidResolver.resolveDn("nonexistent")).isInstanceOf(SearchFilterDnResolver.DnResolutionException.class) + .hasMessageContaining("No LDAP entry found for username: nonexistent"); + + } + } + } +} diff --git a/hivemq-edge/src/test/java/com/hivemq/api/auth/provider/impl/ldap/LdapTlsModesIntegrationTest.java b/hivemq-edge/src/test/java/com/hivemq/api/auth/provider/impl/ldap/LdapTlsModesIntegrationTest.java new file mode 100644 index 0000000000..5b506bc93a --- /dev/null +++ b/hivemq-edge/src/test/java/com/hivemq/api/auth/provider/impl/ldap/LdapTlsModesIntegrationTest.java @@ -0,0 +1,208 @@ +/* + * Copyright 2019-present HiveMQ GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.hivemq.api.auth.provider.impl.ldap; + +import com.hivemq.api.auth.provider.impl.ldap.testcontainer.LdapTestConnection; +import com.hivemq.api.auth.provider.impl.ldap.testcontainer.LldapContainer; +import com.unboundid.ldap.sdk.LDAPException; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import java.nio.charset.StandardCharsets; + +import static com.hivemq.api.auth.provider.impl.ldap.testcontainer.LdapTestConnection.TEST_PASSWORD; +import static com.hivemq.api.auth.provider.impl.ldap.testcontainer.LdapTestConnection.TEST_USERNAME; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Integration tests for different TLS modes and timeouts. + *

+ * Tests three TLS modes: + *

    + *
  • NONE - Plain LDAP without encryption (port 389)
  • + *
  • START_TLS - Plain connection upgraded to TLS (port 389)
  • + *
  • LDAPS - TLS from connection start (tested in LdapIntegrationTest)
  • + *
+ */ +@Testcontainers +class LdapTlsModesIntegrationTest { + + private static final String LDAP_DN_TEMPLATE = "uid={username},ou=people,{baseDn}"; + + @Container + private static final LldapContainer LLDAP_CONTAINER = LldapContainer.builder() + .withLdaps() + .build(); + + private static LdapClient ldapClient; + + @BeforeAll + static void setUp() throws Exception { + // Get the dynamically mapped port from the container + final var host = LLDAP_CONTAINER.getHost(); + final var port = LLDAP_CONTAINER.getLdapPort(); + + // Create LdapSimpleBind for LLDAP admin authentication + // LLDAP admin DN: uid=admin,ou=people,{baseDn} + final var ldapSimpleBind = + new LdapConnectionProperties.LdapSimpleBind( + "uid=" + LLDAP_CONTAINER.getAdminUsername() + ",ou=people", + LLDAP_CONTAINER.getAdminPassword()); + + // Create connection properties for plain LDAP (no TLS) + // 5 second connect timeout + // 10 second response timeout + final var ldapConnectionProperties = + new LdapConnectionProperties(new LdapConnectionProperties.LdapServers(new String[]{host}, + new int[]{port}), TlsMode.NONE, null, 5000, // 5 second connect timeout + 10000, // 10 second response timeout + 1, LDAP_DN_TEMPLATE, LLDAP_CONTAINER.getBaseDn(), "ADMIN", ldapSimpleBind); + + // Create and start LDAP client + ldapClient = new LdapClient(ldapConnectionProperties); + ldapClient.start(); + + // Create test user + new LdapTestConnection(ldapConnectionProperties).createTestUser( + LLDAP_CONTAINER.getAdminDn(), + LLDAP_CONTAINER.getAdminPassword(), + LLDAP_CONTAINER.getBaseDn()); + } + + @AfterAll + static void tearDown() { + if (ldapClient != null && ldapClient.isStarted()) { + ldapClient.stop(); + } + LLDAP_CONTAINER.stop(); + } + + /** + * Tests authentication over plain LDAP (no encryption). + */ + @Test + void testPlainLdapAuthentication() throws LDAPException { + // Act + final boolean authenticated = ldapClient.authenticateUser(TEST_USERNAME, LdapTestConnection.TEST_PASSWORD.getBytes(StandardCharsets.UTF_8)); + + // Assert + assertThat(authenticated) + .as("Authentication should succeed over plain LDAP") + .isTrue(); + } + + /** + * Tests that authentication fails with wrong password over plain LDAP. + */ + @Test + void testPlainLdapAuthenticationFailsWithWrongPassword() throws LDAPException { + // Act + final boolean authenticated = ldapClient.authenticateUser(TEST_USERNAME, "wrongpassword".getBytes(StandardCharsets.UTF_8)); + + // Assert + assertThat(authenticated) + .as("Authentication should fail with wrong password") + .isFalse(); + } + + /** + * Tests connection timeout by trying to connect to a non-responsive host. + */ + @Test + void testConnectionTimeout() throws Exception{ + // Arrange - use a non-routable IP that will timeout + final LdapConnectionProperties.LdapSimpleBind ldapSimpleBind = + new LdapConnectionProperties.LdapSimpleBind( + "uid=" + LLDAP_CONTAINER.getAdminUsername() + ",ou=people", + LLDAP_CONTAINER.getAdminPassword()); + + final LdapConnectionProperties timeoutProps = new LdapConnectionProperties( + new LdapConnectionProperties.LdapServers(new String[]{"10.255.255.1"}, new int[]{389}), // Non-routable IP + TlsMode.NONE, + null, + 1000, // 1 second timeout - should fail quickly + 5000, + 1, + LDAP_DN_TEMPLATE, + LLDAP_CONTAINER.getBaseDn(), + "ADMIN", + ldapSimpleBind); + + final LdapClient timeoutClient = new LdapClient(timeoutProps); + + + timeoutClient.start(); + + final long startTime = System.currentTimeMillis(); + assertThatThrownBy(() -> timeoutClient.authenticateUser("test", "test".getBytes(StandardCharsets.UTF_8))) + .isInstanceOf(LDAPException.class); + final long duration = System.currentTimeMillis() - startTime; + + // Should timeout within reasonable time (less than 5 seconds) + // allowing some margin for processing + assertThat(duration) + .as("Connection should timeout quickly") + .isLessThan(5000); + } + + /** + * Tests that default timeouts are used when set to 0. + */ + @Test + void testDefaultTimeouts() throws Exception { + // Arrange + final String host = LLDAP_CONTAINER.getHost(); + final int port = LLDAP_CONTAINER.getLdapPort(); + + final LdapConnectionProperties.LdapSimpleBind ldapSimpleBind = + new LdapConnectionProperties.LdapSimpleBind( + "uid=" + LLDAP_CONTAINER.getAdminUsername() + ",ou=people", + LLDAP_CONTAINER.getAdminPassword()); + + final LdapConnectionProperties defaultTimeoutProps = new LdapConnectionProperties( + new LdapConnectionProperties.LdapServers(new String[]{host}, new int[]{port}), + TlsMode.NONE, + null, + 0, // Use default timeout + 0, // Use default timeout + 1, + LDAP_DN_TEMPLATE, + LLDAP_CONTAINER.getBaseDn(), + "ADMIN", + ldapSimpleBind); + + final LdapClient defaultTimeoutClient = new LdapClient(defaultTimeoutProps); + + // Act + defaultTimeoutClient.start(); + + try { + final boolean authenticated = defaultTimeoutClient.authenticateUser(TEST_USERNAME, TEST_PASSWORD.getBytes(StandardCharsets.UTF_8)); + + // Assert + assertThat(authenticated) + .as("Authentication should work with default timeouts") + .isTrue(); + } finally { + defaultTimeoutClient.stop(); + } + } + +} diff --git a/hivemq-edge/src/test/java/com/hivemq/api/auth/provider/impl/ldap/OpenLdapTest.java b/hivemq-edge/src/test/java/com/hivemq/api/auth/provider/impl/ldap/OpenLdapTest.java new file mode 100644 index 0000000000..92c63f5449 --- /dev/null +++ b/hivemq-edge/src/test/java/com/hivemq/api/auth/provider/impl/ldap/OpenLdapTest.java @@ -0,0 +1,718 @@ +/* + * Copyright 2019-present HiveMQ GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.hivemq.api.auth.provider.impl.ldap; + +import com.hivemq.api.auth.provider.impl.ldap.testcontainer.LdapTestConnection; +import com.hivemq.api.auth.provider.impl.ldap.testcontainer.OpenLdapContainer; +import com.unboundid.ldap.sdk.BindRequest; +import com.unboundid.ldap.sdk.BindResult; +import com.unboundid.ldap.sdk.LDAPConnection; +import com.unboundid.ldap.sdk.LDAPConnectionPool; +import com.unboundid.ldap.sdk.ResultCode; +import com.unboundid.ldap.sdk.SearchRequest; +import com.unboundid.ldap.sdk.SearchResult; +import com.unboundid.ldap.sdk.SearchResultEntry; +import com.unboundid.ldap.sdk.SearchScope; +import com.unboundid.ldap.sdk.SimpleBindRequest; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import java.nio.charset.StandardCharsets; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Integration tests using OpenLDAP testcontainer. + *

+ * This test demonstrates: + *

    + *
  • Setting up an OpenLDAP server using osixia/openldap Docker image
  • + *
  • Seeding the LDAP directory with test data from an external LDIF file
  • + *
  • Basic connectivity and authentication
  • + *
  • Searching for users and groups
  • + *
  • Testing DN resolution strategies
  • + *
  • START_TLS connection upgrades (plain to encrypted)
  • + *
+ *

+ * OpenLDAP container configuration: + *

    + *
  • Image: osixia/openldap:1.5.0
  • + *
  • Domain: example.org (dc=example,dc=org)
  • + *
  • Admin DN: cn=admin,dc=example,dc=org
  • + *
  • Admin Password: admin
  • + *
  • TLS: Enabled (supports START_TLS and LDAPS with self-signed certificates)
  • + *
  • Test data loaded from: src/test/resources/ldap/test-data.ldif
  • + *
+ *

+ * Note on START_TLS Tests: START_TLS tests use {@code acceptAnyCertificateForTesting} + * to trust the self-signed certificates generated by the OpenLDAP container. This is safe for integration + * tests but should never be used in production. + */ +@Testcontainers +class OpenLdapTest { + + private static final String LDAP_DN_TEMPLATE = "uid={username},ou=people,{baseDn}"; + + /** + * OpenLDAP container with test data seeded from LDIF file and TLS enabled. + *

+ * The container is configured with: + * - Domain: example.org + * - Admin password: admin + * - TLS enabled for START_TLS testing + * - Custom LDIF file loaded from classpath: ldap/test-data.ldif + *

+ * OpenLDAP will automatically load all LDIF files from the bootstrap directory on startup. + * The osixia/openldap image generates self-signed certificates automatically when TLS is enabled. + * START_TLS tests use acceptAnyCertificateForTesting to trust these self-signed certificates. + */ + @Container + private static final OpenLdapContainer OPENLDAP_CONTAINER = OpenLdapContainer.builder() + .withTls(true) // Enable TLS for START_TLS testing + .withLdifFile("ldap/test-data.ldif") + .build(); + + private static LdapClient ldapClient; + private static LdapConnectionProperties connectionProperties; + + @BeforeAll + static void setUp() throws Exception { + // Get dynamically mapped port + final String host = OPENLDAP_CONTAINER.getHost(); + final int port = OPENLDAP_CONTAINER.getLdapPort(); + + // Create LdapSimpleBind for OpenLDAP admin authentication + // OpenLDAP admin DN: cn=admin,{baseDn} + final LdapConnectionProperties.LdapSimpleBind ldapSimpleBind = + new LdapConnectionProperties.LdapSimpleBind( + "cn=admin", + OPENLDAP_CONTAINER.getAdminPassword()); + + // Create connection properties + connectionProperties = new LdapConnectionProperties( + new LdapConnectionProperties.LdapServers(new String[]{host}, new int[]{port}), + TlsMode.NONE, // Plain LDAP for this test + null, + 5000, + 10000, + 1, + LDAP_DN_TEMPLATE, + OPENLDAP_CONTAINER.getBaseDn(), + "ADMIN", + ldapSimpleBind); + + // Create and start LDAP client + ldapClient = new LdapClient(connectionProperties); + ldapClient.start(); + + // Wait for OpenLDAP to finish loading LDIF files and TLS configuration + // When TLS is enabled, the container needs time to generate certificates + Thread.sleep(8000); + } + + @AfterAll + static void tearDown() { + if (ldapClient != null && ldapClient.isStarted()) { + ldapClient.stop(); + } + OPENLDAP_CONTAINER.stop(); + } + + /** + * Tests basic connectivity to OpenLDAP server. + *

+ * Verifies that: + * - Container is running + * - LDAP port is accessible + * - Admin bind succeeds + */ + @Test + void testBasicConnectivity() throws Exception { + // Act - Connect and bind as admin + final var testconnection = new LdapTestConnection(connectionProperties); + try (final LDAPConnection connection = testconnection.createConnection()) { + final BindRequest bindRequest = new SimpleBindRequest(OPENLDAP_CONTAINER.getAdminDn(), OPENLDAP_CONTAINER.getAdminPassword()); + final BindResult bindResult = connection.bind(bindRequest); + + // Assert + assertThat(bindResult.getResultCode()) + .as("Admin bind should succeed") + .isEqualTo(ResultCode.SUCCESS); + + assertThat(connection.isConnected()) + .as("Connection should be active") + .isTrue(); + } + } + + /** + * Tests that LDIF file data was successfully loaded into the directory. + *

+ * This test verifies: + * - Organizational units exist (ou=people, ou=groups) + * - Users were created from LDIF (alice, bob, charlie) + * - Groups were created from LDIF (developers, administrators) + * - User attributes are correct + */ + @Test + void testLdifDataLoaded() throws Exception { + final var testconnection = new LdapTestConnection(connectionProperties); + try (final LDAPConnection connection = testconnection.createConnection()) { + // Bind as admin + connection.bind(OPENLDAP_CONTAINER.getAdminDn(), OPENLDAP_CONTAINER.getAdminPassword()); + + // Search for all users in ou=people + final SearchRequest searchRequest = new SearchRequest( + "ou=people," + OPENLDAP_CONTAINER.getBaseDn(), + SearchScope.ONE, + "(objectClass=inetOrgPerson)"); + + final SearchResult searchResult = connection.search(searchRequest); + + // Assert - Should find 3 users (alice, bob, charlie) + assertThat(searchResult.getResultCode()).isEqualTo(ResultCode.SUCCESS); + assertThat(searchResult.getEntryCount()) + .as("Should have loaded 3 users from LDIF file") + .isEqualTo(3); + + // Verify alice exists with correct attributes + final SearchResultEntry alice = searchResult.getSearchEntries().stream() + .filter(entry -> entry.getAttributeValue("uid").equals("alice")) + .findFirst() + .orElseThrow(() -> new AssertionError("Alice not found")); + + assertThat(alice.getAttributeValue("cn")).isEqualTo("Alice Anderson"); + assertThat(alice.getAttributeValue("sn")).isEqualTo("Anderson"); + assertThat(alice.getAttributeValue("mail")).isEqualTo("alice@example.org"); + assertThat(alice.getAttributeValue("description")).isEqualTo("Software Engineer"); + } + } + + /** + * Tests authentication with users loaded from LDIF file. + *

+ * Demonstrates that users from the LDIF file can authenticate successfully. + */ + @Test + void testAuthenticationWithLdifUsers() throws Exception { + // Test alice + final boolean aliceAuth = ldapClient.authenticateUser("alice", "alice123".getBytes(StandardCharsets.UTF_8)); + assertThat(aliceAuth) + .as("Alice should authenticate with correct password") + .isTrue(); + + // Test bob + final boolean bobAuth = ldapClient.authenticateUser("bob", "bob456".getBytes(StandardCharsets.UTF_8)); + assertThat(bobAuth) + .as("Bob should authenticate with correct password") + .isTrue(); + + // Test charlie + final boolean charlieAuth = ldapClient.authenticateUser("charlie", "charlie789".getBytes(StandardCharsets.UTF_8)); + assertThat(charlieAuth) + .as("Charlie should authenticate with correct password") + .isTrue(); + + // Test wrong password + final boolean wrongAuth = ldapClient.authenticateUser("alice", "wrongpassword".getBytes(StandardCharsets.UTF_8)); + assertThat(wrongAuth) + .as("Authentication should fail with wrong password") + .isFalse(); + } + + /** + * Tests searching for groups loaded from LDIF file. + *

+ * Verifies that groups and their members are correctly loaded. + */ + @Test + void testGroupsFromLdif() throws Exception { + final var testconnection = new LdapTestConnection(connectionProperties); + try (final LDAPConnection connection = testconnection.createConnection()) { + // Bind as admin + connection.bind(OPENLDAP_CONTAINER.getAdminDn(), OPENLDAP_CONTAINER.getAdminPassword()); + + // Search for the developers group + final SearchRequest searchRequest = new SearchRequest( + "cn=developers,ou=groups," + OPENLDAP_CONTAINER.getBaseDn(), + SearchScope.BASE, + "(objectClass=groupOfNames)"); + + final SearchResult searchResult = connection.search(searchRequest); + + // Assert + assertThat(searchResult.getResultCode()).isEqualTo(ResultCode.SUCCESS); + assertThat(searchResult.getEntryCount()).isEqualTo(1); + + final SearchResultEntry developersGroup = searchResult.getSearchEntries().getFirst(); + + // Verify group members + final String[] members = developersGroup.getAttributeValues("member"); + assertThat(members) + .as("Developers group should have 2 members") + .hasSize(2) + .contains( + "uid=alice,ou=people," + OPENLDAP_CONTAINER.getBaseDn(), + "uid=bob,ou=people," + OPENLDAP_CONTAINER.getBaseDn() + ); + + assertThat(developersGroup.getAttributeValue("description")) + .isEqualTo("Software Development Team"); + } + } + + /** + * Tests SearchFilterDnResolver with OpenLDAP and LDIF data. + *

+ * Demonstrates using search filters to find users by different attributes. + */ + @Test + void testSearchFilterDnResolver() throws Exception { + final var testconnection = new LdapTestConnection(connectionProperties); + try (final LDAPConnection connection = testconnection.createConnection()) { + // Bind as admin to create authenticated pool + connection.bind(OPENLDAP_CONTAINER.getAdminDn(), OPENLDAP_CONTAINER.getAdminPassword()); + + try (final LDAPConnectionPool pool = new LDAPConnectionPool(connection, 1, 5)) { + // Test 1: Search by UID + final SearchFilterDnResolver uidResolver = new SearchFilterDnResolver(pool, + OPENLDAP_CONTAINER.getBaseDn(), + "(uid={username})", + SearchScope.SUB, + 5); + + String dn = uidResolver.resolveDn("alice"); + assertThat(dn).as("Should resolve alice's DN by UID") + .isEqualTo("uid=alice,ou=people," + OPENLDAP_CONTAINER.getBaseDn()); + + // Test 2: Search by email + final SearchFilterDnResolver emailResolver = new SearchFilterDnResolver(pool, + OPENLDAP_CONTAINER.getBaseDn(), + "(mail={username})", + SearchScope.SUB, + 5); + + dn = emailResolver.resolveDn("bob@example.org"); + assertThat(dn).as("Should resolve bob's DN by email") + .isEqualTo("uid=bob,ou=people," + OPENLDAP_CONTAINER.getBaseDn()); + + // Test 3: Search by common name + final SearchFilterDnResolver cnResolver = new SearchFilterDnResolver(pool, + OPENLDAP_CONTAINER.getBaseDn(), + "(cn={username})", + SearchScope.SUB, + 5); + + dn = cnResolver.resolveDn("Charlie Chen"); + assertThat(dn).as("Should resolve charlie's DN by common name") + .isEqualTo("uid=charlie,ou=people," + OPENLDAP_CONTAINER.getBaseDn()); + + } + } + } + + /** + * Tests searching for users by attributes from LDIF data. + *

+ * Demonstrates various LDAP search queries on the loaded test data. + */ + @Test + void testComplexSearchQueries() throws Exception { + final var testconnection = new LdapTestConnection(connectionProperties); + try (final LDAPConnection connection = testconnection.createConnection()) { + // Bind as admin + connection.bind(OPENLDAP_CONTAINER.getAdminDn(), OPENLDAP_CONTAINER.getAdminPassword()); + + // Search 1: Find all users with email ending in @example.org + final SearchRequest search1 = new SearchRequest( + OPENLDAP_CONTAINER.getBaseDn(), + SearchScope.SUB, + "(mail=*@example.org)"); + + final SearchResult result1 = connection.search(search1); + assertThat(result1.getEntryCount()) + .as("All users should have @example.org email") + .isEqualTo(3); + + // Search 2: Find users with uidNumber >= 10002 + final SearchRequest search2 = new SearchRequest( + OPENLDAP_CONTAINER.getBaseDn(), + SearchScope.SUB, + "(uidNumber>=10002)"); + + final SearchResult result2 = connection.search(search2); + assertThat(result2.getEntryCount()) + .as("Bob and Charlie have uidNumber >= 10002") + .isEqualTo(2); + + // Search 3: Find all groups + final SearchRequest search3 = new SearchRequest( + "ou=groups," + OPENLDAP_CONTAINER.getBaseDn(), + SearchScope.ONE, + "(objectClass=groupOfNames)"); + + final SearchResult result3 = connection.search(search3); + assertThat(result3.getEntryCount()) + .as("Should have 2 groups from LDIF") + .isEqualTo(2); + + // Verify group names + final List groupNames = result3.getSearchEntries().stream() + .map(entry -> entry.getAttributeValue("cn")) + .toList(); + + assertThat(groupNames) + .containsExactlyInAnyOrder("developers", "administrators"); + } + } + + /** + * Demonstrates verifying organizational structure from LDIF. + *

+ * Tests that the organizational units (ou=people, ou=groups) exist. + */ + @Test + void testOrganizationalStructure() throws Exception { + final var testconnection = new LdapTestConnection(connectionProperties); + try (final LDAPConnection connection = testconnection.createConnection()) { + // Bind as admin + connection.bind(OPENLDAP_CONTAINER.getAdminDn(), OPENLDAP_CONTAINER.getAdminPassword()); + + // Verify ou=people exists + final SearchRequest peopleSearch = new SearchRequest( + "ou=people," + OPENLDAP_CONTAINER.getBaseDn(), + SearchScope.BASE, + "(objectClass=organizationalUnit)"); + + final SearchResult peopleResult = connection.search(peopleSearch); + assertThat(peopleResult.getEntryCount()) + .as("ou=people should exist") + .isEqualTo(1); + + assertThat(peopleResult.getSearchEntries().getFirst().getAttributeValue("description")) + .isEqualTo("Container for user accounts"); + + // Verify ou=groups exists + final SearchRequest groupsSearch = new SearchRequest( + "ou=groups," + OPENLDAP_CONTAINER.getBaseDn(), + SearchScope.BASE, + "(objectClass=organizationalUnit)"); + + final SearchResult groupsResult = connection.search(groupsSearch); + assertThat(groupsResult.getEntryCount()) + .as("ou=groups should exist") + .isEqualTo(1); + + assertThat(groupsResult.getSearchEntries().getFirst().getAttributeValue("description")) + .isEqualTo("Container for group definitions"); + } + } + + /** + * Tests authentication over START_TLS (upgrade from plain to encrypted). + *

+ * START_TLS is a common approach where: + *

    + *
  • Client connects on standard LDAP port (389)
  • + *
  • Client sends StartTLS extended operation
  • + *
  • Connection is upgraded to TLS
  • + *
  • All subsequent traffic is encrypted
  • + *
+ *

+ * This is different from LDAPS which uses TLS from the start on port 636. + *

+ * Note: This test uses acceptAnyCertificateForTesting to trust + * the self-signed certificate generated by the OpenLDAP container. This is safe + * for integration tests but should never be used in production. + */ + @Test + void testStartTlsAuthentication() throws Exception { + // Arrange + final String host = OPENLDAP_CONTAINER.getHost(); + final int port = OPENLDAP_CONTAINER.getLdapPort(); + + // Note: Using acceptAnyCertificateForTesting to trust self-signed cert from OpenLDAP container + // In production, use proper CA-signed certificates and validate them + final LdapConnectionProperties.LdapSimpleBind ldapSimpleBind = + new LdapConnectionProperties.LdapSimpleBind( + "cn=admin", + OPENLDAP_CONTAINER.getAdminPassword()); + + final LdapConnectionProperties startTlsProps = new LdapConnectionProperties( + new LdapConnectionProperties.LdapServers(new String[]{host}, new int[]{port}), + TlsMode.START_TLS, + null, // No truststore needed + 5000, + 10000, + 1, + LDAP_DN_TEMPLATE, + OPENLDAP_CONTAINER.getBaseDn(), + "ADMIN", + true, // TEST ONLY: Accept any certificate + ldapSimpleBind); + + final LdapClient startTlsClient = new LdapClient(startTlsProps); + + // Act + startTlsClient.start(); + + try { + // Authenticate alice over START_TLS + final boolean aliceAuth = startTlsClient.authenticateUser("alice", "alice123".getBytes(StandardCharsets.UTF_8)); + assertThat(aliceAuth) + .as("Alice should authenticate with correct password over START_TLS") + .isTrue(); + + // Authenticate bob over START_TLS + final boolean bobAuth = startTlsClient.authenticateUser("bob", "bob456".getBytes(StandardCharsets.UTF_8)); + assertThat(bobAuth) + .as("Bob should authenticate with correct password over START_TLS") + .isTrue(); + + // Verify wrong password fails even over START_TLS + final boolean wrongPasswordAuth = startTlsClient.authenticateUser("alice", "wrongpassword".getBytes(StandardCharsets.UTF_8)); + assertThat(wrongPasswordAuth) + .as("Authentication should fail with wrong password even over START_TLS") + .isFalse(); + + } finally { + if (startTlsClient.isStarted()) { + startTlsClient.stop(); + } + } + } + + /** + * Tests START_TLS connection upgrade is working correctly. + *

+ * This test verifies that: + *

    + *
  • Connection starts in plain mode
  • + *
  • Successfully upgrades to TLS via START_TLS
  • + *
  • Authentication works after upgrade
  • + *
  • Multiple operations can be performed over the encrypted connection
  • + *
+ */ + @Test + void testStartTlsEncryptionIsActive() throws Exception { + // Arrange + final String host = OPENLDAP_CONTAINER.getHost(); + final int port = OPENLDAP_CONTAINER.getLdapPort(); + + final LdapConnectionProperties.LdapSimpleBind ldapSimpleBind = + new LdapConnectionProperties.LdapSimpleBind( + "cn=admin", + OPENLDAP_CONTAINER.getAdminPassword()); + + final LdapConnectionProperties startTlsProps = new LdapConnectionProperties( + new LdapConnectionProperties.LdapServers(new String[]{host}, new int[]{port}), + TlsMode.START_TLS, + null, + 5000, + 10000, + 1, + LDAP_DN_TEMPLATE, + OPENLDAP_CONTAINER.getBaseDn(), + "ADMIN", + true, // TEST ONLY: Accept any certificate + ldapSimpleBind); + + final LdapClient startTlsClient = new LdapClient(startTlsProps); + + // Act + startTlsClient.start(); + + try { + // Multiple authentication attempts to verify encryption stays active + for (int i = 0; i < 3; i++) { + final boolean authenticated = startTlsClient.authenticateUser("alice", "alice123".getBytes(StandardCharsets.UTF_8)); + assertThat(authenticated) + .as("Authentication attempt %d should succeed over encrypted connection", i + 1) + .isTrue(); + } + + // Also test with different users + assertThat(startTlsClient.authenticateUser("bob", "bob456".getBytes(StandardCharsets.UTF_8))) + .as("Bob should authenticate over encrypted connection") + .isTrue(); + + assertThat(startTlsClient.authenticateUser("charlie", "charlie789".getBytes(StandardCharsets.UTF_8))) + .as("Charlie should authenticate over encrypted connection") + .isTrue(); + + } finally { + if (startTlsClient.isStarted()) { + startTlsClient.stop(); + } + } + } + + /** + * Compares plain LDAP vs START_TLS to demonstrate the difference. + *

+ * This test shows that: + *

    + *
  • Both plain and START_TLS connections can authenticate
  • + *
  • START_TLS provides encryption after upgrade
  • + *
  • Both use the same port (389)
  • + *
+ *

+ * The key difference is that START_TLS encrypts all traffic after the upgrade, + * while plain LDAP sends credentials and data in cleartext. + */ + @Test + void testComparisonPlainVsStartTls() throws Exception { + // Arrange + final String host = OPENLDAP_CONTAINER.getHost(); + final int port = OPENLDAP_CONTAINER.getLdapPort(); + + final LdapConnectionProperties.LdapSimpleBind ldapSimpleBind = + new LdapConnectionProperties.LdapSimpleBind( + "cn=admin", + OPENLDAP_CONTAINER.getAdminPassword()); + + // Plain LDAP client + final LdapConnectionProperties plainProps = new LdapConnectionProperties( + new LdapConnectionProperties.LdapServers(new String[]{host}, new int[]{port}), + TlsMode.NONE, + null, + 5000, + 10000, + 1, + LDAP_DN_TEMPLATE, + OPENLDAP_CONTAINER.getBaseDn(), + "ADMIN", + ldapSimpleBind); + final LdapClient plainClient = new LdapClient(plainProps); + + // START_TLS client + final LdapConnectionProperties startTlsProps = new LdapConnectionProperties( + new LdapConnectionProperties.LdapServers(new String[]{host}, new int[]{port}), + TlsMode.START_TLS, + null, + 5000, + 10000, + 1, + LDAP_DN_TEMPLATE, + OPENLDAP_CONTAINER.getBaseDn(), + "ADMIN", + true, // TEST ONLY: Accept any certificate + ldapSimpleBind); + final LdapClient startTlsClient = new LdapClient(startTlsProps); + + // Act & Assert + try { + // Both should start successfully (same port!) + plainClient.start(); + startTlsClient.start(); + + // Both should authenticate successfully + final boolean plainAuth = plainClient.authenticateUser("alice", "alice123".getBytes(StandardCharsets.UTF_8)); + final boolean startTlsAuth = startTlsClient.authenticateUser("alice", "alice123".getBytes(StandardCharsets.UTF_8)); + + assertThat(plainAuth) + .as("Plain LDAP authentication should succeed") + .isTrue(); + + assertThat(startTlsAuth) + .as("START_TLS authentication should succeed") + .isTrue(); + + // Key difference: START_TLS encrypts credentials and data, + // while plain LDAP sends everything in cleartext. + // We can't easily verify encryption in the test, but the fact that + // START_TLS works proves the upgrade mechanism is functioning. + + } finally { + if (plainClient.isStarted()) { + plainClient.stop(); + } + if (startTlsClient.isStarted()) { + startTlsClient.stop(); + } + } + } + + /** + * Tests START_TLS with SearchFilterDnResolver to ensure encrypted search operations work. + *

+ * This demonstrates that complex LDAP operations (not just authentication) + * work correctly over START_TLS encrypted connections. + *

+ * Note: This test creates a manually authenticated connection pool because + * SearchFilterDnResolver requires admin privileges to search the directory. + */ + @Test + void testStartTlsWithSearchFilterDnResolver() throws Exception { + // Arrange + final String host = OPENLDAP_CONTAINER.getHost(); + final int port = OPENLDAP_CONTAINER.getLdapPort(); + + final LdapConnectionProperties.LdapSimpleBind ldapSimpleBind = + new LdapConnectionProperties.LdapSimpleBind( + "cn=admin", + OPENLDAP_CONTAINER.getAdminPassword()); + + final LdapConnectionProperties startTlsProps = new LdapConnectionProperties( + new LdapConnectionProperties.LdapServers(new String[]{host}, new int[]{port}), + TlsMode.START_TLS, + null, + 5000, + 10000, + 1, + LDAP_DN_TEMPLATE, + OPENLDAP_CONTAINER.getBaseDn(), + "ADMIN", + true, // TEST ONLY: Accept any certificate + ldapSimpleBind); + + // Create a START_TLS connection and bind as admin to enable searches + final var testconnection = new LdapTestConnection(connectionProperties); + try (final LDAPConnection connection = testconnection.createConnection()) { + // Bind as admin to create authenticated pool + connection.bind(OPENLDAP_CONTAINER.getAdminDn(), OPENLDAP_CONTAINER.getAdminPassword()); + + try (final LDAPConnectionPool pool = new LDAPConnectionPool(connection, 1, 5)) { + // Test search operations over encrypted connection + final SearchFilterDnResolver resolver = new SearchFilterDnResolver(pool, + OPENLDAP_CONTAINER.getBaseDn(), + "(uid={username})", + SearchScope.SUB, + 5); + + // Act - Perform searches over START_TLS + final String aliceDn = resolver.resolveDn("alice"); + final String bobDn = resolver.resolveDn("bob"); + final String charlieDn = resolver.resolveDn("charlie"); + + // Assert - All searches should work over encrypted connection + assertThat(aliceDn).as("Should resolve alice's DN over START_TLS") + .isEqualTo("uid=alice,ou=people," + OPENLDAP_CONTAINER.getBaseDn()); + + assertThat(bobDn).as("Should resolve bob's DN over START_TLS") + .isEqualTo("uid=bob,ou=people," + OPENLDAP_CONTAINER.getBaseDn()); + + assertThat(charlieDn).as("Should resolve charlie's DN over START_TLS") + .isEqualTo("uid=charlie,ou=people," + OPENLDAP_CONTAINER.getBaseDn()); + + } + } + } +} diff --git a/hivemq-edge/src/test/java/com/hivemq/api/auth/provider/impl/ldap/SearchFilterDnResolverTest.java b/hivemq-edge/src/test/java/com/hivemq/api/auth/provider/impl/ldap/SearchFilterDnResolverTest.java new file mode 100644 index 0000000000..c13d4579f9 --- /dev/null +++ b/hivemq-edge/src/test/java/com/hivemq/api/auth/provider/impl/ldap/SearchFilterDnResolverTest.java @@ -0,0 +1,448 @@ +/* + * Copyright 2019-present HiveMQ GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.hivemq.api.auth.provider.impl.ldap; + +import com.unboundid.ldap.sdk.LDAPConnectionPool; +import com.unboundid.ldap.sdk.LDAPSearchException; +import com.unboundid.ldap.sdk.ResultCode; +import com.unboundid.ldap.sdk.SearchRequest; +import com.unboundid.ldap.sdk.SearchResult; +import com.unboundid.ldap.sdk.SearchResultEntry; +import com.unboundid.ldap.sdk.SearchScope; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; + +import java.util.Arrays; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Unit tests for {@link SearchFilterDnResolver}. + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class SearchFilterDnResolverTest { + + @Mock + private LDAPConnectionPool connectionPool; + + @Test + void testConstructorValidation_emptySearchBase() { + assertThatThrownBy(() -> new SearchFilterDnResolver( + connectionPool, + "", + "(uid={username})", + SearchScope.SUB, + 5)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Search base cannot be empty"); + } + + @Test + void testConstructorValidation_blankSearchBase() { + assertThatThrownBy(() -> new SearchFilterDnResolver( + connectionPool, + " ", + "(uid={username})", + SearchScope.SUB, + 5)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Search base cannot be empty"); + } + + @Test + void testConstructorValidation_emptySearchFilter() { + assertThatThrownBy(() -> new SearchFilterDnResolver( + connectionPool, + "ou=people,dc=example,dc=com", + "", + SearchScope.SUB, + 5)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Search filter template cannot be empty"); + } + + @Test + void testConstructorValidation_searchFilterWithoutPlaceholder() { + assertThatThrownBy(() -> new SearchFilterDnResolver( + connectionPool, + "ou=people,dc=example,dc=com", + "(uid=hardcoded)", + SearchScope.SUB, + 5)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Search filter template must contain {username} placeholder"); + } + + @Test + void testConstructorValidation_negativeTimeout() { + assertThatThrownBy(() -> new SearchFilterDnResolver( + connectionPool, + "ou=people,dc=example,dc=com", + "(uid={username})", + SearchScope.SUB, + -1)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Timeout cannot be negative"); + } + + @Test + void testSimpleConstructor_usesDefaults() { + final SearchFilterDnResolver resolver = new SearchFilterDnResolver( + connectionPool, + "ou=people,dc=example,dc=com", + "(uid={username})" + ); + + assertThat(resolver.getSearchBase()).isEqualTo("ou=people,dc=example,dc=com"); + assertThat(resolver.getSearchFilterTemplate()).isEqualTo("(uid={username})"); + assertThat(resolver.getSearchScope()).isEqualTo(SearchScope.SUB); + assertThat(resolver.getTimeoutSeconds()).isEqualTo(5); + } + + @Test + void testResolveDn_emptyUsername() { + final SearchFilterDnResolver resolver = new SearchFilterDnResolver( + connectionPool, + "ou=people,dc=example,dc=com", + "(uid={username})" + ); + + assertThatThrownBy(() -> resolver.resolveDn("")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Username cannot be empty"); + } + + @Test + void testResolveDn_blankUsername() { + final SearchFilterDnResolver resolver = new SearchFilterDnResolver( + connectionPool, + "ou=people,dc=example,dc=com", + "(uid={username})" + ); + + assertThatThrownBy(() -> resolver.resolveDn(" ")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Username cannot be empty"); + } + + @Test + void testResolveDn_successfulSearch() throws Exception { + // Arrange + final SearchFilterDnResolver resolver = new SearchFilterDnResolver( + connectionPool, + "ou=people,dc=example,dc=com", + "(uid={username})", + SearchScope.SUB, + 10 + ); + + final SearchResultEntry mockEntry = mock(SearchResultEntry.class); + when(mockEntry.getDN()).thenReturn("uid=jdoe,ou=people,dc=example,dc=com"); + + final SearchResult mockResult = mock(SearchResult.class); + when(mockResult.getResultCode()).thenReturn(ResultCode.SUCCESS); + when(mockResult.getEntryCount()).thenReturn(1); + when(mockResult.getSearchEntries()).thenReturn(Arrays.asList(mockEntry)); + + when(connectionPool.search(any(SearchRequest.class))).thenReturn(mockResult); + + // Act + final String dn = resolver.resolveDn("jdoe"); + + // Assert + assertThat(dn).isEqualTo("uid=jdoe,ou=people,dc=example,dc=com"); + + // Verify search request parameters + final ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(SearchRequest.class); + verify(connectionPool).search(requestCaptor.capture()); + + final SearchRequest capturedRequest = requestCaptor.getValue(); + assertThat(capturedRequest.getBaseDN()).isEqualTo("ou=people,dc=example,dc=com"); + assertThat(capturedRequest.getScope()).isEqualTo(SearchScope.SUB); + assertThat(capturedRequest.getTimeLimitSeconds()).isEqualTo(10); + assertThat(capturedRequest.getSizeLimit()).isEqualTo(1); + assertThat(capturedRequest.getFilter().toString()).isEqualTo("(uid=jdoe)"); + } + + @Test + void testResolveDn_noResultsFound() throws Exception { + // Arrange + final SearchFilterDnResolver resolver = new SearchFilterDnResolver( + connectionPool, + "ou=people,dc=example,dc=com", + "(uid={username})" + ); + + final SearchResult mockResult = mock(SearchResult.class); + when(mockResult.getResultCode()).thenReturn(ResultCode.SUCCESS); + when(mockResult.getEntryCount()).thenReturn(0); + + when(connectionPool.search(any(SearchRequest.class))).thenReturn(mockResult); + + // Act & Assert + assertThatThrownBy(() -> resolver.resolveDn("nonexistent")) + .isInstanceOf(SearchFilterDnResolver.DnResolutionException.class) + .hasMessageContaining("No LDAP entry found for username: nonexistent"); + } + + @Test + void testResolveDn_multipleResultsFound_usesFirst() throws Exception { + // Arrange + final SearchFilterDnResolver resolver = new SearchFilterDnResolver( + connectionPool, + "dc=example,dc=com", + "(uid={username})" + ); + + final SearchResultEntry mockEntry1 = mock(SearchResultEntry.class); + when(mockEntry1.getDN()).thenReturn("uid=jdoe,ou=engineering,dc=example,dc=com"); + + final SearchResultEntry mockEntry2 = mock(SearchResultEntry.class); + when(mockEntry2.getDN()).thenReturn("uid=jdoe,ou=sales,dc=example,dc=com"); + + final SearchResult mockResult = mock(SearchResult.class); + when(mockResult.getResultCode()).thenReturn(ResultCode.SUCCESS); + when(mockResult.getEntryCount()).thenReturn(2); + when(mockResult.getSearchEntries()).thenReturn(Arrays.asList(mockEntry1, mockEntry2)); + + when(connectionPool.search(any(SearchRequest.class))).thenReturn(mockResult); + + // Act + final String dn = resolver.resolveDn("jdoe"); + + // Assert - should use first result + assertThat(dn).isEqualTo("uid=jdoe,ou=engineering,dc=example,dc=com"); + } + + @Test + void testResolveDn_searchFailureResultCode() throws Exception { + // Arrange + final SearchFilterDnResolver resolver = new SearchFilterDnResolver( + connectionPool, + "ou=people,dc=example,dc=com", + "(uid={username})" + ); + + final SearchResult mockResult = mock(SearchResult.class); + when(mockResult.getResultCode()).thenReturn(ResultCode.NO_SUCH_OBJECT); + when(mockResult.getDiagnosticMessage()).thenReturn("Search base does not exist"); + + when(connectionPool.search(any(SearchRequest.class))).thenReturn(mockResult); + + // Act & Assert + assertThatThrownBy(() -> resolver.resolveDn("jdoe")) + .isInstanceOf(SearchFilterDnResolver.DnResolutionException.class) + .hasMessageContaining("LDAP search failed with result code") + .hasMessageContaining("no such object"); + } + + @Test + void testResolveDn_searchTimeout() throws Exception { + // Arrange + final SearchFilterDnResolver resolver = new SearchFilterDnResolver( + connectionPool, + "ou=people,dc=example,dc=com", + "(uid={username})", + SearchScope.SUB, + 1 + ); + + final LDAPSearchException timeoutException = new LDAPSearchException( + ResultCode.TIME_LIMIT_EXCEEDED, + "Search time limit exceeded" + ); + + when(connectionPool.search(any(SearchRequest.class))).thenThrow(timeoutException); + + // Act & Assert + assertThatThrownBy(() -> resolver.resolveDn("jdoe")) + .isInstanceOf(SearchFilterDnResolver.DnResolutionException.class) + .hasMessageContaining("LDAP search timed out after 1 seconds") + .hasCause(timeoutException); + } + + @Test + void testResolveDn_ldapException() throws Exception { + // Arrange + final SearchFilterDnResolver resolver = new SearchFilterDnResolver( + connectionPool, + "ou=people,dc=example,dc=com", + "(uid={username})" + ); + + final LDAPSearchException ldapException = new LDAPSearchException( + ResultCode.OPERATIONS_ERROR, + "LDAP server error" + ); + + when(connectionPool.search(any(SearchRequest.class))).thenThrow(ldapException); + + // Act & Assert + assertThatThrownBy(() -> resolver.resolveDn("jdoe")) + .isInstanceOf(SearchFilterDnResolver.DnResolutionException.class) + .hasMessageContaining("LDAP search failed") + .hasCause(ldapException); + } + + @Test + void testResolveDn_specialCharactersInUsername() throws Exception { + // Arrange + final SearchFilterDnResolver resolver = new SearchFilterDnResolver( + connectionPool, + "ou=people,dc=example,dc=com", + "(uid={username})" + ); + + final SearchResultEntry mockEntry = mock(SearchResultEntry.class); + when(mockEntry.getDN()).thenReturn("uid=user\\(special\\),ou=people,dc=example,dc=com"); + + final SearchResult mockResult = mock(SearchResult.class); + when(mockResult.getResultCode()).thenReturn(ResultCode.SUCCESS); + when(mockResult.getEntryCount()).thenReturn(1); + when(mockResult.getSearchEntries()).thenReturn(Arrays.asList(mockEntry)); + + when(connectionPool.search(any(SearchRequest.class))).thenReturn(mockResult); + + // Act + final String dn = resolver.resolveDn("user(special)"); + + // Assert + assertThat(dn).isNotNull(); + + // Verify that special characters were escaped in the filter + final ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(SearchRequest.class); + verify(connectionPool).search(requestCaptor.capture()); + + final SearchRequest capturedRequest = requestCaptor.getValue(); + // The filter should have escaped the parentheses + assertThat(capturedRequest.getFilter().toString()).contains("\\28").contains("\\29"); + } + + @Test + void testResolveDn_complexFilter() throws Exception { + // Arrange + final SearchFilterDnResolver resolver = new SearchFilterDnResolver( + connectionPool, + "dc=example,dc=com", + "(&(objectClass=inetOrgPerson)(uid={username}))", + SearchScope.SUB, + 10 + ); + + final SearchResultEntry mockEntry = mock(SearchResultEntry.class); + when(mockEntry.getDN()).thenReturn("uid=jdoe,ou=people,dc=example,dc=com"); + + final SearchResult mockResult = mock(SearchResult.class); + when(mockResult.getResultCode()).thenReturn(ResultCode.SUCCESS); + when(mockResult.getEntryCount()).thenReturn(1); + when(mockResult.getSearchEntries()).thenReturn(Arrays.asList(mockEntry)); + + when(connectionPool.search(any(SearchRequest.class))).thenReturn(mockResult); + + // Act + final String dn = resolver.resolveDn("jdoe"); + + // Assert + assertThat(dn).isEqualTo("uid=jdoe,ou=people,dc=example,dc=com"); + + final ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(SearchRequest.class); + verify(connectionPool).search(requestCaptor.capture()); + + final SearchRequest capturedRequest = requestCaptor.getValue(); + assertThat(capturedRequest.getFilter().toString()).isEqualTo("(&(objectClass=inetOrgPerson)(uid=jdoe))"); + } + + @Test + void testResolveDn_orFilter() throws Exception { + // Arrange - search by either uid or mail + final SearchFilterDnResolver resolver = new SearchFilterDnResolver( + connectionPool, + "ou=people,dc=example,dc=com", + "(|(uid={username})(mail={username}))" + ); + + final SearchResultEntry mockEntry = mock(SearchResultEntry.class); + when(mockEntry.getDN()).thenReturn("uid=jdoe,ou=people,dc=example,dc=com"); + + final SearchResult mockResult = mock(SearchResult.class); + when(mockResult.getResultCode()).thenReturn(ResultCode.SUCCESS); + when(mockResult.getEntryCount()).thenReturn(1); + when(mockResult.getSearchEntries()).thenReturn(Arrays.asList(mockEntry)); + + when(connectionPool.search(any(SearchRequest.class))).thenReturn(mockResult); + + // Act + final String dn = resolver.resolveDn("jdoe@example.com"); + + // Assert + assertThat(dn).isNotNull(); + + final ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(SearchRequest.class); + verify(connectionPool).search(requestCaptor.capture()); + + final SearchRequest capturedRequest = requestCaptor.getValue(); + assertThat(capturedRequest.getFilter().toString()) + .isEqualTo("(|(uid=jdoe@example.com)(mail=jdoe@example.com))"); + } + + @Test + void testGetters() { + final SearchFilterDnResolver resolver = new SearchFilterDnResolver( + connectionPool, + "ou=people,dc=example,dc=com", + "(uid={username})", + SearchScope.ONE, + 15 + ); + + assertThat(resolver.getSearchBase()).isEqualTo("ou=people,dc=example,dc=com"); + assertThat(resolver.getSearchFilterTemplate()).isEqualTo("(uid={username})"); + assertThat(resolver.getSearchScope()).isEqualTo(SearchScope.ONE); + assertThat(resolver.getTimeoutSeconds()).isEqualTo(15); + } + + @Test + void testDnResolutionException_withCause() { + final Exception cause = new RuntimeException("test cause"); + final SearchFilterDnResolver.DnResolutionException exception = + new SearchFilterDnResolver.DnResolutionException("test message", "testuser", cause); + + assertThat(exception.getMessage()).isEqualTo("test message"); + assertThat(exception.getUsername()).isEqualTo("testuser"); + assertThat(exception.getCause()).isEqualTo(cause); + } + + @Test + void testDnResolutionException_withoutCause() { + final SearchFilterDnResolver.DnResolutionException exception = + new SearchFilterDnResolver.DnResolutionException("test message", "testuser"); + + assertThat(exception.getMessage()).isEqualTo("test message"); + assertThat(exception.getUsername()).isEqualTo("testuser"); + assertThat(exception.getCause()).isNull(); + } +} diff --git a/hivemq-edge/src/test/java/com/hivemq/api/auth/provider/impl/ldap/TemplateDnResolverTest.java b/hivemq-edge/src/test/java/com/hivemq/api/auth/provider/impl/ldap/TemplateDnResolverTest.java new file mode 100644 index 0000000000..ccb69e7a37 --- /dev/null +++ b/hivemq-edge/src/test/java/com/hivemq/api/auth/provider/impl/ldap/TemplateDnResolverTest.java @@ -0,0 +1,167 @@ +/* + * Copyright 2019-present HiveMQ GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.hivemq.api.auth.provider.impl.ldap; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Unit tests for {@link TemplateDnResolver}. + */ +class TemplateDnResolverTest { + + @Test + void testOpenLdapTemplate() { + final TemplateDnResolver resolver = new TemplateDnResolver( + "uid={username},ou=people,{baseDn}", + "dc=example,dc=com" + ); + + assertThat(resolver.resolveDn("jdoe")) + .isEqualTo("uid=jdoe,ou=people,dc=example,dc=com"); + } + + @Test + void testActiveDirectoryTemplate() { + final TemplateDnResolver resolver = new TemplateDnResolver( + "cn={username},cn=Users,{baseDn}", + "dc=company,dc=com" + ); + + assertThat(resolver.resolveDn("John Doe")) + .isEqualTo("cn=John Doe,cn=Users,dc=company,dc=com"); + } + + @Test + void testEmailBasedTemplate() { + final TemplateDnResolver resolver = new TemplateDnResolver( + "mail={username},ou=staff,{baseDn}", + "dc=company,dc=com" + ); + + assertThat(resolver.resolveDn("jdoe@company.com")) + .isEqualTo("mail=jdoe@company.com,ou=staff,dc=company,dc=com"); + } + + @Test + void testCustomAttributeTemplate() { + final TemplateDnResolver resolver = new TemplateDnResolver( + "employeeNumber={username},ou=employees,{baseDn}", + "dc=corp,dc=com" + ); + + assertThat(resolver.resolveDn("12345")) + .isEqualTo("employeeNumber=12345,ou=employees,dc=corp,dc=com"); + } + + @Test + void testMultipleOuTemplate() { + final TemplateDnResolver resolver = new TemplateDnResolver( + "uid={username},ou=engineering,ou=staff,{baseDn}", + "dc=company,dc=com" + ); + + assertThat(resolver.resolveDn("jdoe")) + .isEqualTo("uid=jdoe,ou=engineering,ou=staff,dc=company,dc=com"); + } + + @Test + void testTemplateWithoutBaseDnPlaceholder() { + // Template doesn't use {baseDn} placeholder - should work fine + final TemplateDnResolver resolver = new TemplateDnResolver( + "uid={username},ou=people,dc=example,dc=com", + "dc=ignored,dc=com" + ); + + assertThat(resolver.resolveDn("jdoe")) + .isEqualTo("uid={username},ou=people,dc=example,dc=com".replace("{username}", "jdoe")); + } + + @Test + void testValidationRejectsEmptyTemplate() { + assertThatThrownBy(() -> new TemplateDnResolver("", "dc=example,dc=com")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("DN template cannot be empty"); + } + + @Test + void testValidationRejectsBlankTemplate() { + assertThatThrownBy(() -> new TemplateDnResolver(" ", "dc=example,dc=com")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("DN template cannot be empty"); + } + + @Test + void testValidationRejectsTemplateWithoutUsernamePlaceholder() { + assertThatThrownBy(() -> new TemplateDnResolver( + "uid=hardcoded,ou=people,{baseDn}", + "dc=example,dc=com")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("DN template must contain {username} placeholder"); + } + + @Test + void testValidationRejectsEmptyBaseDn() { + assertThatThrownBy(() -> new TemplateDnResolver("uid={username},ou=people,{baseDn}", "")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Base DN cannot be empty"); + } + + @Test + void testResolveDnRejectsEmptyUsername() { + final TemplateDnResolver resolver = new TemplateDnResolver( + "uid={username},ou=people,{baseDn}", + "dc=example,dc=com" + ); + + assertThatThrownBy(() -> resolver.resolveDn("")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Username cannot be empty"); + } + + @Test + void testResolveDnRejectsBlankUsername() { + final TemplateDnResolver resolver = new TemplateDnResolver( + "uid={username},ou=people,{baseDn}", + "dc=example,dc=com" + ); + + assertThatThrownBy(() -> resolver.resolveDn(" ")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Username cannot be empty"); + } + + @Test + void testGetTemplate() { + final String template = "uid={username},ou=people,{baseDn}"; + final TemplateDnResolver resolver = new TemplateDnResolver(template, "dc=example,dc=com"); + + assertThat(resolver.getTemplate()).isEqualTo(template); + } + + @Test + void testGetBaseDn() { + final String baseDn = "dc=example,dc=com"; + final TemplateDnResolver resolver = new TemplateDnResolver( + "uid={username},ou=people,{baseDn}", + baseDn + ); + + assertThat(resolver.getBaseDn()).isEqualTo(baseDn); + } +} diff --git a/hivemq-edge/src/test/java/com/hivemq/api/auth/provider/impl/ldap/testcontainer/LdapTestConnection.java b/hivemq-edge/src/test/java/com/hivemq/api/auth/provider/impl/ldap/testcontainer/LdapTestConnection.java new file mode 100644 index 0000000000..9ac0d6e830 --- /dev/null +++ b/hivemq-edge/src/test/java/com/hivemq/api/auth/provider/impl/ldap/testcontainer/LdapTestConnection.java @@ -0,0 +1,223 @@ +package com.hivemq.api.auth.provider.impl.ldap.testcontainer; + +import com.hivemq.api.auth.provider.impl.ldap.LdapConnectionProperties; +import com.hivemq.api.auth.provider.impl.ldap.TlsMode; +import com.unboundid.ldap.sdk.AddRequest; +import com.unboundid.ldap.sdk.Attribute; +import com.unboundid.ldap.sdk.BindRequest; +import com.unboundid.ldap.sdk.BindResult; +import com.unboundid.ldap.sdk.ExtendedResult; +import com.unboundid.ldap.sdk.LDAPConnection; +import com.unboundid.ldap.sdk.LDAPConnectionOptions; +import com.unboundid.ldap.sdk.LDAPException; +import com.unboundid.ldap.sdk.Modification; +import com.unboundid.ldap.sdk.ModificationType; +import com.unboundid.ldap.sdk.ModifyRequest; +import com.unboundid.ldap.sdk.ResultCode; +import com.unboundid.ldap.sdk.SimpleBindRequest; +import com.unboundid.ldap.sdk.extensions.StartTLSExtendedRequest; +import com.unboundid.util.ssl.SSLUtil; +import com.unboundid.util.ssl.TrustAllTrustManager; +import com.unboundid.util.ssl.TrustStoreTrustManager; +import org.jetbrains.annotations.NotNull; + +import javax.net.ssl.SSLContext; +import java.security.GeneralSecurityException; + +import static org.assertj.core.api.Assertions.assertThat; + +public class LdapTestConnection { + public static final String TEST_USERNAME = "test"; + public static final String TEST_PASSWORD = "test"; + + private final LdapConnectionProperties ldapConnectionProperties; + + public LdapTestConnection(final LdapConnectionProperties ldapConnectionProperties) { + this.ldapConnectionProperties = ldapConnectionProperties; + } + + /** + * Creates an SSLContext from the truststore configuration. + *

+ * Certificate validation behavior depends on configuration: + *

    + *
  • acceptAnyCertificateForTesting = true: ⚠️ Disables ALL certificate validation. + * Accepts any certificate including self-signed, expired, or invalid certificates. + * NEVER use in production! Only for integration tests.
  • + *
  • trustStorePath = null: Uses system's default CA certificates + * (e.g., Let's Encrypt, DigiCert). Suitable for production with properly signed certificates.
  • + *
  • trustStorePath provided: Uses custom truststore. + * Useful for self-signed certificates or internal CAs in production.
  • + *
+ * + * @return A configured SSLContext + * @throws GeneralSecurityException if there's an issue creating the SSLContext + * @throws IllegalStateException if called when TLS mode is NONE + */ + public @NotNull SSLContext createSSLContext() throws GeneralSecurityException { + if (ldapConnectionProperties.tlsMode().equals(TlsMode.NONE)) { + throw new IllegalStateException("SSLContext is not needed for TLS mode: " + ldapConnectionProperties.tlsMode()); + } + + // TEST ONLY: Accept any certificate without validation + if (ldapConnectionProperties.acceptAnyCertificateForTesting()) { + // WARNING: This disables certificate validation - only for testing! + // Configure SSLUtil to accept any certificate and hostname + final SSLUtil sslUtil = new SSLUtil(new TrustAllTrustManager()); + // Set default SSLContext protocol to work with OpenLDAP container + return sslUtil.createSSLContext("TLS"); + } + + if (ldapConnectionProperties.trustStore() == null) { + // Use system default CA certificates (Java's default truststore) + final SSLUtil sslUtil = new SSLUtil(); + return sslUtil.createSSLContext(); + } + + // Use custom truststore for self-signed certificates or internal CAs + final SSLUtil sslUtil = new SSLUtil(new TrustStoreTrustManager( + ldapConnectionProperties.trustStore().trustStorePath(), + ldapConnectionProperties.trustStore().trustStorePassword() != null ? ldapConnectionProperties.trustStore().trustStorePassword().toCharArray() : null, + ldapConnectionProperties.trustStore().trustStoreType(), + true)); + return sslUtil.createSSLContext(); + } + + /** + * Creates connection options with configured timeouts. + * + * @return Configured LDAPConnectionOptions + */ + private @NotNull LDAPConnectionOptions createConnectionOptions() { + final LDAPConnectionOptions options = new LDAPConnectionOptions(); + + if (ldapConnectionProperties.connectTimeoutMillis() > 0) { + options.setConnectTimeoutMillis(ldapConnectionProperties.connectTimeoutMillis()); + } + + if (ldapConnectionProperties.responseTimeoutMillis() > 0) { + options.setResponseTimeoutMillis(ldapConnectionProperties.responseTimeoutMillis()); + } + + return options; + } + + /** + * Creates a new LDAP connection using the configured properties. + *

+ * The connection type depends on the configured TLS mode: + *

    + *
  • NONE: Plain LDAP connection without encryption
  • + *
  • LDAPS: TLS connection established from the start
  • + *
  • START_TLS: Plain connection upgraded to TLS using StartTLS
  • + *
+ * + * @return A new LDAPConnection instance + * @throws LDAPException if the connection fails + * @throws GeneralSecurityException if there's an SSL/TLS issue + */ + public @NotNull LDAPConnection createConnection() throws LDAPException, GeneralSecurityException { + final LDAPConnectionOptions connectionOptions = createConnectionOptions(); + + return switch (ldapConnectionProperties.tlsMode()) { + case NONE -> createPlainConnection(connectionOptions); + case LDAPS -> createLdapsConnection(connectionOptions); + case START_TLS -> createStartTlsConnection(connectionOptions); + }; + } + + /** + * Creates a plain LDAP connection without encryption. + */ + private @NotNull LDAPConnection createPlainConnection(final @NotNull LDAPConnectionOptions options) + throws LDAPException { + return new LDAPConnection(options, ldapConnectionProperties.servers().hosts()[0], ldapConnectionProperties.servers().ports()[0]); + } + + /** + * Creates an LDAPS connection (TLS from start). + */ + private @NotNull LDAPConnection createLdapsConnection(final @NotNull LDAPConnectionOptions options) + throws LDAPException, GeneralSecurityException { + final SSLContext sslContext = createSSLContext(); + return new LDAPConnection(sslContext.getSocketFactory(), options, ldapConnectionProperties.servers().hosts()[0], ldapConnectionProperties.servers().ports()[0]); + } + + /** + * Creates a connection and upgrades it to TLS using StartTLS. + */ + private @NotNull LDAPConnection createStartTlsConnection(final @NotNull LDAPConnectionOptions options) + throws LDAPException, GeneralSecurityException { + // First create plain connection + final LDAPConnection connection = new LDAPConnection(options, ldapConnectionProperties.servers().hosts()[0], ldapConnectionProperties.servers().ports()[0]); + + try { + // Upgrade to TLS using StartTLS extended operation + final SSLContext sslContext = createSSLContext(); + final StartTLSExtendedRequest startTLSRequest = new StartTLSExtendedRequest(sslContext); + final ExtendedResult startTLSResult = connection.processExtendedOperation(startTLSRequest); + + if (startTLSResult.getResultCode() != ResultCode.SUCCESS) { + throw new LDAPException(startTLSResult.getResultCode(), + "StartTLS failed: " + startTLSResult.getDiagnosticMessage()); + } + + return connection; + } catch (final Exception e) { + // Close the connection if StartTLS fails + connection.close(); + throw e; + } + } + + + /** + * Creates a test user in LLDAP using the admin account. + *

+ * LLDAP provides a simplified user management approach. We need to: + * 1. Connect as admin + * 2. Add the test user to the people organizational unit + * 3. Set the user's password + *

+ * Note: This method uses the low-level connection API directly since it performs + * administrative operations (adding users) that are not part of the normal client API. + */ + public void createTestUser(@NotNull final String adminDn, @NotNull final String adminPassword, @NotNull final String baseDn) throws LDAPException, GeneralSecurityException { + final var testconnection = new LdapTestConnection(ldapConnectionProperties); + try (final var adminConnection = testconnection.createConnection()) { + // Bind as admin using LldapContainer's convenience methods + final BindRequest bindRequest = new SimpleBindRequest( + adminDn, + adminPassword); + final BindResult bindResult = adminConnection.bind(bindRequest); + + assertThat(bindResult.getResultCode()).isEqualTo(ResultCode.SUCCESS); + + // Add test user using proper Attribute objects + final String testUserDnString = "uid=" + TEST_USERNAME + ",ou=people," + baseDn; + + final AddRequest addRequest = new AddRequest(testUserDnString, + new Attribute("objectClass", "inetOrgPerson", "posixAccount"), + new Attribute("uid", TEST_USERNAME), + new Attribute("cn", TEST_USERNAME), + new Attribute("sn", "User"), + new Attribute("mail", TEST_USERNAME + "@example.com"), // Required by LLDAP + new Attribute("uidNumber", "1000"), + new Attribute("gidNumber", "1000"), + new Attribute("homeDirectory", "/home/" + TEST_USERNAME) + ); + + adminConnection.add(addRequest); + + // Set the password using a ModifyRequest (this ensures proper password hashing) + final ModifyRequest modifyRequest = new ModifyRequest(testUserDnString, + new Modification(ModificationType.REPLACE, + "userPassword", + TEST_PASSWORD)); + + adminConnection.modify(modifyRequest); + } + } + +} + diff --git a/hivemq-edge/src/test/java/com/hivemq/api/auth/provider/impl/ldap/testcontainer/LldapContainer.java b/hivemq-edge/src/test/java/com/hivemq/api/auth/provider/impl/ldap/testcontainer/LldapContainer.java new file mode 100644 index 0000000000..819901207e --- /dev/null +++ b/hivemq-edge/src/test/java/com/hivemq/api/auth/provider/impl/ldap/testcontainer/LldapContainer.java @@ -0,0 +1,520 @@ +/* + * Copyright 2019-present HiveMQ GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.hivemq.api.auth.provider.impl.ldap.testcontainer; + +import com.hivemq.api.auth.provider.impl.ldap.LdapConnectionProperties; +import org.bouncycastle.asn1.x500.X500Name; +import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo; +import org.bouncycastle.cert.X509v3CertificateBuilder; +import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; +import org.bouncycastle.jce.provider.BouncyCastleProvider; +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; +import org.bouncycastle.util.io.pem.PemObject; +import org.bouncycastle.util.io.pem.PemWriter; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.utility.DockerImageName; +import org.testcontainers.utility.MountableFile; + +import java.io.File; +import java.io.FileWriter; +import java.math.BigInteger; +import java.nio.file.Files; +import java.security.KeyPairGenerator; +import java.security.KeyStore; +import java.security.SecureRandom; +import java.security.Security; +import java.time.Duration; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Date; + +/** + * Testcontainer for LLDAP (Light LDAP) server. + *

+ * LLDAP is a lightweight LDAP server designed for authentication and user management. + * It supports: + *

    + *
  • Standard LDAP operations (bind, search, modify)
  • + *
  • LDAPS (TLS from connection start)
  • + *
  • Custom base DN and admin credentials
  • + *
+ *

+ * Limitations: + *

    + *
  • Does NOT support START_TLS extended operation
  • + *
  • Limited schema support compared to OpenLDAP/Active Directory
  • + *
+ *

+ * Example usage with defaults: + *

{@code
+ * @Container
+ * static LldapContainer ldap = new LldapContainer();
+ *
+ * @Test
+ * void testLdapConnection() {
+ *     String host = ldap.getHost();
+ *     int port = ldap.getLdapPort();
+ *     String baseDn = ldap.getBaseDn(); // dc=example,dc=com
+ *     String adminDn = ldap.getAdminDn(); // uid=admin,dc=example,dc=com
+ *     String password = ldap.getAdminPassword(); // admin_password
+ * }
+ * }
+ *

+ * Example usage with builder: + *

{@code
+ * @Container
+ * static LldapContainer ldap = LldapContainer.builder()
+ *     .baseDn("dc=mycompany,dc=org")
+ *     .adminUsername("admin")
+ *     .adminPassword("secret123")
+ *     .ldapPort(3890)
+ *     .withLdaps(certFile, keyFile)
+ *     .build();
+ * }
+ * + * @see OpenLdapContainer for a full-featured alternative that supports START_TLS + */ +public class LldapContainer extends GenericContainer { + + public static final String KEYSTORE_PASSWORD = "changeit"; + + private static final String DEFAULT_IMAGE_NAME = "lldap/lldap:v0.5.0"; + private static final int DEFAULT_LDAP_PORT = 3890; + private static final int DEFAULT_LDAPS_PORT = 6360; + private static final int DEFAULT_HTTP_PORT = 17170; + private static final String DEFAULT_BASE_DN = "dc=example,dc=com"; + private static final String DEFAULT_ADMIN_USERNAME = "admin"; + private static final String DEFAULT_ADMIN_PASSWORD = "admin_password"; + + private final int ldapPort; + private final int ldapsPort; + private final int httpPort; + private final String baseDn; + private final String adminUsername; + private final String adminPassword; + private final boolean ldapsEnabled; + final @Nullable File trustStoreFile; + private @Nullable File certFile; + private @Nullable File keyFile; + + /** + * Creates a new LLDAP container with default configuration. + *

+ * Default configuration: + *

    + *
  • Image: lldap/lldap:v0.5.0
  • + *
  • LDAP Port: 3890
  • + *
  • LDAPS Port: 6360
  • + *
  • HTTP Port: 17170
  • + *
  • Base DN: dc=example,dc=com
  • + *
  • Admin Username: admin
  • + *
  • Admin Password: admin_password
  • + *
+ */ + public LldapContainer() { + this(DEFAULT_IMAGE_NAME, DEFAULT_LDAP_PORT, DEFAULT_LDAPS_PORT, DEFAULT_HTTP_PORT, DEFAULT_BASE_DN, + DEFAULT_ADMIN_USERNAME, DEFAULT_ADMIN_PASSWORD, null, null, null); + } + + private LldapContainer( + final @NotNull String dockerImageName, + final int ldapPort, + final int ldapsPort, + final int httpPort, + final @NotNull String baseDn, + final @NotNull String adminUsername, + final @NotNull String adminPassword, + final @Nullable File certFile, + final @Nullable File keyFile, + final @Nullable File trustStoreFile) { + super(DockerImageName.parse(dockerImageName)); + this.ldapPort = ldapPort; + this.ldapsPort = ldapsPort; + this.httpPort = httpPort; + this.baseDn = baseDn; + this.adminUsername = adminUsername; + this.adminPassword = adminPassword; + this.ldapsEnabled = (certFile != null && keyFile != null); + this.trustStoreFile = trustStoreFile; + configure(certFile, keyFile); + } + + private void configure(final @Nullable File certFile, final @Nullable File keyFile) { + if (ldapsEnabled) { + withExposedPorts(ldapPort, ldapsPort, httpPort); + } else { + withExposedPorts(ldapPort, httpPort); + } + + withEnv("LLDAP_LDAP_PORT", String.valueOf(ldapPort)); + withEnv("LLDAP_LDAP_BASE_DN", baseDn); + withEnv("LLDAP_LDAP_USER_DN", adminUsername); + withEnv("LLDAP_LDAP_USER_PASS", adminPassword); + + // Configure LDAPS if certificates are provided + if (ldapsEnabled) { + withEnv("LLDAP_LDAPS_PORT", String.valueOf(ldapsPort)); + withEnv("LLDAP_LDAPS_OPTIONS__ENABLED", "true"); + withEnv("LLDAP_LDAPS_OPTIONS__CERT_FILE", "/data/cert.pem"); + withEnv("LLDAP_LDAPS_OPTIONS__KEY_FILE", "/data/key.pem"); + withCopyFileToContainer(MountableFile.forHostPath(certFile.toPath()), "/data/cert.pem"); + withCopyFileToContainer(MountableFile.forHostPath(keyFile.toPath()), "/data/key.pem"); + } + + waitingFor(Wait.forLogMessage(".*Starting LLDAP.*", 1) + .withStartupTimeout(Duration.ofSeconds(60))); + } + + @Override + public void stop() { + super.stop(); + // Clean up certificate and truststore files + if (certFile != null && certFile.exists()) { + certFile.delete(); + } + if (keyFile != null && keyFile.exists()) { + keyFile.delete(); + } + if (trustStoreFile != null && trustStoreFile.exists()) { + trustStoreFile.delete(); + } + } + + /** + * Creates a new builder for LldapContainer. + * + * @return a new builder instance + */ + public static @NotNull Builder builder() { + return new Builder(); + } + + public @Nullable File getTrustStoreFile() { + return trustStoreFile; + } + + /** + * Gets the dynamically mapped LDAP port. + *

+ * Can only be called after the container has started. + * + * @return the mapped LDAP port + */ + public int getLdapPort() { + return getMappedPort(ldapPort); + } + + /** + * Gets the dynamically mapped LDAPS port. + *

+ * Can only be called after the container has started and if LDAPS is enabled. + * + * @return the mapped LDAPS port + * @throws IllegalStateException if LDAPS is not enabled + */ + public int getLdapsPort() { + if (!ldapsEnabled) { + throw new IllegalStateException("LDAPS is not enabled. Use withLdaps() builder method to enable LDAPS."); + } + return getMappedPort(ldapsPort); + } + + /** + * Returns whether LDAPS is enabled for this container. + * + * @return true if LDAPS is enabled, false otherwise + */ + public boolean isLdapsEnabled() { + return ldapsEnabled; + } + + /** + * Gets the dynamically mapped HTTP port. + *

+ * Can only be called after the container has started. + * + * @return the mapped HTTP port + */ + public int getHttpPort() { + return getMappedPort(httpPort); + } + + /** + * Gets the base DN configured for this container. + * + * @return the base DN + */ + public @NotNull String getBaseDn() { + return baseDn; + } + + /** + * Gets the admin username configured for this container. + * + * @return the admin username + */ + public @NotNull String getAdminUsername() { + return adminUsername; + } + + /** + * Gets the admin password configured for this container. + * + * @return the admin password + */ + public @NotNull String getAdminPassword() { + return adminPassword; + } + + /** + * Gets the admin DN (Distinguished Name) for binding as admin. + *

+ * Format: uid={adminUsername},ou=people,{baseDn} + * + * @return the admin DN + */ + public @NotNull String getAdminDn() { + return "uid=" + adminUsername + ",ou=people," + baseDn; + } + + /** + * Builder for LldapContainer. + *

+ * Provides a fluent API for configuring the container with custom settings. + * All settings are optional and have reasonable defaults. + */ + public static class Builder { + private String dockerImageName = DEFAULT_IMAGE_NAME; + private int ldapPort = DEFAULT_LDAP_PORT; + private int ldapsPort = DEFAULT_LDAPS_PORT; + private int httpPort = DEFAULT_HTTP_PORT; + private String baseDn = DEFAULT_BASE_DN; + private String adminUsername = DEFAULT_ADMIN_USERNAME; + private String adminPassword = DEFAULT_ADMIN_PASSWORD; + private File certFile; + private File keyFile; + private File trustStoreFile; + + private Builder() { + } + + /** + * Sets the Docker image name. + *

+ * Default: lldap/lldap:v0.5.0 + * + * @param dockerImageName the Docker image name + * @return this builder + */ + public @NotNull Builder dockerImageName(final @NotNull String dockerImageName) { + this.dockerImageName = dockerImageName; + return this; + } + + /** + * Sets the LDAP port inside the container. + *

+ * Default: 3890 + *

+ * Note: This is the internal port. Use {@link LldapContainer#getLdapPort()} to get the mapped external port. + * + * @param ldapPort the LDAP port + * @return this builder + */ + public @NotNull Builder ldapPort(final int ldapPort) { + this.ldapPort = ldapPort; + return this; + } + + /** + * Sets the LDAPS port inside the container. + *

+ * Default: 6360 + *

+ * Note: This is the internal port. Use {@link LldapContainer#getLdapsPort()} to get the mapped external port. + * This port is only exposed if LDAPS is enabled via {@link #withLdaps()}. + * + * @param ldapsPort the LDAPS port + * @return this builder + */ + public @NotNull Builder ldapsPort(final int ldapsPort) { + this.ldapsPort = ldapsPort; + return this; + } + + /** + * Sets the HTTP port inside the container. + *

+ * Default: 17170 + *

+ * Note: This is the internal port. Use {@link LldapContainer#getHttpPort()} to get the mapped external port. + * + * @param httpPort the HTTP port + * @return this builder + */ + public @NotNull Builder httpPort(final int httpPort) { + this.httpPort = httpPort; + return this; + } + + /** + * Sets the base DN for the LDAP directory. + *

+ * Default: dc=example,dc=com + * + * @param baseDn the base DN (e.g., "dc=example,dc=com") + * @return this builder + */ + public @NotNull Builder baseDn(final @NotNull String baseDn) { + this.baseDn = baseDn; + return this; + } + + /** + * Sets the admin username. + *

+ * Default: admin + * + * @param adminUsername the admin username + * @return this builder + */ + public @NotNull Builder adminUsername(final @NotNull String adminUsername) { + this.adminUsername = adminUsername; + return this; + } + + /** + * Sets the admin password. + *

+ * Default: admin_password + * + * @param adminPassword the admin password + * @return this builder + */ + public @NotNull Builder adminPassword(final @NotNull String adminPassword) { + this.adminPassword = adminPassword; + return this; + } + + /** + * Enables LDAPS (TLS from connection start) with custom certificates. + *

+ * When enabled, LLDAP will accept TLS connections using an internally generated certificate and key. + * + * @return this builder + */ + public @NotNull Builder withLdaps() { + try { + final var certFiles = generateSelfSignedCertificate(); + certFile = certFiles.certFile(); + keyFile = certFiles.keyFile(); + trustStoreFile = certFiles.trustStoreFile(); + } catch (Exception e) { + throw new RuntimeException(e); + } + return this; + } + + /** + * Builds the LldapContainer with the configured settings. + * + * @return a new LldapContainer instance + */ + public @NotNull LldapContainer build() { + return new LldapContainer(dockerImageName, ldapPort, ldapsPort, httpPort, baseDn, + adminUsername, adminPassword, certFile, keyFile, trustStoreFile); + } + } + + + + public record CertFiles(@com.hivemq.extension.sdk.api.annotations.NotNull File certFile, @com.hivemq.extension.sdk.api.annotations.NotNull File keyFile, @com.hivemq.extension.sdk.api.annotations.NotNull File trustStoreFile) {}; + /** + * Generates a self-signed certificate for TLS testing using BouncyCastle. + *

+ * This creates: + * - A private key + * - A self-signed X.509 certificate + * - PEM-formatted files for LLDAP server configuration + * - A TrustStore file containing the certificate for client-side certificate validation + *

+ * The SSLContext will be created dynamically from the truststore by {@link LdapConnectionProperties}. + */ + public static CertFiles generateSelfSignedCertificate() throws Exception { + Security.addProvider(new BouncyCastleProvider()); + + // Generate RSA key pair + final var keyPairGenerator = KeyPairGenerator.getInstance("RSA"); + keyPairGenerator.initialize(2048, new SecureRandom()); + final var keyPair = keyPairGenerator.generateKeyPair(); + + // Build X.509 certificate using BouncyCastle + final var now = Instant.now(); + final var notBefore = Date.from(now); + final var notAfter = Date.from(now.plus(365, ChronoUnit.DAYS)); + + final var subject = new X500Name("CN=localhost"); + final var serial = BigInteger.valueOf(System.currentTimeMillis()); + final var publicKeyInfo = SubjectPublicKeyInfo.getInstance(keyPair.getPublic().getEncoded()); + + final var certBuilder = new X509v3CertificateBuilder( + subject, // issuer + serial, + notBefore, + notAfter, + subject, // subject (same as issuer for self-signed) + publicKeyInfo + ); + + // Sign the certificate + final var signer = new JcaContentSignerBuilder("SHA256WithRSAEncryption") + .setProvider("BC") + .build(keyPair.getPrivate()); + + final var certHolder = certBuilder.build(signer); + final var cert = new JcaX509CertificateConverter() + .setProvider("BC") + .getCertificate(certHolder); + + // Write certificate to PEM file + final var certFile = Files.createTempFile("ldap-cert", ".pem").toFile(); + try (final var pemWriter = new PemWriter(new FileWriter(certFile))) { + pemWriter.writeObject(new PemObject("CERTIFICATE", cert.getEncoded())); + } + + // Write private key to PEM file + final var keyFile = Files.createTempFile("ldap-key", ".pem").toFile(); + try (final var pemWriter = new PemWriter(new FileWriter(keyFile))) { + pemWriter.writeObject(new PemObject("PRIVATE KEY", keyPair.getPrivate().getEncoded())); + } + + // Create a TrustStore and add our self-signed certificate to it + final var trustStore = KeyStore.getInstance(KeyStore.getDefaultType()); + trustStore.load(null, null); // Initialize empty truststore + trustStore.setCertificateEntry("ldap-server", cert); + + // Save the TrustStore to a file + // The SSLContext will be created dynamically from this truststore by LdapConnectionProperties + final var trustStoreFile = Files.createTempFile("ldap-truststore", ".jks").toFile(); + try (final var outputStream = new java.io.FileOutputStream(trustStoreFile)) { + trustStore.store(outputStream, KEYSTORE_PASSWORD.toCharArray()); + } + return new CertFiles(certFile, keyFile, trustStoreFile); + } +} diff --git a/hivemq-edge/src/test/java/com/hivemq/api/auth/provider/impl/ldap/testcontainer/OpenLdapContainer.java b/hivemq-edge/src/test/java/com/hivemq/api/auth/provider/impl/ldap/testcontainer/OpenLdapContainer.java new file mode 100644 index 0000000000..73b59c41d4 --- /dev/null +++ b/hivemq-edge/src/test/java/com/hivemq/api/auth/provider/impl/ldap/testcontainer/OpenLdapContainer.java @@ -0,0 +1,522 @@ +/* + * Copyright 2019-present HiveMQ GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.hivemq.api.auth.provider.impl.ldap.testcontainer; + +import org.jetbrains.annotations.NotNull; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.utility.DockerImageName; +import org.testcontainers.utility.MountableFile; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; + +/** + * Testcontainer for OpenLDAP server using osixia/openldap Docker image. + *

+ * OpenLDAP is a full-featured LDAP server implementation that supports: + *

    + *
  • All standard LDAP operations (bind, search, modify, add, delete)
  • + *
  • START_TLS (upgrade from plain to encrypted connection)
  • + *
  • LDAPS (TLS from connection start)
  • + *
  • LDIF file seeding for initial data
  • + *
  • Multiple backends (mdb, hdb, etc.)
  • + *
  • Replication and advanced features
  • + *
+ *

+ * Example usage with defaults: + *

{@code
+ * @Container
+ * static OpenLdapContainer ldap = new OpenLdapContainer();
+ *
+ * @Test
+ * void testLdapConnection() {
+ *     String host = ldap.getHost();
+ *     int port = ldap.getLdapPort();
+ *     String baseDn = ldap.getBaseDn(); // dc=example,dc=org
+ *     String adminDn = ldap.getAdminDn(); // cn=admin,dc=example,dc=org
+ *     String password = ldap.getAdminPassword(); // admin
+ * }
+ * }
+ *

+ * Example usage with builder and LDIF seeding: + *

{@code
+ * @Container
+ * static OpenLdapContainer ldap = OpenLdapContainer.builder()
+ *     .domain("mycompany.org")
+ *     .organisation("My Company Inc")
+ *     .adminPassword("secret123")
+ *     .withLdifFile("test-data.ldif")
+ *     .build();
+ * }
+ *

+ * Example with custom configuration: + *

{@code
+ * @Container
+ * static OpenLdapContainer ldap = OpenLdapContainer.builder()
+ *     .domain("example.com")
+ *     .adminPassword("admin")
+ *     .withTls(true)
+ *     .withBackend("mdb")
+ *     .withRfc2307bisSchema(false)
+ *     .withReadonlyUser(false)
+ *     .withReplication(false)
+ *     .build();
+ * }
+ * + * @see LldapContainer for a lightweight alternative (does not support START_TLS) + */ +public class OpenLdapContainer extends GenericContainer { + + private static final String DEFAULT_IMAGE_NAME = "osixia/openldap:1.5.0"; + private static final int DEFAULT_LDAP_PORT = 389; + private static final int DEFAULT_LDAPS_PORT = 636; + private static final String DEFAULT_DOMAIN = "example.org"; + private static final String DEFAULT_ORGANISATION = "Example Inc"; + private static final String DEFAULT_ADMIN_PASSWORD = "admin"; + private static final String DEFAULT_CONFIG_PASSWORD = "config"; + private static final String DEFAULT_BACKEND = "mdb"; + private static final String DEFAULT_SSL_HELPER_PREFIX = "ldap"; + + private final int ldapPort; + private final String domain; + private final String baseDn; + private final String adminPassword; + + /** + * Creates a new OpenLDAP container with default configuration. + *

+ * Default configuration: + *

    + *
  • Image: osixia/openldap:1.5.0
  • + *
  • LDAP Port: 389
  • + *
  • Domain: example.org
  • + *
  • Base DN: dc=example,dc=org
  • + *
  • Organisation: Example Inc
  • + *
  • Admin Password: admin
  • + *
  • Config Password: config
  • + *
  • Backend: mdb
  • + *
  • TLS: disabled
  • + *
  • Replication: disabled
  • + *
+ */ + public OpenLdapContainer() { + this(DEFAULT_IMAGE_NAME, DEFAULT_LDAP_PORT, DEFAULT_DOMAIN, DEFAULT_ORGANISATION, + DEFAULT_ADMIN_PASSWORD, DEFAULT_CONFIG_PASSWORD, false, DEFAULT_BACKEND, false, + false, false, true, DEFAULT_SSL_HELPER_PREFIX, new ArrayList<>()); + } + + private OpenLdapContainer( + final @NotNull String dockerImageName, + final int ldapPort, + final @NotNull String domain, + final @NotNull String organisation, + final @NotNull String adminPassword, + final @NotNull String configPassword, + final boolean readonlyUser, + final @NotNull String backend, + final boolean rfc2307bisSchema, + final boolean tls, + final boolean replication, + final boolean removeConfigAfterSetup, + final @NotNull String sslHelperPrefix, + final @NotNull List ldifFiles) { + super(DockerImageName.parse(dockerImageName)); + this.ldapPort = ldapPort; + this.domain = domain; + this.baseDn = domainToBaseDn(domain); + this.adminPassword = adminPassword; + + configure(organisation, configPassword, readonlyUser, backend, rfc2307bisSchema, + tls, replication, removeConfigAfterSetup, sslHelperPrefix, ldifFiles); + } + + private void configure( + final @NotNull String organisation, + final @NotNull String configPassword, + final boolean readonlyUser, + final @NotNull String backend, + final boolean rfc2307bisSchema, + final boolean tls, + final boolean replication, + final boolean removeConfigAfterSetup, + final @NotNull String sslHelperPrefix, + final @NotNull List ldifFiles) { + + // Expose LDAP port (and LDAPS port if TLS is enabled) + if (tls) { + withExposedPorts(ldapPort, DEFAULT_LDAPS_PORT); + withEnv("LDAP_TLS_VERIFY_CLIENT", "never"); // Don't require client certificates + } else { + withExposedPorts(ldapPort); + } + + withEnv("LDAP_ORGANISATION", organisation); + withEnv("LDAP_DOMAIN", domain); + withEnv("LDAP_ADMIN_PASSWORD", adminPassword); + withEnv("LDAP_CONFIG_PASSWORD", configPassword); + withEnv("LDAP_READONLY_USER", String.valueOf(readonlyUser)); + withEnv("LDAP_RFC2307BIS_SCHEMA", String.valueOf(rfc2307bisSchema)); + withEnv("LDAP_BACKEND", backend); + withEnv("LDAP_TLS", String.valueOf(tls)); + withEnv("LDAP_REPLICATION", String.valueOf(replication)); + withEnv("KEEP_EXISTING_CONFIG", "false"); + withEnv("LDAP_REMOVE_CONFIG_AFTER_SETUP", String.valueOf(removeConfigAfterSetup)); + withEnv("LDAP_SSL_HELPER_PREFIX", sslHelperPrefix); + + // Copy LDIF files to bootstrap directory - OpenLDAP will load them on startup + for (int i = 0; i < ldifFiles.size(); i++) { + final String ldifFile = ldifFiles.get(i); + final String targetPath = String.format( + "/container/service/slapd/assets/config/bootstrap/ldif/custom/%02d-custom.ldif", + i + 1); + withCopyFileToContainer(MountableFile.forClasspathResource(ldifFile), targetPath); + } + + // Wait for slapd to start + // Note: The container waits for "slapd starting" but TLS configuration might take additional time + waitingFor(Wait.forLogMessage(".*slapd starting.*", 1) + .withStartupTimeout(Duration.ofSeconds(tls ? 90 : 60))); + } + + /** + * Converts a domain name to an LDAP base DN. + *

+ * Example: "example.org" -> "dc=example,dc=org" + * + * @param domain the domain name + * @return the base DN + */ + private static @NotNull String domainToBaseDn(final @NotNull String domain) { + final String[] parts = domain.split("\\."); + final StringBuilder baseDn = new StringBuilder(); + for (int i = 0; i < parts.length; i++) { + if (i > 0) { + baseDn.append(","); + } + baseDn.append("dc=").append(parts[i]); + } + return baseDn.toString(); + } + + /** + * Creates a new builder for OpenLdapContainer. + * + * @return a new builder instance + */ + public static @NotNull Builder builder() { + return new Builder(); + } + + /** + * Gets the dynamically mapped LDAP port. + *

+ * Can only be called after the container has started. + * + * @return the mapped LDAP port + */ + public int getLdapPort() { + return getMappedPort(ldapPort); + } + + /** + * Gets the domain configured for this container. + * + * @return the domain (e.g., "example.org") + */ + public @NotNull String getDomain() { + return domain; + } + + /** + * Gets the base DN derived from the configured domain. + *

+ * The base DN is automatically derived from the domain. + * Example: "example.org" -> "dc=example,dc=org" + * + * @return the base DN + */ + public @NotNull String getBaseDn() { + return baseDn; + } + + /** + * Gets the admin password configured for this container. + * + * @return the admin password + */ + public @NotNull String getAdminPassword() { + return adminPassword; + } + + /** + * Gets the admin DN (Distinguished Name) for binding as admin. + *

+ * Format: cn=admin,{baseDn} + * + * @return the admin DN + */ + public @NotNull String getAdminDn() { + return "cn=admin," + baseDn; + } + + /** + * Builder for OpenLdapContainer. + *

+ * Provides a fluent API for configuring the container with custom settings. + * All settings are optional and have reasonable defaults. + */ + public static class Builder { + private String dockerImageName = DEFAULT_IMAGE_NAME; + private int ldapPort = DEFAULT_LDAP_PORT; + private String domain = DEFAULT_DOMAIN; + private String organisation = DEFAULT_ORGANISATION; + private String adminPassword = DEFAULT_ADMIN_PASSWORD; + private String configPassword = DEFAULT_CONFIG_PASSWORD; + private boolean readonlyUser = false; + private String backend = DEFAULT_BACKEND; + private boolean rfc2307bisSchema = false; + private boolean tls = false; + private boolean replication = false; + private boolean removeConfigAfterSetup = true; + private String sslHelperPrefix = DEFAULT_SSL_HELPER_PREFIX; + private final List ldifFiles = new ArrayList<>(); + + private Builder() { + } + + /** + * Sets the Docker image name. + *

+ * Default: osixia/openldap:1.5.0 + * + * @param dockerImageName the Docker image name + * @return this builder + */ + public @NotNull Builder dockerImageName(final @NotNull String dockerImageName) { + this.dockerImageName = dockerImageName; + return this; + } + + /** + * Sets the LDAP port inside the container. + *

+ * Default: 389 + *

+ * Note: This is the internal port. Use {@link OpenLdapContainer#getLdapPort()} to get the mapped external port. + * + * @param ldapPort the LDAP port + * @return this builder + */ + public @NotNull Builder ldapPort(final int ldapPort) { + this.ldapPort = ldapPort; + return this; + } + + /** + * Sets the domain for the LDAP directory. + *

+ * The base DN is automatically derived from this domain. + * Example: "example.org" -> base DN will be "dc=example,dc=org" + *

+ * Default: example.org + * + * @param domain the domain (e.g., "example.org") + * @return this builder + */ + public @NotNull Builder domain(final @NotNull String domain) { + this.domain = domain; + return this; + } + + /** + * Sets the organisation name. + *

+ * This is used for the organisation attribute in the directory. + *

+ * Default: Example Inc + * + * @param organisation the organisation name + * @return this builder + */ + public @NotNull Builder organisation(final @NotNull String organisation) { + this.organisation = organisation; + return this; + } + + /** + * Sets the admin password. + *

+ * Default: admin + * + * @param adminPassword the admin password + * @return this builder + */ + public @NotNull Builder adminPassword(final @NotNull String adminPassword) { + this.adminPassword = adminPassword; + return this; + } + + /** + * Sets the config password. + *

+ * This is the password for the config admin user (cn=admin,cn=config). + *

+ * Default: config + * + * @param configPassword the config password + * @return this builder + */ + public @NotNull Builder configPassword(final @NotNull String configPassword) { + this.configPassword = configPassword; + return this; + } + + /** + * Enables or disables the readonly user. + *

+ * When enabled, creates a readonly user account in the directory. + *

+ * Default: false + * + * @param readonlyUser true to enable readonly user, false otherwise + * @return this builder + */ + public @NotNull Builder withReadonlyUser(final boolean readonlyUser) { + this.readonlyUser = readonlyUser; + return this; + } + + /** + * Sets the database backend. + *

+ * Common values: "mdb" (default, recommended), "hdb" (deprecated), "bdb" (deprecated) + *

+ * Default: mdb + * + * @param backend the backend type + * @return this builder + */ + public @NotNull Builder withBackend(final @NotNull String backend) { + this.backend = backend; + return this; + } + + /** + * Enables or disables RFC 2307bis schema. + *

+ * RFC 2307bis extends the standard POSIX schema with group membership. + *

+ * Default: false + * + * @param rfc2307bisSchema true to enable RFC 2307bis schema, false otherwise + * @return this builder + */ + public @NotNull Builder withRfc2307bisSchema(final boolean rfc2307bisSchema) { + this.rfc2307bisSchema = rfc2307bisSchema; + return this; + } + + /** + * Enables or disables TLS. + *

+ * When enabled, the server will support LDAPS and START_TLS. + *

+ * Default: false + * + * @param tls true to enable TLS, false otherwise + * @return this builder + */ + public @NotNull Builder withTls(final boolean tls) { + this.tls = tls; + return this; + } + + /** + * Enables or disables replication. + *

+ * Default: false + * + * @param replication true to enable replication, false otherwise + * @return this builder + */ + public @NotNull Builder withReplication(final boolean replication) { + this.replication = replication; + return this; + } + + /** + * Enables or disables removal of config after setup. + *

+ * Default: true + * + * @param removeConfigAfterSetup true to remove config after setup, false otherwise + * @return this builder + */ + public @NotNull Builder withRemoveConfigAfterSetup(final boolean removeConfigAfterSetup) { + this.removeConfigAfterSetup = removeConfigAfterSetup; + return this; + } + + /** + * Sets the SSL helper prefix. + *

+ * Default: ldap + * + * @param sslHelperPrefix the SSL helper prefix + * @return this builder + */ + public @NotNull Builder withSslHelperPrefix(final @NotNull String sslHelperPrefix) { + this.sslHelperPrefix = sslHelperPrefix; + return this; + } + + /** + * Adds an LDIF file to be loaded on container startup. + *

+ * The LDIF file must be available as a classpath resource. + * Multiple LDIF files can be added and will be loaded in the order they are added. + *

+ * Example: + *

{@code
+         * OpenLdapContainer ldap = OpenLdapContainer.builder()
+         *     .withLdifFile("ldap/users.ldif")
+         *     .withLdifFile("ldap/groups.ldif")
+         *     .build();
+         * }
+ * + * @param classpathResourcePath the classpath path to the LDIF file (e.g., "ldap/test-data.ldif") + * @return this builder + */ + public @NotNull Builder withLdifFile(final @NotNull String classpathResourcePath) { + this.ldifFiles.add(classpathResourcePath); + return this; + } + + /** + * Builds the OpenLdapContainer with the configured settings. + * + * @return a new OpenLdapContainer instance + */ + public @NotNull OpenLdapContainer build() { + return new OpenLdapContainer(dockerImageName, ldapPort, domain, organisation, + adminPassword, configPassword, readonlyUser, backend, + rfc2307bisSchema, tls, replication, removeConfigAfterSetup, + sslHelperPrefix, ldifFiles); + } + } +} diff --git a/hivemq-edge/src/test/java/com/hivemq/api/auth/provider/impl/ldap/testcontainer/package-info.java b/hivemq-edge/src/test/java/com/hivemq/api/auth/provider/impl/ldap/testcontainer/package-info.java new file mode 100644 index 0000000000..4303d625a1 --- /dev/null +++ b/hivemq-edge/src/test/java/com/hivemq/api/auth/provider/impl/ldap/testcontainer/package-info.java @@ -0,0 +1,283 @@ +/* + * Copyright 2019-present HiveMQ GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Reusable testcontainer implementations for LDAP servers. + *

+ * This package provides ready-to-use Testcontainers wrappers for different LDAP server implementations, + * making it easy to write integration tests against real LDAP servers running in Docker containers. + * + *

Available Containers

+ *
    + *
  • {@link com.hivemq.api.auth.provider.impl.ldap.testcontainer.LldapContainer} - + * Lightweight LDAP server (LLDAP) container
  • + *
  • {@link com.hivemq.api.auth.provider.impl.ldap.testcontainer.OpenLdapContainer} - + * Full-featured OpenLDAP server container
  • + *
+ * + *

Quick Start

+ * + *

LLDAP Container (Lightweight, Simple Setup)

+ *
{@code
+ * @Testcontainers
+ * class MyLdapTest {
+ *     @Container
+ *     static LldapContainer ldap = new LldapContainer();  // Uses sensible defaults
+ *
+ *     @Test
+ *     void testAuthentication() {
+ *         String host = ldap.getHost();
+ *         int port = ldap.getLdapPort();
+ *         String baseDn = ldap.getBaseDn();        // dc=example,dc=com
+ *         String adminDn = ldap.getAdminDn();      // uid=admin,dc=example,dc=com
+ *         String adminPw = ldap.getAdminPassword(); // admin_password
+ *         // ... connect and test
+ *     }
+ * }
+ * }
+ * + *

OpenLDAP Container (Full-Featured, LDIF Seeding)

+ *
{@code
+ * @Testcontainers
+ * class MyOpenLdapTest {
+ *     @Container
+ *     static OpenLdapContainer ldap = OpenLdapContainer.builder()
+ *         .withLdifFile("ldap/users.ldif")    // Load test data from classpath
+ *         .withLdifFile("ldap/groups.ldif")   // Multiple files supported
+ *         .build();
+ *
+ *     @Test
+ *     void testWithPreloadedData() {
+ *         String host = ldap.getHost();
+ *         int port = ldap.getLdapPort();
+ *         String baseDn = ldap.getBaseDn();        // dc=example,dc=org (from domain)
+ *         String adminDn = ldap.getAdminDn();      // cn=admin,dc=example,dc=org
+ *         String adminPw = ldap.getAdminPassword(); // admin
+ *         // ... test with preloaded users and groups
+ *     }
+ * }
+ * }
+ * + *

Builder Pattern

+ * + * Both containers support fluent builder API for custom configuration: + * + *

LLDAP Builder Example

+ *
{@code
+ * LldapContainer ldap = LldapContainer.builder()
+ *     .baseDn("dc=mycompany,dc=org")
+ *     .adminUsername("admin")
+ *     .adminPassword("secret123")
+ *     .ldapPort(3890)
+ *     .withLdaps(certFile, keyFile)  // Enable LDAPS
+ *     .build();
+ * }
+ * + *

OpenLDAP Builder Example

+ *
{@code
+ * OpenLdapContainer ldap = OpenLdapContainer.builder()
+ *     .domain("mycompany.org")           // Base DN derived automatically
+ *     .organisation("My Company Inc")
+ *     .adminPassword("secret123")
+ *     .withTls(true)                     // Enable TLS/START_TLS
+ *     .withBackend("mdb")
+ *     .withRfc2307bisSchema(false)
+ *     .withLdifFile("ldap/test-data.ldif")
+ *     .build();
+ * }
+ * + *

Comparison: LLDAP vs OpenLDAP

+ * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + *
FeatureLLDAPOpenLDAP
Container SizeSmall (~50MB)Larger (~200MB)
Startup TimeFast (2-5s)Slower (5-10s)
Plain LDAP✓ Yes✓ Yes
LDAPS✓ Yes✓ Yes
START_TLS✗ No✓ Yes
LDIF SeedingManual (via code)✓ Automatic
Schema SupportBasicFull
Best ForSimple auth testsComplex LDAP scenarios
+ * + *

When to Use Which

+ * + *

Use LLDAP ({@link com.hivemq.api.auth.provider.impl.ldap.testcontainer.LldapContainer}) when:

+ *
    + *
  • Testing basic authentication flows
  • + *
  • Need fast test execution
  • + *
  • Testing plain LDAP or LDAPS
  • + *
  • Creating test users programmatically is acceptable
  • + *
  • Don't need advanced LDAP features
  • + *
+ * + *

Use OpenLDAP ({@link com.hivemq.api.auth.provider.impl.ldap.testcontainer.OpenLdapContainer}) when:

+ *
    + *
  • Testing START_TLS connections
  • + *
  • Need to seed complex directory structures from LDIF files
  • + *
  • Testing group membership and complex queries
  • + *
  • Need full LDAP schema support
  • + *
  • Testing against production-like LDAP server
  • + *
+ * + *

Common Patterns

+ * + *

Pattern 1: Default Configuration Test

+ *
{@code
+ * @Testcontainers
+ * class QuickTest {
+ *     @Container
+ *     static LldapContainer ldap = new LldapContainer();
+ *
+ *     @Test
+ *     void testConnection() {
+ *         // All defaults work out of the box
+ *         LdapConnectionProperties props = new LdapConnectionProperties(
+ *             ldap.getHost(),
+ *             ldap.getLdapPort(),
+ *             TlsMode.NONE,
+ *             null, null, null,
+ *             5000, 10000,
+ *             "uid={username},ou=people,{baseDn}",
+ *             ldap.getBaseDn()
+ *         );
+ *         LdapClient client = new LdapClient(props);
+ *         client.start();
+ *         // ... test
+ *     }
+ * }
+ * }
+ * + *

Pattern 2: LDIF Seeding with OpenLDAP

+ *
{@code
+ * @Testcontainers
+ * class DataTest {
+ *     @Container
+ *     static OpenLdapContainer ldap = OpenLdapContainer.builder()
+ *         .withLdifFile("ldap/users.ldif")
+ *         .withLdifFile("ldap/groups.ldif")
+ *         .build();
+ *
+ *     @Test
+ *     void testWithPreloadedUsers() {
+ *         // Users and groups from LDIF files are already loaded
+ *         try (LDAPConnection conn = createConnection()) {
+ *             conn.bind(ldap.getAdminDn(), ldap.getAdminPassword());
+ *             SearchResult result = conn.search("ou=people," + ldap.getBaseDn(), ...);
+ *             // ... assertions
+ *         }
+ *     }
+ * }
+ * }
+ * + *

Pattern 3: Custom Domain and Credentials

+ *
{@code
+ * @Testcontainers
+ * class CustomConfigTest {
+ *     @Container
+ *     static OpenLdapContainer ldap = OpenLdapContainer.builder()
+ *         .domain("mycompany.internal")     // Base DN: dc=mycompany,dc=internal
+ *         .organisation("MyCompany")
+ *         .adminPassword("MySecretPassword")
+ *         .build();
+ *
+ *     @Test
+ *     void testCustomConfig() {
+ *         assertEquals("dc=mycompany,dc=internal", ldap.getBaseDn());
+ *         assertEquals("cn=admin,dc=mycompany,dc=internal", ldap.getAdminDn());
+ *         assertEquals("MySecretPassword", ldap.getAdminPassword());
+ *     }
+ * }
+ * }
+ * + *

Pattern 4: TLS/LDAPS Testing

+ *
{@code
+ * @Testcontainers
+ * class TlsTest {
+ *     static File certFile;
+ *     static File keyFile;
+ *
+ *     static {
+ *         // Generate self-signed certificate
+ *         // ... (see LdapTlsModesIntegrationTest for example)
+ *     }
+ *
+ *     @Container
+ *     static LldapContainer ldap = LldapContainer.builder()
+ *         .withLdaps(certFile, keyFile)
+ *         .build();
+ *
+ *     @Test
+ *     void testLdaps() {
+ *         // Connect with LDAPS
+ *         LdapConnectionProperties props = new LdapConnectionProperties(
+ *             ldap.getHost(),
+ *             ldap.getLdapPort(),
+ *             TlsMode.LDAPS,
+ *             trustStorePath,
+ *             trustStorePassword,
+ *             KeyStore.getDefaultType(),
+ *             5000, 10000,
+ *             dnTemplate,
+ *             ldap.getBaseDn()
+ *         );
+ *         // ... test encrypted connection
+ *     }
+ * }
+ * }
+ * + * @see com.hivemq.api.auth.provider.impl.ldap.testcontainer.LldapContainer + * @see com.hivemq.api.auth.provider.impl.ldap.testcontainer.OpenLdapContainer + * @see com.hivemq.api.auth.provider.impl.ldap.OpenLdapTest + * @see com.hivemq.api.auth.provider.impl.ldap.LdapTlsModesIntegrationTest + */ +package com.hivemq.api.auth.provider.impl.ldap.testcontainer; diff --git a/hivemq-edge/src/test/java/com/hivemq/configuration/writer/ConfigFileWriterTest.java b/hivemq-edge/src/test/java/com/hivemq/configuration/writer/ConfigFileWriterTest.java index bf1e059364..a4e036f86a 100644 --- a/hivemq-edge/src/test/java/com/hivemq/configuration/writer/ConfigFileWriterTest.java +++ b/hivemq-edge/src/test/java/com/hivemq/configuration/writer/ConfigFileWriterTest.java @@ -53,6 +53,8 @@ public void rewriteUnchangedConfigurationYieldsSameXML() throws IOException, SAX configFileReader.writeConfigToXML(new ConfigurationFile(tempCopyFile).file().get(), false, false); final String copiedFileContent = FileUtils.readFileToString(tempCopyFile, UTF_8); + + final Diff diff = XMLUnit.compareXML(originalXml, copiedFileContent); if (!diff.identical()) { System.err.println("xml diff found " + diff); diff --git a/hivemq-edge/src/test/resources/ldap/test-data.ldif b/hivemq-edge/src/test/resources/ldap/test-data.ldif new file mode 100644 index 0000000000..b85767d096 --- /dev/null +++ b/hivemq-edge/src/test/resources/ldap/test-data.ldif @@ -0,0 +1,95 @@ +# Test LDIF file for OpenLDAP container seeding +# This file demonstrates how to populate an LDAP directory with test data +# +# Structure: +# dc=example,dc=org +# ├── ou=people (organizational unit for users) +# │ ├── uid=alice (user) +# │ ├── uid=bob (user) +# │ └── uid=charlie (user) +# └── ou=groups (organizational unit for groups) +# ├── cn=developers (group) +# └── cn=administrators (group) + +# Create organizational unit for people +dn: ou=people,dc=example,dc=org +objectClass: organizationalUnit +objectClass: top +ou: people +description: Container for user accounts + +# Create organizational unit for groups +dn: ou=groups,dc=example,dc=org +objectClass: organizationalUnit +objectClass: top +ou: groups +description: Container for group definitions + +# User: Alice (Software Engineer) +dn: uid=alice,ou=people,dc=example,dc=org +objectClass: inetOrgPerson +objectClass: posixAccount +objectClass: top +uid: alice +cn: Alice Anderson +sn: Anderson +givenName: Alice +mail: alice@example.org +userPassword: alice123 +uidNumber: 10001 +gidNumber: 10001 +homeDirectory: /home/alice +loginShell: /bin/bash +description: Software Engineer + +# User: Bob (DevOps Engineer) +dn: uid=bob,ou=people,dc=example,dc=org +objectClass: inetOrgPerson +objectClass: posixAccount +objectClass: top +uid: bob +cn: Bob Builder +sn: Builder +givenName: Bob +mail: bob@example.org +userPassword: bob456 +uidNumber: 10002 +gidNumber: 10002 +homeDirectory: /home/bob +loginShell: /bin/bash +description: DevOps Engineer + +# User: Charlie (System Administrator) +dn: uid=charlie,ou=people,dc=example,dc=org +objectClass: inetOrgPerson +objectClass: posixAccount +objectClass: top +uid: charlie +cn: Charlie Chen +sn: Chen +givenName: Charlie +mail: charlie@example.org +userPassword: charlie789 +uidNumber: 10003 +gidNumber: 10003 +homeDirectory: /home/charlie +loginShell: /bin/bash +description: System Administrator + +# Group: Developers +dn: cn=developers,ou=groups,dc=example,dc=org +objectClass: groupOfNames +objectClass: top +cn: developers +description: Software Development Team +member: uid=alice,ou=people,dc=example,dc=org +member: uid=bob,ou=people,dc=example,dc=org + +# Group: Administrators +dn: cn=administrators,ou=groups,dc=example,dc=org +objectClass: groupOfNames +objectClass: top +cn: administrators +description: System Administrators +member: uid=charlie,ou=people,dc=example,dc=org +member: uid=bob,ou=people,dc=example,dc=org