diff --git a/.github/workflows/build/conf/topologies/knoxldap.xml b/.github/workflows/build/conf/topologies/knoxldap.xml index 102797634f..dc1b32237a 100644 --- a/.github/workflows/build/conf/topologies/knoxldap.xml +++ b/.github/workflows/build/conf/topologies/knoxldap.xml @@ -35,7 +35,7 @@ limitations under the License. main.ldapRealm.contextFactory.url - ldap://ldap:33389 + ldap://localhost:33390 main.ldapRealm.contextFactory.authenticationMechanism diff --git a/.github/workflows/build/gateway-site.xml b/.github/workflows/build/gateway-site.xml index 5f333f0639..74a13a4a04 100644 --- a/.github/workflows/build/gateway-site.xml +++ b/.github/workflows/build/gateway-site.xml @@ -160,4 +160,52 @@ limitations under the License. max-age=300; includeSubDomains + + + gateway.ldap.enabled + true + + + gateway.ldap.port + 33390 + + + gateway.ldap.base.dn + dc=hadoop,dc=apache,dc=org + + + gateway.ldap.backend.type + ldap + + + + + gateway.ldap.backend.ldap.url + ldap://ldap:33389 + + + gateway.ldap.backend.ldap.remoteBaseDn + dc=hadoop,dc=apache,dc=org + + + gateway.ldap.backend.ldap.systemUsername + uid=guest,ou=people,dc=hadoop,dc=apache,dc=org + + + gateway.ldap.backend.ldap.systemPassword + guest-password + + + gateway.ldap.backend.ldap.userSearchBase + ou=people,dc=hadoop,dc=apache,dc=org + + + gateway.ldap.backend.ldap.groupSearchBase + ou=groups,dc=hadoop,dc=apache,dc=org + + + gateway.ldap.backend.ldap.groupMemberAttribute + member + + diff --git a/.github/workflows/tests/test_knox_auth_service_and_LDAP.py b/.github/workflows/tests/test_knox_auth_service_and_LDAP.py index 51a42d84e4..43fb886abb 100644 --- a/.github/workflows/tests/test_knox_auth_service_and_LDAP.py +++ b/.github/workflows/tests/test_knox_auth_service_and_LDAP.py @@ -31,7 +31,7 @@ class TestKnoxAuthService(unittest.TestCase): def setUp(self): self.base_url = gateway_base_url() # The topology name is based on the filename knoxldap.xml - self.topology_url = self.base_url + "gateway/knoxldap/auth/api/v1/extauthz" + self.topology_url = self.base_url + "gateway/knoxldap/auth/api/v1/pre" def test_auth_service_guest(self): """ @@ -47,7 +47,7 @@ def test_auth_service_guest(self): self.assertEqual(response.status_code, 200) # Check for Actor ID header - # The config in knoxtoken.xml sets 'preauth.auth.header.actor.id.name' to 'x-knox-actor-username' + # The config in knoxldap.xml sets 'preauth.auth.header.actor.id.name' to 'x-knox-actor-username' actor_id_header = 'x-knox-actor-username' self.assertIn(actor_id_header, response.headers) self.assertEqual(response.headers[actor_id_header], 'guest') diff --git a/gateway-server/pom.xml b/gateway-server/pom.xml index ab7ae5bb10..41341bbfe1 100644 --- a/gateway-server/pom.xml +++ b/gateway-server/pom.xml @@ -519,6 +519,12 @@ api-ldap-client-api provided + + + org.apache.mina + mina-core + + @@ -612,10 +618,5 @@ test - - org.apache.mina - mina-core - test - diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/GroupLookupInterceptor.java b/gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/GroupLookupInterceptor.java index 12a1cd4280..1218c8c78b 100644 --- a/gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/GroupLookupInterceptor.java +++ b/gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/GroupLookupInterceptor.java @@ -17,15 +17,19 @@ */ package org.apache.knox.gateway.services.ldap; +import org.apache.directory.api.ldap.model.constants.AuthenticationLevel; import org.apache.directory.api.ldap.model.cursor.ListCursor; import org.apache.directory.api.ldap.model.entry.Entry; import org.apache.directory.api.ldap.model.exception.LdapException; import org.apache.directory.api.ldap.model.schema.SchemaManager; +import org.apache.directory.server.core.api.CoreSession; import org.apache.directory.server.core.api.DirectoryService; +import org.apache.directory.server.core.api.LdapPrincipal; import org.apache.directory.server.core.api.filtering.EntryFilteringCursor; import org.apache.directory.server.core.api.filtering.EntryFilteringCursorImpl; import org.apache.directory.server.core.api.interceptor.BaseInterceptor; import org.apache.directory.server.core.api.interceptor.context.BindOperationContext; +import org.apache.directory.server.core.api.interceptor.context.LookupOperationContext; import org.apache.directory.server.core.api.interceptor.context.SearchOperationContext; import org.apache.knox.gateway.i18n.messages.MessagesFactory; import org.apache.knox.gateway.services.ldap.backend.LdapBackend; @@ -51,6 +55,22 @@ public GroupLookupInterceptor(DirectoryService directoryService, LdapBackend bac this.backend = backend; } + @Override + public Entry lookup(LookupOperationContext ctx) throws LdapException { + Entry entry = next(ctx); + if (entry == null) { + String username = LdapUtils.extractUsernameFromDn(ctx.getDn()); + if (username != null) { + try { + entry = backend.getUser(username, directoryService.getSchemaManager()); + } catch (Exception e) { + LOG.ldapServiceStopFailed(e); + } + } + } + return entry; + } + @Override public EntryFilteringCursor search(SearchOperationContext ctx) throws LdapException { String filter = ctx.getFilter() != null ? ctx.getFilter().toString() : ""; @@ -120,9 +140,25 @@ public EntryFilteringCursor search(SearchOperationContext ctx) throws LdapExcept } @Override - public void bind(BindOperationContext ctx) { - // Allow anonymous bind or simple bind + public void bind(BindOperationContext ctx) throws LdapException { LOG.ldapBind(ctx.getDn() != null ? ctx.getDn().toString() : "anonymous"); + + // Try backend first for non-system users + if (ctx.getDn() != null && !ctx.getDn().toString().endsWith("ou=system")) { + byte[] credentials = ctx.getCredentials(); + if (credentials != null) { + String password = new String(credentials, java.nio.charset.StandardCharsets.UTF_8); + if (backend.authenticate(ctx.getDn(), password)) { + // Create session for the authenticated user and set it in context + // This is required by LdapServer to avoid NullPointerException + LdapPrincipal principal = new LdapPrincipal(directoryService.getSchemaManager(), ctx.getDn(), AuthenticationLevel.SIMPLE); + CoreSession session = directoryService.getSession(principal); + ctx.setSession(session); + return; // Successfully authenticated via backend, bypass local check + } + } + } + try { next(ctx); } catch (Exception e) { diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/KnoxLDAPServerManager.java b/gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/KnoxLDAPServerManager.java index f0a87dc5b8..300e6f936f 100644 --- a/gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/KnoxLDAPServerManager.java +++ b/gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/KnoxLDAPServerManager.java @@ -24,6 +24,7 @@ import org.apache.directory.server.core.DefaultDirectoryService; import org.apache.directory.server.core.api.DirectoryService; import org.apache.directory.server.core.api.InstanceLayout; +import org.apache.directory.server.core.api.interceptor.Interceptor; import org.apache.directory.server.core.api.schema.SchemaPartition; import org.apache.directory.server.core.partition.impl.btree.jdbm.JdbmPartition; import org.apache.directory.server.core.partition.ldif.LdifPartition; @@ -34,6 +35,8 @@ import org.apache.knox.gateway.services.ldap.backend.LdapBackend; import java.io.File; +import java.util.ArrayList; +import java.util.List; import java.util.Map; /** @@ -125,8 +128,7 @@ public void start() throws Exception { directoryService.addPartition(remotePartition); } - // Add our interceptor for group lookups - directoryService.addLast(new GroupLookupInterceptor(directoryService, backend)); + addGroupLookupInterceptor(); // Allow anonymous access directoryService.setAllowAnonymousAccess(true); @@ -147,6 +149,27 @@ public void start() throws Exception { LOG.ldapServiceStarted(port); } + private void addGroupLookupInterceptor() { + // Add our interceptor for group lookups and bind proxying + // We need to insert it before AuthenticationInterceptor to intercept bind requests + final List interceptors = new ArrayList<>(directoryService.getInterceptors()); + int authIdx = -1; + for (int i = 0; i < interceptors.size(); i++) { + if (interceptors.get(i).getName().equalsIgnoreCase("authenticationInterceptor")) { + authIdx = i; + break; + } + } + + final GroupLookupInterceptor interceptor = new GroupLookupInterceptor(directoryService, backend); + if (authIdx != -1) { + interceptors.add(authIdx, interceptor); + } else { + interceptors.add(interceptor); + } + directoryService.setInterceptors(interceptors); + } + /** * Stop the LDAP server */ diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/LdapMessages.java b/gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/LdapMessages.java index a0b09c2449..4845cda79e 100644 --- a/gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/LdapMessages.java +++ b/gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/LdapMessages.java @@ -96,4 +96,10 @@ public interface LdapMessages { @Message(level = MessageLevel.ERROR, text = "Failed to copy attribute: {0}") void ldapAttributeCopyError(@StackTrace(level = MessageLevel.DEBUG) Exception e); + + @Message(level = MessageLevel.DEBUG, text = "LDAP authentication succeeded for user: {0}") + void ldapAuthSucceeded(String user); + + @Message(level = MessageLevel.WARN, text = "LDAP authentication failed for user: {0}") + void ldapAuthFailed(String user, @StackTrace(level = MessageLevel.INFO) Throwable cause); } diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/LdapUtils.java b/gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/LdapUtils.java new file mode 100644 index 0000000000..117c778ea2 --- /dev/null +++ b/gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/LdapUtils.java @@ -0,0 +1,36 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. The ASF + * licenses this file to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package org.apache.knox.gateway.services.ldap; + +import org.apache.directory.api.ldap.model.name.Dn; + +public class LdapUtils { + + public static String extractUsernameFromDn(Dn dn) { + if (dn == null || dn.isEmpty()) { + return null; + } + + try { + return "uid".equalsIgnoreCase(dn.getRdn().getType()) + ? dn.getRdn().getValue() + : null; + } catch (Exception ignored) { + return null; + } + } +} diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/backend/FileBackend.java b/gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/backend/FileBackend.java index b0cdcd8607..9c3d96aed4 100644 --- a/gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/backend/FileBackend.java +++ b/gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/backend/FileBackend.java @@ -20,9 +20,11 @@ import com.google.gson.Gson; import org.apache.directory.api.ldap.model.entry.DefaultEntry; import org.apache.directory.api.ldap.model.entry.Entry; +import org.apache.directory.api.ldap.model.name.Dn; import org.apache.directory.api.ldap.model.schema.SchemaManager; import org.apache.knox.gateway.i18n.messages.MessagesFactory; import org.apache.knox.gateway.services.ldap.LdapMessages; +import org.apache.knox.gateway.services.ldap.LdapUtils; import java.nio.file.Files; import java.nio.file.Path; @@ -45,6 +47,7 @@ public class FileBackend implements LdapBackend { static class UserData { String username; + String password; String cn; String sn; List groups; @@ -141,4 +144,17 @@ public List searchUsers(String filter, SchemaManager schemaManager) throw return results; } + + @Override + public boolean authenticate(Dn userDn, String password) { + // Extract username from DN (e.g., uid=admin, ou=people,dc=hadoop,dc=apache,dc=org) + final String username = LdapUtils.extractUsernameFromDn(userDn); + + if (username != null) { + UserData userData = users.get(username); + return userData != null && password != null && password.equals(userData.password); + } + + return false; + } } diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/backend/LdapBackend.java b/gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/backend/LdapBackend.java index 6530c13b37..4c459ff265 100644 --- a/gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/backend/LdapBackend.java +++ b/gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/backend/LdapBackend.java @@ -18,6 +18,7 @@ package org.apache.knox.gateway.services.ldap.backend; import org.apache.directory.api.ldap.model.entry.Entry; +import org.apache.directory.api.ldap.model.name.Dn; import org.apache.directory.api.ldap.model.schema.SchemaManager; import java.util.List; @@ -65,4 +66,13 @@ public interface LdapBackend { * @return List of matching entries */ List searchUsers(String filter, SchemaManager schemaManager) throws Exception; + + /** + * Authenticate a user with password + * + * @param userDn The user's Distinguished Name + * @param password The user's password + * @return true if authentication is successful, false otherwise + */ + boolean authenticate(Dn userDn, String password); } diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/backend/LdapProxyBackend.java b/gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/backend/LdapProxyBackend.java index 38328b5713..053a487bb2 100644 --- a/gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/backend/LdapProxyBackend.java +++ b/gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/backend/LdapProxyBackend.java @@ -24,11 +24,13 @@ import org.apache.directory.api.ldap.model.entry.Entry; import org.apache.directory.api.ldap.model.exception.LdapException; import org.apache.directory.api.ldap.model.message.SearchScope; +import org.apache.directory.api.ldap.model.name.Dn; import org.apache.directory.api.ldap.model.schema.SchemaManager; import org.apache.directory.ldap.client.api.DefaultLdapConnectionFactory; import org.apache.directory.ldap.client.api.LdapConnection; import org.apache.directory.ldap.client.api.LdapConnectionConfig; import org.apache.directory.ldap.client.api.LdapConnectionPool; +import org.apache.directory.ldap.client.api.LdapNetworkConnection; import org.apache.directory.ldap.client.api.ValidatingPoolableLdapConnectionFactory; import org.apache.knox.gateway.i18n.messages.MessagesFactory; import org.apache.knox.gateway.services.ldap.LdapMessages; @@ -253,6 +255,25 @@ public void close() { } } + @Override + public boolean authenticate(Dn userDn, String password) { + final String userDnText = userDn.toString(); //at this point we are sure it's not NULL + // Create a temporary connection for authentication (bind) + final LdapConnectionConfig authConfig = new LdapConnectionConfig(); + authConfig.setLdapHost(host); + authConfig.setLdapPort(port); + authConfig.setName(userDnText); + authConfig.setCredentials(password); + try (LdapConnection connection = new LdapNetworkConnection(authConfig)){ + connection.bind(); + LOG.ldapAuthSucceeded(userDnText); + return true; + } catch (Exception e) { + LOG.ldapAuthFailed(userDnText, e); + return false; + } + } + @Override public Entry getUser(String username, SchemaManager schemaManager) throws Exception { LdapConnection connection = null; diff --git a/pom.xml b/pom.xml index 4e3cf3d351..055f5e955a 100644 --- a/pom.xml +++ b/pom.xml @@ -800,6 +800,7 @@ com.nimbusds:nimbus-jose-jwt org.apache.httpcomponents:httpcore org.apache.knox:gateway-server + org.apache.mina:mina-core org.glassfish.hk2.external:jakarta.inject