From 90dac105390d20291d74ee59e6d012245d881134 Mon Sep 17 00:00:00 2001 From: zhaohehuhu Date: Fri, 31 Jul 2026 15:43:55 +0800 Subject: [PATCH 1/2] Support LDAPS Signed-off-by: zhaohehuhu --- docs/configuration/settings.md | 56 +++--- .../org/apache/kyuubi/config/KyuubiConf.scala | 36 ++++ .../ldap/LdapSSLSocketFactory.scala | 76 ++++++++ .../authentication/ldap/LdapSSLUtils.scala | 119 ++++++++++++ .../authentication/ldap/LdapSearch.scala | 9 +- .../ldap/LdapSearchFactory.scala | 73 ++++++- .../ldap/LdapSearchFactorySuite.scala | 183 ++++++++++++++++++ 7 files changed, 523 insertions(+), 29 deletions(-) create mode 100644 kyuubi-common/src/main/scala/org/apache/kyuubi/service/authentication/ldap/LdapSSLSocketFactory.scala create mode 100644 kyuubi-common/src/main/scala/org/apache/kyuubi/service/authentication/ldap/LdapSSLUtils.scala create mode 100644 kyuubi-common/src/test/scala/org/apache/kyuubi/service/authentication/ldap/LdapSearchFactorySuite.scala diff --git a/docs/configuration/settings.md b/docs/configuration/settings.md index 020cb0efdd5..b8f584d7bdf 100644 --- a/docs/configuration/settings.md +++ b/docs/configuration/settings.md @@ -31,32 +31,36 @@ You can configure the Kyuubi properties in `$KYUUBI_HOME/conf/kyuubi-defaults.co ### Authentication -| Key | Default | Meaning | Type | Since | -|-----------------------------------------------|-------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------|--------| -| kyuubi.authentication | NONE | A comma-separated list of client authentication types.The following tree describes the catalog of each option. Note that: for SASL authentication, KERBEROS and PLAIN auth types are supported at the same time, and only the first specified PLAIN auth type is valid. | seq | 1.0.0 | -| kyuubi.authentication.custom.basic.class | <undefined> | User-defined authentication implementation of org.apache.kyuubi.service.authentication.PasswdAuthenticationProvider for http basic authentication. | string | 1.10.0 | -| kyuubi.authentication.custom.bearer.class | <undefined> | User-defined authentication implementation of org.apache.kyuubi.service.authentication.TokenAuthenticationProvider for http bearer authentication. | string | 1.10.0 | -| kyuubi.authentication.custom.class | <undefined> | User-defined authentication implementation of org.apache.kyuubi.service.authentication.PasswdAuthenticationProvider | string | 1.3.0 | -| kyuubi.authentication.jdbc.driver.class | <undefined> | Driver class name for JDBC Authentication Provider. | string | 1.6.0 | -| kyuubi.authentication.jdbc.password | <undefined> | Database password for JDBC Authentication Provider. | string | 1.6.0 | -| kyuubi.authentication.jdbc.query | <undefined> | Query SQL template with placeholders for JDBC Authentication Provider to execute. Authentication passes if the result set is not empty.The SQL statement must start with the `SELECT` clause. Available placeholders are `${user}` and `${password}`. | string | 1.6.0 | -| kyuubi.authentication.jdbc.url | <undefined> | JDBC URL for JDBC Authentication Provider. | string | 1.6.0 | -| kyuubi.authentication.jdbc.user | <undefined> | Database user for JDBC Authentication Provider. | string | 1.6.0 | -| kyuubi.authentication.ldap.baseDN | <undefined> | LDAP base DN. | string | 1.7.0 | -| kyuubi.authentication.ldap.binddn | <undefined> | The user with which to bind to the LDAP server, and search for the full domain name of the user being authenticated. This should be the full domain name of the user, and should have search access across all users in the LDAP tree. If not specified, then the user being authenticated will be used as the bind user. For example: CN=bindUser,CN=Users,DC=subdomain,DC=domain,DC=com | string | 1.7.0 | -| kyuubi.authentication.ldap.bindpw | <undefined> | The password for the bind user, to be used to search for the full name of the user being authenticated. If the username is specified, this parameter must also be specified. | string | 1.7.0 | -| kyuubi.authentication.ldap.customLDAPQuery | <undefined> | A full LDAP query that LDAP Atn provider uses to execute against LDAP Server. If this query returns a null resultset, the LDAP Provider fails the Authentication request, succeeds if the user is part of the resultset.For example: `(&(objectClass=group)(objectClass=top)(instanceType=4)(cn=Domain*))`, `(&(objectClass=person)(|(sAMAccountName=admin)(|(memberOf=CN=Domain Admins,CN=Users,DC=domain,DC=com)(memberOf=CN=Administrators,CN=Builtin,DC=domain,DC=com))))` | string | 1.7.0 | -| kyuubi.authentication.ldap.domain | <undefined> | LDAP domain. | string | 1.0.0 | -| kyuubi.authentication.ldap.groupClassKey | groupOfNames | LDAP attribute name on the group entry that is to be used in LDAP group searches. For example: group, groupOfNames or groupOfUniqueNames. | string | 1.7.0 | -| kyuubi.authentication.ldap.groupDNPattern | <undefined> | COLON-separated list of patterns to use to find DNs for group entities in this directory. Use %s where the actual group name is to be substituted for. For example: CN=%s,CN=Groups,DC=subdomain,DC=domain,DC=com. | string | 1.7.0 | -| kyuubi.authentication.ldap.groupFilter || COMMA-separated list of LDAP Group names (short name not full DNs). For example: HiveAdmins,HadoopAdmins,Administrators | set | 1.7.0 | -| kyuubi.authentication.ldap.groupMembershipKey | member | LDAP attribute name on the group object that contains the list of distinguished names for the user, group, and contact objects that are members of the group. For example: member, uniqueMember or memberUid | string | 1.7.0 | -| kyuubi.authentication.ldap.guidKey | uid | LDAP attribute name whose values are unique in this LDAP server. For example: uid or CN. | string | 1.2.0 | -| kyuubi.authentication.ldap.url | <undefined> | SPACE character separated LDAP connection URL(s). | string | 1.0.0 | -| kyuubi.authentication.ldap.userDNPattern | <undefined> | COLON-separated list of patterns to use to find DNs for users in this directory. Use %s where the actual group name is to be substituted for. For example: CN=%s,CN=Users,DC=subdomain,DC=domain,DC=com. | string | 1.7.0 | -| kyuubi.authentication.ldap.userFilter || COMMA-separated list of LDAP usernames (just short names, not full DNs). For example: hiveuser,impalauser,hiveadmin,hadoopadmin | set | 1.7.0 | -| kyuubi.authentication.ldap.userMembershipKey | <undefined> | LDAP attribute name on the user object that contains groups of which the user is a direct member, except for the primary group, which is represented by the primaryGroupId. For example: memberOf | string | 1.7.0 | -| kyuubi.authentication.sasl.qop | auth | Sasl QOP enable higher levels of protection for Kyuubi communication with clients. | string | 1.0.0 | +| Key | Default | Meaning | Type | Since | +|----------------------------------------------------|-------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------|--------| +| kyuubi.authentication | NONE | A comma-separated list of client authentication types.The following tree describes the catalog of each option. Note that: for SASL authentication, KERBEROS and PLAIN auth types are supported at the same time, and only the first specified PLAIN auth type is valid. | seq | 1.0.0 | +| kyuubi.authentication.custom.basic.class | <undefined> | User-defined authentication implementation of org.apache.kyuubi.service.authentication.PasswdAuthenticationProvider for http basic authentication. | string | 1.10.0 | +| kyuubi.authentication.custom.bearer.class | <undefined> | User-defined authentication implementation of org.apache.kyuubi.service.authentication.TokenAuthenticationProvider for http bearer authentication. | string | 1.10.0 | +| kyuubi.authentication.custom.class | <undefined> | User-defined authentication implementation of org.apache.kyuubi.service.authentication.PasswdAuthenticationProvider | string | 1.3.0 | +| kyuubi.authentication.jdbc.driver.class | <undefined> | Driver class name for JDBC Authentication Provider. | string | 1.6.0 | +| kyuubi.authentication.jdbc.password | <undefined> | Database password for JDBC Authentication Provider. | string | 1.6.0 | +| kyuubi.authentication.jdbc.query | <undefined> | Query SQL template with placeholders for JDBC Authentication Provider to execute. Authentication passes if the result set is not empty.The SQL statement must start with the `SELECT` clause. Available placeholders are `${user}` and `${password}`. | string | 1.6.0 | +| kyuubi.authentication.jdbc.url | <undefined> | JDBC URL for JDBC Authentication Provider. | string | 1.6.0 | +| kyuubi.authentication.jdbc.user | <undefined> | Database user for JDBC Authentication Provider. | string | 1.6.0 | +| kyuubi.authentication.ldap.baseDN | <undefined> | LDAP base DN. | string | 1.7.0 | +| kyuubi.authentication.ldap.binddn | <undefined> | The user with which to bind to the LDAP server, and search for the full domain name of the user being authenticated. This should be the full domain name of the user, and should have search access across all users in the LDAP tree. If not specified, then the user being authenticated will be used as the bind user. For example: CN=bindUser,CN=Users,DC=subdomain,DC=domain,DC=com | string | 1.7.0 | +| kyuubi.authentication.ldap.bindpw | <undefined> | The password for the bind user, to be used to search for the full name of the user being authenticated. If the username is specified, this parameter must also be specified. | string | 1.7.0 | +| kyuubi.authentication.ldap.customLDAPQuery | <undefined> | A full LDAP query that LDAP Atn provider uses to execute against LDAP Server. If this query returns a null resultset, the LDAP Provider fails the Authentication request, succeeds if the user is part of the resultset.For example: `(&(objectClass=group)(objectClass=top)(instanceType=4)(cn=Domain*))`, `(&(objectClass=person)(|(sAMAccountName=admin)(|(memberOf=CN=Domain Admins,CN=Users,DC=domain,DC=com)(memberOf=CN=Administrators,CN=Builtin,DC=domain,DC=com))))` | string | 1.7.0 | +| kyuubi.authentication.ldap.domain | <undefined> | LDAP domain. | string | 1.0.0 | +| kyuubi.authentication.ldap.groupClassKey | groupOfNames | LDAP attribute name on the group entry that is to be used in LDAP group searches. For example: group, groupOfNames or groupOfUniqueNames. | string | 1.7.0 | +| kyuubi.authentication.ldap.groupDNPattern | <undefined> | COLON-separated list of patterns to use to find DNs for group entities in this directory. Use %s where the actual group name is to be substituted for. For example: CN=%s,CN=Groups,DC=subdomain,DC=domain,DC=com. | string | 1.7.0 | +| kyuubi.authentication.ldap.groupFilter || COMMA-separated list of LDAP Group names (short name not full DNs). For example: HiveAdmins,HadoopAdmins,Administrators | set | 1.7.0 | +| kyuubi.authentication.ldap.groupMembershipKey | member | LDAP attribute name on the group object that contains the list of distinguished names for the user, group, and contact objects that are members of the group. For example: member, uniqueMember or memberUid | string | 1.7.0 | +| kyuubi.authentication.ldap.guidKey | uid | LDAP attribute name whose values are unique in this LDAP server. For example: uid or CN. | string | 1.2.0 | +| kyuubi.authentication.ldap.ssl.enable | false | Set this to true for using SSL encryption when connecting to LDAP servers. | boolean | 1.13.0 | +| kyuubi.authentication.ldap.ssl.truststore.password | <undefined> | The truststore password used for SSL connections to LDAP servers. | string | 1.13.0 | +| kyuubi.authentication.ldap.ssl.truststore.path | <undefined> | The truststore path used for SSL connections to LDAP servers. | string | 1.13.0 | +| kyuubi.authentication.ldap.ssl.truststore.type | <undefined> | The truststore type used for SSL connections to LDAP servers. | string | 1.13.0 | +| kyuubi.authentication.ldap.url | <undefined> | SPACE character separated LDAP connection URL(s). | string | 1.0.0 | +| kyuubi.authentication.ldap.userDNPattern | <undefined> | COLON-separated list of patterns to use to find DNs for users in this directory. Use %s where the actual group name is to be substituted for. For example: CN=%s,CN=Users,DC=subdomain,DC=domain,DC=com. | string | 1.7.0 | +| kyuubi.authentication.ldap.userFilter || COMMA-separated list of LDAP usernames (just short names, not full DNs). For example: hiveuser,impalauser,hiveadmin,hadoopadmin | set | 1.7.0 | +| kyuubi.authentication.ldap.userMembershipKey | <undefined> | LDAP attribute name on the user object that contains groups of which the user is a direct member, except for the primary group, which is represented by the primaryGroupId. For example: memberOf | string | 1.7.0 | +| kyuubi.authentication.sasl.qop | auth | Sasl QOP enable higher levels of protection for Kyuubi communication with clients. | string | 1.0.0 | ### Backend diff --git a/kyuubi-common/src/main/scala/org/apache/kyuubi/config/KyuubiConf.scala b/kyuubi-common/src/main/scala/org/apache/kyuubi/config/KyuubiConf.scala index 4239f8b6187..b9ead409b73 100644 --- a/kyuubi-common/src/main/scala/org/apache/kyuubi/config/KyuubiConf.scala +++ b/kyuubi-common/src/main/scala/org/apache/kyuubi/config/KyuubiConf.scala @@ -1086,6 +1086,42 @@ object KyuubiConf { .stringConf .createOptional + val AUTHENTICATION_LDAP_SSL_ENABLE: ConfigEntry[Boolean] = + buildConf("kyuubi.authentication.ldap.ssl.enable") + .doc("Set this to true for using SSL encryption when connecting to LDAP servers.") + .version("1.13.0") + .audience(SERVER) + .immutable + .booleanConf + .createWithDefault(false) + + val AUTHENTICATION_LDAP_SSL_TRUSTSTORE_PATH: OptionalConfigEntry[String] = + buildConf("kyuubi.authentication.ldap.ssl.truststore.path") + .doc("The truststore path used for SSL connections to LDAP servers.") + .version("1.13.0") + .audience(SERVER) + .immutable + .stringConf + .createOptional + + val AUTHENTICATION_LDAP_SSL_TRUSTSTORE_PASSWORD: OptionalConfigEntry[String] = + buildConf("kyuubi.authentication.ldap.ssl.truststore.password") + .doc("The truststore password used for SSL connections to LDAP servers.") + .version("1.13.0") + .audience(SERVER) + .immutable + .stringConf + .createOptional + + val AUTHENTICATION_LDAP_SSL_TRUSTSTORE_TYPE: OptionalConfigEntry[String] = + buildConf("kyuubi.authentication.ldap.ssl.truststore.type") + .doc("The truststore type used for SSL connections to LDAP servers.") + .version("1.13.0") + .audience(SERVER) + .immutable + .stringConf + .createOptional + val AUTHENTICATION_LDAP_BASE_DN: OptionalConfigEntry[String] = buildConf("kyuubi.authentication.ldap.baseDN") .withAlternative("kyuubi.authentication.ldap.base.dn") diff --git a/kyuubi-common/src/main/scala/org/apache/kyuubi/service/authentication/ldap/LdapSSLSocketFactory.scala b/kyuubi-common/src/main/scala/org/apache/kyuubi/service/authentication/ldap/LdapSSLSocketFactory.scala new file mode 100644 index 00000000000..24dcef0c452 --- /dev/null +++ b/kyuubi-common/src/main/scala/org/apache/kyuubi/service/authentication/ldap/LdapSSLSocketFactory.scala @@ -0,0 +1,76 @@ +/* + * 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.kyuubi.service.authentication.ldap + +import java.io.IOException +import java.net.{InetAddress, Socket} +import javax.net.SocketFactory +import javax.net.ssl.SSLContext + +final class LdapSSLSocketFactory private (socketFactory: SocketFactory) extends SocketFactory { + + @throws[IOException] + override def createSocket(): Socket = socketFactory.createSocket() + + @throws[IOException] + override def createSocket(host: String, port: Int): Socket = + socketFactory.createSocket(host, port) + + @throws[IOException] + override def createSocket( + host: String, + port: Int, + localHost: InetAddress, + localPort: Int): Socket = { + socketFactory.createSocket(host, port, localHost, localPort) + } + + @throws[IOException] + override def createSocket(host: InetAddress, port: Int): Socket = + socketFactory.createSocket(host, port) + + @throws[IOException] + override def createSocket( + address: InetAddress, + port: Int, + localAddress: InetAddress, + localPort: Int): Socket = { + socketFactory.createSocket(address, port, localAddress, localPort) + } +} + +object LdapSSLSocketFactory { + + private val sslContext = new ThreadLocal[SSLContext] + + def getDefault(): SocketFactory = { + val context = sslContext.get() + if (context == null) { + throw new IllegalStateException("SSLContext was not set for LDAP SSL connection") + } + new LdapSSLSocketFactory(context.getSocketFactory) + } + + def setSSLContextForCurrentThread(context: SSLContext): Unit = { + sslContext.set(context) + } + + def clearSslContextForCurrentThread(): Unit = { + sslContext.remove() + } +} diff --git a/kyuubi-common/src/main/scala/org/apache/kyuubi/service/authentication/ldap/LdapSSLUtils.scala b/kyuubi-common/src/main/scala/org/apache/kyuubi/service/authentication/ldap/LdapSSLUtils.scala new file mode 100644 index 00000000000..6dc039ecf90 --- /dev/null +++ b/kyuubi-common/src/main/scala/org/apache/kyuubi/service/authentication/ldap/LdapSSLUtils.scala @@ -0,0 +1,119 @@ +/* + * 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.kyuubi.service.authentication.ldap + +import java.io.{FileInputStream, IOException} +import java.security.{GeneralSecurityException, KeyStore} +import java.security.cert.{CertificateFactory, X509Certificate} +import javax.net.ssl.{SSLContext, TrustManagerFactory} + +import scala.collection.JavaConverters._ + +private[ldap] object LdapSSLUtils { + + @throws[GeneralSecurityException] + @throws[IOException] + def createSSLContext( + trustStorePath: String, + trustStorePassword: String, + trustStoreType: String): SSLContext = { + val trustStore = + if (Option(trustStorePath).exists(_.trim.nonEmpty)) { + loadTrustStore(trustStorePath, trustStorePassword, trustStoreType) + } else { + null + } + + val trustManagerFactory = + TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm) + trustManagerFactory.init(trustStore) + + val sslContext = SSLContext.getInstance("SSL") + sslContext.init(null, trustManagerFactory.getTrustManagers, null) + sslContext + } + + @throws[GeneralSecurityException] + @throws[IOException] + private def loadTrustStore( + trustStorePath: String, + trustStorePassword: String, + trustStoreType: String): KeyStore = { + val certificatesKeyStore = + try { + loadCertificates(trustStorePath) + } catch { + case _: GeneralSecurityException | _: IOException => None + } + certificatesKeyStore + .getOrElse(loadKeyStore(trustStorePath, trustStorePassword, trustStoreType)) + } + + @throws[GeneralSecurityException] + @throws[IOException] + private def loadKeyStore( + trustStorePath: String, + trustStorePassword: String, + trustStoreType: String): KeyStore = { + val trustStore = + KeyStore.getInstance( + Option(trustStoreType) + .filter(_.trim.nonEmpty) + .getOrElse(KeyStore.getDefaultType)) + val in = new FileInputStream(trustStorePath) + try { + trustStore.load(in, toCharArray(trustStorePassword)) + } finally { + in.close() + } + trustStore + } + + @throws[GeneralSecurityException] + @throws[IOException] + private def loadCertificates(trustStorePath: String): Option[KeyStore] = { + val certificateFactory = CertificateFactory.getInstance("X.509") + val certificateChain = { + val in = new FileInputStream(trustStorePath) + try { + val certificates = certificateFactory.generateCertificates(in) + certificates.asScala.map(_.asInstanceOf[X509Certificate]).toSeq + } finally { + in.close() + } + } + + if (certificateChain.isEmpty) { + None + } else { + val trustStore = KeyStore.getInstance(KeyStore.getDefaultType) + trustStore.load(null, null) + var index = 1 + certificateChain.foreach { certificate => + val certificateAlias = s"Certificate_$index"; + trustStore.setCertificateEntry(certificateAlias, certificate) + index += 1 + } + Some(trustStore) + } + } + + private def toCharArray(password: String): Array[Char] = { + if (password == null) null else password.toCharArray + } +} diff --git a/kyuubi-common/src/main/scala/org/apache/kyuubi/service/authentication/ldap/LdapSearch.scala b/kyuubi-common/src/main/scala/org/apache/kyuubi/service/authentication/ldap/LdapSearch.scala index 09dca1d5c3a..84bf31c8d3b 100644 --- a/kyuubi-common/src/main/scala/org/apache/kyuubi/service/authentication/ldap/LdapSearch.scala +++ b/kyuubi-common/src/main/scala/org/apache/kyuubi/service/authentication/ldap/LdapSearch.scala @@ -30,7 +30,10 @@ import org.apache.kyuubi.config.KyuubiConf * @param conf Kyuubi configuration * @param ctx Directory service that will be used for the queries. */ -class LdapSearch(conf: KyuubiConf, ctx: DirContext) extends DirSearch with Logging { +class LdapSearch( + conf: KyuubiConf, + ctx: DirContext, + clearSslContextOnClose: Boolean = false) extends DirSearch with Logging { final private val baseDn = conf.get(KyuubiConf.AUTHENTICATION_LDAP_BASE_DN).orNull final private val groupBases: Array[String] = @@ -51,6 +54,10 @@ class LdapSearch(conf: KyuubiConf, ctx: DirContext) extends DirSearch with Loggi catch { case e: NamingException => warn("Exception when closing LDAP context:", e) + } finally { + if (clearSslContextOnClose) { + LdapSSLSocketFactory.clearSslContextForCurrentThread() + } } } diff --git a/kyuubi-common/src/main/scala/org/apache/kyuubi/service/authentication/ldap/LdapSearchFactory.scala b/kyuubi-common/src/main/scala/org/apache/kyuubi/service/authentication/ldap/LdapSearchFactory.scala index e3649d359e7..9c3287d8de3 100644 --- a/kyuubi-common/src/main/scala/org/apache/kyuubi/service/authentication/ldap/LdapSearchFactory.scala +++ b/kyuubi-common/src/main/scala/org/apache/kyuubi/service/authentication/ldap/LdapSearchFactory.scala @@ -17,20 +17,24 @@ package org.apache.kyuubi.service.authentication.ldap +import java.io.IOException +import java.security.{GeneralSecurityException, KeyStore} import java.util import javax.naming.{Context, NamingException} import javax.naming.directory.{DirContext, InitialDirContext} +import javax.net.ssl.SSLContext import javax.security.sasl.AuthenticationException import org.apache.kyuubi.Logging import org.apache.kyuubi.config.KyuubiConf +import org.apache.kyuubi.config.KyuubiConf._ class LdapSearchFactory extends DirSearchFactory with Logging { @throws[AuthenticationException] override def getInstance(conf: KyuubiConf, principal: String, password: String): DirSearch = { try { val ctx = createDirContext(conf, principal, password) - new LdapSearch(conf, ctx) + new LdapSearch(conf, ctx, conf.get(AUTHENTICATION_LDAP_SSL_ENABLE)) } catch { case e: NamingException => debug(s"Could not connect to the LDAP Server: Authentication failed for $principal") @@ -43,6 +47,22 @@ class LdapSearchFactory extends DirSearchFactory with Logging { conf: KyuubiConf, principal: String, password: String): DirContext = { + val env = createDirContextEnvironment(conf, principal, password) + try { + new InitialDirContext(env) + } catch { + case e: NamingException => + if (env.containsKey(LdapSearchFactory.LDAP_SOCKET_FACTORY)) { + LdapSSLSocketFactory.clearSslContextForCurrentThread() + } + throw e + } + } + + private[ldap] def createDirContextEnvironment( + conf: KyuubiConf, + principal: String, + password: String): util.Hashtable[String, AnyRef] = { val ldapUrl = conf.get(KyuubiConf.AUTHENTICATION_LDAP_URL) val env = new util.Hashtable[String, AnyRef] ldapUrl.foreach(env.put(Context.PROVIDER_URL, _)) @@ -50,7 +70,56 @@ class LdapSearchFactory extends DirSearchFactory with Logging { env.put(Context.SECURITY_AUTHENTICATION, "simple") env.put(Context.SECURITY_PRINCIPAL, principal) env.put(Context.SECURITY_CREDENTIALS, password) + if (conf.get(AUTHENTICATION_LDAP_SSL_ENABLE)) { + configureSSLSocketFactory(conf, env) + } debug(s"Connecting using principal $principal to ldap server: ${ldapUrl.orNull}") - new InitialDirContext(env) + env + } + + @throws[NamingException] + private def configureSSLSocketFactory( + conf: KyuubiConf, + env: util.Hashtable[String, AnyRef]): Unit = { + val trustStorePath = conf.get(AUTHENTICATION_LDAP_SSL_TRUSTSTORE_PATH) + val trustStorePassword = conf.get(AUTHENTICATION_LDAP_SSL_TRUSTSTORE_PASSWORD) + val trustStoreType = conf.get(AUTHENTICATION_LDAP_SSL_TRUSTSTORE_TYPE) + + if (trustStorePath.isEmpty) { + throw new IllegalArgumentException( + s"${AUTHENTICATION_LDAP_SSL_TRUSTSTORE_PATH.key} not configured for SSL connection") + } + if (trustStorePassword.isEmpty) { + throw new IllegalArgumentException( + s"${AUTHENTICATION_LDAP_SSL_TRUSTSTORE_PASSWORD.key} not configured for SSL connection") + } + + try { + val sslContext = getSSLContext( + trustStorePath.get, + trustStorePassword.get, + trustStoreType.getOrElse(KeyStore.getDefaultType)) + LdapSSLSocketFactory.setSSLContextForCurrentThread(sslContext) + env.put(LdapSearchFactory.LDAP_SOCKET_FACTORY, classOf[LdapSSLSocketFactory].getName) + } catch { + case e @ (_: GeneralSecurityException | _: IOException) => + val namingException = new NamingException("Failed to configure LDAP SSL context") + namingException.initCause(e) + throw namingException + } + } + + private def getSSLContext( + trustStorePath: String, + trustStorePassword: String, + trustStoreType: String): SSLContext = { + LdapSSLUtils.createSSLContext( + trustStorePath, + trustStorePassword, + trustStoreType) } } + +object LdapSearchFactory { + private val LDAP_SOCKET_FACTORY = "java.naming.ldap.factory.socket" +} diff --git a/kyuubi-common/src/test/scala/org/apache/kyuubi/service/authentication/ldap/LdapSearchFactorySuite.scala b/kyuubi-common/src/test/scala/org/apache/kyuubi/service/authentication/ldap/LdapSearchFactorySuite.scala new file mode 100644 index 00000000000..fe059efaeb1 --- /dev/null +++ b/kyuubi-common/src/test/scala/org/apache/kyuubi/service/authentication/ldap/LdapSearchFactorySuite.scala @@ -0,0 +1,183 @@ +/* + * 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.kyuubi.service.authentication.ldap + +import java.io.{ByteArrayInputStream, FileOutputStream, IOException} +import java.nio.charset.StandardCharsets +import java.nio.file.Files +import java.security.KeyStore +import java.security.cert.CertificateFactory +import javax.naming.Context +import javax.naming.directory.DirContext + +import org.scalatestplus.mockito.MockitoSugar.mock + +import org.apache.kyuubi.{KyuubiFunSuite, Utils} +import org.apache.kyuubi.config.KyuubiConf +import org.apache.kyuubi.config.KyuubiConf._ + +class LdapSearchFactorySuite extends KyuubiFunSuite { + + private val socketFactoryKey = "java.naming.ldap.factory.socket" + private val ldapUrl = "ldaps://127.0.0.1:1" + private val trustStorePassword = "password123" + private val trustStoreType = "PKCS12" + + test("configure ldap ssl socket factory with PKCS12 truststore") { + val trustStorePath = createTrustStore(trustStoreType, trustStorePassword) + val conf = ldapSSLConf(trustStorePath) + + assertSSLSocketFactoryConfigured(conf) + } + + test("configure ldap ssl socket factory with PEM certificate truststore") { + val trustStorePath = createPemTrustStore() + val conf = ldapSSLConf(trustStorePath) + + assertSSLSocketFactoryConfigured(conf) + } + + test("do not configure ldap ssl socket factory when ldap ssl is disabled") { + val conf = KyuubiConf(loadSysDefault = false) + .set(AUTHENTICATION_LDAP_URL, ldapUrl) + + val env = new LdapSearchFactory() + .createDirContextEnvironment(conf, "uid=user,ou=users", "password") + + assert(env.get(Context.PROVIDER_URL) === ldapUrl) + assert(env.get(socketFactoryKey) === null) + } + + test("require truststore path and password when ldap ssl is enabled") { + Seq( + KyuubiConf(loadSysDefault = false) + .set(AUTHENTICATION_LDAP_URL, ldapUrl) + .set(AUTHENTICATION_LDAP_SSL_ENABLE, true) + .set(AUTHENTICATION_LDAP_SSL_TRUSTSTORE_PASSWORD, "password123") -> + AUTHENTICATION_LDAP_SSL_TRUSTSTORE_PATH.key, + KyuubiConf(loadSysDefault = false) + .set(AUTHENTICATION_LDAP_URL, ldapUrl) + .set(AUTHENTICATION_LDAP_SSL_ENABLE, true) + .set(AUTHENTICATION_LDAP_SSL_TRUSTSTORE_PATH, "/tmp/ldap.p12") -> + AUTHENTICATION_LDAP_SSL_TRUSTSTORE_PASSWORD.key).foreach { case (conf, expectedKey) => + val e = intercept[IllegalArgumentException] { + new LdapSearchFactory().createDirContextEnvironment(conf, "uid=user,ou=users", "password") + } + assert(e.getMessage.contains(expectedKey)) + } + } + + test("clear ldap ssl context when ldap search is closed") { + val trustStorePath = createTrustStore(trustStoreType, trustStorePassword) + val conf = ldapSSLConf(trustStorePath) + + new LdapSearchFactory().createDirContextEnvironment(conf, "uid=user,ou=users", "password") + assert(LdapSSLSocketFactory.getDefault().isInstanceOf[LdapSSLSocketFactory]) + + new LdapSearch(conf, mock[DirContext], clearSslContextOnClose = true).close() + + intercept[IllegalStateException] { + LdapSSLSocketFactory.getDefault() + } + } + + test("report truststore loading error when truststore password is wrong") { + val trustStorePath = createTrustStore("PKCS12", "password123") + + val e = intercept[IOException] { + LdapSSLUtils.createSSLContext(trustStorePath, "wrong-password", "PKCS12") + } + + assert(e.getSuppressed.isEmpty) + } + + private def createTrustStore(trustStoreType: String, password: String): String = { + val path = Utils.createTempDir().resolve(s"ldap-truststore.$trustStoreType") + val trustStore = KeyStore.getInstance(trustStoreType) + trustStore.load(null, password.toCharArray) + trustStore.setCertificateEntry("ldap", loadCertificate()) + val out = new FileOutputStream(path.toFile) + try { + trustStore.store(out, password.toCharArray) + } finally { + out.close() + } + path.toAbsolutePath.toString + } + + private def createPemTrustStore(): String = { + val path = Utils.createTempDir().resolve("ldap-truststore.pem") + Files.write(path, testCertificatePem.getBytes(StandardCharsets.UTF_8)) + path.toAbsolutePath.toString + } + + private def loadCertificate() = { + val certificateFactory = CertificateFactory.getInstance("X.509") + val in = new ByteArrayInputStream(testCertificatePem.getBytes(StandardCharsets.UTF_8)) + try { + certificateFactory.generateCertificate(in) + } finally { + in.close() + } + } + + private def ldapSSLConf(trustStorePath: String): KyuubiConf = { + KyuubiConf(loadSysDefault = false) + .set(AUTHENTICATION_LDAP_URL, ldapUrl) + .set(AUTHENTICATION_LDAP_SSL_ENABLE, true) + .set(AUTHENTICATION_LDAP_SSL_TRUSTSTORE_PATH, trustStorePath) + .set(AUTHENTICATION_LDAP_SSL_TRUSTSTORE_PASSWORD, trustStorePassword) + .set(AUTHENTICATION_LDAP_SSL_TRUSTSTORE_TYPE, trustStoreType) + } + + private def assertSSLSocketFactoryConfigured(conf: KyuubiConf): Unit = { + val env = new LdapSearchFactory() + .createDirContextEnvironment(conf, "uid=user,ou=users", "password") + + assert(env.get(Context.PROVIDER_URL) === ldapUrl) + assert(env.get(socketFactoryKey) === classOf[LdapSSLSocketFactory].getName) + assert(LdapSSLSocketFactory.getDefault().isInstanceOf[LdapSSLSocketFactory]) + } + + private val testCertificatePem = + """-----BEGIN CERTIFICATE----- + |MIIDCTCCAfGgAwIBAgIUWpdT1drB26WnuUu6xQrRBRwRVWgwDQYJKoZIhvcNAQEL + |BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTI2MDczMTA4MDIyMloXDTM2MDcy + |ODA4MDIyMlowFDESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEF + |AAOCAQ8AMIIBCgKCAQEA26bz4WtVhatDvNXzEkE9C5MpdKVop1lLtkFkgVcisdX4 + |HcIM0Lv/1ryrofxvIgJjt8frzC0awX7NPrN55fLyQ+NtIt3xwdcE8TkdwNuStQpi + |R/TxpUtr+aZw40cDH35KeJNfEex6HKKbCWHTeItvwRhVt4vuATCavC/Tw9R5hF01 + |c42txratWk83xg83Scy6sZwIhXZ9kGmoTvidaAT212q+onwjW087axd3npZ5iyjV + |pqkAZ4jzaqAlSuoEYHXsivqdAFIstx/BCqxPuIpfVh28fGBgovM0ROZFnCd/R0Pb + |YAQwmypNjiBHMJjiWjexqm7nDA2Nj6fIWpDQmN0kMQIDAQABo1MwUTAdBgNVHQ4E + |FgQUbtvmsQW8PbxcQ84f8SIMd9Ua7YswHwYDVR0jBBgwFoAUbtvmsQW8PbxcQ84f + |8SIMd9Ua7YswDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAcs4I + |fP9tkF8L2gXkNE5pH/c8+zvH3j/QFj0pAz12YR02yjJKBXw/JKTxLAbOKZuyY6bV + |X4953ppMluvqm108IkOChkXPrD8nnF1WfgpZh61FXkboLWbZXS+BEb9j+5W+jeC3 + |xeVZZG1GP9CiL6F4MdDnQaOuyRMY+iCM+zYbDe0r56my1UNIEymdXiEriy17lllw + |ho/IzPZKqt/JFGMQ8kkzWaYgF52cF6iuNdnYatd8HVcqYiyFG44XF5s3L9QHrZ5F + |1364kBO0IXST4cFNbyQ+DHekbQoUxT2SdHW2JN8TMvr+mjwVWRoH6lg7+4Mb/6JB + |36XlC2HY/TejOS5PyA== + |-----END CERTIFICATE----- + |""".stripMargin + + override protected def afterEach(): Unit = { + LdapSSLSocketFactory.clearSslContextForCurrentThread() + super.afterEach() + } +} From 8d44a4250fb8eb9930f93de611f49ddbbcf67433 Mon Sep 17 00:00:00 2001 From: zhaohehuhu Date: Mon, 3 Aug 2026 15:14:07 +0800 Subject: [PATCH 2/2] refactor Signed-off-by: zhaohehuhu --- .../org/apache/kyuubi/config/KyuubiConf.scala | 72 +++++++++---------- 1 file changed, 36 insertions(+), 36 deletions(-) diff --git a/kyuubi-common/src/main/scala/org/apache/kyuubi/config/KyuubiConf.scala b/kyuubi-common/src/main/scala/org/apache/kyuubi/config/KyuubiConf.scala index b9ead409b73..f28ca98f3cd 100644 --- a/kyuubi-common/src/main/scala/org/apache/kyuubi/config/KyuubiConf.scala +++ b/kyuubi-common/src/main/scala/org/apache/kyuubi/config/KyuubiConf.scala @@ -1086,42 +1086,6 @@ object KyuubiConf { .stringConf .createOptional - val AUTHENTICATION_LDAP_SSL_ENABLE: ConfigEntry[Boolean] = - buildConf("kyuubi.authentication.ldap.ssl.enable") - .doc("Set this to true for using SSL encryption when connecting to LDAP servers.") - .version("1.13.0") - .audience(SERVER) - .immutable - .booleanConf - .createWithDefault(false) - - val AUTHENTICATION_LDAP_SSL_TRUSTSTORE_PATH: OptionalConfigEntry[String] = - buildConf("kyuubi.authentication.ldap.ssl.truststore.path") - .doc("The truststore path used for SSL connections to LDAP servers.") - .version("1.13.0") - .audience(SERVER) - .immutable - .stringConf - .createOptional - - val AUTHENTICATION_LDAP_SSL_TRUSTSTORE_PASSWORD: OptionalConfigEntry[String] = - buildConf("kyuubi.authentication.ldap.ssl.truststore.password") - .doc("The truststore password used for SSL connections to LDAP servers.") - .version("1.13.0") - .audience(SERVER) - .immutable - .stringConf - .createOptional - - val AUTHENTICATION_LDAP_SSL_TRUSTSTORE_TYPE: OptionalConfigEntry[String] = - buildConf("kyuubi.authentication.ldap.ssl.truststore.type") - .doc("The truststore type used for SSL connections to LDAP servers.") - .version("1.13.0") - .audience(SERVER) - .immutable - .stringConf - .createOptional - val AUTHENTICATION_LDAP_BASE_DN: OptionalConfigEntry[String] = buildConf("kyuubi.authentication.ldap.baseDN") .withAlternative("kyuubi.authentication.ldap.base.dn") @@ -1266,6 +1230,42 @@ object KyuubiConf { .stringConf .createOptional + val AUTHENTICATION_LDAP_SSL_ENABLE: ConfigEntry[Boolean] = + buildConf("kyuubi.authentication.ldap.ssl.enable") + .doc("Set this to true for using SSL encryption when connecting to LDAP servers.") + .version("1.13.0") + .audience(SERVER) + .immutable + .booleanConf + .createWithDefault(false) + + val AUTHENTICATION_LDAP_SSL_TRUSTSTORE_PATH: OptionalConfigEntry[String] = + buildConf("kyuubi.authentication.ldap.ssl.truststore.path") + .doc("The truststore path used for SSL connections to LDAP servers.") + .version("1.13.0") + .audience(SERVER) + .immutable + .stringConf + .createOptional + + val AUTHENTICATION_LDAP_SSL_TRUSTSTORE_PASSWORD: OptionalConfigEntry[String] = + buildConf("kyuubi.authentication.ldap.ssl.truststore.password") + .doc("The truststore password used for SSL connections to LDAP servers.") + .version("1.13.0") + .audience(SERVER) + .immutable + .stringConf + .createOptional + + val AUTHENTICATION_LDAP_SSL_TRUSTSTORE_TYPE: OptionalConfigEntry[String] = + buildConf("kyuubi.authentication.ldap.ssl.truststore.type") + .doc("The truststore type used for SSL connections to LDAP servers.") + .version("1.13.0") + .audience(SERVER) + .immutable + .stringConf + .createOptional + val AUTHENTICATION_JDBC_DRIVER: OptionalConfigEntry[String] = buildConf("kyuubi.authentication.jdbc.driver.class") .doc("Driver class name for JDBC Authentication Provider.")