Skip to content

Commit f022df4

Browse files
authored
Add LDAP as an option for authenticating a User. (#1225)
* Add LDAP as an option for authenticating a User.
1 parent ac366cf commit f022df4

File tree

54 files changed

+6936
-167
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

54 files changed

+6936
-167
lines changed

.claude/settings.local.json

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"permissions": {
3+
"allow": [
4+
"Bash(./gradlew :hivemq-edge:compileJava:*)",
5+
"Bash(./gradlew:*)"
6+
],
7+
"deny": [],
8+
"ask": []
9+
}
10+
}

gradle/libs.versions.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,8 @@ spotBugs = "4.9.4"
5555
swagger-annotations = "2.2.39"
5656
swagger-jaxrs = "1.6.16"
5757
systemstubs = "2.1.8"
58+
testcontainers = "1.21.0"
59+
unboundid-ldap-sdk = "7.0.3"
5860
victools = "4.38.0"
5961
wiremock = "3.0.1"
6062
zeroallocationhashing = "0.27ea0"
@@ -154,6 +156,9 @@ slf4j-jultoslf4j = { module = "org.slf4j:jul-to-slf4j", version.ref = "slf4j" }
154156
swagger-annotations = { module = "io.swagger.core.v3:swagger-annotations", version.ref = "swagger-annotations" }
155157
swagger-jaxrs = { module = "io.swagger:swagger-jaxrs", version.ref = "swagger-jaxrs" }
156158
systemstubs = { module = "uk.org.webcompere:system-stubs-jupiter", version.ref = "systemstubs" }
159+
testcontainers = { module = "org.testcontainers:testcontainers", version.ref = "testcontainers" }
160+
testcontainers-junit-jupiter = { module = "org.testcontainers:junit-jupiter", version.ref = "testcontainers" }
161+
unboundid-ldap-sdk = { module = "com.unboundid:unboundid-ldapsdk", version.ref = "unboundid-ldap-sdk" }
157162
victools-jsonschema-generator = { module = "com.github.victools:jsonschema-generator", version.ref = "victools" }
158163
victools-jsonschema-jackson = { module = "com.github.victools:jsonschema-module-jackson", version.ref = "victools" }
159164
wiremock-jre8-standalone = { module = "com.github.tomakehurst:wiremock-jre8-standalone", version.ref = "wiremock" }

hivemq-edge/build.gradle.kts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,9 @@ dependencies {
188188
//JWT
189189
implementation(libs.jose4j)
190190

191+
//LDAP
192+
implementation(libs.unboundid.ldap.sdk)
193+
191194
//json schema
192195
implementation(libs.json.schema.validator)
193196
implementation(libs.victools.jsonschema.generator)
@@ -247,6 +250,8 @@ dependencies {
247250
testImplementation(libs.awaitility)
248251
testImplementation(libs.assertj)
249252
testImplementation(libs.systemstubs)
253+
testImplementation(libs.testcontainers)
254+
testImplementation(libs.testcontainers.junit.jupiter)
250255
}
251256

252257
tasks.test {
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
# OpenLDAP Test Data for Directory Descent Testing
2+
# This LDIF file creates users in different organizational units to test Directory Descent
3+
# (search-based DN resolution) vs Direct Reference (template-based DN resolution)
4+
5+
# Organizational units
6+
dn: ou=people,dc=example,dc=org
7+
objectClass: organizationalUnit
8+
ou: people
9+
description: Container for user accounts
10+
11+
dn: ou=groups,dc=example,dc=org
12+
objectClass: organizationalUnit
13+
ou: groups
14+
description: Container for group definitions
15+
16+
# Engineering department (nested OU)
17+
dn: ou=engineering,ou=people,dc=example,dc=org
18+
objectClass: organizationalUnit
19+
ou: engineering
20+
description: Engineering department
21+
22+
# Sales department (nested OU)
23+
dn: ou=sales,ou=people,dc=example,dc=org
24+
objectClass: organizationalUnit
25+
ou: sales
26+
description: Sales department
27+
28+
# Users in engineering department (for Directory Descent testing)
29+
dn: uid=alice,ou=engineering,ou=people,dc=example,dc=org
30+
objectClass: inetOrgPerson
31+
objectClass: posixAccount
32+
objectClass: top
33+
uid: alice
34+
cn: Alice Anderson
35+
sn: Anderson
36+
givenName: Alice
37+
38+
userPassword: alice123
39+
uidNumber: 10001
40+
gidNumber: 10001
41+
homeDirectory: /home/alice
42+
description: Software Engineer
43+
44+
dn: uid=bob,ou=engineering,ou=people,dc=example,dc=org
45+
objectClass: inetOrgPerson
46+
objectClass: posixAccount
47+
objectClass: top
48+
uid: bob
49+
cn: Bob Brown
50+
sn: Brown
51+
givenName: Bob
52+
53+
userPassword: bob456
54+
uidNumber: 10002
55+
gidNumber: 10001
56+
homeDirectory: /home/bob
57+
description: DevOps Engineer
58+
59+
# User in sales department (different OU for testing)
60+
dn: uid=charlie,ou=sales,ou=people,dc=example,dc=org
61+
objectClass: inetOrgPerson
62+
objectClass: posixAccount
63+
objectClass: top
64+
uid: charlie
65+
cn: Charlie Chen
66+
sn: Chen
67+
givenName: Charlie
68+
69+
userPassword: charlie789
70+
uidNumber: 10003
71+
gidNumber: 10002
72+
homeDirectory: /home/charlie
73+
description: Sales Manager
74+
75+
# Groups
76+
dn: cn=developers,ou=groups,dc=example,dc=org
77+
objectClass: groupOfNames
78+
cn: developers
79+
description: Software Development Team
80+
member: uid=alice,ou=engineering,ou=people,dc=example,dc=org
81+
member: uid=bob,ou=engineering,ou=people,dc=example,dc=org
82+
83+
dn: cn=administrators,ou=groups,dc=example,dc=org
84+
objectClass: groupOfNames
85+
cn: administrators
86+
description: System Administrators
87+
member: uid=bob,ou=engineering,ou=people,dc=example,dc=org
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
<?xml version="1.0"?>
2+
<!--
3+
~ Copyright 2025-present HiveMQ GmbH
4+
~
5+
~ Licensed under the Apache License, Version 2.0 (the "License");
6+
~ you may not use this file except in compliance with the License.
7+
~ You may obtain a copy of the License at
8+
~
9+
~ http://www.apache.org/licenses/LICENSE-2.0
10+
~
11+
~ Unless required by applicable law or agreed to in writing, software
12+
~ distributed under the License is distributed on an "AS IS" BASIS,
13+
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
~ See the License for the specific language governing permissions and
15+
~ limitations under the License.
16+
-->
17+
18+
<hivemq xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
19+
xsi:noNamespaceSchemaLocation="config.xsd">
20+
21+
<!--
22+
Example: Admin API with LDAP Authentication
23+
24+
This configuration enables LDAP-based authentication for the Admin API.
25+
Instead of managing users in the config file, users are authenticated
26+
against an LDAP directory server.
27+
28+
Supported TLS modes:
29+
- NONE: Plain LDAP (port 389, not recommended for production)
30+
- LDAPS: LDAP over TLS from connection start (port 636, recommended)
31+
- START_TLS: Plain connection upgraded to TLS (port 389)
32+
-->
33+
34+
<admin-api>
35+
<enabled>true</enabled>
36+
37+
<!-- LDAP Authentication Configuration -->
38+
<ldap>
39+
<ldap-authentication>
40+
<!-- LDAP Server Configuration -->
41+
<host>ldap.example.com</host>
42+
<port>636</port> <!-- 636 for LDAPS, 389 for plain LDAP or START_TLS -->
43+
<tls-mode>LDAPS</tls-mode> <!-- Options: NONE, LDAPS, START_TLS -->
44+
45+
<!-- TLS/SSL Configuration (required for LDAPS and START_TLS) -->
46+
<tls>
47+
<!-- Path to truststore containing CA certificates -->
48+
<truststore-path>/path/to/truststore.jks</truststore-path>
49+
<truststore-password>changeit</truststore-password>
50+
<truststore-type>JKS</truststore-type> <!-- JKS or PKCS12 -->
51+
</tls>
52+
53+
<!-- Optional: Connection Timeouts (in milliseconds) -->
54+
<connect-timeout-millis>5000</connect-timeout-millis> <!-- 5 seconds -->
55+
<response-timeout-millis>10000</response-timeout-millis> <!-- 10 seconds -->
56+
57+
<!--
58+
User DN Template
59+
Defines how usernames are mapped to LDAP Distinguished Names.
60+
Placeholders:
61+
- {username}: The username entered in the login form
62+
- {baseDn}: The base DN specified below
63+
64+
Examples:
65+
- OpenLDAP: uid={username},ou=people,{baseDn}
66+
- Active Directory: cn={username},cn=Users,{baseDn}
67+
- Email-based: mail={username},ou=staff,{baseDn}
68+
-->
69+
<user-dn-template>uid={username},ou=people,{baseDn}</user-dn-template>
70+
71+
<!-- Base DN: The root of your LDAP directory tree -->
72+
<base-dn>dc=example,dc=com</base-dn>
73+
</ldap-authentication>
74+
</ldap>
75+
</admin-api>
76+
77+
</hivemq>
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
<?xml version="1.0"?>
2+
<!--
3+
~ Copyright 2025-present HiveMQ GmbH
4+
~
5+
~ Licensed under the Apache License, Version 2.0 (the "License");
6+
~ you may not use this file except in compliance with the License.
7+
~ You may obtain a copy of the License at
8+
~
9+
~ http://www.apache.org/licenses/LICENSE-2.0
10+
~
11+
~ Unless required by applicable law or agreed to in writing, software
12+
~ distributed under the License is distributed on an "AS IS" BASIS,
13+
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
~ See the License for the specific language governing permissions and
15+
~ limitations under the License.
16+
-->
17+
18+
<hivemq xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
19+
xsi:noNamespaceSchemaLocation="config.xsd">
20+
21+
<!--
22+
Example: Admin API with LDAP Authentication using System CA Certificates
23+
24+
This configuration uses the system's default CA certificates for TLS validation.
25+
This is suitable when your LDAP server uses certificates signed by well-known
26+
certificate authorities (e.g., Let's Encrypt, DigiCert, etc.).
27+
28+
No custom truststore configuration is needed.
29+
-->
30+
31+
<admin-api>
32+
<enabled>true</enabled>
33+
34+
<ldap>
35+
<ldap-authentication>
36+
<!-- LDAP Server Configuration -->
37+
<host>ldap.example.com</host>
38+
<port>636</port>
39+
<tls-mode>LDAPS</tls-mode>
40+
41+
<!-- No <tls> section needed - system CA certificates will be used -->
42+
43+
<!-- User DN Template for Active Directory -->
44+
<user-dn-template>cn={username},cn=Users,{baseDn}</user-dn-template>
45+
46+
<!-- Base DN -->
47+
<base-dn>dc=company,dc=local</base-dn>
48+
</ldap-authentication>
49+
</ldap>
50+
</admin-api>
51+
52+
</hivemq>

hivemq-edge/src/distribution/conf/logback.xml

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,23 @@
6363
</triggeringPolicy>
6464
</appender>
6565

66+
<appender name="SECURITY-FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
67+
<file>${hivemq.log.folder}/security.log</file>
68+
<append>true</append>
69+
<encoder>
70+
<pattern>%-24(%d)- %msg%n%ex</pattern>
71+
</encoder>
72+
<rollingPolicy class="ch.qos.logback.core.rolling.FixedWindowRollingPolicy">
73+
<fileNamePattern>${hivemq.log.folder}/event-%i.log.gz</fileNamePattern>
74+
<minIndex>1</minIndex>
75+
<maxIndex>5</maxIndex>
76+
</rollingPolicy>
77+
<triggeringPolicy class="ch.qos.logback.core.rolling.SizeBasedTriggeringPolicy">
78+
<maxFileSize>100MB</maxFileSize>
79+
<checkIncrement>10000</checkIncrement>
80+
</triggeringPolicy>
81+
</appender>
82+
6683
<!-- appender for the script events of HiveMQ Data Hub -->
6784
<appender name="SCRIPT-FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
6885
<file>${hivemq.log.folder}/script.log</file>
@@ -97,6 +114,14 @@
97114
<appender-ref ref="EVENT-FILE"/>
98115
</logger>
99116

117+
<logger name="security.authentication-failed" level="DEBUG" additivity="false">
118+
<appender-ref ref="SECURITY-FILE"/>
119+
</logger>
120+
121+
<logger name="security.authentication-succeeded" level="DEBUG" additivity="false">
122+
<appender-ref ref="SECURITY-FILE"/>
123+
</logger>
124+
100125
<logger name="migrations" level="DEBUG" additivity="false">
101126
<appender-ref ref="MIGRATIONS-FILE"/>
102127
</logger>

hivemq-edge/src/main/java/com/hivemq/api/auth/handler/impl/BasicAuthenticationHandler.java

Lines changed: 22 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -16,18 +16,18 @@
1616
package com.hivemq.api.auth.handler.impl;
1717

1818
import com.google.common.base.Preconditions;
19-
import com.hivemq.api.auth.ApiPrincipal;
2019
import com.hivemq.api.auth.handler.AuthenticationResult;
21-
import com.hivemq.api.auth.provider.IUsernamePasswordProvider;
22-
import org.jetbrains.annotations.NotNull;
20+
import com.hivemq.api.auth.provider.IUsernameRolesProvider;
2321
import com.hivemq.http.HttpConstants;
2422
import com.hivemq.http.core.UsernamePasswordRoles;
25-
2623
import jakarta.inject.Inject;
2724
import jakarta.inject.Singleton;
2825
import jakarta.ws.rs.container.ContainerRequestContext;
2926
import jakarta.ws.rs.core.Response;
3027
import jakarta.ws.rs.core.SecurityContext;
28+
import org.jetbrains.annotations.NotNull;
29+
30+
import java.nio.charset.StandardCharsets;
3131
import java.util.Base64;
3232
import java.util.Optional;
3333

@@ -39,27 +39,26 @@ public class BasicAuthenticationHandler extends AbstractHeaderAuthenticationHand
3939

4040
static final String SEP = ":";
4141
static final String METHOD = "Basic";
42-
private final IUsernamePasswordProvider provider;
42+
private final IUsernameRolesProvider provider;
4343

4444
@Inject
45-
public BasicAuthenticationHandler(final @NotNull IUsernamePasswordProvider provider) {
45+
public BasicAuthenticationHandler(final @NotNull IUsernameRolesProvider provider) {
4646
this.provider = provider;
4747
}
4848

4949
@Override
50-
protected AuthenticationResult authenticateInternal(final @NotNull ContainerRequestContext requestContext, String authValue) {
51-
Optional<UsernamePasswordRoles> usernamePassword = parseValue(authValue);
52-
if(usernamePassword.isPresent()){
53-
UsernamePasswordRoles supplied = usernamePassword.get();
54-
Optional<UsernamePasswordRoles> record = provider.findByUsername(supplied.getUserName());
55-
if(record.isPresent() && record.get().getPassword().equals(supplied.getPassword())){
56-
AuthenticationResult result = AuthenticationResult.allowed(this);
57-
ApiPrincipal principal = new ApiPrincipal(supplied.getUserName(), record.get().getRoles());
58-
result.setPrincipal(principal);
59-
return result;
60-
}
61-
}
62-
return AuthenticationResult.denied(this);
50+
protected AuthenticationResult authenticateInternal(final @NotNull ContainerRequestContext requestContext,
51+
final @NotNull String authValue) {
52+
return parseValue(authValue)
53+
.flatMap(supplied ->
54+
provider.findByUsernameAndPassword(
55+
supplied.getUserName(),
56+
supplied.getPassword()))
57+
.map(record -> {
58+
final var result = AuthenticationResult.allowed(this);
59+
result.setPrincipal(record.toPrincipal());
60+
return result;
61+
}).orElseGet(() -> AuthenticationResult.denied(this));
6362
}
6463

6564
@Override
@@ -72,14 +71,13 @@ public void decorateResponse(final AuthenticationResult result, final Response.R
7271

7372
protected static Optional<UsernamePasswordRoles> parseValue(final @NotNull String headerValue){
7473
Preconditions.checkNotNull(headerValue);
75-
String userPass = headerValue.trim();
76-
userPass = new String(Base64.getDecoder().decode(userPass));
74+
final var userPass = new String(Base64.getDecoder().decode(headerValue.trim()));
7775
if(userPass.contains(SEP)){
78-
String[] userNamePassword = userPass.split(SEP);
76+
final var userNamePassword = userPass.split(SEP);
7977
if(userNamePassword.length == 2){
80-
UsernamePasswordRoles usernamePassword = new UsernamePasswordRoles();
78+
final var usernamePassword = new UsernamePasswordRoles();
8179
usernamePassword.setUserName(userNamePassword[0]);
82-
usernamePassword.setPassword(userNamePassword[1]);
80+
usernamePassword.setPassword(userNamePassword[1].getBytes(StandardCharsets.UTF_8));
8381
return Optional.of(usernamePassword);
8482
}
8583
}

0 commit comments

Comments
 (0)