You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@kyuubi.apache.org by ch...@apache.org on 2023/02/03 05:48:13 UTC

[kyuubi] branch master updated: [KYUUBI #4152] Enhance LDAP authentication

This is an automated email from the ASF dual-hosted git repository.

chengpan pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/kyuubi.git


The following commit(s) were added to refs/heads/master by this push:
     new eb1b11cd1 [KYUUBI #4152] Enhance LDAP authentication
eb1b11cd1 is described below

commit eb1b11cd171fd4475cd2168e1d2d573232e16bd9
Author: Cheng Pan <ch...@apache.org>
AuthorDate: Fri Feb 3 05:48:02 2023 +0000

    [KYUUBI #4152] Enhance LDAP authentication
    
    ### _Why are the changes needed?_
    
    This PR proposes to enhance the LDAP support, which mainly referring the code introduced in HIVE-14713.
    
    Currently, Kyuubi has very limited LDAP support, and the implementation is from the early Hive codebase. Hive enhanced the LDAP support in later versions, considering the Hive ecosystem is quite mature, I think we'd better to porting this functionality and keep the same behavior w/ Hive first, and we can improve it if meet certain requirements/issues in the future.
    
    Basically, this PR introduces the following configurations
    
    ```
    kyuubi.authentication.ldap.url (since 1.0.0)
    kyuubi.authentication.ldap.domain (since 1.0.0)
    kyuubi.authentication.ldap.guidKey (since 1.2.0)
    kyuubi.authentication.ldap.base.dn (since 1.0.0 deprecated)
    kyuubi.authentication.ldap.baseDN
    kyuubi.authentication.ldap.groupMembershipKey
    kyuubi.authentication.ldap.userMembershipKey
    kyuubi.authentication.ldap.groupClassKey
    kyuubi.authentication.ldap.groupDNPattern
    kyuubi.authentication.ldap.userDNPattern
    kyuubi.authentication.ldap.groupFilter
    kyuubi.authentication.ldap.userFilter
    kyuubi.authentication.ldap.customLDAPQuery
    kyuubi.authentication.ldap.binddn
    kyuubi.authentication.ldap.bindpw
    ```
    
    ### _How was this patch tested?_
    - [x] Add some test cases that check the changes thoroughly including negative and positive cases if possible
    
    This PR ports all LDAP-related UT&IT from Hive codebase
    
    - [ ] Add screenshots for manual tests if appropriate
    
    - [x] [Run test](https://kyuubi.apache.org/docs/latest/develop_tools/testing.html#running-tests) locally before make a pull request
    
    Closes #4152 from pan3793/ldap.
    
    Closes #4152
    
    d251c959 [Cheng Pan] nit
    6d14f44b [Cheng Pan] nit
    6b3d116c [Cheng Pan] nit
    ab47d822 [Cheng Pan] nit
    a56e8702 [Cheng Pan] nit
    4624619a [Cheng Pan] nit
    b82c0c05 [Cheng Pan] LDAP test password uses alphanumeric
    86a01cca [Cheng Pan] Enhance LDAP authentication
    
    Authored-by: Cheng Pan <ch...@apache.org>
    Signed-off-by: Cheng Pan <ch...@apache.org>
---
 .gitignore                                         |   7 +-
 LICENSE-binary                                     |   2 +
 dev/dependencyList                                 |   2 +
 docs/deployment/settings.md                        |  38 +-
 kyuubi-common/pom.xml                              |  11 +
 .../src/main/scala/org/apache/kyuubi/Logging.scala |  12 +
 .../org/apache/kyuubi/config/KyuubiConf.scala      | 108 ++++-
 .../org/apache/kyuubi/service/ServiceUtils.scala   |  24 +
 .../LdapAuthenticationProviderImpl.scala           |  99 +++--
 .../authentication/ldap/ChainFilterFactory.scala   |  44 ++
 .../ldap/CustomQueryFilterFactory.scala            |  77 ++++
 .../service/authentication/ldap/DirSearch.scala    |  73 +++
 .../authentication/ldap/DirSearchFactory.scala     |  39 ++
 .../service/authentication/ldap/Filter.scala       |  37 ++
 .../authentication/ldap/FilterFactory.scala        |  34 ++
 .../authentication/ldap/GroupFilterFactory.scala   | 108 +++++
 .../service/authentication/ldap/LdapSearch.scala   | 126 ++++++
 .../authentication/ldap/LdapSearchFactory.scala    |  56 +++
 .../service/authentication/ldap/LdapUtils.scala    | 199 +++++++++
 .../kyuubi/service/authentication/ldap/Query.scala | 136 ++++++
 .../service/authentication/ldap/QueryFactory.scala | 145 ++++++
 .../authentication/ldap/SearchResultHandler.scala  | 148 +++++++
 .../authentication/ldap/UserFilterFactory.scala    |  46 ++
 .../ldap/UserSearchFilterFactory.scala             |  53 +++
 .../src/test/resources/ldap/ad.example.com.ldif    | 150 +++++++
 .../src/test/resources/ldap/example.com.ldif       | 113 +++++
 .../src/test/resources/ldap/microsoft.schema.ldif  |  62 +++
 .../authentication/LdapAtnProviderSuite.scala      | 493 +++++++++++++++++++++
 .../LdapAuthenticationProviderImplSuite.scala      | 368 +++++++++++++--
 .../service/authentication/WithLdapServer.scala    |  24 +-
 .../authentication/ldap/ChainFilterSuite.scala     |  88 ++++
 .../ldap/CustomQueryFilterSuite.scala              |  69 +++
 .../authentication/ldap/GroupFilterSuite.scala     | 161 +++++++
 .../ldap/LdapAuthenticationTestCase.scala          | 117 +++++
 .../authentication/ldap/LdapSearchSuite.scala      | 298 +++++++++++++
 .../authentication/ldap/LdapTestUtils.scala        | 116 +++++
 .../authentication/ldap/LdapUtilsSuite.scala       |  88 ++++
 .../authentication/ldap/QueryFactorySuite.scala    |  97 ++++
 .../service/authentication/ldap/QuerySuite.scala   |  54 +++
 .../ldap/SearchResultHandlerSuite.scala            | 203 +++++++++
 .../authentication/ldap/UserFilterSuite.scala      |  60 +++
 .../ldap/UserSearchFilterSuite.scala               |  79 ++++
 .../org/apache/kyuubi/RestClientTestHelper.scala   |   2 +-
 .../KyuubiOperationKerberosAndPlainAuthSuite.scala |   5 +-
 ...rationThriftHttpKerberosAndPlainAuthSuite.scala |   2 +-
 licenses-binary/LICENSE-antlr.txt                  |   8 +
 licenses-binary/LICENSE-antlr4.txt                 |  26 ++
 pom.xml                                            |   6 +
 48 files changed, 4209 insertions(+), 104 deletions(-)

diff --git a/.gitignore b/.gitignore
index d2c1ba3b7..bbdd246c4 100644
--- a/.gitignore
+++ b/.gitignore
@@ -32,11 +32,7 @@
 .ensime_lucene
 .generated-mima*
 .vscode/
-# The star is required for further !/.idea/ to work, see https://git-scm.com/docs/gitignore
-/.idea/*
-# Icon for JetBrains Toolbox
-!/.idea/icon.png
-!/.idea/vcs.xml
+.idea/
 .idea_modules/
 .project
 .pydevproject
@@ -59,7 +55,6 @@ hs_err_pid*
 spark-warehouse/
 metastore_db
 derby.log
-ldap
 **/dependency-reduced-pom.xml
 metrics/report.json
 metrics/.report.json.crc
diff --git a/LICENSE-binary b/LICENSE-binary
index 017dd53f8..92daf62ab 100644
--- a/LICENSE-binary
+++ b/LICENSE-binary
@@ -322,7 +322,9 @@ org.apache.zookeeper:zookeeper
 
 BSD
 ------------
+org.antlr:antlr-runtime
 org.antlr:antlr4-runtime
+org.antlr:ST4
 jline:jline
 com.thoughtworks.paranamer:paranamer
 dk.brics.automaton:automaton
diff --git a/dev/dependencyList b/dev/dependencyList
index 9b8064e42..10933b9fa 100644
--- a/dev/dependencyList
+++ b/dev/dependencyList
@@ -16,8 +16,10 @@
 #
 
 HikariCP/4.0.3//HikariCP-4.0.3.jar
+ST4/4.3.4//ST4-4.3.4.jar
 animal-sniffer-annotations/1.21//animal-sniffer-annotations-1.21.jar
 annotations/4.1.1.4//annotations-4.1.1.4.jar
+antlr-runtime/3.5.3//antlr-runtime-3.5.3.jar
 antlr4-runtime/4.9.3//antlr4-runtime-4.9.3.jar
 aopalliance-repackaged/2.6.1//aopalliance-repackaged-2.6.1.jar
 automaton/1.11-8//automaton-1.11-8.jar
diff --git a/docs/deployment/settings.md b/docs/deployment/settings.md
index f8beaa83b..26147ff8a 100644
--- a/docs/deployment/settings.md
+++ b/docs/deployment/settings.md
@@ -132,20 +132,30 @@ You can configure the Kyuubi properties in `$KYUUBI_HOME/conf/kyuubi-defaults.co
 
 ### Authentication
 
-|                   Key                   |      Default      |                                                                                                                                                                                                                                                                                                                                                                                                                                              [...]
-|-----------------------------------------|-------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- [...]
-| kyuubi.authentication                   | NONE              | A comma-separated list of client authentication types.<ul> <li>NOSASL: raw transport.</li> <li>NONE: no authentication check.</li> <li>KERBEROS: Kerberos/GSSAPI authentication.</li> <li>CUSTOM: User-defined authentication.</li> <li>JDBC: JDBC query authentication.</li> <li>LDAP: Lightweight Directory Access Protocol authentication.</li></ul>The following tree describes the catalog of each option.<ul>  <li><code>NOSASL</code> [...]
-| kyuubi.authentication.custom.class      | &lt;undefined&gt; | User-defined authentication implementation of org.apache.kyuubi.service.authentication.PasswdAuthenticationProvider                                                                                                                                                                                                                                                                                                                          [...]
-| kyuubi.authentication.jdbc.driver.class | &lt;undefined&gt; | Driver class name for JDBC Authentication Provider.                                                                                                                                                                                                                                                                                                                                                                                          [...]
-| kyuubi.authentication.jdbc.password     | &lt;undefined&gt; | Database password for JDBC Authentication Provider.                                                                                                                                                                                                                                                                                                                                                                                          [...]
-| kyuubi.authentication.jdbc.query        | &lt;undefined&gt; | 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}`.                                                                                                                                                                                        [...]
-| kyuubi.authentication.jdbc.url          | &lt;undefined&gt; | JDBC URL for JDBC Authentication Provider.                                                                                                                                                                                                                                                                                                                                                                                                   [...]
-| kyuubi.authentication.jdbc.user         | &lt;undefined&gt; | Database user for JDBC Authentication Provider.                                                                                                                                                                                                                                                                                                                                                                                              [...]
-| kyuubi.authentication.ldap.base.dn      | &lt;undefined&gt; | LDAP base DN.                                                                                                                                                                                                                                                                                                                                                                                                                                [...]
-| kyuubi.authentication.ldap.domain       | &lt;undefined&gt; | LDAP domain.                                                                                                                                                                                                                                                                                                                                                                                                                                 [...]
-| kyuubi.authentication.ldap.guidKey      | uid               | LDAP attribute name whose values are unique in this LDAP server.For example:uid or cn.                                                                                                                                                                                                                                                                                                                                                       [...]
-| kyuubi.authentication.ldap.url          | &lt;undefined&gt; | SPACE character separated LDAP connection URL(s).                                                                                                                                                                                                                                                                                                                                                                                            [...]
-| kyuubi.authentication.sasl.qop          | auth              | Sasl QOP enable higher levels of protection for Kyuubi communication with clients.<ul> <li>auth - authentication only (default)</li> <li>auth-int - authentication plus integrity protection</li> <li>auth-conf - authentication plus integrity and confidentiality protection. This is applicable only if Kyuubi is configured to use Kerberos authentication.</li> </ul>                                                                   [...]
+|                      Key                      |      Default      |                                                                                                                                                                                                                                                                                                                                                                                                                                        [...]
+|-----------------------------------------------|-------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- [...]
+| kyuubi.authentication                         | NONE              | A comma-separated list of client authentication types.<ul> <li>NOSASL: raw transport.</li> <li>NONE: no authentication check.</li> <li>KERBEROS: Kerberos/GSSAPI authentication.</li> <li>CUSTOM: User-defined authentication.</li> <li>JDBC: JDBC query authentication.</li> <li>LDAP: Lightweight Directory Access Protocol authentication.</li></ul>The following tree describes the catalog of each option.<ul>  <li><code>NOSASL< [...]
+| kyuubi.authentication.custom.class            | &lt;undefined&gt; | User-defined authentication implementation of org.apache.kyuubi.service.authentication.PasswdAuthenticationProvider                                                                                                                                                                                                                                                                                                                    [...]
+| kyuubi.authentication.jdbc.driver.class       | &lt;undefined&gt; | Driver class name for JDBC Authentication Provider.                                                                                                                                                                                                                                                                                                                                                                                    [...]
+| kyuubi.authentication.jdbc.password           | &lt;undefined&gt; | Database password for JDBC Authentication Provider.                                                                                                                                                                                                                                                                                                                                                                                    [...]
+| kyuubi.authentication.jdbc.query              | &lt;undefined&gt; | 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}`.                                                                                                                                                                                  [...]
+| kyuubi.authentication.jdbc.url                | &lt;undefined&gt; | JDBC URL for JDBC Authentication Provider.                                                                                                                                                                                                                                                                                                                                                                                             [...]
+| kyuubi.authentication.jdbc.user               | &lt;undefined&gt; | Database user for JDBC Authentication Provider.                                                                                                                                                                                                                                                                                                                                                                                        [...]
+| kyuubi.authentication.ldap.baseDN             | &lt;undefined&gt; | LDAP base DN.                                                                                                                                                                                                                                                                                                                                                                                                                          [...]
+| kyuubi.authentication.ldap.binddn             | &lt;undefined&gt; | 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                                              [...]
+| kyuubi.authentication.ldap.bindpw             | &lt;undefined&gt; | 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.                                                                                                                                                                                                                                                           [...]
+| kyuubi.authentication.ldap.customLDAPQuery    | &lt;undefined&gt; | 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=Adminis [...]
+| kyuubi.authentication.ldap.domain             | &lt;undefined&gt; | LDAP domain.                                                                                                                                                                                                                                                                                                                                                                                                                           [...]
+| 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.                                                                                                                                                                                                                                                                                              [...]
+| kyuubi.authentication.ldap.groupDNPattern     | &lt;undefined&gt; | 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.                                                                                                                                                                                                                     [...]
+| kyuubi.authentication.ldap.groupFilter                           || COMMA-separated list of LDAP Group names (short name not full DNs). For example: HiveAdmins,HadoopAdmins,Administrators                                                                                                                                                                                                                                                                                                                [...]
+| 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                                                                                                                                                                                                                           [...]
+| kyuubi.authentication.ldap.guidKey            | uid               | LDAP attribute name whose values are unique in this LDAP server. For example: uid or CN.                                                                                                                                                                                                                                                                                                                                               [...]
+| kyuubi.authentication.ldap.url                | &lt;undefined&gt; | SPACE character separated LDAP connection URL(s).                                                                                                                                                                                                                                                                                                                                                                                      [...]
+| kyuubi.authentication.ldap.userDNPattern      | &lt;undefined&gt; | 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.                                                                                                                                                                                                                               [...]
+| kyuubi.authentication.ldap.userFilter                            || COMMA-separated list of LDAP usernames (just short names, not full DNs). For example: hiveuser,impalauser,hiveadmin,hadoopadmin                                                                                                                                                                                                                                                                                                        [...]
+| kyuubi.authentication.ldap.userMembershipKey  | &lt;undefined&gt; | 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                                                                                                                                                                                                                                      [...]
+| kyuubi.authentication.sasl.qop                | auth              | Sasl QOP enable higher levels of protection for Kyuubi communication with clients.<ul> <li>auth - authentication only (default)</li> <li>auth-int - authentication plus integrity protection</li> <li>auth-conf - authentication plus integrity and confidentiality protection. This is applicable only if Kyuubi is configured to use Kerberos authentication.</li> </ul>                                                             [...]
 
 ### Backend
 
diff --git a/kyuubi-common/pom.xml b/kyuubi-common/pom.xml
index 26cdc271d..61ae4c0e7 100644
--- a/kyuubi-common/pom.xml
+++ b/kyuubi-common/pom.xml
@@ -88,6 +88,11 @@
             <scope>runtime</scope>
         </dependency>
 
+        <dependency>
+            <groupId>org.antlr</groupId>
+            <artifactId>ST4</artifactId>
+        </dependency>
+
         <dependency>
             <groupId>org.apache.commons</groupId>
             <artifactId>commons-lang3</artifactId>
@@ -141,6 +146,12 @@
             <scope>test</scope>
         </dependency>
 
+        <dependency>
+            <groupId>org.scalatestplus</groupId>
+            <artifactId>mockito-4-6_${scala.binary.version}</artifactId>
+            <scope>test</scope>
+        </dependency>
+
         <dependency>
             <groupId>com.google.guava</groupId>
             <artifactId>failureaccess</artifactId>
diff --git a/kyuubi-common/src/main/scala/org/apache/kyuubi/Logging.scala b/kyuubi-common/src/main/scala/org/apache/kyuubi/Logging.scala
index 4944b9fcc..d70df0952 100644
--- a/kyuubi-common/src/main/scala/org/apache/kyuubi/Logging.scala
+++ b/kyuubi-common/src/main/scala/org/apache/kyuubi/Logging.scala
@@ -54,12 +54,24 @@ trait Logging {
     }
   }
 
+  def debug(message: => Any, t: Throwable): Unit = {
+    if (logger.isDebugEnabled) {
+      logger.debug(message.toString, t)
+    }
+  }
+
   def info(message: => Any): Unit = {
     if (logger.isInfoEnabled) {
       logger.info(message.toString)
     }
   }
 
+  def info(message: => Any, t: Throwable): Unit = {
+    if (logger.isInfoEnabled) {
+      logger.info(message.toString, t)
+    }
+  }
+
   def warn(message: => Any): Unit = {
     if (logger.isWarnEnabled) {
       logger.warn(message.toString)
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 f9d9bcda6..e4c48218a 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
@@ -783,10 +783,11 @@ object KyuubiConf {
       .stringConf
       .createOptional
 
-  val AUTHENTICATION_LDAP_BASEDN: OptionalConfigEntry[String] =
-    buildConf("kyuubi.authentication.ldap.base.dn")
+  val AUTHENTICATION_LDAP_BASE_DN: OptionalConfigEntry[String] =
+    buildConf("kyuubi.authentication.ldap.baseDN")
+      .withAlternative("kyuubi.authentication.ldap.base.dn")
       .doc("LDAP base DN.")
-      .version("1.0.0")
+      .version("1.7.0")
       .stringConf
       .createOptional
 
@@ -797,14 +798,109 @@ object KyuubiConf {
       .stringConf
       .createOptional
 
-  val AUTHENTICATION_LDAP_GUIDKEY: ConfigEntry[String] =
+  val AUTHENTICATION_LDAP_GROUP_DN_PATTERN: OptionalConfigEntry[String] =
+    buildConf("kyuubi.authentication.ldap.groupDNPattern")
+      .doc("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.")
+      .version("1.7.0")
+      .stringConf
+      .createOptional
+
+  val AUTHENTICATION_LDAP_USER_DN_PATTERN: OptionalConfigEntry[String] =
+    buildConf("kyuubi.authentication.ldap.userDNPattern")
+      .doc("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.")
+      .version("1.7.0")
+      .stringConf
+      .createOptional
+
+  val AUTHENTICATION_LDAP_GROUP_FILTER: ConfigEntry[Seq[String]] =
+    buildConf("kyuubi.authentication.ldap.groupFilter")
+      .doc("COMMA-separated list of LDAP Group names (short name not full DNs). " +
+        "For example: HiveAdmins,HadoopAdmins,Administrators")
+      .version("1.7.0")
+      .stringConf
+      .toSequence()
+      .createWithDefault(Nil)
+
+  val AUTHENTICATION_LDAP_USER_FILTER: ConfigEntry[Seq[String]] =
+    buildConf("kyuubi.authentication.ldap.userFilter")
+      .doc("COMMA-separated list of LDAP usernames (just short names, not full DNs). " +
+        "For example: hiveuser,impalauser,hiveadmin,hadoopadmin")
+      .version("1.7.0")
+      .stringConf
+      .toSequence()
+      .createWithDefault(Nil)
+
+  val AUTHENTICATION_LDAP_GUID_KEY: ConfigEntry[String] =
     buildConf("kyuubi.authentication.ldap.guidKey")
-      .doc("LDAP attribute name whose values are unique in this LDAP server." +
-        "For example:uid or cn.")
+      .doc("LDAP attribute name whose values are unique in this LDAP server. " +
+        "For example: uid or CN.")
       .version("1.2.0")
       .stringConf
       .createWithDefault("uid")
 
+  val AUTHENTICATION_LDAP_GROUP_MEMBERSHIP_KEY: ConfigEntry[String] =
+    buildConf("kyuubi.authentication.ldap.groupMembershipKey")
+      .doc("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")
+      .version("1.7.0")
+      .stringConf
+      .createWithDefault("member")
+
+  val AUTHENTICATION_LDAP_USER_MEMBERSHIP_KEY: OptionalConfigEntry[String] =
+    buildConf("kyuubi.authentication.ldap.userMembershipKey")
+      .doc("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")
+      .version("1.7.0")
+      .stringConf
+      .createOptional
+
+  val AUTHENTICATION_LDAP_GROUP_CLASS_KEY: ConfigEntry[String] =
+    buildConf("kyuubi.authentication.ldap.groupClassKey")
+      .doc("LDAP attribute name on the group entry that is to be used in LDAP group searches. " +
+        "For example: group, groupOfNames or groupOfUniqueNames.")
+      .version("1.7.0")
+      .stringConf
+      .createWithDefault("groupOfNames")
+
+  val AUTHENTICATION_LDAP_CUSTOM_LDAP_QUERY: OptionalConfigEntry[String] =
+    buildConf("kyuubi.authentication.ldap.customLDAPQuery")
+      .doc("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))))`")
+      .version("1.7.0")
+      .stringConf
+      .createOptional
+
+  val AUTHENTICATION_LDAP_BIND_USER: OptionalConfigEntry[String] =
+    buildConf("kyuubi.authentication.ldap.binddn")
+      .doc("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")
+      .version("1.7.0")
+      .stringConf
+      .createOptional
+
+  val AUTHENTICATION_LDAP_BIND_PASSWORD: OptionalConfigEntry[String] =
+    buildConf("kyuubi.authentication.ldap.bindpw")
+      .doc("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.")
+      .version("1.7.0")
+      .stringConf
+      .createOptional
+
   val AUTHENTICATION_JDBC_DRIVER: OptionalConfigEntry[String] =
     buildConf("kyuubi.authentication.jdbc.driver.class")
       .doc("Driver class name for JDBC Authentication Provider.")
diff --git a/kyuubi-common/src/main/scala/org/apache/kyuubi/service/ServiceUtils.scala b/kyuubi-common/src/main/scala/org/apache/kyuubi/service/ServiceUtils.scala
index d481aea77..955144af8 100644
--- a/kyuubi-common/src/main/scala/org/apache/kyuubi/service/ServiceUtils.scala
+++ b/kyuubi-common/src/main/scala/org/apache/kyuubi/service/ServiceUtils.scala
@@ -17,6 +17,10 @@
 
 package org.apache.kyuubi.service
 
+import java.io.{Closeable, IOException}
+
+import org.slf4j.Logger
+
 object ServiceUtils {
 
   /**
@@ -49,4 +53,24 @@ object ServiceUtils {
       userName.substring(0, indexOfDomainMatch)
     }
   }
+
+  /**
+   * Close the Closeable objects and <b>ignore</b> any [[IOException]] or
+   * null pointers. Must only be used for cleanup in exception handlers.
+   *
+   * @param log        the log to record problems to at debug level. Can be null.
+   * @param closeables the objects to close
+   */
+  def cleanup(log: Logger, closeables: Closeable*): Unit = {
+    closeables.filter(_ != null).foreach { c =>
+      try {
+        c.close()
+      } catch {
+        case e: IOException =>
+          if (log != null && log.isDebugEnabled) {
+            log.debug(s"Exception in closing $c", e)
+          }
+      }
+    }
+  }
 }
diff --git a/kyuubi-common/src/main/scala/org/apache/kyuubi/service/authentication/LdapAuthenticationProviderImpl.scala b/kyuubi-common/src/main/scala/org/apache/kyuubi/service/authentication/LdapAuthenticationProviderImpl.scala
index b5e08def5..06d08f3e4 100644
--- a/kyuubi-common/src/main/scala/org/apache/kyuubi/service/authentication/LdapAuthenticationProviderImpl.scala
+++ b/kyuubi-common/src/main/scala/org/apache/kyuubi/service/authentication/LdapAuthenticationProviderImpl.scala
@@ -17,17 +17,25 @@
 
 package org.apache.kyuubi.service.authentication
 
-import javax.naming.{Context, NamingException}
-import javax.naming.directory.InitialDirContext
+import javax.naming.NamingException
 import javax.security.sasl.AuthenticationException
 
 import org.apache.commons.lang3.StringUtils
 
+import org.apache.kyuubi.Logging
 import org.apache.kyuubi.config.KyuubiConf
-import org.apache.kyuubi.config.KyuubiConf._
 import org.apache.kyuubi.service.ServiceUtils
+import org.apache.kyuubi.service.authentication.LdapAuthenticationProviderImpl.FILTER_FACTORIES
+import org.apache.kyuubi.service.authentication.ldap._
 
-class LdapAuthenticationProviderImpl(conf: KyuubiConf) extends PasswdAuthenticationProvider {
+class LdapAuthenticationProviderImpl(
+    conf: KyuubiConf,
+    searchFactory: DirSearchFactory = new LdapSearchFactory)
+  extends PasswdAuthenticationProvider with Logging {
+
+  private val filterOpt: Option[Filter] = FILTER_FACTORIES
+    .map { f => f.getInstance(conf) }
+    .collectFirst { case Some(f: Filter) => f }
 
   /**
    * The authenticate method is called by the Kyuubi Server authentication layer
@@ -41,47 +49,72 @@ class LdapAuthenticationProviderImpl(conf: KyuubiConf) extends PasswdAuthenticat
    * @throws AuthenticationException When a user is found to be invalid by the implementation
    */
   override def authenticate(user: String, password: String): Unit = {
+
+    val (usedBind, bindUser, bindPassword) = (
+      conf.get(KyuubiConf.AUTHENTICATION_LDAP_BIND_USER),
+      conf.get(KyuubiConf.AUTHENTICATION_LDAP_BIND_PASSWORD)) match {
+      case (Some(_bindUser), Some(_bindPw)) => (true, _bindUser, _bindPw)
+      case _ =>
+        // If no bind user or bind password was specified,
+        // we assume the user we are authenticating has the ability to search
+        // the LDAP tree, so we use it as the "binding" account.
+        // This is the way it worked before bind users were allowed in the LDAP authenticator,
+        // so we keep existing systems working.
+        (false, user, password)
+    }
+
+    var search: DirSearch = null
+    try {
+      search = createDirSearch(bindUser, bindPassword)
+      applyFilter(search, user)
+      if (usedBind) {
+        // If we used the bind user, then we need to authenticate again,
+        // this time using the full user name we got during the bind process.
+        createDirSearch(search.findUserDn(user), password)
+      }
+    } catch {
+      case e: NamingException =>
+        throw new AuthenticationException(
+          s"Unable to find the user in the LDAP tree. ${e.getMessage}")
+    } finally {
+      ServiceUtils.cleanup(logger, search)
+    }
+  }
+
+  @throws[AuthenticationException]
+  private def createDirSearch(user: String, password: String): DirSearch = {
     if (StringUtils.isBlank(user)) {
       throw new AuthenticationException(s"Error validating LDAP user, user is null" +
         s" or contains blank space")
     }
 
-    if (StringUtils.isBlank(password)) {
+    if (StringUtils.isBlank(password) || password.getBytes()(0) == 0) {
       throw new AuthenticationException(s"Error validating LDAP user, password is null" +
         s" or contains blank space")
     }
 
-    val env = new java.util.Hashtable[String, Any]()
-    env.put(Context.INITIAL_CONTEXT_FACTORY, "com.sun.jndi.ldap.LdapCtxFactory")
-    env.put(Context.SECURITY_AUTHENTICATION, "simple")
-
-    conf.get(AUTHENTICATION_LDAP_URL).foreach(env.put(Context.PROVIDER_URL, _))
-
-    val domain = conf.get(AUTHENTICATION_LDAP_DOMAIN)
-    val u =
-      if (!hasDomain(user) && domain.nonEmpty) {
-        user + "@" + domain.get
-      } else {
-        user
+    val principals = LdapUtils.createCandidatePrincipals(conf, user)
+    val iterator = principals.iterator
+    while (iterator.hasNext) {
+      val principal = iterator.next
+      try {
+        return searchFactory.getInstance(conf, principal, password)
+      } catch {
+        case ex: AuthenticationException => if (iterator.isEmpty) throw ex
       }
-
-    val guidKey = conf.get(AUTHENTICATION_LDAP_GUIDKEY)
-    val bindDn = conf.get(AUTHENTICATION_LDAP_BASEDN) match {
-      case Some(dn) => guidKey + "=" + u + "," + dn
-      case _ => u
     }
+    throw new AuthenticationException(s"No candidate principals for $user was found.")
+  }
 
-    env.put(Context.SECURITY_PRINCIPAL, bindDn)
-    env.put(Context.SECURITY_CREDENTIALS, password)
-
-    try {
-      val ctx = new InitialDirContext(env)
-      ctx.close()
-    } catch {
-      case e: NamingException =>
-        throw new AuthenticationException(s"Error validating LDAP user: $bindDn", e)
-    }
+  @throws[AuthenticationException]
+  private def applyFilter(client: DirSearch, user: String): Unit = filterOpt.foreach { filter =>
+    val username = if (LdapUtils.hasDomain(user)) LdapUtils.extractUserName(user) else user
+    filter.apply(client, username)
   }
+}
 
-  private def hasDomain(userName: String): Boolean = ServiceUtils.indexOfDomainMatch(userName) > 0
+object LdapAuthenticationProviderImpl {
+  val FILTER_FACTORIES: Array[FilterFactory] = Array[FilterFactory](
+    CustomQueryFilterFactory,
+    new ChainFilterFactory(UserSearchFilterFactory, UserFilterFactory, GroupFilterFactory))
 }
diff --git a/kyuubi-common/src/main/scala/org/apache/kyuubi/service/authentication/ldap/ChainFilterFactory.scala b/kyuubi-common/src/main/scala/org/apache/kyuubi/service/authentication/ldap/ChainFilterFactory.scala
new file mode 100644
index 000000000..a5badb15d
--- /dev/null
+++ b/kyuubi-common/src/main/scala/org/apache/kyuubi/service/authentication/ldap/ChainFilterFactory.scala
@@ -0,0 +1,44 @@
+/*
+ * 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 javax.security.sasl.AuthenticationException
+
+import org.apache.kyuubi.config.KyuubiConf
+
+/**
+ * A factory that produces a [[Filter]] that is implemented as a chain of other filters.
+ * The chain of filters are created as a result of [[ChainFilterFactory#getInstance]] method call.
+ * The resulting object filters out all users that don't pass all chained filters.
+ * The filters will be applied in the order they are mentioned in the factory constructor.
+ */
+
+class ChainFilterFactory(chainedFactories: FilterFactory*) extends FilterFactory {
+  override def getInstance(conf: KyuubiConf): Option[Filter] = {
+    val maybeFilters = chainedFactories.map(_.getInstance(conf))
+    val filters = maybeFilters.flatten
+    if (filters.isEmpty) None else Some(new ChainFilter(filters))
+  }
+}
+
+class ChainFilter(chainedFilters: Seq[Filter]) extends Filter {
+  @throws[AuthenticationException]
+  override def apply(client: DirSearch, user: String): Unit = {
+    chainedFilters.foreach(_.apply(client, user))
+  }
+}
diff --git a/kyuubi-common/src/main/scala/org/apache/kyuubi/service/authentication/ldap/CustomQueryFilterFactory.scala b/kyuubi-common/src/main/scala/org/apache/kyuubi/service/authentication/ldap/CustomQueryFilterFactory.scala
new file mode 100644
index 000000000..d10e6523b
--- /dev/null
+++ b/kyuubi-common/src/main/scala/org/apache/kyuubi/service/authentication/ldap/CustomQueryFilterFactory.scala
@@ -0,0 +1,77 @@
+/*
+ * 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 javax.naming.NamingException
+import javax.security.sasl.AuthenticationException
+
+import org.apache.kyuubi.Logging
+import org.apache.kyuubi.config.KyuubiConf
+
+/**
+ * A factory for a [[Filter]] based on a custom query.
+ * <br>
+ * The produced filter object filters out all users that are not found in the search result
+ * of the query provided in Kyuubi configuration.
+ *
+ * @see [[KyuubiConf.AUTHENTICATION_LDAP_CUSTOM_LDAP_QUERY]]
+ */
+object CustomQueryFilterFactory extends FilterFactory {
+  override def getInstance(conf: KyuubiConf): Option[Filter] =
+    conf.get(KyuubiConf.AUTHENTICATION_LDAP_CUSTOM_LDAP_QUERY)
+      .map { customQuery => new CustomQueryFilter(customQuery) }
+}
+class CustomQueryFilter(query: String) extends Filter with Logging {
+  @throws[AuthenticationException]
+  override def apply(client: DirSearch, user: String): Unit = {
+    var resultList: Array[String] = null
+    try {
+      resultList = client.executeCustomQuery(query)
+    } catch {
+      case e: NamingException =>
+        throw new AuthenticationException(s"LDAP Authentication failed for $user", e)
+    }
+    if (resultList != null) {
+      resultList.foreach { matchedDn =>
+        val shortUserName = LdapUtils.getShortName(matchedDn)
+        info(s"<queried user=$shortUserName,user=$user>")
+        if (shortUserName.equalsIgnoreCase(user) || matchedDn.equalsIgnoreCase(user)) {
+          info("Authentication succeeded based on result set from LDAP query")
+          return
+        }
+      }
+      // try a generic user search
+      if (query.contains("%s")) {
+        val userSearchQuery = query.replace("%s", user)
+        info("Trying with generic user search in ldap:" + userSearchQuery)
+        try resultList = client.executeCustomQuery(userSearchQuery)
+        catch {
+          case e: NamingException =>
+            throw new AuthenticationException("LDAP Authentication failed for user", e)
+        }
+        if (resultList != null && resultList.length == 1) {
+          info("Authentication succeeded based on result from custom user search query")
+          return
+        }
+      }
+    }
+    info("Authentication failed based on result set from custom LDAP query")
+    throw new AuthenticationException(
+      "Authentication failed: LDAP query from property returned no data")
+  }
+}
diff --git a/kyuubi-common/src/main/scala/org/apache/kyuubi/service/authentication/ldap/DirSearch.scala b/kyuubi-common/src/main/scala/org/apache/kyuubi/service/authentication/ldap/DirSearch.scala
new file mode 100644
index 000000000..c1c4d5060
--- /dev/null
+++ b/kyuubi-common/src/main/scala/org/apache/kyuubi/service/authentication/ldap/DirSearch.scala
@@ -0,0 +1,73 @@
+/*
+ * 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.Closeable
+import javax.naming.NamingException
+
+/**
+ * The object used for executing queries on the Directory Service.
+ */
+trait DirSearch extends Closeable {
+
+  /**
+   * Finds user's distinguished name.
+   *
+   * @param user username
+   * @return DN for the specified username
+   */
+  @throws[NamingException]
+  def findUserDn(user: String): String
+
+  /**
+   * Finds group's distinguished name.
+   *
+   * @param group group name or unique identifier
+   * @return DN for the specified group name
+   */
+  @throws[NamingException]
+  def findGroupDn(group: String): String
+
+  /**
+   * Verifies that specified user is a member of specified group.
+   *
+   * @param user    user id or distinguished name
+   * @param groupDn group's DN
+   * @return true if the user is a member of the group, false - otherwise.
+   */
+  @throws[NamingException]
+  def isUserMemberOfGroup(user: String, groupDn: String): Boolean
+
+  /**
+   * Finds groups that contain the specified user.
+   *
+   * @param userDn user's distinguished name
+   * @return list of groups
+   */
+  @throws[NamingException]
+  def findGroupsForUser(userDn: String): Array[String]
+
+  /**
+   * Executes an arbitrary query.
+   *
+   * @param query any query
+   * @return list of names in the namespace
+   */
+  @throws[NamingException]
+  def executeCustomQuery(query: String): Array[String]
+}
diff --git a/kyuubi-common/src/main/scala/org/apache/kyuubi/service/authentication/ldap/DirSearchFactory.scala b/kyuubi-common/src/main/scala/org/apache/kyuubi/service/authentication/ldap/DirSearchFactory.scala
new file mode 100644
index 000000000..2046632d8
--- /dev/null
+++ b/kyuubi-common/src/main/scala/org/apache/kyuubi/service/authentication/ldap/DirSearchFactory.scala
@@ -0,0 +1,39 @@
+/*
+ * 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 javax.security.sasl.AuthenticationException
+
+import org.apache.kyuubi.config.KyuubiConf
+
+/**
+ * A factory for [[DirSearch]].
+ */
+trait DirSearchFactory {
+
+  /**
+   * Returns an instance of [[DirSearch]].
+   *
+   * @param conf     Kyuubi configuration
+   * @param user     username
+   * @param password user password
+   * @return instance of [[DirSearch]]
+   */
+  @throws[AuthenticationException]
+  def getInstance(conf: KyuubiConf, user: String, password: String): DirSearch
+}
diff --git a/kyuubi-common/src/main/scala/org/apache/kyuubi/service/authentication/ldap/Filter.scala b/kyuubi-common/src/main/scala/org/apache/kyuubi/service/authentication/ldap/Filter.scala
new file mode 100644
index 000000000..e57eddb0d
--- /dev/null
+++ b/kyuubi-common/src/main/scala/org/apache/kyuubi/service/authentication/ldap/Filter.scala
@@ -0,0 +1,37 @@
+/*
+ * 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 javax.security.sasl.AuthenticationException
+
+/**
+ * The object that filters LDAP users.
+ * <br>
+ * The assumption is that this user was already authenticated by a previous bind operation.
+ */
+trait Filter {
+
+  /**
+   * Applies this filter to the authenticated user.
+   *
+   * @param client LDAP client that will be used for execution of LDAP queries.
+   * @param user   username
+   */
+  @throws[AuthenticationException]
+  def apply(client: DirSearch, user: String): Unit
+}
diff --git a/kyuubi-common/src/main/scala/org/apache/kyuubi/service/authentication/ldap/FilterFactory.scala b/kyuubi-common/src/main/scala/org/apache/kyuubi/service/authentication/ldap/FilterFactory.scala
new file mode 100644
index 000000000..d85104684
--- /dev/null
+++ b/kyuubi-common/src/main/scala/org/apache/kyuubi/service/authentication/ldap/FilterFactory.scala
@@ -0,0 +1,34 @@
+/*
+ * 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 org.apache.kyuubi.config.KyuubiConf
+
+/**
+ * Factory for the filter.
+ */
+trait FilterFactory {
+
+  /**
+   * Returns an instance of the corresponding filter.
+   *
+   * @param conf Kyuubi configurations used to configure the filter.
+   * @return Some(filter) or None if this filter doesn't support provided set of properties
+   */
+  def getInstance(conf: KyuubiConf): Option[Filter]
+}
diff --git a/kyuubi-common/src/main/scala/org/apache/kyuubi/service/authentication/ldap/GroupFilterFactory.scala b/kyuubi-common/src/main/scala/org/apache/kyuubi/service/authentication/ldap/GroupFilterFactory.scala
new file mode 100644
index 000000000..fd1c907ec
--- /dev/null
+++ b/kyuubi-common/src/main/scala/org/apache/kyuubi/service/authentication/ldap/GroupFilterFactory.scala
@@ -0,0 +1,108 @@
+/*
+ * 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 javax.naming.NamingException
+import javax.security.sasl.AuthenticationException
+
+import scala.collection.mutable.ArrayBuffer
+
+import org.apache.kyuubi.Logging
+import org.apache.kyuubi.config.KyuubiConf
+
+object GroupFilterFactory extends FilterFactory {
+  override def getInstance(conf: KyuubiConf): Option[Filter] = {
+    val groupFilter = conf.get(KyuubiConf.AUTHENTICATION_LDAP_GROUP_FILTER)
+    if (groupFilter.isEmpty) {
+      None
+    } else if (conf.get(KyuubiConf.AUTHENTICATION_LDAP_USER_MEMBERSHIP_KEY).isDefined) {
+      Some(new UserMembershipKeyFilter(groupFilter))
+    } else {
+      Some(new GroupMembershipKeyFilter(groupFilter))
+    }
+  }
+}
+
+class GroupMembershipKeyFilter(groupFilter: Seq[String]) extends Filter with Logging {
+
+  @throws[AuthenticationException]
+  override def apply(ldap: DirSearch, user: String): Unit = {
+    info(s"Authenticating user '$user' using ${classOf[GroupMembershipKeyFilter].getSimpleName})")
+
+    var memberOf: Array[String] = null
+    try {
+      val userDn = ldap.findUserDn(user)
+      // Workaround for magic things on Mockito:
+      // unmatched invocation returns an empty list if the method return type is JList,
+      // but null if the method return type is Array
+      memberOf = Option(ldap.findGroupsForUser(userDn)).getOrElse(Array.empty)
+      debug(s"User $userDn member of: ${memberOf.mkString(",")}")
+    } catch {
+      case e: NamingException =>
+        throw new AuthenticationException("LDAP Authentication failed for user", e)
+    }
+    memberOf.foreach { groupDn =>
+      val shortName = LdapUtils.getShortName(groupDn)
+      if (groupFilter.exists(shortName.equalsIgnoreCase)) {
+        debug(s"GroupMembershipKeyFilter passes: user '$user' is a member of '$groupDn' group")
+        info("Authentication succeeded based on group membership")
+        return
+      }
+    }
+    info("Authentication failed based on user membership")
+    throw new AuthenticationException(
+      "Authentication failed: User not a member of specified list")
+  }
+}
+
+class UserMembershipKeyFilter(groupFilter: Seq[String]) extends Filter with Logging {
+  @throws[AuthenticationException]
+  override def apply(ldap: DirSearch, user: String): Unit = {
+    info(s"Authenticating user '$user' using $classOf[UserMembershipKeyFilter].getSimpleName")
+    val groupDns = new ArrayBuffer[String]
+    groupFilter.foreach { groupId =>
+      try {
+        val groupDn = ldap.findGroupDn(groupId)
+        groupDns += groupDn
+      } catch {
+        case e: NamingException =>
+          warn("Cannot find DN for group", e)
+          debug(s"Cannot find DN for group $groupId", e)
+      }
+    }
+    if (groupDns.isEmpty) {
+      debug(s"No DN(s) has been found for any of group(s): ${groupFilter.mkString(",")}")
+      throw new AuthenticationException("No DN(s) has been found for any of specified group(s)")
+    }
+    groupDns.foreach { groupDn =>
+      try {
+        if (ldap.isUserMemberOfGroup(user, groupDn)) {
+          debug(s"UserMembershipKeyFilter passes: user '$user' is a member of '$groupDn' group")
+          info("Authentication succeeded based on user membership")
+          return
+        }
+      } catch {
+        case e: NamingException =>
+          warn("Cannot match user and group", e)
+          debug(s"Cannot match user '$user' and group '$groupDn'", e)
+      }
+    }
+    throw new AuthenticationException(
+      s"Authentication failed: User '$user' is not a member of listed groups")
+  }
+}
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
new file mode 100644
index 000000000..09dca1d5c
--- /dev/null
+++ b/kyuubi-common/src/main/scala/org/apache/kyuubi/service/authentication/ldap/LdapSearch.scala
@@ -0,0 +1,126 @@
+/*
+ * 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 javax.naming.{NamingEnumeration, NamingException}
+import javax.naming.directory.{DirContext, SearchResult}
+
+import scala.collection.mutable.ArrayBuffer
+
+import org.apache.kyuubi.Logging
+import org.apache.kyuubi.config.KyuubiConf
+
+/**
+ * Implements search for LDAP.
+ * @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 {
+
+  final private val baseDn = conf.get(KyuubiConf.AUTHENTICATION_LDAP_BASE_DN).orNull
+  final private val groupBases: Array[String] =
+    LdapUtils.patternsToBaseDns(
+      LdapUtils.parseDnPatterns(conf, KyuubiConf.AUTHENTICATION_LDAP_GROUP_DN_PATTERN))
+  final private val userPatterns: Array[String] =
+    LdapUtils.parseDnPatterns(conf, KyuubiConf.AUTHENTICATION_LDAP_USER_DN_PATTERN)
+  final private val userBases: Array[String] = LdapUtils.patternsToBaseDns(userPatterns)
+  final private val queries: QueryFactory = new QueryFactory(conf)
+
+  /**
+   * Closes this search object and releases any system resources associated
+   * with it. If the search object is already closed then invoking this
+   * method has no effect.
+   */
+  override def close(): Unit = {
+    try ctx.close()
+    catch {
+      case e: NamingException =>
+        warn("Exception when closing LDAP context:", e)
+    }
+  }
+
+  @throws[NamingException]
+  override def findUserDn(user: String): String = {
+    var allLdapNames: Array[String] = null
+    if (LdapUtils.isDn(user)) {
+      val userBaseDn: String = LdapUtils.extractBaseDn(user)
+      val userRdn: String = LdapUtils.extractFirstRdn(user)
+      allLdapNames = execute(Array(userBaseDn), queries.findUserDnByRdn(userRdn)).getAllLdapNames
+    } else {
+      allLdapNames = findDnByPattern(userPatterns, user)
+      if (allLdapNames.isEmpty) {
+        allLdapNames = execute(userBases, queries.findUserDnByName(user)).getAllLdapNames
+      }
+    }
+    if (allLdapNames.length == 1) allLdapNames.head
+    else {
+      info(s"Expected exactly one user result for the user: $user, " +
+        s"but got ${allLdapNames.length}. Returning null")
+      debug("Matched users: $allLdapNames")
+      null
+    }
+  }
+
+  @throws[NamingException]
+  private def findDnByPattern(patterns: Seq[String], name: String): Array[String] = {
+    for (pattern <- patterns) {
+      val baseDnFromPattern: String = LdapUtils.extractBaseDn(pattern)
+      val rdn = LdapUtils.extractFirstRdn(pattern).replaceAll("%s", name)
+      val names = execute(Array(baseDnFromPattern), queries.findDnByPattern(rdn)).getAllLdapNames
+      if (!names.isEmpty) return names
+    }
+    Array.empty
+  }
+
+  @throws[NamingException]
+  override def findGroupDn(group: String): String =
+    execute(groupBases, queries.findGroupDnById(group)).getSingleLdapName
+
+  @throws[NamingException]
+  override def isUserMemberOfGroup(user: String, groupDn: String): Boolean = {
+    val userId = LdapUtils.extractUserName(user)
+    execute(userBases, queries.isUserMemberOfGroup(userId, groupDn)).hasSingleResult
+  }
+
+  @throws[NamingException]
+  override def findGroupsForUser(userDn: String): Array[String] = {
+    val userName = LdapUtils.extractUserName(userDn)
+    execute(groupBases, queries.findGroupsForUser(userName, userDn)).getAllLdapNames
+  }
+
+  @throws[NamingException]
+  override def executeCustomQuery(query: String): Array[String] =
+    execute(Array(baseDn), queries.customQuery(query)).getAllLdapNamesAndAttributes
+
+  private def execute(baseDns: Array[String], query: Query): SearchResultHandler = {
+    val searchResults = new ArrayBuffer[NamingEnumeration[SearchResult]]
+    debug(s"Executing a query: '${query.filter}' with base DNs ${baseDns.mkString(",")}")
+    baseDns.foreach { baseDn =>
+      try {
+        val searchResult = ctx.search(baseDn, query.filter, query.controls)
+        if (searchResult != null) searchResults += searchResult
+      } catch {
+        case ex: NamingException =>
+          debug(
+            s"Exception happened for query '${query.filter}' with base DN '$baseDn'",
+            ex)
+      }
+    }
+    new SearchResultHandler(searchResults.toArray)
+  }
+}
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
new file mode 100644
index 000000000..e3649d359
--- /dev/null
+++ b/kyuubi-common/src/main/scala/org/apache/kyuubi/service/authentication/ldap/LdapSearchFactory.scala
@@ -0,0 +1,56 @@
+/*
+ * 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.util
+import javax.naming.{Context, NamingException}
+import javax.naming.directory.{DirContext, InitialDirContext}
+import javax.security.sasl.AuthenticationException
+
+import org.apache.kyuubi.Logging
+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)
+    } catch {
+      case e: NamingException =>
+        debug(s"Could not connect to the LDAP Server: Authentication failed for $principal")
+        throw new AuthenticationException(s"Error validating LDAP user: $principal", e)
+    }
+  }
+
+  @throws[NamingException]
+  private def createDirContext(
+      conf: KyuubiConf,
+      principal: String,
+      password: String): DirContext = {
+    val ldapUrl = conf.get(KyuubiConf.AUTHENTICATION_LDAP_URL)
+    val env = new util.Hashtable[String, AnyRef]
+    ldapUrl.foreach(env.put(Context.PROVIDER_URL, _))
+    env.put(Context.INITIAL_CONTEXT_FACTORY, "com.sun.jndi.ldap.LdapCtxFactory")
+    env.put(Context.SECURITY_AUTHENTICATION, "simple")
+    env.put(Context.SECURITY_PRINCIPAL, principal)
+    env.put(Context.SECURITY_CREDENTIALS, password)
+    debug(s"Connecting using principal $principal to ldap server: ${ldapUrl.orNull}")
+    new InitialDirContext(env)
+  }
+}
diff --git a/kyuubi-common/src/main/scala/org/apache/kyuubi/service/authentication/ldap/LdapUtils.scala b/kyuubi-common/src/main/scala/org/apache/kyuubi/service/authentication/ldap/LdapUtils.scala
new file mode 100644
index 000000000..a48f9f48f
--- /dev/null
+++ b/kyuubi-common/src/main/scala/org/apache/kyuubi/service/authentication/ldap/LdapUtils.scala
@@ -0,0 +1,199 @@
+/*
+ * 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 scala.collection.mutable.ArrayBuffer
+
+import org.apache.kyuubi.Logging
+import org.apache.kyuubi.config.{KyuubiConf, OptionalConfigEntry}
+import org.apache.kyuubi.service.ServiceUtils
+
+/**
+ * Static utility methods related to LDAP authentication module.
+ */
+object LdapUtils extends Logging {
+
+  /**
+   * Extracts a base DN from the provided distinguished name.
+   * <br>
+   * <b>Example:</b>
+   * <br>
+   * "ou=CORP,dc=mycompany,dc=com" is the base DN for "cn=user1,ou=CORP,dc=mycompany,dc=com"
+   *
+   * @param dn distinguished name
+   * @return base DN
+   */
+  def extractBaseDn(dn: String): String = {
+    val indexOfFirstDelimiter = dn.indexOf(",")
+    if (indexOfFirstDelimiter > -1) {
+      return dn.substring(indexOfFirstDelimiter + 1)
+    }
+    null
+  }
+
+  /**
+   * Extracts the first Relative Distinguished Name (RDN).
+   * <br>
+   * <b>Example:</b>
+   * <br>
+   * For DN "cn=user1,ou=CORP,dc=mycompany,dc=com" this method will return "cn=user1"
+   *
+   * @param dn distinguished name
+   * @return first RDN
+   */
+  def extractFirstRdn(dn: String): String = dn.substring(0, dn.indexOf(","))
+
+  /**
+   * Extracts username from user DN.
+   * <br>
+   * <b>Examples:</b>
+   * <pre>
+   * LdapUtils.extractUserName("UserName")                        = "UserName"
+   * LdapUtils.extractUserName("UserName@mycorp.com")             = "UserName"
+   * LdapUtils.extractUserName("cn=UserName,dc=mycompany,dc=com") = "UserName"
+   * </pre>
+   */
+  def extractUserName(userDn: String): String = {
+    if (!isDn(userDn) && !hasDomain(userDn)) {
+      return userDn
+    }
+    val domainIdx: Int = ServiceUtils.indexOfDomainMatch(userDn)
+    if (domainIdx > 0) {
+      return userDn.substring(0, domainIdx)
+    }
+    if (userDn.contains("=")) {
+      return userDn.substring(userDn.indexOf("=") + 1, userDn.indexOf(","))
+    }
+    userDn
+  }
+
+  /**
+   * Gets value part of the first attribute in the provided RDN.
+   * <br>
+   * <b>Example:</b>
+   * <br>
+   * For RDN "cn=user1,ou=CORP" this method will return "user1"
+   *
+   * @param rdn Relative Distinguished Name
+   * @return value part of the first attribute
+   */
+  def getShortName(rdn: String): String = rdn.split(",")(0).split("=")(1)
+
+  /**
+   * Check for a domain part in the provided username.
+   * <br>
+   * <b>Example:</b>
+   * <br>
+   * <pre>
+   * LdapUtils.hasDomain("user1@mycorp.com") = true
+   * LdapUtils.hasDomain("user1")            = false
+   * </pre>
+   *
+   * @param userName username
+   * @return true if `userName`` contains `@<domain>` part
+   */
+  def hasDomain(userName: String): Boolean = {
+    ServiceUtils.indexOfDomainMatch(userName) > 0
+  }
+
+  /**
+   * Detects DN names.
+   * <br>
+   * <b>Example:</b>
+   * <br>
+   * <pre>
+   * LdapUtils.isDn("cn=UserName,dc=mycompany,dc=com") = true
+   * LdapUtils.isDn("user1")                           = false
+   * </pre>
+   *
+   * @param name name to be checked
+   * @return true if the provided name is a distinguished name
+   */
+  def isDn(name: String): Boolean = {
+    name.contains("=")
+  }
+
+  /**
+   * Reads and parses DN patterns from Kyuubi configuration.
+   * <br>
+   * If no patterns are provided in the configuration, then the base DN will be used.
+   *
+   * @param conf Kyuubi configuration
+   * @param confKey  configuration key to be read
+   * @return a list of DN patterns
+   * @see [[KyuubiConf.AUTHENTICATION_LDAP_BASE_DN]]
+   * @see [[KyuubiConf.AUTHENTICATION_LDAP_GUID_KEY]]
+   * @see [[KyuubiConf.AUTHENTICATION_LDAP_GROUP_DN_PATTERN]]
+   * @see [[KyuubiConf.AUTHENTICATION_LDAP_USER_DN_PATTERN]]
+   */
+  def parseDnPatterns(conf: KyuubiConf, confKey: OptionalConfigEntry[String]): Array[String] = {
+    val result = new ArrayBuffer[String]
+    conf.get(confKey).map { patternsString =>
+      patternsString.split(":").foreach { pattern =>
+        if (pattern.contains(",") && pattern.contains("=")) {
+          result += pattern
+        } else {
+          warn(s"Unexpected format for $confKey, ignoring $pattern")
+        }
+      }
+    }.getOrElse {
+      val guidAttr = conf.get(KyuubiConf.AUTHENTICATION_LDAP_GUID_KEY)
+      conf.get(KyuubiConf.AUTHENTICATION_LDAP_BASE_DN).foreach { defaultBaseDn =>
+        result += s"$guidAttr=%s,$defaultBaseDn"
+      }
+    }
+    result.toArray
+  }
+
+  private def patternToBaseDn(pattern: String): String =
+    if (pattern.contains("=%s")) pattern.split(",", 2)(1) else pattern
+
+  /**
+   * Converts a collection of Distinguished Name patterns to a collection of base DNs.
+   *
+   * @param patterns Distinguished Name patterns
+   * @return a list of base DNs
+   * @see [[KyuubiConf.AUTHENTICATION_LDAP_GROUP_DN_PATTERN]]
+   * @see [[KyuubiConf.AUTHENTICATION_LDAP_USER_DN_PATTERN]]
+   */
+  def patternsToBaseDns(patterns: Array[String]): Array[String] = {
+    patterns.map(patternToBaseDn)
+  }
+
+  /**
+   * Creates a list of principals to be used for user authentication.
+   *
+   * @param conf Kyuubi configuration
+   * @param user username
+   * @return a list of user's principals
+   */
+  def createCandidatePrincipals(conf: KyuubiConf, user: String): Array[String] = {
+    if (hasDomain(user) || isDn(user)) {
+      return Array(user)
+    }
+    conf.get(KyuubiConf.AUTHENTICATION_LDAP_DOMAIN).map { ldapDomain =>
+      Array(user + "@" + ldapDomain)
+    }.getOrElse {
+      val userPatterns = parseDnPatterns(conf, KyuubiConf.AUTHENTICATION_LDAP_USER_DN_PATTERN)
+      if (userPatterns.isEmpty) {
+        return Array(user)
+      }
+      userPatterns.map(_.replaceAll("%s", user))
+    }
+  }
+}
diff --git a/kyuubi-common/src/main/scala/org/apache/kyuubi/service/authentication/ldap/Query.scala b/kyuubi-common/src/main/scala/org/apache/kyuubi/service/authentication/ldap/Query.scala
new file mode 100644
index 000000000..ce9a7d472
--- /dev/null
+++ b/kyuubi-common/src/main/scala/org/apache/kyuubi/service/authentication/ldap/Query.scala
@@ -0,0 +1,136 @@
+/*
+ * 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.util
+import javax.naming.directory.SearchControls
+
+import org.stringtemplate.v4.ST
+
+/**
+ * The object that encompasses all components of a Directory Service search query.
+ *
+ * @see [[LdapSearch]]
+ */
+object Query {
+
+  /**
+   * Creates Query Builder.
+   *
+   * @return query builder.
+   */
+  def builder: Query.QueryBuilder = new Query.QueryBuilder
+
+  /**
+   * A builder of the [[Query]].
+   */
+  final class QueryBuilder {
+    private var filterTemplate: ST = _
+    private val controls: SearchControls = {
+      val _controls = new SearchControls
+      _controls.setSearchScope(SearchControls.SUBTREE_SCOPE)
+      _controls.setReturningAttributes(new Array[String](0))
+      _controls
+    }
+    private val returningAttributes: util.List[String] = new util.ArrayList[String]
+
+    /**
+     * Sets search filter template.
+     *
+     * @param filterTemplate search filter template
+     * @return the current instance of the builder
+     */
+    def filter(filterTemplate: String): Query.QueryBuilder = {
+      this.filterTemplate = new ST(filterTemplate)
+      this
+    }
+
+    /**
+     * Sets mapping between names in the search filter template and actual values.
+     *
+     * @param key   marker in the search filter template.
+     * @param value actual value
+     * @return the current instance of the builder
+     */
+    def map(key: String, value: String): Query.QueryBuilder = {
+      filterTemplate.add(key, value)
+      this
+    }
+
+    /**
+     * Sets mapping between names in the search filter template and actual values.
+     *
+     * @param key    marker in the search filter template.
+     * @param values array of values
+     * @return the current instance of the builder
+     */
+    def map(key: String, values: Array[String]): Query.QueryBuilder = {
+      filterTemplate.add(key, values)
+      this
+    }
+
+    /**
+     * Sets attribute that should be returned in results for the query.
+     *
+     * @param attributeName attribute name
+     * @return the current instance of the builder
+     */
+    def returnAttribute(attributeName: String): Query.QueryBuilder = {
+      returningAttributes.add(attributeName)
+      this
+    }
+
+    /**
+     * Sets the maximum number of entries to be returned as a result of the search.
+     * <br>
+     * 0 indicates no limit: all entries will be returned.
+     *
+     * @param limit The maximum number of entries that will be returned.
+     * @return the current instance of the builder
+     */
+    def limit(limit: Int): Query.QueryBuilder = {
+      controls.setCountLimit(limit)
+      this
+    }
+
+    private def validate(): Unit = {
+      require(filterTemplate != null, "filter is required for LDAP search query")
+    }
+
+    private def createFilter: String = filterTemplate.render
+
+    private def updateControls(): Unit = {
+      if (!returningAttributes.isEmpty) controls.setReturningAttributes(
+        returningAttributes.toArray(new Array[String](returningAttributes.size)))
+    }
+
+    /**
+     * Builds an instance of [[Query]].
+     *
+     * @return configured directory service query
+     */
+    def build: Query = {
+      validate()
+      val filter: String = createFilter
+      updateControls()
+      new Query(filter, controls)
+    }
+  }
+}
+
+case class Query(filter: String, controls: SearchControls)
diff --git a/kyuubi-common/src/main/scala/org/apache/kyuubi/service/authentication/ldap/QueryFactory.scala b/kyuubi-common/src/main/scala/org/apache/kyuubi/service/authentication/ldap/QueryFactory.scala
new file mode 100644
index 000000000..849006e38
--- /dev/null
+++ b/kyuubi-common/src/main/scala/org/apache/kyuubi/service/authentication/ldap/QueryFactory.scala
@@ -0,0 +1,145 @@
+/*
+ * 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 org.apache.kyuubi.config.KyuubiConf
+
+/**
+ * A factory for common types of directory service search queries.
+ */
+final class QueryFactory(conf: KyuubiConf) {
+  private val USER_OBJECT_CLASSES = Array("person", "user", "inetOrgPerson")
+
+  private val guidAttr = conf.get(KyuubiConf.AUTHENTICATION_LDAP_GUID_KEY)
+  private val groupClassAttr = conf.get(KyuubiConf.AUTHENTICATION_LDAP_GROUP_CLASS_KEY)
+  private val groupMembershipAttr = conf.get(KyuubiConf.AUTHENTICATION_LDAP_GROUP_MEMBERSHIP_KEY)
+  private val userMembershipAttrOpt = conf.get(KyuubiConf.AUTHENTICATION_LDAP_USER_MEMBERSHIP_KEY)
+
+  /**
+   * Returns a query for finding Group DN based on group unique ID.
+   *
+   * @param groupId group unique identifier
+   * @return an instance of [[Query]]
+   */
+  def findGroupDnById(groupId: String): Query = Query.builder
+    .filter("(&(objectClass=<groupClassAttr>)(<guidAttr>=<groupID>))")
+    .map("guidAttr", guidAttr)
+    .map("groupClassAttr", groupClassAttr)
+    .map("groupID", groupId).limit(2)
+    .build
+
+  /**
+   * Returns a query for finding user DN based on user RDN.
+   *
+   * @param userRdn user RDN
+   * @return an instance of [[Query]]
+   */
+  def findUserDnByRdn(userRdn: String): Query = Query.builder
+    .filter("(&(|<classes:{ class |(objectClass=<class>)}>)(<userRdn>))")
+    .limit(2)
+    .map("classes", USER_OBJECT_CLASSES)
+    .map("userRdn", userRdn).build
+
+  /**
+   * Returns a query for finding user DN based on DN pattern.
+   * <br>
+   * Name of this method was derived from the original implementation of LDAP authentication.
+   * This method should be replaced by [[QueryFactory.findUserDnByRdn]].
+   *
+   * @param rdn user RDN
+   * @return an instance of [[Query]]
+   */
+  def findDnByPattern(rdn: String): Query = Query.builder
+    .filter("(<rdn>)")
+    .map("rdn", rdn)
+    .limit(2)
+    .build
+
+  /**
+   * Returns a query for finding user DN based on user unique name.
+   *
+   * @param userName user unique name (uid or sAMAccountName)
+   * @return an instance of [[Query]]
+   */
+  def findUserDnByName(userName: String): Query = Query.builder
+    .filter("(&(|<classes:{ class |(objectClass=<class>)}>)" +
+      "(|(uid=<userName>)(sAMAccountName=<userName>)))")
+    .map("classes", USER_OBJECT_CLASSES)
+    .map("userName", userName)
+    .limit(2)
+    .build
+
+  /**
+   * Returns a query for finding groups to which the user belongs.
+   *
+   * @param userName username
+   * @param userDn   user DN
+   * @return an instance of [[Query]]
+   */
+  def findGroupsForUser(userName: String, userDn: String): Query = Query.builder
+    .filter("(&(objectClass=<groupClassAttr>)" +
+      "(|(<groupMembershipAttr>=<userDn>)(<groupMembershipAttr>=<userName>)))")
+    .map("groupClassAttr", groupClassAttr)
+    .map("groupMembershipAttr", groupMembershipAttr)
+    .map("userName", userName)
+    .map("userDn", userDn)
+    .build
+
+  /**
+   * Returns a query for checking whether specified user is a member of specified group.
+   *
+   * The query requires [[KyuubiConf.AUTHENTICATION_LDAP_USER_MEMBERSHIP_KEY]]
+   * configuration property to be set.
+   *
+   * @param userId  user unique identifier
+   * @param groupDn group DN
+   * @return an instance of [[Query]]
+   * @see [[KyuubiConf.AUTHENTICATION_LDAP_USER_MEMBERSHIP_KEY]]
+   */
+  def isUserMemberOfGroup(userId: String, groupDn: String): Query = {
+    require(
+      userMembershipAttrOpt.isDefined,
+      s"${KyuubiConf.AUTHENTICATION_LDAP_USER_MEMBERSHIP_KEY.key} is not configured.")
+
+    Query.builder
+      .filter("(&(|<classes:{ class |(objectClass=<class>)}>)" +
+        "(<userMembershipAttr>=<groupDn>)(<guidAttr>=<userId>))")
+      .map("classes", USER_OBJECT_CLASSES)
+      .map("guidAttr", guidAttr)
+      .map("userMembershipAttr", userMembershipAttrOpt.get)
+      .map("userId", userId)
+      .map("groupDn", groupDn)
+      .limit(2)
+      .build
+  }
+
+  /**
+   * Returns a query object created for the custom filter.
+   * <br>
+   * This query is configured to return a group membership attribute as part of the search result.
+   *
+   * @param searchFilter custom search filter
+   * @return an instance of [[Query]]
+   */
+  def customQuery(searchFilter: String): Query = {
+    val builder = Query.builder
+    builder.filter(searchFilter)
+    builder.returnAttribute(groupMembershipAttr)
+    builder.build
+  }
+}
diff --git a/kyuubi-common/src/main/scala/org/apache/kyuubi/service/authentication/ldap/SearchResultHandler.scala b/kyuubi-common/src/main/scala/org/apache/kyuubi/service/authentication/ldap/SearchResultHandler.scala
new file mode 100644
index 000000000..52d5b6a90
--- /dev/null
+++ b/kyuubi-common/src/main/scala/org/apache/kyuubi/service/authentication/ldap/SearchResultHandler.scala
@@ -0,0 +1,148 @@
+/*
+ * 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 javax.naming.{NamingEnumeration, NamingException}
+import javax.naming.directory.SearchResult
+
+import scala.collection.mutable.ArrayBuffer
+
+import org.apache.kyuubi.Logging
+
+/**
+ * The object that handles Directory Service search results.
+ * In most cases it converts search results into a list of names in the namespace.
+ */
+object SearchResultHandler {
+
+  /**
+   * An interface used by [[SearchResultHandler]] for processing records of
+   * a [[SearchResult]] on a per-record basis.
+   * <br>
+   * Implementations of this interface perform the actual work of processing each record,
+   * but don't need to worry about exception handling, closing underlying data structures,
+   * and combining results from several search requests.
+   *
+   * @see SearchResultHandler
+   */
+  trait RecordProcessor extends (SearchResult => Boolean) {
+
+    /**
+     * Implementations must implement this method to process each record in [[SearchResult]].
+     *
+     * @param record the [[SearchResult]] to precess
+     * @return true to continue processing, false to stop iterating
+     *         over search results
+     */
+    @throws[NamingException]
+    override def apply(record: SearchResult): Boolean
+  }
+}
+
+/**
+ * Constructs a search result handler object for the provided search results.
+ *
+ * @param searchResults directory service search results
+ */
+class SearchResultHandler(val searchResults: Array[NamingEnumeration[SearchResult]])
+  extends Logging {
+
+  /**
+   * Returns all entries from the search result.
+   *
+   * @return a list of names in the namespace
+   */
+  @throws[NamingException]
+  def getAllLdapNames: Array[String] = {
+    val result = new ArrayBuffer[String]
+    handle { record => result += record.getNameInNamespace; true }
+    result.toArray
+  }
+
+  /**
+   * Checks whether search result contains exactly one entry.
+   *
+   * @return true if the search result contains a single entry.
+   */
+  @throws[NamingException]
+  def hasSingleResult: Boolean = {
+    val allResults = getAllLdapNames
+    allResults != null && allResults.length == 1
+  }
+
+  /**
+   * Returns a single entry from the search result.
+   * Throws [[NamingException]] if the search result doesn't contain exactly one entry.
+   *
+   * @return name in the namespace
+   */
+  @throws[NamingException]
+  def getSingleLdapName: String = {
+    val allLdapNames = getAllLdapNames
+    if (allLdapNames.length == 1) return allLdapNames.head
+    throw new NamingException("Single result was expected")
+  }
+
+  /**
+   * Returns all entries and all attributes for these entries.
+   *
+   * @return a list that includes all entries and all attributes from these entries.
+   */
+  @throws[NamingException]
+  def getAllLdapNamesAndAttributes: Array[String] = {
+    val result = new ArrayBuffer[String]
+
+    @throws[NamingException]
+    def addAllAttributeValuesToResult(values: NamingEnumeration[_]): Unit = {
+      while (values.hasMore) result += String.valueOf(values.next)
+    }
+    handle { record =>
+      result += record.getNameInNamespace
+      val allAttributes = record.getAttributes.getAll
+      while (allAttributes.hasMore) {
+        val attribute = allAttributes.next
+        addAllAttributeValuesToResult(attribute.getAll)
+      }
+      true
+    }
+    result.toArray
+  }
+
+  /**
+   * Allows for custom processing of the search results.
+   *
+   * @param processor [[SearchResultHandler.RecordProcessor]] implementation
+   */
+  @throws[NamingException]
+  def handle(processor: SearchResultHandler.RecordProcessor): Unit = {
+    try {
+      searchResults.foreach { searchResult =>
+        while (searchResult.hasMore) if (!processor.apply(searchResult.next)) return
+      }
+    } finally {
+      searchResults.foreach { searchResult =>
+        try {
+          searchResult.close()
+        } catch {
+          case ex: NamingException =>
+            warn("Failed to close LDAP search result", ex)
+        }
+      }
+    }
+  }
+}
diff --git a/kyuubi-common/src/main/scala/org/apache/kyuubi/service/authentication/ldap/UserFilterFactory.scala b/kyuubi-common/src/main/scala/org/apache/kyuubi/service/authentication/ldap/UserFilterFactory.scala
new file mode 100644
index 000000000..7c2f22ed8
--- /dev/null
+++ b/kyuubi-common/src/main/scala/org/apache/kyuubi/service/authentication/ldap/UserFilterFactory.scala
@@ -0,0 +1,46 @@
+/*
+ * 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 javax.security.sasl.AuthenticationException
+
+import org.apache.kyuubi.Logging
+import org.apache.kyuubi.config.KyuubiConf
+
+object UserFilterFactory extends FilterFactory with Logging {
+  override def getInstance(conf: KyuubiConf): Option[Filter] = {
+    val userFilter = conf.get(KyuubiConf.AUTHENTICATION_LDAP_USER_FILTER)
+    if (userFilter.isEmpty) None else Some(new UserFilter(userFilter))
+  }
+}
+
+class UserFilter(_userFilter: Seq[String]) extends Filter with Logging {
+
+  lazy val userFilter: Seq[String] = _userFilter.map(_.toLowerCase)
+
+  @throws[AuthenticationException]
+  override def apply(ldap: DirSearch, user: String): Unit = {
+    info("Authenticating user '$user' using user filter")
+    val userName = LdapUtils.extractUserName(user).toLowerCase
+    if (!userFilter.contains(userName)) {
+      info("Authentication failed based on user membership")
+      throw new AuthenticationException(
+        "Authentication failed: User not a member of specified list")
+    }
+  }
+}
diff --git a/kyuubi-common/src/main/scala/org/apache/kyuubi/service/authentication/ldap/UserSearchFilterFactory.scala b/kyuubi-common/src/main/scala/org/apache/kyuubi/service/authentication/ldap/UserSearchFilterFactory.scala
new file mode 100644
index 000000000..9e8bdf364
--- /dev/null
+++ b/kyuubi-common/src/main/scala/org/apache/kyuubi/service/authentication/ldap/UserSearchFilterFactory.scala
@@ -0,0 +1,53 @@
+/*
+ * 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 javax.naming.NamingException
+import javax.security.sasl.AuthenticationException
+
+import org.apache.kyuubi.config.KyuubiConf
+
+/**
+ * A factory for a [[Filter]] that check whether provided user could be found in the directory.
+ * <br>
+ * The produced filter object filters out all users that are not found in the directory.
+ */
+object UserSearchFilterFactory extends FilterFactory {
+  override def getInstance(conf: KyuubiConf): Option[Filter] = {
+    val groupFilter = conf.get(KyuubiConf.AUTHENTICATION_LDAP_GROUP_FILTER)
+    val userFilter = conf.get(KyuubiConf.AUTHENTICATION_LDAP_USER_FILTER)
+    if (groupFilter.isEmpty && userFilter.isEmpty) None else Some(UserSearchFilter)
+  }
+}
+
+object UserSearchFilter extends Filter {
+  @throws[AuthenticationException]
+  override def apply(client: DirSearch, user: String): Unit = {
+    try {
+      val userDn = client.findUserDn(user)
+      // This should not be null because we were allowed to bind with this username
+      // safe check in case we were able to bind anonymously.
+      if (userDn == null) {
+        throw new AuthenticationException("Authentication failed: User search failed")
+      }
+    } catch {
+      case e: NamingException =>
+        throw new AuthenticationException("LDAP Authentication failed for user", e)
+    }
+  }
+}
diff --git a/kyuubi-common/src/test/resources/ldap/ad.example.com.ldif b/kyuubi-common/src/test/resources/ldap/ad.example.com.ldif
new file mode 100644
index 000000000..68cd01d0f
--- /dev/null
+++ b/kyuubi-common/src/test/resources/ldap/ad.example.com.ldif
@@ -0,0 +1,150 @@
+#
+# 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.
+#
+
+dn: dc=ad,dc=example,dc=com
+dc: ad
+objectClass: top
+objectClass: domain
+
+dn: ou=Engineering,dc=ad,dc=example,dc=com
+objectClass: top
+objectClass: organizationalUnit
+ou: Engineering
+
+dn: ou=Management,dc=ad,dc=example,dc=com
+objectClass: top
+objectClass: organizationalUnit
+ou: Management
+
+dn: ou=Administration,dc=ad,dc=example,dc=com
+objectClass: top
+objectClass: organizationalUnit
+ou: Administration
+
+dn: ou=Teams,dc=ad,dc=example,dc=com
+objectClass: top
+objectClass: organizationalUnit
+ou: Teams
+
+dn: ou=Resources,dc=ad,dc=example,dc=com
+objectClass: top
+objectClass: organizationalUnit
+ou: Resources
+
+dn: cn=Team 1,ou=Teams,dc=ad,dc=example,dc=com
+objectClass: top
+objectClass: groupOfNames
+objectClass: microsoftSecurityPrincipal
+sAMAccountName: team1
+cn: Team 1
+member: sAMAccountName=engineer1,ou=Engineering,dc=ad,dc=example,dc=com
+member: sAMAccountName=manager1,ou=Management,dc=ad,dc=example,dc=com
+
+dn: cn=Team 2,ou=Teams,dc=ad,dc=example,dc=com
+objectClass: top
+objectClass: groupOfNames
+objectClass: microsoftSecurityPrincipal
+sAMAccountName: team2
+cn: Team 2
+member: sAMAccountName=engineer2,ou=Engineering,dc=ad,dc=example,dc=com
+member: sAMAccountName=manager2,ou=Management,dc=ad,dc=example,dc=com
+
+dn: cn=Resource 1,ou=Resources,dc=ad,dc=example,dc=com
+objectClass: top
+objectClass: groupOfNames
+objectClass: microsoftSecurityPrincipal
+sAMAccountName: resource1
+cn: Resource 1
+member: sAMAccountName=engineer1,ou=Engineering,dc=ad,dc=example,dc=com
+
+dn: cn=Resource 2,ou=Resources,dc=ad,dc=example,dc=com
+objectClass: top
+objectClass: groupOfNames
+objectClass: microsoftSecurityPrincipal
+sAMAccountName: resource2
+cn: Resource 2
+member: sAMAccountName=engineer2,ou=Engineering,dc=ad,dc=example,dc=com
+
+dn: cn=Admins,ou=Administration,dc=ad,dc=example,dc=com
+objectClass: top
+objectClass: groupOfUniqueNames
+objectClass: microsoftSecurityPrincipal
+sAMAccountName: admins
+cn: Admins
+uniqueMember: sAMAccountName=admin1,ou=Administration,dc=ad,dc=example,dc=com
+
+dn: sAMAccountName=engineer1,ou=Engineering,dc=ad,dc=example,dc=com
+objectClass: top
+objectClass: person
+objectClass: organizationalPerson
+objectClass: inetOrgPerson
+objectClass: microsoftSecurityPrincipal
+sAMAccountName: engineer1
+cn: Engineer 1
+sn: Surname 1
+userPassword: engineer1-password
+memberOf: cn=Team 1,ou=Teams,dc=ad,dc=example,dc=com
+memberOf: cn=Resource 1,ou=Resources,dc=ad,dc=example,dc=com
+
+dn: sAMAccountName=engineer2,ou=Engineering,dc=ad,dc=example,dc=com
+objectClass: top
+objectClass: person
+objectClass: organizationalPerson
+objectClass: inetOrgPerson
+objectClass: microsoftSecurityPrincipal
+sAMAccountName: engineer2
+cn: Engineer 2
+sn: Surname 2
+userPassword: engineer2-password
+memberOf: cn=Team 2,ou=Teams,dc=ad,dc=example,dc=com
+memberOf: cn=Resource 2,ou=Resources,dc=ad,dc=example,dc=com
+
+dn: sAMAccountName=manager1,ou=Management,dc=ad,dc=example,dc=com
+objectClass: top
+objectClass: person
+objectClass: organizationalPerson
+objectClass: inetOrgPerson
+objectClass: microsoftSecurityPrincipal
+sAMAccountName: manager1
+cn: Manager 1
+sn: Surname 1
+userPassword: manager1-password
+memberOf: cn=Team 1,ou=Teams,dc=ad,dc=example,dc=com
+
+dn: sAMAccountName=manager2,ou=Management,dc=ad,dc=example,dc=com
+objectClass: top
+objectClass: person
+objectClass: organizationalPerson
+objectClass: inetOrgPerson
+objectClass: microsoftSecurityPrincipal
+sAMAccountName: manager2
+cn: Manager 2
+sn: Surname 2
+userPassword: manager2-password
+memberOf: cn=Team 2,ou=Teams,dc=ad,dc=example,dc=com
+
+dn: sAMAccountName=admin1,ou=Administration,dc=ad,dc=example,dc=com
+objectClass: top
+objectClass: person
+objectClass: organizationalPerson
+objectClass: inetOrgPerson
+objectClass: microsoftSecurityPrincipal
+sAMAccountName: admin1
+cn: Admin 1
+sn: Surname 1
+userPassword: admin1-password
+memberOf: cn=Admins,ou=Administration,dc=ad,dc=example,dc=com
diff --git a/kyuubi-common/src/test/resources/ldap/example.com.ldif b/kyuubi-common/src/test/resources/ldap/example.com.ldif
new file mode 100644
index 000000000..f19eb2f93
--- /dev/null
+++ b/kyuubi-common/src/test/resources/ldap/example.com.ldif
@@ -0,0 +1,113 @@
+#
+# 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.
+#
+
+dn: ou=People,dc=example,dc=com
+objectClass: top
+objectClass: organizationalUnit
+ou: People
+description: Contains entries which describe persons (seamen)
+
+dn: ou=Groups,dc=example,dc=com
+objectClass: top
+objectClass: organizationalUnit
+ou: Groups
+description: Contains entries which describe groups (crews, for instance)
+
+dn: uid=group1,ou=Groups,dc=example,dc=com
+objectClass: top
+objectClass: groupOfNames
+objectClass: uidObject
+uid: group1
+cn: group1
+ou: Groups
+member: uid=user1,ou=People,dc=example,dc=com
+
+dn: uid=group2,ou=Groups,dc=example,dc=com
+objectClass: top
+objectClass: groupOfNames
+objectClass: uidObject
+uid: group2
+cn: group2
+ou: Groups
+member: uid=user2,ou=People,dc=example,dc=com
+
+dn: cn=group3,ou=Groups,dc=example,dc=com
+objectClass: top
+objectClass: groupOfNames
+objectClass: uidObject
+uid: group3
+cn: group3
+ou: Groups
+member: cn=user3,ou=People,dc=example,dc=com
+
+dn: cn=group4,ou=Groups,dc=example,dc=com
+objectClass: top
+objectClass: groupOfUniqueNames
+objectClass: uidObject
+uid: group4
+ou: Groups
+cn: group4
+uniqueMember: cn=user4,ou=People,dc=example,dc=com
+
+dn: uid=user1,ou=People,dc=example,dc=com
+objectClass: top
+objectClass: person
+objectClass: organizationalPerson
+objectClass: inetOrgPerson
+objectClass: uidObject
+givenName: Test1
+cn: Test User1
+sn: user1
+uid: user1
+userPassword: user1
+
+dn: uid=user2,ou=People,dc=example,dc=com
+objectClass: top
+objectClass: person
+objectClass: organizationalPerson
+objectClass: inetOrgPerson
+objectClass: uidObject
+givenName: Test2
+cn: Test User2
+sn: user2
+uid: user2
+userPassword: user2
+
+dn: cn=user3,ou=People,dc=example,dc=com
+objectClass: top
+objectClass: person
+objectClass: organizationalPerson
+objectClass: inetOrgPerson
+objectClass: uidObject
+givenName: Test3
+cn: Test User3
+sn: user3
+uid: user3
+userPassword: user3
+
+dn: cn=user4,ou=People,dc=example,dc=com
+objectClass: top
+objectClass: person
+objectClass: organizationalPerson
+objectClass: inetOrgPerson
+objectClass: uidObject
+givenName: Test4
+cn: Test User4
+sn: user4
+uid: user4
+userPassword: user4
+
diff --git a/kyuubi-common/src/test/resources/ldap/microsoft.schema.ldif b/kyuubi-common/src/test/resources/ldap/microsoft.schema.ldif
new file mode 100644
index 000000000..3e3a9a5c1
--- /dev/null
+++ b/kyuubi-common/src/test/resources/ldap/microsoft.schema.ldif
@@ -0,0 +1,62 @@
+#
+# 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.
+#
+
+dn: cn=microsoft, ou=schema
+objectclass: metaSchema
+objectclass: top
+cn: microsoft
+
+dn: ou=attributetypes, cn=microsoft, ou=schema
+objectclass: organizationalUnit
+objectclass: top
+ou: attributetypes
+
+dn: m-oid=1.2.840.113556.1.4.221, ou=attributetypes, cn=microsoft, ou=schema
+objectclass: metaAttributeType
+objectclass: metaTop
+objectclass: top
+m-oid: 1.2.840.113556.1.4.221
+m-name: sAMAccountName
+m-equality: caseIgnoreMatch
+m-syntax: 1.3.6.1.4.1.1466.115.121.1.15
+m-singleValue: TRUE
+
+dn: m-oid=1.2.840.113556.1.4.222, ou=attributetypes, cn=microsoft, ou=schema
+objectclass: metaAttributeType
+objectclass: metaTop
+objectclass: top
+m-oid: 1.2.840.113556.1.4.222
+m-name: memberOf
+m-equality: caseIgnoreMatch
+m-syntax: 1.3.6.1.4.1.1466.115.121.1.15
+m-singleValue: FALSE
+
+dn: ou=objectClasses, cn=microsoft, ou=schema
+objectclass: organizationalUnit
+objectclass: top
+ou: objectClasses
+
+dn: m-oid=1.2.840.113556.1.5.6, ou=objectClasses, cn=microsoft, ou=schema
+objectclass: metaObjectClass
+objectclass: metaTop
+objectclass: top
+m-oid: 1.2.840.113556.1.5.6
+m-name: microsoftSecurityPrincipal
+m-supObjectClass: top
+m-typeObjectClass: AUXILIARY
+m-must: sAMAccountName
+m-may: memberOf
diff --git a/kyuubi-common/src/test/scala/org/apache/kyuubi/service/authentication/LdapAtnProviderSuite.scala b/kyuubi-common/src/test/scala/org/apache/kyuubi/service/authentication/LdapAtnProviderSuite.scala
new file mode 100644
index 000000000..c3c67e421
--- /dev/null
+++ b/kyuubi-common/src/test/scala/org/apache/kyuubi/service/authentication/LdapAtnProviderSuite.scala
@@ -0,0 +1,493 @@
+/*
+ * 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
+
+import com.unboundid.ldap.sdk.Entry
+
+import org.apache.kyuubi.service.authentication.ldap.{LdapAuthenticationTestCase, User}
+
+class LdapAtnProviderSuite extends WithLdapServer {
+
+  override protected val ldapBaseDn: Array[String] = Array(
+    "dc=example,dc=com",
+    "cn=microsoft,ou=schema")
+
+  private val GROUP1_NAME = "group1"
+  private val GROUP2_NAME = "group2"
+  private val GROUP3_NAME = "group3"
+  private val GROUP4_NAME = "group4"
+
+  private val GROUP_ADMINS_NAME = "admins"
+  private val GROUP_TEAM1_NAME = "team1"
+  private val GROUP_TEAM2_NAME = "team2"
+  private val GROUP_RESOURCE1_NAME = "resource1"
+  private val GROUP_RESOURCE2_NAME = "resource2"
+
+  private val USER1 =
+    User.useIdForPassword(id = "user1", dn = "uid=user1,ou=People,dc=example,dc=com")
+
+  private val USER2 =
+    User.useIdForPassword(id = "user2", dn = "uid=user2,ou=People,dc=example,dc=com")
+
+  private val USER3 =
+    User.useIdForPassword(id = "user3", dn = "cn=user3,ou=People,dc=example,dc=com")
+
+  private val USER4 =
+    User.useIdForPassword(id = "user4", dn = "cn=user4,ou=People,dc=example,dc=com")
+
+  private val ENGINEER_1 = User(
+    id = "engineer1",
+    dn = "sAMAccountName=engineer1,ou=Engineering,dc=ad,dc=example,dc=com",
+    password = "engineer1-password")
+
+  private val ENGINEER_2 = User(
+    id = "engineer2",
+    dn = "sAMAccountName=engineer2,ou=Engineering,dc=ad,dc=example,dc=com",
+    password = "engineer2-password")
+
+  private val MANAGER_1 = User(
+    id = "manager1",
+    dn = "sAMAccountName=manager1,ou=Management,dc=ad,dc=example,dc=com",
+    password = "manager1-password")
+
+  private val MANAGER_2 = User(
+    id = "manager2",
+    dn = "sAMAccountName=manager2,ou=Management,dc=ad,dc=example,dc=com",
+    password = "manager2-password")
+
+  private val ADMIN_1 = User(
+    id = "admin1",
+    dn = "sAMAccountName=admin1,ou=Administration,dc=ad,dc=example,dc=com",
+    password = "admin1-password")
+
+  private var testCase: LdapAuthenticationTestCase = _
+
+  private def defaultBuilder = LdapAuthenticationTestCase.builder.ldapUrl(ldapUrl)
+
+  override def beforeAll(): Unit = {
+    super.beforeAll()
+    ldapServer.add(new Entry(
+      "dn: dc=example,dc=com",
+      "dc: example",
+      "objectClass: top",
+      "objectClass: domain"))
+
+    applyLDIF("ldap/example.com.ldif")
+    applyLDIF("ldap/microsoft.schema.ldif")
+    applyLDIF("ldap/ad.example.com.ldif")
+  }
+
+  test("In-Memory LDAP server is started") {
+    assert(ldapServer.getListenPort > 0)
+  }
+
+  test("UserBindPositiveWithShortname") {
+    testCase = defaultBuilder
+      .userDNPatterns("uid=%s,ou=People,dc=example,dc=com")
+      .groupDNPatterns("uid=%s,ou=Groups,dc=example,dc=com")
+      .build
+    testCase.assertAuthenticatePasses(USER1.credentialsWithId)
+    testCase.assertAuthenticatePasses(USER2.credentialsWithId)
+  }
+
+  test("UserBindPositiveWithShortnameOldConfig") {
+    testCase = defaultBuilder
+      .baseDN("ou=People,dc=example,dc=com")
+      .build
+    testCase.assertAuthenticatePasses(USER1.credentialsWithId)
+    testCase.assertAuthenticatePasses(USER2.credentialsWithId)
+  }
+
+  test("UserBindNegativeWithShortname") {
+    testCase = defaultBuilder
+      .userDNPatterns("uid=%s,ou=People,dc=example,dc=com")
+      .groupDNPatterns("uid=%s,ou=Groups,dc=example,dc=com")
+      .build
+    testCase.assertAuthenticateFailsUsingWrongPassword(USER1.credentialsWithId)
+    testCase.assertAuthenticateFailsUsingWrongPassword(USER2.credentialsWithId)
+  }
+
+  test("UserBindNegativeWithShortnameOldConfig") {
+    testCase = defaultBuilder
+      .baseDN("ou=People,dc=example,dc=com")
+      .build
+    testCase.assertAuthenticateFailsUsingWrongPassword(USER1.credentialsWithId)
+    testCase.assertAuthenticateFails(USER1.dn, USER2.password)
+    testCase.assertAuthenticateFailsUsingWrongPassword(USER2.credentialsWithId)
+  }
+
+  test("UserBindPositiveWithDN") {
+    testCase = defaultBuilder
+      .userDNPatterns("uid=%s,ou=People,dc=example,dc=com")
+      .groupDNPatterns("uid=%s,ou=Groups,dc=example,dc=com")
+      .build
+    testCase.assertAuthenticatePasses(USER1.credentialsWithDn)
+    testCase.assertAuthenticatePasses(USER2.credentialsWithDn)
+  }
+
+  test("UserBindPositiveWithDNOldConfig") {
+    testCase = defaultBuilder
+      .baseDN("ou=People,dc=example,dc=com")
+      .build
+    testCase.assertAuthenticatePasses(USER1.credentialsWithDn)
+    testCase.assertAuthenticatePasses(USER2.credentialsWithDn)
+  }
+
+  test("UserBindPositiveWithDNWrongOldConfig") {
+    testCase = defaultBuilder
+      .baseDN("ou=DummyPeople,dc=example,dc=com")
+      .build
+    testCase.assertAuthenticatePasses(USER1.credentialsWithDn)
+    testCase.assertAuthenticatePasses(USER2.credentialsWithDn)
+  }
+
+  test("UserBindPositiveWithDNWrongConfig") {
+    testCase = defaultBuilder
+      .userDNPatterns("uid=%s,ou=DummyPeople,dc=example,dc=com")
+      .groupDNPatterns("uid=%s,ou=DummyGroups,dc=example,dc=com")
+      .build
+    testCase.assertAuthenticatePasses(USER1.credentialsWithDn)
+    testCase.assertAuthenticatePasses(USER2.credentialsWithDn)
+  }
+
+  test("UserBindPositiveWithDNBlankConfig") {
+    testCase = defaultBuilder
+      .userDNPatterns(" ")
+      .groupDNPatterns(" ")
+      .build
+    testCase.assertAuthenticatePasses(USER1.credentialsWithDn)
+    testCase.assertAuthenticatePasses(USER2.credentialsWithDn)
+  }
+
+  test("UserBindPositiveWithDNBlankOldConfig") {
+    testCase = defaultBuilder.baseDN("").build
+    testCase.assertAuthenticatePasses(USER1.credentialsWithDn)
+    testCase.assertAuthenticatePasses(USER2.credentialsWithDn)
+  }
+
+  test("UserBindNegativeWithDN") {
+    testCase = defaultBuilder
+      .userDNPatterns("uid=%s,ou=People,dc=example,dc=com")
+      .groupDNPatterns("uid=%s,ou=Groups,dc=example,dc=com")
+      .build
+    testCase.assertAuthenticateFailsUsingWrongPassword(USER1.credentialsWithDn)
+    testCase.assertAuthenticateFails(USER1.dn, USER2.password)
+    testCase.assertAuthenticateFailsUsingWrongPassword(USER2.credentialsWithDn)
+  }
+
+  test("UserBindNegativeWithDNOldConfig") {
+    testCase = defaultBuilder
+      .baseDN("ou=People,dc=example,dc=com")
+      .build
+    testCase.assertAuthenticateFailsUsingWrongPassword(USER1.credentialsWithDn)
+    testCase.assertAuthenticateFails(USER1.dn, USER2.password)
+    testCase.assertAuthenticateFailsUsingWrongPassword(USER2.credentialsWithDn)
+  }
+
+  test("UserFilterPositive") {
+    testCase = defaultBuilder
+      .userDNPatterns("uid=%s,ou=People,dc=example,dc=com")
+      .userFilters(USER1.id)
+      .build
+    testCase.assertAuthenticatePasses(USER1.credentialsWithId)
+    testCase.assertAuthenticatePasses(USER1.credentialsWithDn)
+    testCase = defaultBuilder
+      .userDNPatterns("uid=%s,ou=People,dc=example,dc=com")
+      .userFilters(USER2.id)
+      .build
+    testCase.assertAuthenticatePasses(USER2.credentialsWithId)
+    testCase.assertAuthenticatePasses(USER2.credentialsWithDn)
+    testCase = defaultBuilder
+      .userDNPatterns("uid=%s,ou=People,dc=example,dc=com")
+      .userFilters(USER1.id, USER2.id)
+      .build
+    testCase.assertAuthenticatePasses(USER1.credentialsWithId)
+    testCase.assertAuthenticatePasses(USER1.credentialsWithDn)
+    testCase.assertAuthenticatePasses(USER2.credentialsWithId)
+    testCase.assertAuthenticatePasses(USER2.credentialsWithDn)
+  }
+
+  test("UserFilterNegative") {
+    testCase = defaultBuilder
+      .userDNPatterns("uid=%s,ou=People,dc=example,dc=com")
+      .userFilters(USER2.id)
+      .build
+    testCase.assertAuthenticateFails(USER1.credentialsWithId)
+    testCase.assertAuthenticateFails(USER1.credentialsWithDn)
+    testCase = defaultBuilder
+      .userDNPatterns("uid=%s,ou=People,dc=example,dc=com")
+      .userFilters(USER1.id)
+      .build
+    testCase.assertAuthenticateFails(USER2.credentialsWithId)
+    testCase.assertAuthenticateFails(USER2.credentialsWithDn)
+    testCase = defaultBuilder
+      .userDNPatterns("uid=%s,ou=People,dc=example,dc=com")
+      .userFilters(USER3.id)
+      .build
+    testCase.assertAuthenticateFails(USER1.credentialsWithId)
+    testCase.assertAuthenticateFails(USER2.credentialsWithId)
+  }
+
+  test("GroupFilterPositive") {
+    testCase = defaultBuilder
+      .userDNPatterns("uid=%s,ou=People,dc=example,dc=com")
+      .groupDNPatterns("uid=%s,ou=Groups,dc=example,dc=com")
+      .groupFilters(GROUP1_NAME, GROUP2_NAME)
+      .build
+    testCase.assertAuthenticatePasses(USER1.credentialsWithId)
+    testCase.assertAuthenticatePasses(USER1.credentialsWithDn)
+    testCase.assertAuthenticatePasses(USER2.credentialsWithId)
+    testCase.assertAuthenticatePasses(USER2.credentialsWithDn)
+    testCase = defaultBuilder
+      .userDNPatterns("uid=%s,ou=People,dc=example,dc=com")
+      .groupDNPatterns("uid=%s,ou=Groups,dc=example,dc=com")
+      .groupFilters(GROUP2_NAME)
+      .build
+    testCase.assertAuthenticatePasses(USER2.credentialsWithId)
+    testCase.assertAuthenticatePasses(USER2.credentialsWithDn)
+  }
+
+  test("GroupFilterNegative") {
+    testCase = defaultBuilder
+      .userDNPatterns("uid=%s,ou=People,dc=example,dc=com")
+      .groupDNPatterns("uid=%s,ou=Groups,dc=example,dc=com")
+      .groupFilters(GROUP2_NAME)
+      .build
+    testCase.assertAuthenticateFails(USER1.credentialsWithId)
+    testCase.assertAuthenticateFails(USER1.credentialsWithDn)
+    testCase = defaultBuilder
+      .userDNPatterns("uid=%s,ou=People,dc=example,dc=com")
+      .groupDNPatterns("uid=%s,ou=Groups,dc=example,dc=com")
+      .groupFilters(GROUP1_NAME)
+      .build
+    testCase.assertAuthenticateFails(USER2.credentialsWithId)
+    testCase.assertAuthenticateFails(USER2.credentialsWithDn)
+  }
+
+  test("UserAndGroupFilterPositive") {
+    testCase = defaultBuilder
+      .userDNPatterns("uid=%s,ou=People,dc=example,dc=com")
+      .groupDNPatterns("uid=%s,ou=Groups,dc=example,dc=com")
+      .userFilters(USER1.id, USER2.id)
+      .groupFilters(GROUP1_NAME, GROUP2_NAME)
+      .build
+    testCase.assertAuthenticatePasses(USER1.credentialsWithId)
+    testCase.assertAuthenticatePasses(USER1.credentialsWithDn)
+    testCase.assertAuthenticatePasses(USER2.credentialsWithId)
+    testCase.assertAuthenticatePasses(USER2.credentialsWithDn)
+  }
+
+  test("UserAndGroupFilterNegative") {
+    testCase = defaultBuilder
+      .userDNPatterns("uid=%s,ou=People,dc=example,dc=com")
+      .groupDNPatterns("uid=%s,ou=Groups,dc=example,dc=com")
+      .userFilters(USER1.id, USER2.id)
+      .groupFilters(GROUP3_NAME, GROUP3_NAME)
+      .build
+    testCase.assertAuthenticateFails(USER2.credentialsWithDn)
+    testCase.assertAuthenticateFails(USER2.credentialsWithId)
+    testCase.assertAuthenticateFails(USER3.credentialsWithDn)
+    testCase.assertAuthenticateFails(USER3.credentialsWithId)
+  }
+
+  test("CustomQueryPositive") {
+    testCase = defaultBuilder
+      .baseDN("ou=People,dc=example,dc=com")
+      .userDNPatterns("cn=%s,ou=People,dc=example,dc=com", "uid=%s,ou=People,dc=example,dc=com")
+      .groupDNPatterns("cn=%s,ou=People,dc=example,dc=com")
+      .customQuery(String.format("(&(objectClass=person)(|(uid=%s)(uid=%s)))", USER1.id, USER4.id))
+      .build
+    testCase.assertAuthenticatePasses(USER1.credentialsWithId)
+    testCase.assertAuthenticatePasses(USER1.credentialsWithDn)
+    testCase.assertAuthenticatePasses(USER4.credentialsWithId)
+    testCase.assertAuthenticatePasses(USER4.credentialsWithDn)
+    testCase = defaultBuilder
+      .baseDN("ou=People,dc=example,dc=com")
+      .customQuery("(&(objectClass=person)(uid=%s))")
+      .build
+    testCase.assertAuthenticatePasses(USER1.credentialsWithId)
+    testCase.assertAuthenticatePasses(USER2.credentialsWithId)
+  }
+
+  test("CustomQueryNegative") {
+    testCase = defaultBuilder
+      .baseDN("ou=People,dc=example,dc=com")
+      .customQuery("(&(objectClass=person)(cn=%s))")
+      .build
+    testCase.assertAuthenticateFails(USER2.credentialsWithDn)
+    testCase.assertAuthenticateFails(USER2.credentialsWithId)
+  }
+
+  /**
+   * Test to test the LDAP Atn to use a custom LDAP query that returns
+   * a) A set of group DNs
+   * b) A combination of group(s) DN and user DN
+   * LDAP atn is expected to extract the members of the group using the attribute value for
+   * `kyuubi.authentication.ldap.userMembershipKey`
+   */
+  test("CustomQueryWithGroupsPositive") {
+    testCase = defaultBuilder
+      .baseDN("dc=example,dc=com")
+      .userDNPatterns("cn=%s,ou=People,dc=example,dc=com", "uid=%s,ou=People,dc=example,dc=com")
+      .customQuery(s"(&(objectClass=groupOfNames)(|(cn=$GROUP1_NAME)(cn=$GROUP2_NAME)))")
+      .build
+    testCase.assertAuthenticatePasses(USER1.credentialsWithId)
+    testCase.assertAuthenticatePasses(USER1.credentialsWithDn)
+    testCase.assertAuthenticatePasses(USER2.credentialsWithId)
+    testCase.assertAuthenticatePasses(USER2.credentialsWithDn)
+    // the following test uses a query that returns a group and a user entry.
+    // the ldap atn should use the groupMembershipKey to identify the users for the returned group
+    // and the authentication should succeed for the users of that group as well as the lone user4
+    // in this case
+    testCase = defaultBuilder
+      .baseDN("dc=example,dc=com")
+      .userDNPatterns("cn=%s,ou=People,dc=example,dc=com", "uid=%s,ou=People,dc=example,dc=com")
+      .customQuery(
+        s"(|(&(objectClass=groupOfNames)(cn=$GROUP1_NAME))(&(objectClass=person)(sn=${USER4.id})))")
+      .build
+    testCase.assertAuthenticatePasses(USER1.credentialsWithId)
+    testCase.assertAuthenticatePasses(USER1.credentialsWithDn)
+    testCase.assertAuthenticatePasses(USER4.credentialsWithId)
+    testCase.assertAuthenticatePasses(USER4.credentialsWithDn)
+    testCase = defaultBuilder
+      .baseDN("dc=example,dc=com")
+      .userDNPatterns("cn=%s,ou=People,dc=example,dc=com", "uid=%s,ou=People,dc=example,dc=com")
+      .groupMembershipKey("uniqueMember")
+      .customQuery(s"(&(objectClass=groupOfUniqueNames)(cn=$GROUP4_NAME))")
+      .build
+    testCase.assertAuthenticatePasses(USER4.credentialsWithId)
+    testCase.assertAuthenticatePasses(USER4.credentialsWithDn)
+  }
+
+  test("CustomQueryWithGroupsNegative") {
+    testCase = defaultBuilder
+      .baseDN("dc=example,dc=com")
+      .userDNPatterns("cn=%s,ou=People,dc=example,dc=com", "uid=%s,ou=People,dc=example,dc=com")
+      .customQuery(s"(&(objectClass=groupOfNames)(|(cn=$GROUP1_NAME)(cn=$GROUP2_NAME)))")
+      .build
+    testCase.assertAuthenticateFails(USER3.credentialsWithDn)
+    testCase.assertAuthenticateFails(USER3.credentialsWithId)
+  }
+
+  test("GroupFilterPositiveWithCustomGUID") {
+    testCase = defaultBuilder
+      .userDNPatterns("cn=%s,ou=People,dc=example,dc=com")
+      .groupDNPatterns("cn=%s,ou=Groups,dc=example,dc=com")
+      .groupFilters(GROUP3_NAME)
+      .guidKey("cn")
+      .build
+    testCase.assertAuthenticatePasses(USER3.credentialsWithId)
+    testCase.assertAuthenticatePasses(USER3.credentialsWithDn)
+  }
+
+  test("GroupFilterPositiveWithCustomAttributes") {
+    testCase = defaultBuilder
+      .userDNPatterns("cn=%s,ou=People,dc=example,dc=com")
+      .groupDNPatterns("cn=%s,ou=Groups,dc=example,dc=com")
+      .groupFilters(GROUP4_NAME)
+      .guidKey("cn")
+      .groupMembershipKey("uniqueMember")
+      .groupClassKey("groupOfUniqueNames")
+      .build
+    testCase.assertAuthenticatePasses(USER4.credentialsWithId)
+    testCase.assertAuthenticatePasses(USER4.credentialsWithDn)
+  }
+
+  test("DirectUserMembershipGroupFilterPositive") {
+    testCase = defaultBuilder
+      .userDNPatterns(
+        "sAMAccountName=%s,ou=Engineering,dc=ad,dc=example,dc=com",
+        "sAMAccountName=%s,ou=Management,dc=ad,dc=example,dc=com")
+      .groupDNPatterns(
+        "sAMAccountName=%s,ou=Teams,dc=ad,dc=example,dc=com",
+        "sAMAccountName=%s,ou=Resources,dc=ad,dc=example,dc=com")
+      .groupFilters(GROUP_TEAM1_NAME, GROUP_TEAM2_NAME, GROUP_RESOURCE1_NAME, GROUP_RESOURCE2_NAME)
+      .guidKey("sAMAccountName")
+      .userMembershipKey("memberOf")
+      .build
+    testCase.assertAuthenticatePasses(ENGINEER_1.credentialsWithId)
+    testCase.assertAuthenticatePasses(ENGINEER_2.credentialsWithId)
+    testCase.assertAuthenticatePasses(MANAGER_1.credentialsWithId)
+    testCase.assertAuthenticatePasses(MANAGER_2.credentialsWithId)
+  }
+
+  test("DirectUserMembershipGroupFilterNegative") {
+    testCase = defaultBuilder
+      .userDNPatterns(
+        "sAMAccountName=%s,ou=Engineering,dc=ad,dc=example,dc=com",
+        "sAMAccountName=%s,ou=Management,dc=ad,dc=example,dc=com")
+      .groupDNPatterns("cn=%s,ou=Teams,dc=ad,dc=example,dc=com")
+      .groupFilters(GROUP_TEAM1_NAME)
+      .guidKey("sAMAccountName")
+      .userMembershipKey("memberOf")
+      .build
+    testCase.assertAuthenticateFails(ENGINEER_2.credentialsWithId)
+    testCase.assertAuthenticateFails(MANAGER_2.credentialsWithId)
+  }
+
+  test("DirectUserMembershipGroupFilterNegativeWithoutUserBases") {
+    testCase = defaultBuilder
+      .groupDNPatterns("cn=%s,ou=Teams,dc=ad,dc=example,dc=com")
+      .groupFilters(GROUP_TEAM1_NAME)
+      .guidKey("sAMAccountName")
+      .userMembershipKey("memberOf")
+      .build
+    testCase.assertAuthenticateFails(ENGINEER_1.credentialsWithId)
+    testCase.assertAuthenticateFails(ENGINEER_2.credentialsWithId)
+    testCase.assertAuthenticateFails(MANAGER_1.credentialsWithId)
+    testCase.assertAuthenticateFails(MANAGER_2.credentialsWithId)
+  }
+
+  test("DirectUserMembershipGroupFilterWithDNCredentials") {
+    testCase = defaultBuilder
+      .userDNPatterns("sAMAccountName=%s,ou=Engineering,dc=ad,dc=example,dc=com")
+      .groupDNPatterns("cn=%s,ou=Teams,dc=ad,dc=example,dc=com")
+      .groupFilters(GROUP_TEAM1_NAME)
+      .guidKey("sAMAccountName")
+      .userMembershipKey("memberOf")
+      .build
+    testCase.assertAuthenticatePasses(ENGINEER_1.credentialsWithDn)
+    testCase.assertAuthenticateFails(MANAGER_1.credentialsWithDn)
+  }
+
+  test("DirectUserMembershipGroupFilterWithDifferentGroupClassKey") {
+    testCase = defaultBuilder
+      .userDNPatterns("sAMAccountName=%s,ou=Administration,dc=ad,dc=example,dc=com")
+      .groupDNPatterns("cn=%s,ou=Administration,dc=ad,dc=example,dc=com")
+      .groupFilters(GROUP_ADMINS_NAME).guidKey("sAMAccountName")
+      .userMembershipKey("memberOf")
+      .groupClassKey("groupOfUniqueNames")
+      .build
+    testCase.assertAuthenticatePasses(ADMIN_1.credentialsWithId)
+    testCase.assertAuthenticateFails(ENGINEER_1.credentialsWithId)
+    testCase.assertAuthenticateFails(MANAGER_1.credentialsWithDn)
+  }
+
+  test("DirectUserMembershipGroupFilterNegativeWithWrongGroupClassKey") {
+    testCase = defaultBuilder
+      .userDNPatterns("sAMAccountName=%s,ou=Administration,dc=ad,dc=example,dc=com")
+      .groupDNPatterns("cn=%s,ou=Administration,dc=ad,dc=example,dc=com")
+      .groupFilters(GROUP_ADMINS_NAME).guidKey("sAMAccountName")
+      .userMembershipKey("memberOf")
+      .groupClassKey("wrongClass")
+      .build
+    testCase.assertAuthenticateFails(ADMIN_1.credentialsWithId)
+    testCase.assertAuthenticateFails(ENGINEER_1.credentialsWithId)
+    testCase.assertAuthenticateFails(MANAGER_1.credentialsWithDn)
+  }
+}
diff --git a/kyuubi-common/src/test/scala/org/apache/kyuubi/service/authentication/LdapAuthenticationProviderImplSuite.scala b/kyuubi-common/src/test/scala/org/apache/kyuubi/service/authentication/LdapAuthenticationProviderImplSuite.scala
index 639411628..718fc6f6e 100644
--- a/kyuubi-common/src/test/scala/org/apache/kyuubi/service/authentication/LdapAuthenticationProviderImplSuite.scala
+++ b/kyuubi-common/src/test/scala/org/apache/kyuubi/service/authentication/LdapAuthenticationProviderImplSuite.scala
@@ -17,63 +17,357 @@
 
 package org.apache.kyuubi.service.authentication
 
-import javax.naming.CommunicationException
+import javax.naming.NamingException
 import javax.security.sasl.AuthenticationException
 
+import org.mockito.ArgumentMatchers.{any, anyString, eq => mockEq, isA}
+import org.mockito.Mockito._
+import org.scalatestplus.mockito.MockitoSugar.mock
+
 import org.apache.kyuubi.config.KyuubiConf
 import org.apache.kyuubi.config.KyuubiConf._
+import org.apache.kyuubi.service.authentication.ldap.{DirSearch, DirSearchFactory, LdapSearchFactory}
 
 class LdapAuthenticationProviderImplSuite extends WithLdapServer {
-  override protected val ldapUser: String = "kentyao"
-  override protected val ldapUserPasswd: String = "kentyao"
 
-  private val conf = new KyuubiConf()
+  private var conf: KyuubiConf = _
+  private var factory: DirSearchFactory = _
+  private var search: DirSearch = _
+  private var auth: LdapAuthenticationProviderImpl = _
 
-  override def beforeAll(): Unit = {
-    super.beforeAll()
+  override protected def beforeEach(): Unit = {
+    super.beforeEach()
+    conf = new KyuubiConf()
     conf.set(AUTHENTICATION_LDAP_URL, ldapUrl)
+    factory = mock[DirSearchFactory]
+    search = mock[DirSearch]
+    when(factory.getInstance(any(classOf[KyuubiConf]), anyString, anyString))
+      .thenReturn(search)
+  }
+
+  test("authenticateGivenBlankOrNullPassword") {
+    Seq("", "\0", null).foreach { pwd =>
+      auth = new LdapAuthenticationProviderImpl(conf, new LdapSearchFactory)
+      val thrown = intercept[AuthenticationException] {
+        auth.authenticate("user", pwd)
+      }
+      assert(thrown.getMessage.contains("is null or contains blank space"))
+    }
+  }
+
+  test("AuthenticateNoUserOrGroupFilter") {
+    conf.set(
+      AUTHENTICATION_LDAP_USER_DN_PATTERN,
+      "cn=%s,ou=Users,dc=mycorp,dc=com:cn=%s,ou=PowerUsers,dc=mycorp,dc=com")
+    val factory = mock[DirSearchFactory]
+    lenient
+      .when(search.findUserDn("user1"))
+      .thenReturn("cn=user1,ou=PowerUsers,dc=mycorp,dc=com")
+    when(factory.getInstance(conf, "cn=user1,ou=PowerUsers,dc=mycorp,dc=com", "Blah"))
+      .thenReturn(search)
+    when(factory.getInstance(conf, "cn=user1,ou=Users,dc=mycorp,dc=com", "Blah"))
+      .thenThrow(classOf[AuthenticationException])
+
+    auth = new LdapAuthenticationProviderImpl(conf, factory)
+    auth.authenticate("user1", "Blah")
+
+    verify(factory, times(2)).getInstance(isA(classOf[KyuubiConf]), anyString, mockEq("Blah"))
+    verify(search, atLeastOnce).close()
+  }
+
+  test("AuthenticateWhenUserFilterPasses") {
+    conf.set(AUTHENTICATION_LDAP_USER_FILTER.key, "user1,user2")
+
+    when(search.findUserDn("user1")).thenReturn("cn=user1,ou=PowerUsers,dc=mycorp,dc=com")
+    when(search.findUserDn("user2")).thenReturn("cn=user2,ou=PowerUsers,dc=mycorp,dc=com")
+
+    authenticateUserAndCheckSearchIsClosed("user1")
+    authenticateUserAndCheckSearchIsClosed("user2")
+  }
+
+  test("AuthenticateWhenLoginWithDomainAndUserFilterPasses") {
+    conf.set(AUTHENTICATION_LDAP_USER_FILTER.key, "user1")
+
+    when(search.findUserDn("user1")).thenReturn("cn=user1,ou=PowerUsers,dc=mycorp,dc=com")
+
+    authenticateUserAndCheckSearchIsClosed("user1@mydomain.com")
+  }
+
+  test("AuthenticateWhenLoginWithDnAndUserFilterPasses") {
+    conf.set(AUTHENTICATION_LDAP_USER_FILTER.key, "user1")
+
+    when(search.findUserDn("cn=user1,ou=PowerUsers,dc=mycorp,dc=com"))
+      .thenReturn("cn=user1,ou=PowerUsers,dc=mycorp,dc=com")
+
+    authenticateUserAndCheckSearchIsClosed("cn=user1,ou=PowerUsers,dc=mycorp,dc=com")
   }
 
-  override def afterAll(): Unit = {
-    super.afterAll()
+  test("AuthenticateWhenUserSearchFails") {
+    conf.set(AUTHENTICATION_LDAP_USER_FILTER.key, "user1,user2")
+    intercept[AuthenticationException] {
+      when(search.findUserDn("user1")).thenReturn(null)
+      authenticateUserAndCheckSearchIsClosed("user1")
+    }
   }
 
-  test("ldap server is started") {
-    assert(ldapServer.getListenPort > 0)
+  test("AuthenticateWhenUserFilterFails") {
+    conf.set(AUTHENTICATION_LDAP_USER_FILTER.key, "user1,user2")
+    intercept[AuthenticationException] {
+      when(search.findUserDn("user3")).thenReturn("cn=user3,ou=PowerUsers,dc=mycorp,dc=com")
+      authenticateUserAndCheckSearchIsClosed("user3")
+    }
   }
 
-  test("authenticate tests") {
-    val providerImpl = new LdapAuthenticationProviderImpl(conf)
-    val e1 = intercept[AuthenticationException](providerImpl.authenticate("", ""))
-    assert(e1.getMessage.contains("user is null"))
-    val e2 = intercept[AuthenticationException](providerImpl.authenticate("kyuubi", ""))
-    assert(e2.getMessage.contains("password is null"))
+  test("AuthenticateWhenGroupMembershipKeyFilterPasses") {
+    conf.set(AUTHENTICATION_LDAP_GROUP_FILTER.key, "group1,group2")
+
+    when(search.findUserDn("user1")).thenReturn("cn=user1,ou=PowerUsers,dc=mycorp,dc=com")
+    when(search.findUserDn("user2")).thenReturn("cn=user2,ou=PowerUsers,dc=mycorp,dc=com")
 
-    val user = "uid=kentyao,ou=users"
-    providerImpl.authenticate(user, "kentyao")
-    val e3 = intercept[AuthenticationException](
-      providerImpl.authenticate(user, "kent"))
-    assert(e3.getMessage.contains(user))
-    assert(e3.getCause.isInstanceOf[javax.naming.AuthenticationException])
+    when(search.findGroupsForUser("cn=user1,ou=PowerUsers,dc=mycorp,dc=com"))
+      .thenReturn(Array(
+        "cn=testGroup,ou=Groups,dc=mycorp,dc=com",
+        "cn=group1,ou=Groups,dc=mycorp,dc=com"))
+    when(search.findGroupsForUser("cn=user2,ou=PowerUsers,dc=mycorp,dc=com"))
+      .thenReturn(Array(
+        "cn=testGroup,ou=Groups,dc=mycorp,dc=com",
+        "cn=group2,ou=Groups,dc=mycorp,dc=com"))
+
+    authenticateUserAndCheckSearchIsClosed("user1")
+    authenticateUserAndCheckSearchIsClosed("user2")
+  }
 
-    conf.set(AUTHENTICATION_LDAP_BASEDN, ldapBaseDn)
-    val providerImpl2 = new LdapAuthenticationProviderImpl(conf)
-    providerImpl2.authenticate("kentyao", "kentyao")
+  test("AuthenticateWhenUserAndGroupMembershipKeyFiltersPass") {
+    conf.set(AUTHENTICATION_LDAP_GROUP_FILTER.key, "group1,group2")
+    conf.set(AUTHENTICATION_LDAP_USER_FILTER.key, "user1,user2")
 
-    val e4 = intercept[AuthenticationException](
-      providerImpl.authenticate("kentyao", "kent"))
-    assert(e4.getMessage.contains(user))
+    when(search.findUserDn("user1")).thenReturn("cn=user1,ou=PowerUsers,dc=mycorp,dc=com")
+    when(search.findUserDn("user2")).thenReturn("cn=user2,ou=PowerUsers,dc=mycorp,dc=com")
 
-    conf.unset(AUTHENTICATION_LDAP_URL)
-    val providerImpl3 = new LdapAuthenticationProviderImpl(conf)
-    val e5 = intercept[AuthenticationException](
-      providerImpl3.authenticate("kentyao", "kentyao"))
+    when(search.findGroupsForUser("cn=user1,ou=PowerUsers,dc=mycorp,dc=com"))
+      .thenReturn(Array(
+        "cn=testGroup,ou=Groups,dc=mycorp,dc=com",
+        "cn=group1,ou=Groups,dc=mycorp,dc=com"))
+    when(search.findGroupsForUser("cn=user2,ou=PowerUsers,dc=mycorp,dc=com"))
+      .thenReturn(Array(
+        "cn=testGroup,ou=Groups,dc=mycorp,dc=com",
+        "cn=group2,ou=Groups,dc=mycorp,dc=com"))
+
+    authenticateUserAndCheckSearchIsClosed("user1")
+    authenticateUserAndCheckSearchIsClosed("user2")
+  }
+
+  test("AuthenticateWhenUserFilterPassesAndGroupMembershipKeyFilterFails") {
+    conf.set(AUTHENTICATION_LDAP_GROUP_FILTER.key, "group1,group2")
+    conf.set(AUTHENTICATION_LDAP_USER_FILTER.key, "user1,user2")
+    intercept[AuthenticationException] {
+      when(search.findUserDn("user1")).thenReturn("cn=user1,ou=PowerUsers,dc=mycorp,dc=com")
+      when(search.findGroupsForUser("cn=user1,ou=PowerUsers,dc=mycorp,dc=com"))
+        .thenReturn(Array(
+          "cn=testGroup,ou=Groups,dc=mycorp,dc=com",
+          "cn=OtherGroup,ou=Groups,dc=mycorp,dc=com"))
+      authenticateUserAndCheckSearchIsClosed("user1")
+    }
+  }
+
+  test("AuthenticateWhenUserFilterFailsAndGroupMembershipKeyFilterPasses") {
+    conf.set(AUTHENTICATION_LDAP_GROUP_FILTER.key, "group3")
+    conf.set(AUTHENTICATION_LDAP_USER_FILTER.key, "user1,user2")
+    intercept[AuthenticationException] {
+      when(search.findUserDn("user3")).thenReturn("cn=user3,ou=PowerUsers,dc=mycorp,dc=com")
+      lenient.when(search.findGroupsForUser("cn=user3,ou=PowerUsers,dc=mycorp,dc=com"))
+        .thenReturn(Array(
+          "cn=testGroup,ou=Groups,dc=mycorp,dc=com",
+          "cn=group3,ou=Groups,dc=mycorp,dc=com"))
+      authenticateUserAndCheckSearchIsClosed("user3")
+    }
+  }
+
+  test("AuthenticateWhenCustomQueryFilterPasses") {
+    conf.set(AUTHENTICATION_LDAP_BASE_DN, "dc=mycorp,dc=com")
+    conf.set(
+      AUTHENTICATION_LDAP_CUSTOM_LDAP_QUERY,
+      "(&(objectClass=person)(|(memberOf=CN=Domain Admins,CN=Users,DC=apache,DC=org)" +
+        "(memberOf=CN=Administrators,CN=Builtin,DC=apache,DC=org)))")
+
+    when(search.executeCustomQuery(anyString))
+      .thenReturn(Array(
+        "cn=user1,ou=PowerUsers,dc=mycorp,dc=com",
+        "cn=user2,ou=PowerUsers,dc=mycorp,dc=com"))
+
+    authenticateUserAndCheckSearchIsClosed("user1")
+  }
+
+  test("AuthenticateWhenCustomQueryFilterFailsAndUserFilterPasses") {
+    conf.set(AUTHENTICATION_LDAP_BASE_DN, "dc=mycorp,dc=com")
+    conf.set(
+      AUTHENTICATION_LDAP_CUSTOM_LDAP_QUERY,
+      "(&(objectClass=person)(|(memberOf=CN=Domain Admins,CN=Users,DC=apache,DC=org)" +
+        "(memberOf=CN=Administrators,CN=Builtin,DC=apache,DC=org)))")
+    conf.set(AUTHENTICATION_LDAP_USER_FILTER.key, "user3")
+    intercept[AuthenticationException] {
+      lenient.when(search.findUserDn("user3")).thenReturn("cn=user3,ou=PowerUsers,dc=mycorp,dc=com")
+      when(search.executeCustomQuery(anyString))
+        .thenReturn(Array(
+          "cn=user1,ou=PowerUsers,dc=mycorp,dc=com",
+          "cn=user2,ou=PowerUsers,dc=mycorp,dc=com"))
+      authenticateUserAndCheckSearchIsClosed("user3")
+    }
+  }
 
-    assert(e5.getMessage.contains(user))
-    assert(e5.getCause.isInstanceOf[CommunicationException])
+  test("AuthenticateWhenUserMembershipKeyFilterPasses") {
+    conf.set(AUTHENTICATION_LDAP_BASE_DN, "dc=mycorp,dc=com")
+    conf.set(AUTHENTICATION_LDAP_GROUP_FILTER.key, "HIVE-USERS")
+    conf.set(AUTHENTICATION_LDAP_USER_MEMBERSHIP_KEY, "memberOf")
+
+    when(search.findUserDn("user1")).thenReturn("cn=user1,ou=PowerUsers,dc=mycorp,dc=com")
+
+    val groupDn = "cn=HIVE-USERS,ou=Groups,dc=mycorp,dc=com"
+    when(search.findGroupDn("HIVE-USERS")).thenReturn(groupDn)
+    when(search.isUserMemberOfGroup("user1", groupDn)).thenReturn(true)
+
+    auth = new LdapAuthenticationProviderImpl(conf, factory)
+    auth.authenticate("user1", "Blah")
+
+    verify(factory, times(1)).getInstance(isA(classOf[KyuubiConf]), anyString, mockEq("Blah"))
+    verify(search, times(1)).findGroupDn(anyString)
+    verify(search, times(1)).isUserMemberOfGroup(anyString, anyString)
+    verify(search, atLeastOnce).close()
+  }
+
+  test("AuthenticateWhenUserMembershipKeyFilterFails") {
+    conf.set(AUTHENTICATION_LDAP_BASE_DN, "dc=mycorp,dc=com")
+    conf.set(AUTHENTICATION_LDAP_GROUP_FILTER.key, "HIVE-USERS")
+    conf.set(AUTHENTICATION_LDAP_USER_MEMBERSHIP_KEY, "memberOf")
+    intercept[AuthenticationException] {
+      when(search.findUserDn("user1")).thenReturn("cn=user1,ou=PowerUsers,dc=mycorp,dc=com")
+
+      val groupDn = "cn=HIVE-USERS,ou=Groups,dc=mycorp,dc=com"
+      when(search.findGroupDn("HIVE-USERS")).thenReturn(groupDn)
+      when(search.isUserMemberOfGroup("user1", groupDn)).thenReturn(false)
+
+      auth = new LdapAuthenticationProviderImpl(conf, factory)
+      auth.authenticate("user1", "Blah")
+    }
+  }
+
+  test("AuthenticateWhenUserMembershipKeyFilter2x2PatternsPasses") {
+    conf.set(AUTHENTICATION_LDAP_GROUP_FILTER.key, "HIVE-USERS1,HIVE-USERS2")
+    conf.set(AUTHENTICATION_LDAP_GROUP_DN_PATTERN, "cn=%s,ou=Groups,ou=branch1,dc=mycorp,dc=com")
+    conf.set(AUTHENTICATION_LDAP_USER_DN_PATTERN, "cn=%s,ou=Userss,ou=branch1,dc=mycorp,dc=com")
+    conf.set(AUTHENTICATION_LDAP_USER_MEMBERSHIP_KEY, "memberOf")
+
+    when(search.findUserDn("user1")).thenReturn("cn=user1,ou=PowerUsers,dc=mycorp,dc=com")
+
+    when(search.findGroupDn("HIVE-USERS1"))
+      .thenReturn("cn=HIVE-USERS1,ou=Groups,ou=branch1,dc=mycorp,dc=com")
+    when(search.findGroupDn("HIVE-USERS2"))
+      .thenReturn("cn=HIVE-USERS2,ou=Groups,ou=branch1,dc=mycorp,dc=com")
+
+    when(search.isUserMemberOfGroup(
+      "user1",
+      "cn=HIVE-USERS1,ou=Groups,ou=branch1,dc=mycorp,dc=com"))
+      .thenThrow(classOf[NamingException])
+    when(search.isUserMemberOfGroup(
+      "user1",
+      "cn=HIVE-USERS2,ou=Groups,ou=branch1,dc=mycorp,dc=com"))
+      .thenReturn(true)
+
+    auth = new LdapAuthenticationProviderImpl(conf, factory)
+    auth.authenticate("user1", "Blah")
+
+    verify(factory, times(1)).getInstance(isA(classOf[KyuubiConf]), anyString, mockEq("Blah"))
+    verify(search, times(2)).findGroupDn(anyString)
+    verify(search, times(2)).isUserMemberOfGroup(anyString, anyString)
+    verify(search, atLeastOnce).close()
+  }
+
+  // Kyuubi does not implement it
+  // test("AuthenticateWithBindInCredentialFilePasses")
+  // test("testAuthenticateWithBindInMissingCredentialFilePasses")
+
+  test("AuthenticateWithBindUserPasses") {
+    val bindUser = "cn=BindUser,ou=Users,ou=branch1,dc=mycorp,dc=com"
+    val bindPass = "Blah"
+    val authFullUser = "cn=user1,ou=Users,ou=branch1,dc=mycorp,dc=com"
+    val authUser = "user1"
+    val authPass = "Blah2"
+    conf.set(AUTHENTICATION_LDAP_BIND_USER, bindUser)
+    conf.set(AUTHENTICATION_LDAP_BIND_PASSWORD, bindPass)
+
+    when(search.findUserDn(mockEq(authUser))).thenReturn(authFullUser)
+
+    auth = new LdapAuthenticationProviderImpl(conf, factory)
+    auth.authenticate(authUser, authPass)
+
+    verify(factory, times(1)).getInstance(
+      isA(classOf[KyuubiConf]),
+      mockEq(bindUser),
+      mockEq(bindPass))
+    verify(factory, times(1)).getInstance(
+      isA(classOf[KyuubiConf]),
+      mockEq(authFullUser),
+      mockEq(authPass))
+    verify(search, times(1)).findUserDn(mockEq(authUser))
+  }
+
+  test("AuthenticateWithBindUserFailsOnAuthentication") {
+    val bindUser = "cn=BindUser,ou=Users,ou=branch1,dc=mycorp,dc=com"
+    val bindPass = "Blah"
+    val authFullUser = "cn=user1,ou=Users,ou=branch1,dc=mycorp,dc=com"
+    val authUser = "user1"
+    val authPass = "Blah2"
+    conf.set(AUTHENTICATION_LDAP_BIND_USER, bindUser)
+    conf.set(AUTHENTICATION_LDAP_BIND_PASSWORD, bindPass)
+
+    intercept[AuthenticationException] {
+      when(
+        factory.getInstance(
+          any(classOf[KyuubiConf]),
+          mockEq(authFullUser),
+          mockEq(authPass))).thenThrow(classOf[AuthenticationException])
+      when(search.findUserDn(mockEq(authUser))).thenReturn(authFullUser)
+
+      auth = new LdapAuthenticationProviderImpl(conf, factory)
+      auth.authenticate(authUser, authPass)
+    }
+  }
+
+  test("AuthenticateWithBindUserFailsOnGettingDn") {
+    val bindUser = "cn=BindUser,ou=Users,ou=branch1,dc=mycorp,dc=com"
+    val bindPass = "Blah"
+    val authUser = "user1"
+    val authPass = "Blah2"
+    conf.set(AUTHENTICATION_LDAP_BIND_USER, bindUser)
+    conf.set(AUTHENTICATION_LDAP_BIND_PASSWORD, bindPass)
+
+    intercept[AuthenticationException] {
+      when(search.findUserDn(mockEq(authUser))).thenThrow(classOf[NamingException])
+      auth = new LdapAuthenticationProviderImpl(conf, factory)
+      auth.authenticate(authUser, authPass)
+    }
+  }
+
+  test("AuthenticateWithBindUserFailsOnBinding") {
+    val bindUser = "cn=BindUser,ou=Users,ou=branch1,dc=mycorp,dc=com"
+    val bindPass = "Blah"
+    val authUser = "user1"
+    val authPass = "Blah2"
+    conf.set(AUTHENTICATION_LDAP_BIND_USER, bindUser)
+    conf.set(AUTHENTICATION_LDAP_BIND_PASSWORD, bindPass)
+
+    intercept[AuthenticationException] {
+      when(factory.getInstance(any(classOf[KyuubiConf]), mockEq(bindUser), mockEq(bindPass)))
+        .thenThrow(classOf[AuthenticationException])
+
+      auth = new LdapAuthenticationProviderImpl(conf, factory)
+      auth.authenticate(authUser, authPass)
+    }
+  }
 
-    conf.set(AUTHENTICATION_LDAP_DOMAIN, "kyuubi.com")
-    val providerImpl4 = new LdapAuthenticationProviderImpl(conf)
-    intercept[AuthenticationException](providerImpl4.authenticate("kentyao", "kentyao"))
+  private def authenticateUserAndCheckSearchIsClosed(user: String): Unit = {
+    auth = new LdapAuthenticationProviderImpl(conf, factory)
+    try auth.authenticate(user, "password doesn't matter")
+    finally verify(search, atLeastOnce).close()
   }
 }
diff --git a/kyuubi-common/src/test/scala/org/apache/kyuubi/service/authentication/WithLdapServer.scala b/kyuubi-common/src/test/scala/org/apache/kyuubi/service/authentication/WithLdapServer.scala
index 0bb38684e..b31a06f20 100644
--- a/kyuubi-common/src/test/scala/org/apache/kyuubi/service/authentication/WithLdapServer.scala
+++ b/kyuubi-common/src/test/scala/org/apache/kyuubi/service/authentication/WithLdapServer.scala
@@ -17,20 +17,36 @@
 
 package org.apache.kyuubi.service.authentication
 
+import scala.util.Random
+
 import com.unboundid.ldap.listener.{InMemoryDirectoryServer, InMemoryDirectoryServerConfig}
+import com.unboundid.ldif.LDIFReader
 
 import org.apache.kyuubi.{KyuubiFunSuite, Utils}
 
 trait WithLdapServer extends KyuubiFunSuite {
   protected var ldapServer: InMemoryDirectoryServer = _
-  protected val ldapBaseDn = "ou=users"
-  protected val ldapUser = Utils.currentUser
-  protected val ldapUserPasswd = "ldapPassword"
+  protected val ldapBaseDn: Array[String] = Array("ou=users")
+  protected val ldapUser: String = Utils.currentUser
+  protected val ldapUserPasswd: String = Random.alphanumeric.take(16).mkString
 
   protected def ldapUrl = s"ldap://localhost:${ldapServer.getListenPort}"
 
+  /**
+   * Apply LDIF files
+   * @param resource the LDIF file under classpath
+   */
+  def applyLDIF(resource: String): Unit = {
+    ldapServer.applyChangesFromLDIF(
+      new LDIFReader(Utils.getContextOrKyuubiClassLoader.getResource(resource).openStream()))
+  }
+
   override def beforeAll(): Unit = {
-    val config = new InMemoryDirectoryServerConfig(ldapBaseDn)
+    val config = new InMemoryDirectoryServerConfig(ldapBaseDn: _*)
+    // disable the schema so that we can apply LDIF which contains Microsoft's Active Directory
+    // specific definitions.
+    // https://myshittycode.com/2017/03/28/
+    config.setSchema(null)
     config.addAdditionalBindCredentials(s"uid=$ldapUser,ou=users", ldapUserPasswd)
     ldapServer = new InMemoryDirectoryServer(config)
     ldapServer.startListening()
diff --git a/kyuubi-common/src/test/scala/org/apache/kyuubi/service/authentication/ldap/ChainFilterSuite.scala b/kyuubi-common/src/test/scala/org/apache/kyuubi/service/authentication/ldap/ChainFilterSuite.scala
new file mode 100644
index 000000000..d76611b6e
--- /dev/null
+++ b/kyuubi-common/src/test/scala/org/apache/kyuubi/service/authentication/ldap/ChainFilterSuite.scala
@@ -0,0 +1,88 @@
+/*
+ * 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 javax.security.sasl.AuthenticationException
+
+import org.mockito.ArgumentMatchers.{any, anyString}
+import org.mockito.Mockito.{doThrow, times, verify, when}
+import org.scalatestplus.mockito.MockitoSugar.mock
+
+import org.apache.kyuubi.KyuubiFunSuite
+import org.apache.kyuubi.config.KyuubiConf
+
+class ChainFilterSuite extends KyuubiFunSuite {
+  private var conf: KyuubiConf = _
+  private var filter1: Filter = _
+  private var filter2: Filter = _
+  private var filter3: Filter = _
+  private var factory1: FilterFactory = _
+  private var factory2: FilterFactory = _
+  private var factory3: FilterFactory = _
+  private var factory: FilterFactory = _
+  private var search: DirSearch = _
+
+  override def beforeEach(): Unit = {
+    conf = new KyuubiConf()
+    filter1 = mock[Filter]
+    filter2 = mock[Filter]
+    filter3 = mock[Filter]
+    factory1 = mock[FilterFactory]
+    factory2 = mock[FilterFactory]
+    factory3 = mock[FilterFactory]
+    factory = new ChainFilterFactory(factory1, factory2, factory3)
+    search = mock[DirSearch]
+    super.beforeEach()
+  }
+
+  test("FactoryAllNull") {
+    when(factory1.getInstance(any(classOf[KyuubiConf]))).thenReturn(None)
+    when(factory2.getInstance(any(classOf[KyuubiConf]))).thenReturn(None)
+    when(factory3.getInstance(any(classOf[KyuubiConf]))).thenReturn(None)
+    assert(factory.getInstance(conf).isEmpty)
+  }
+
+  test("FactoryAllEmpty") {
+    val emptyFactory = new ChainFilterFactory()
+    assert(emptyFactory.getInstance(conf).isEmpty)
+  }
+
+  test("Factory") {
+    when(factory1.getInstance(any(classOf[KyuubiConf]))).thenReturn(Some(filter1))
+    when(factory2.getInstance(any(classOf[KyuubiConf]))).thenReturn(Some(filter2))
+    when(factory3.getInstance(any(classOf[KyuubiConf]))).thenReturn(Some(filter3))
+    val filter = factory.getInstance(conf).get
+    filter.apply(search, "User")
+    verify(filter1, times(1)).apply(search, "User")
+    verify(filter2, times(1)).apply(search, "User")
+    verify(filter3, times(1)).apply(search, "User")
+  }
+
+  test("ApplyNegative") {
+    intercept[AuthenticationException] {
+      doThrow(classOf[AuthenticationException])
+        .when(filter3)
+        .apply(any().asInstanceOf[DirSearch], anyString)
+      when(factory1.getInstance(any(classOf[KyuubiConf]))).thenReturn(Some(filter1))
+      when(factory2.getInstance(any(classOf[KyuubiConf]))).thenReturn(None)
+      when(factory3.getInstance(any(classOf[KyuubiConf]))).thenReturn(Some(filter3))
+      val filter = factory.getInstance(conf).get
+      filter.apply(search, "User")
+    }
+  }
+}
diff --git a/kyuubi-common/src/test/scala/org/apache/kyuubi/service/authentication/ldap/CustomQueryFilterSuite.scala b/kyuubi-common/src/test/scala/org/apache/kyuubi/service/authentication/ldap/CustomQueryFilterSuite.scala
new file mode 100644
index 000000000..5ece4c88c
--- /dev/null
+++ b/kyuubi-common/src/test/scala/org/apache/kyuubi/service/authentication/ldap/CustomQueryFilterSuite.scala
@@ -0,0 +1,69 @@
+/*
+ * 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 javax.security.sasl.AuthenticationException
+
+import org.mockito.ArgumentMatchers.{eq => mockEq}
+import org.mockito.Mockito.when
+import org.scalatestplus.mockito.MockitoSugar.mock
+
+import org.apache.kyuubi.KyuubiFunSuite
+import org.apache.kyuubi.config.KyuubiConf
+
+class CustomQueryFilterSuite extends KyuubiFunSuite {
+  private val USER2_DN: String = "uid=user2,ou=People,dc=example,dc=com"
+  private val USER1_DN: String = "uid=user1,ou=People,dc=example,dc=com"
+  private val CUSTOM_QUERY: String = "(&(objectClass=person)(|(uid=user1)(uid=user2)))"
+
+  private val factory: FilterFactory = CustomQueryFilterFactory
+  private var conf: KyuubiConf = _
+  private var search: DirSearch = _
+
+  override def beforeEach(): Unit = {
+    conf = new KyuubiConf()
+    search = mock[DirSearch]
+    super.beforeEach()
+  }
+
+  test("Factory") {
+    conf.unset(KyuubiConf.AUTHENTICATION_LDAP_CUSTOM_LDAP_QUERY)
+    assert(factory.getInstance(conf).isEmpty)
+    conf.set(KyuubiConf.AUTHENTICATION_LDAP_CUSTOM_LDAP_QUERY, CUSTOM_QUERY)
+    assert(factory.getInstance(conf).isDefined)
+  }
+
+  test("ApplyPositive") {
+    conf.set(KyuubiConf.AUTHENTICATION_LDAP_CUSTOM_LDAP_QUERY, CUSTOM_QUERY)
+    when(search.executeCustomQuery(mockEq(CUSTOM_QUERY)))
+      .thenReturn(Array(USER1_DN, USER2_DN))
+    val filter: Filter = factory.getInstance(conf).get
+    filter.apply(search, "user1")
+    filter.apply(search, "user2")
+  }
+
+  test("ApplyNegative") {
+    intercept[AuthenticationException] {
+      conf.set(KyuubiConf.AUTHENTICATION_LDAP_CUSTOM_LDAP_QUERY, CUSTOM_QUERY)
+      when(search.executeCustomQuery(mockEq(CUSTOM_QUERY)))
+        .thenReturn(Array(USER1_DN, USER2_DN))
+      val filter: Filter = factory.getInstance(conf).get
+      filter.apply(search, "user3")
+    }
+  }
+}
diff --git a/kyuubi-common/src/test/scala/org/apache/kyuubi/service/authentication/ldap/GroupFilterSuite.scala b/kyuubi-common/src/test/scala/org/apache/kyuubi/service/authentication/ldap/GroupFilterSuite.scala
new file mode 100644
index 000000000..f1e3c3581
--- /dev/null
+++ b/kyuubi-common/src/test/scala/org/apache/kyuubi/service/authentication/ldap/GroupFilterSuite.scala
@@ -0,0 +1,161 @@
+/*
+ * 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 javax.security.sasl.AuthenticationException
+
+import org.mockito.ArgumentMatchers.{eq => mockEq}
+import org.mockito.Mockito.{lenient, when}
+import org.scalatestplus.mockito.MockitoSugar.mock
+
+import org.apache.kyuubi.KyuubiFunSuite
+import org.apache.kyuubi.config.KyuubiConf
+
+class GroupFilterSuite extends KyuubiFunSuite {
+  private val factory: FilterFactory = GroupFilterFactory
+  private var conf: KyuubiConf = _
+  private var search: DirSearch = _
+
+  override def beforeEach(): Unit = {
+    conf = new KyuubiConf
+    search = mock[DirSearch]
+    super.beforeEach()
+  }
+
+  test("GetInstanceWhenGroupFilterIsEmpty") {
+    conf.unset(KyuubiConf.AUTHENTICATION_LDAP_GROUP_FILTER)
+    assert(factory.getInstance(conf).isEmpty)
+  }
+
+  test("GetInstanceOfGroupMembershipKeyFilter") {
+    conf.set(KyuubiConf.AUTHENTICATION_LDAP_GROUP_FILTER.key, "G1")
+    val instance: Filter = factory.getInstance(conf).get
+    assert(instance.isInstanceOf[GroupMembershipKeyFilter])
+  }
+
+  test("GetInstanceOfUserMembershipKeyFilter") {
+    conf.set(KyuubiConf.AUTHENTICATION_LDAP_GROUP_FILTER.key, "G1")
+    conf.set(KyuubiConf.AUTHENTICATION_LDAP_USER_MEMBERSHIP_KEY, "memberof")
+    val instance: Filter = factory.getInstance(conf).get
+    assert(instance.isInstanceOf[UserMembershipKeyFilter])
+  }
+
+  test("GroupMembershipKeyFilterApplyPositive") {
+    conf.set(KyuubiConf.AUTHENTICATION_LDAP_GROUP_FILTER.key, "HiveUsers")
+    when(search.findUserDn(mockEq("user1")))
+      .thenReturn("cn=user1,ou=People,dc=example,dc=com")
+    when(search.findUserDn(mockEq("cn=user2,dc=example,dc=com")))
+      .thenReturn("cn=user2,ou=People,dc=example,dc=com")
+    when(search.findUserDn(mockEq("user3@mydomain.com")))
+      .thenReturn("cn=user3,ou=People,dc=example,dc=com")
+    when(search.findGroupsForUser(mockEq("cn=user1,ou=People,dc=example,dc=com")))
+      .thenReturn(Array(
+        "cn=SuperUsers,ou=Groups,dc=example,dc=com",
+        "cn=Office1,ou=Groups,dc=example,dc=com",
+        "cn=HiveUsers,ou=Groups,dc=example,dc=com",
+        "cn=G1,ou=Groups,dc=example,dc=com"))
+    when(search.findGroupsForUser(mockEq("cn=user2,ou=People,dc=example,dc=com")))
+      .thenReturn(Array("cn=HiveUsers,ou=Groups,dc=example,dc=com"))
+    when(search.findGroupsForUser(mockEq("cn=user3,ou=People,dc=example,dc=com")))
+      .thenReturn(Array(
+        "cn=HiveUsers,ou=Groups,dc=example,dc=com",
+        "cn=G1,ou=Groups,dc=example,dc=com",
+        "cn=G2,ou=Groups,dc=example,dc=com"))
+    val filter: Filter = factory.getInstance(conf).get
+    filter.apply(search, "user1")
+    filter.apply(search, "cn=user2,dc=example,dc=com")
+    filter.apply(search, "user3@mydomain.com")
+  }
+
+  test("GroupMembershipKeyCaseInsensitiveFilterApplyPositive") {
+    conf.set(KyuubiConf.AUTHENTICATION_LDAP_GROUP_FILTER.key, "hiveusers,g1")
+    when(search.findUserDn(mockEq("user1")))
+      .thenReturn("cn=user1,ou=People,dc=example,dc=com")
+    when(search.findUserDn(mockEq("cn=user2,dc=example,dc=com")))
+      .thenReturn("cn=user2,ou=People,dc=example,dc=com")
+    when(search.findUserDn(mockEq("user3@mydomain.com")))
+      .thenReturn("cn=user3,ou=People,dc=example,dc=com")
+    when(search.findGroupsForUser(mockEq("cn=user1,ou=People,dc=example,dc=com")))
+      .thenReturn(Array(
+        "cn=SuperUsers,ou=Groups,dc=example,dc=com",
+        "cn=Office1,ou=Groups,dc=example,dc=com",
+        "cn=HiveUsers,ou=Groups,dc=example,dc=com",
+        "cn=G1,ou=Groups,dc=example,dc=com"))
+    when(search.findGroupsForUser(mockEq("cn=user2,ou=People,dc=example,dc=com")))
+      .thenReturn(Array("cn=HiveUsers,ou=Groups,dc=example,dc=com"))
+    when(search.findGroupsForUser(mockEq("cn=user3,ou=People,dc=example,dc=com")))
+      .thenReturn(Array(
+        "cn=G1,ou=Groups,dc=example,dc=com",
+        "cn=G2,ou=Groups,dc=example,dc=com"))
+    val filter: Filter = factory.getInstance(conf).get
+    filter.apply(search, "user1")
+    filter.apply(search, "cn=user2,dc=example,dc=com")
+    filter.apply(search, "user3@mydomain.com")
+  }
+
+  test("GroupMembershipKeyCaseInsensitiveFilterApplyNegative") {
+    intercept[AuthenticationException] {
+      conf.set(KyuubiConf.AUTHENTICATION_LDAP_GROUP_FILTER.key, "hiveusers,containsg1")
+      lenient.when(search.findGroupsForUser(mockEq("user1")))
+        .thenReturn(Array("SuperUsers", "Office1", "G1", "G2"))
+      val filter: Filter = factory.getInstance(conf).get
+      filter.apply(search, "user1")
+    }
+  }
+
+  test("GroupMembershipKeyFilterApplyNegative") {
+    intercept[AuthenticationException] {
+      conf.set(KyuubiConf.AUTHENTICATION_LDAP_GROUP_FILTER.key, "HiveUsers")
+      lenient.when(search.findGroupsForUser(mockEq("user1")))
+        .thenReturn(Array("SuperUsers", "Office1", "G1", "G2"))
+      val filter: Filter = factory.getInstance(conf).get
+      filter.apply(search, "user1")
+    }
+  }
+
+  test("UserMembershipKeyFilterApplyPositiveWithUserId") {
+    conf.set(KyuubiConf.AUTHENTICATION_LDAP_USER_MEMBERSHIP_KEY.key, "memberOf")
+    conf.set(KyuubiConf.AUTHENTICATION_LDAP_GROUP_FILTER.key, "Group1,Group2")
+    when(search.findGroupDn("Group1")).thenReturn("cn=Group1,dc=a,dc=b")
+    when(search.findGroupDn("Group2")).thenReturn("cn=Group2,dc=a,dc=b")
+    when(search.isUserMemberOfGroup("User1", "cn=Group2,dc=a,dc=b")).thenReturn(true)
+    val filter: Filter = factory.getInstance(conf).get
+    filter.apply(search, "User1")
+  }
+
+  test("UserMembershipKeyFilterApplyPositiveWithUserDn") {
+    conf.set(KyuubiConf.AUTHENTICATION_LDAP_USER_MEMBERSHIP_KEY.key, "memberOf")
+    conf.set(KyuubiConf.AUTHENTICATION_LDAP_GROUP_FILTER.key, "Group1,Group2")
+    when(search.findGroupDn("Group1")).thenReturn("cn=Group1,dc=a,dc=b")
+    when(search.findGroupDn("Group2")).thenReturn("cn=Group2,dc=a,dc=b")
+    when(search.isUserMemberOfGroup("cn=User1,dc=a,dc=b", "cn=Group2,dc=a,dc=b")).thenReturn(true)
+    val filter: Filter = factory.getInstance(conf).get
+    filter.apply(search, "cn=User1,dc=a,dc=b")
+  }
+
+  test("UserMembershipKeyFilterApplyNegative") {
+    intercept[AuthenticationException] {
+      conf.set(KyuubiConf.AUTHENTICATION_LDAP_USER_MEMBERSHIP_KEY.key, "memberOf")
+      conf.set(KyuubiConf.AUTHENTICATION_LDAP_GROUP_FILTER.key, "Group1,Group2")
+      when(search.findGroupDn("Group1")).thenReturn("cn=Group1,dc=a,dc=b")
+      when(search.findGroupDn("Group2")).thenReturn("cn=Group2,dc=a,dc=b")
+      val filter: Filter = factory.getInstance(conf).get
+      filter.apply(search, "User1")
+    }
+  }
+}
diff --git a/kyuubi-common/src/test/scala/org/apache/kyuubi/service/authentication/ldap/LdapAuthenticationTestCase.scala b/kyuubi-common/src/test/scala/org/apache/kyuubi/service/authentication/ldap/LdapAuthenticationTestCase.scala
new file mode 100644
index 000000000..e8b92ebc0
--- /dev/null
+++ b/kyuubi-common/src/test/scala/org/apache/kyuubi/service/authentication/ldap/LdapAuthenticationTestCase.scala
@@ -0,0 +1,117 @@
+/*
+ * 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 javax.security.sasl.AuthenticationException
+
+import scala.collection.mutable
+
+import org.scalatest.Assertions.{fail, intercept}
+
+import org.apache.kyuubi.config.{ConfigEntry, KyuubiConf}
+import org.apache.kyuubi.service.authentication.LdapAuthenticationProviderImpl
+
+object LdapAuthenticationTestCase {
+  def builder: LdapAuthenticationTestCase.Builder = new LdapAuthenticationTestCase.Builder
+
+  class Builder {
+    private val overrides: mutable.Map[ConfigEntry[_], String] = new mutable.HashMap
+
+    var conf: KyuubiConf = _
+
+    def baseDN(baseDN: String): LdapAuthenticationTestCase.Builder =
+      setVarOnce(KyuubiConf.AUTHENTICATION_LDAP_BASE_DN, baseDN)
+
+    def guidKey(guidKey: String): LdapAuthenticationTestCase.Builder =
+      setVarOnce(KyuubiConf.AUTHENTICATION_LDAP_GUID_KEY, guidKey)
+
+    def userDNPatterns(userDNPatterns: String*): LdapAuthenticationTestCase.Builder =
+      setVarOnce(KyuubiConf.AUTHENTICATION_LDAP_USER_DN_PATTERN, userDNPatterns.mkString(":"))
+
+    def userFilters(userFilters: String*): LdapAuthenticationTestCase.Builder =
+      setVarOnce(KyuubiConf.AUTHENTICATION_LDAP_USER_FILTER, userFilters.mkString(","))
+
+    def groupDNPatterns(groupDNPatterns: String*): LdapAuthenticationTestCase.Builder =
+      setVarOnce(KyuubiConf.AUTHENTICATION_LDAP_GROUP_DN_PATTERN, groupDNPatterns.mkString(":"))
+
+    def groupFilters(groupFilters: String*): LdapAuthenticationTestCase.Builder =
+      setVarOnce(KyuubiConf.AUTHENTICATION_LDAP_GROUP_FILTER, groupFilters.mkString(","))
+
+    def groupClassKey(groupClassKey: String): LdapAuthenticationTestCase.Builder =
+      setVarOnce(KyuubiConf.AUTHENTICATION_LDAP_GROUP_CLASS_KEY, groupClassKey)
+
+    def ldapUrl(ldapUrl: String): LdapAuthenticationTestCase.Builder =
+      setVarOnce(KyuubiConf.AUTHENTICATION_LDAP_URL, ldapUrl)
+
+    def customQuery(customQuery: String): LdapAuthenticationTestCase.Builder =
+      setVarOnce(KyuubiConf.AUTHENTICATION_LDAP_CUSTOM_LDAP_QUERY, customQuery)
+
+    def groupMembershipKey(groupMembershipKey: String): LdapAuthenticationTestCase.Builder =
+      setVarOnce(KyuubiConf.AUTHENTICATION_LDAP_GROUP_MEMBERSHIP_KEY, groupMembershipKey)
+
+    def userMembershipKey(userMembershipKey: String): LdapAuthenticationTestCase.Builder =
+      setVarOnce(KyuubiConf.AUTHENTICATION_LDAP_USER_MEMBERSHIP_KEY, userMembershipKey)
+
+    private def setVarOnce(
+        confVar: ConfigEntry[_],
+        value: String): LdapAuthenticationTestCase.Builder = {
+      require(!overrides.contains(confVar), s"Property $confVar has been set already")
+      overrides.put(confVar, value)
+      this
+    }
+
+    def build: LdapAuthenticationTestCase = {
+      require(conf == null, "Test Case Builder should not be reused. Please create a new instance.")
+      conf = new KyuubiConf()
+      overrides.foreach { case (k, v) => conf.set(k.key, v) }
+      new LdapAuthenticationTestCase(this)
+    }
+  }
+}
+
+final class LdapAuthenticationTestCase(builder: LdapAuthenticationTestCase.Builder) {
+
+  private val ldapProvider = new LdapAuthenticationProviderImpl(builder.conf)
+
+  def assertAuthenticatePasses(credentials: Credentials): Unit =
+    try {
+      ldapProvider.authenticate(credentials.user, credentials.password)
+    } catch {
+      case e: AuthenticationException =>
+        throw new AssertionError(
+          s"Authentication failed for user '${credentials.user}' " +
+            s"with password '${credentials.password}'",
+          e)
+    }
+
+  def assertAuthenticateFails(credentials: Credentials): Unit = {
+    assertAuthenticateFails(credentials.user, credentials.password)
+  }
+
+  def assertAuthenticateFailsUsingWrongPassword(credentials: Credentials): Unit = {
+    assertAuthenticateFails(credentials.user, "not" + credentials.password)
+  }
+
+  def assertAuthenticateFails(user: String, password: String): Unit = {
+    val e = intercept[AuthenticationException] {
+      ldapProvider.authenticate(user, password)
+      fail(s"Expected authentication to fail for $user")
+    }
+    assert(e != null)
+  }
+}
diff --git a/kyuubi-common/src/test/scala/org/apache/kyuubi/service/authentication/ldap/LdapSearchSuite.scala b/kyuubi-common/src/test/scala/org/apache/kyuubi/service/authentication/ldap/LdapSearchSuite.scala
new file mode 100644
index 000000000..3bf27127b
--- /dev/null
+++ b/kyuubi-common/src/test/scala/org/apache/kyuubi/service/authentication/ldap/LdapSearchSuite.scala
@@ -0,0 +1,298 @@
+/*
+ * 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 javax.naming.{NamingEnumeration, NamingException}
+import javax.naming.directory.{DirContext, SearchControls, SearchResult}
+
+import org.mockito.ArgumentMatchers.{any, anyString, contains, eq => mockEq}
+import org.mockito.Mockito.{atLeastOnce, verify, when}
+import org.scalatestplus.mockito.MockitoSugar.mock
+
+import org.apache.kyuubi.KyuubiFunSuite
+import org.apache.kyuubi.config.KyuubiConf
+import org.apache.kyuubi.service.authentication.ldap.LdapTestUtils._
+
+class LdapSearchSuite extends KyuubiFunSuite {
+  private var conf: KyuubiConf = _
+  private var ctx: DirContext = _
+  private var search: LdapSearch = _
+
+  override protected def beforeEach(): Unit = {
+    conf = new KyuubiConf()
+    conf.set(KyuubiConf.AUTHENTICATION_LDAP_USER_MEMBERSHIP_KEY, "memberOf")
+    ctx = mock[DirContext]
+    super.beforeEach()
+  }
+
+  test("close") {
+    search = new LdapSearch(conf, ctx)
+    search.close()
+    verify(ctx, atLeastOnce).close()
+  }
+
+  test("FindUserDnWhenUserDnPositive") {
+    val searchResult: NamingEnumeration[SearchResult] =
+      mockNamingEnumeration("CN=User1,OU=org1,DC=foo,DC=bar")
+    when(ctx.search(anyString, anyString, any(classOf[SearchControls])))
+      .thenReturn(searchResult)
+      .thenThrow(classOf[NamingException])
+    search = new LdapSearch(conf, ctx)
+    val expected: String = "CN=User1,OU=org1,DC=foo,DC=bar"
+    val actual: String = search.findUserDn("CN=User1,OU=org1")
+    assert(expected === actual)
+  }
+
+  test("FindUserDnWhenUserDnNegativeDuplicates") {
+    val searchResult: NamingEnumeration[SearchResult] =
+      mockNamingEnumeration("CN=User1,OU=org1,DC=foo,DC=bar", "CN=User1,OU=org2,DC=foo,DC=bar")
+    when(ctx.search(anyString, anyString, any(classOf[SearchControls])))
+      .thenReturn(searchResult)
+    search = new LdapSearch(conf, ctx)
+    assert(search.findUserDn("CN=User1,DC=foo,DC=bar") === null)
+  }
+
+  test("FindUserDnWhenUserDnNegativeNone") {
+    val searchResult: NamingEnumeration[SearchResult] = mockEmptyNamingEnumeration
+    when(ctx.search(anyString, anyString, any(classOf[SearchControls])))
+      .thenReturn(searchResult)
+    search = new LdapSearch(conf, ctx)
+    assert(search.findUserDn("CN=User1,DC=foo,DC=bar") === null)
+  }
+
+  test("FindUserDnWhenUserPatternFoundBySecondPattern") {
+    conf.set(
+      KyuubiConf.AUTHENTICATION_LDAP_USER_DN_PATTERN,
+      "CN=%s,OU=org1,DC=foo,DC=bar:CN=%s,OU=org2,DC=foo,DC=bar")
+    val emptyResult: NamingEnumeration[SearchResult] = mockEmptyNamingEnumeration
+    val validResult: NamingEnumeration[SearchResult] =
+      mockNamingEnumeration("CN=User1,OU=org2,DC=foo,DC=bar")
+    when(ctx.search(anyString, anyString, any(classOf[SearchControls])))
+      .thenReturn(emptyResult)
+      .thenReturn(validResult)
+    search = new LdapSearch(conf, ctx)
+    val expected: String = "CN=User1,OU=org2,DC=foo,DC=bar"
+    val actual: String = search.findUserDn("User1")
+    assert(expected === actual)
+    verify(ctx).search(
+      mockEq("OU=org1,DC=foo,DC=bar"),
+      contains("CN=User1"),
+      any(classOf[SearchControls]))
+    verify(ctx).search(
+      mockEq("OU=org2,DC=foo,DC=bar"),
+      contains("CN=User1"),
+      any(classOf[SearchControls]))
+  }
+
+  test("FindUserDnWhenUserPatternFoundByFirstPattern") {
+    conf.set(
+      KyuubiConf.AUTHENTICATION_LDAP_USER_DN_PATTERN,
+      "CN=%s,OU=org1,DC=foo,DC=bar:CN=%s,OU=org2,DC=foo,DC=bar")
+    val emptyResult: NamingEnumeration[SearchResult] = mockEmptyNamingEnumeration
+    val validResult: NamingEnumeration[SearchResult] =
+      mockNamingEnumeration("CN=User1,OU=org2,DC=foo,DC=bar")
+    when(ctx.search(anyString, anyString, any(classOf[SearchControls])))
+      .thenReturn(validResult)
+      .thenReturn(emptyResult)
+    search = new LdapSearch(conf, ctx)
+    val expected: String = "CN=User1,OU=org2,DC=foo,DC=bar"
+    val actual: String = search.findUserDn("User1")
+    assert(expected === actual)
+    verify(ctx).search(
+      mockEq("OU=org1,DC=foo,DC=bar"),
+      contains("CN=User1"),
+      any(classOf[SearchControls]))
+  }
+
+  test("FindUserDnWhenUserPatternFoundByUniqueIdentifier") {
+    conf.set(
+      KyuubiConf.AUTHENTICATION_LDAP_USER_DN_PATTERN,
+      "CN=%s,OU=org1,DC=foo,DC=bar")
+    val validResult: NamingEnumeration[SearchResult] =
+      mockNamingEnumeration("CN=User1,OU=org1,DC=foo,DC=bar")
+    when(ctx.search(anyString, anyString, any(classOf[SearchControls])))
+      .thenReturn(null)
+      .thenReturn(validResult)
+    search = new LdapSearch(conf, ctx)
+    val expected: String = "CN=User1,OU=org1,DC=foo,DC=bar"
+    val actual: String = search.findUserDn("User1")
+    assert(expected === actual)
+    verify(ctx).search(
+      mockEq("OU=org1,DC=foo,DC=bar"),
+      contains("CN=User1"),
+      any(classOf[SearchControls]))
+    verify(ctx).search(
+      mockEq("OU=org1,DC=foo,DC=bar"),
+      contains("uid=User1"),
+      any(classOf[SearchControls]))
+  }
+
+  test("FindUserDnWhenUserPatternFoundByUniqueIdentifierNegativeNone") {
+    conf.set(
+      KyuubiConf.AUTHENTICATION_LDAP_USER_DN_PATTERN,
+      "CN=%s,OU=org1,DC=foo,DC=bar")
+    when(ctx.search(anyString, anyString, any(classOf[SearchControls])))
+      .thenReturn(null)
+      .thenReturn(null)
+    search = new LdapSearch(conf, ctx)
+    assert(search.findUserDn("User1") === null)
+  }
+
+  test("FindUserDnWhenUserPatternFoundByUniqueIdentifierNegativeMany") {
+    conf.set(
+      KyuubiConf.AUTHENTICATION_LDAP_USER_DN_PATTERN,
+      "CN=%s,OU=org1,DC=foo,DC=bar")
+    val manyResult: NamingEnumeration[SearchResult] =
+      mockNamingEnumeration("CN=User1,OU=org1,DC=foo,DC=bar", "CN=User12,OU=org1,DC=foo,DC=bar")
+    when(ctx.search(anyString, anyString, any(classOf[SearchControls])))
+      .thenReturn(null)
+      .thenReturn(manyResult)
+    search = new LdapSearch(conf, ctx)
+    assert(search.findUserDn("User1") === null)
+  }
+
+  test("FindGroupsForUser") {
+    conf.set(
+      KyuubiConf.AUTHENTICATION_LDAP_GROUP_DN_PATTERN,
+      "CN=%s,OU=org1,DC=foo,DC=bar")
+    val groupsResult: NamingEnumeration[SearchResult] =
+      mockNamingEnumeration("CN=Group1,OU=org1,DC=foo,DC=bar")
+    when(
+      ctx.search(
+        mockEq("OU=org1,DC=foo,DC=bar"),
+        contains("User1"),
+        any(classOf[SearchControls]))).thenReturn(groupsResult)
+    search = new LdapSearch(conf, ctx)
+    val expected = Array("CN=Group1,OU=org1,DC=foo,DC=bar")
+    val actual = search.findGroupsForUser("CN=User1,OU=org1,DC=foo,DC=bar")
+    assert(expected === actual)
+  }
+
+  test("ExecuteCustomQuery") {
+    conf.set(KyuubiConf.AUTHENTICATION_LDAP_BASE_DN, "dc=example,dc=com")
+    val customQueryResult: NamingEnumeration[SearchResult] = mockNamingEnumeration(Array(
+      mockSearchResult(
+        "uid=group1,ou=Groups,dc=example,dc=com",
+        mockAttributes("member", "uid=user1,ou=People,dc=example,dc=com")),
+      mockSearchResult(
+        "uid=group2,ou=Groups,dc=example,dc=com",
+        mockAttributes("member", "uid=user2,ou=People,dc=example,dc=com"))))
+    when(
+      ctx.search(
+        mockEq("dc=example,dc=com"),
+        anyString,
+        any(classOf[SearchControls])))
+      .thenReturn(customQueryResult)
+    search = new LdapSearch(conf, ctx)
+    val expected = Array(
+      "uid=group1,ou=Groups,dc=example,dc=com",
+      "uid=user1,ou=People,dc=example,dc=com",
+      "uid=group2,ou=Groups,dc=example,dc=com",
+      "uid=user2,ou=People,dc=example,dc=com")
+    val actual = search.executeCustomQuery("(&(objectClass=groupOfNames)(|(cn=group1)(cn=group2)))")
+    assert(expected.sorted === actual.sorted)
+  }
+
+  test("FindGroupDnPositive") {
+    conf.set(
+      KyuubiConf.AUTHENTICATION_LDAP_GROUP_DN_PATTERN,
+      "CN=%s,OU=org1,DC=foo,DC=bar")
+    val groupDn: String = "CN=Group1"
+    val result: NamingEnumeration[SearchResult] = mockNamingEnumeration(groupDn)
+    when(ctx.search(anyString, anyString, any(classOf[SearchControls])))
+      .thenReturn(result)
+    search = new LdapSearch(conf, ctx)
+    val expected: String = groupDn
+    val actual: String = search.findGroupDn("grp1")
+    assert(expected === actual)
+  }
+
+  test("FindGroupDNNoResults") {
+    intercept[NamingException] {
+      conf.set(
+        KyuubiConf.AUTHENTICATION_LDAP_GROUP_DN_PATTERN,
+        "CN=%s,OU=org1,DC=foo,DC=bar")
+      val result: NamingEnumeration[SearchResult] = mockEmptyNamingEnumeration
+      when(ctx.search(anyString, anyString, any(classOf[SearchControls])))
+        .thenReturn(result)
+      search = new LdapSearch(conf, ctx)
+      search.findGroupDn("anyGroup")
+    }
+  }
+
+  test("FindGroupDNTooManyResults") {
+    intercept[NamingException] {
+      conf.set(
+        KyuubiConf.AUTHENTICATION_LDAP_GROUP_DN_PATTERN,
+        "CN=%s,OU=org1,DC=foo,DC=bar")
+      val result: NamingEnumeration[SearchResult] =
+        LdapTestUtils.mockNamingEnumeration("Result1", "Result2", "Result3")
+      when(ctx.search(anyString, anyString, any(classOf[SearchControls])))
+        .thenReturn(result)
+      search = new LdapSearch(conf, ctx)
+      search.findGroupDn("anyGroup")
+    }
+
+  }
+
+  test("FindGroupDNWhenExceptionInSearch") {
+    conf.set(
+      KyuubiConf.AUTHENTICATION_LDAP_GROUP_DN_PATTERN,
+      Array("CN=%s,OU=org1,DC=foo,DC=bar", "CN=%s,OU=org2,DC=foo,DC=bar").mkString(":"))
+    val result: NamingEnumeration[SearchResult] = LdapTestUtils.mockNamingEnumeration("CN=Group1")
+    when(ctx.search(anyString, anyString, any(classOf[SearchControls])))
+      .thenReturn(result)
+      .thenThrow(classOf[NamingException])
+    search = new LdapSearch(conf, ctx)
+    val expected: String = "CN=Group1"
+    val actual: String = search.findGroupDn("grp1")
+    assert(expected === actual)
+  }
+
+  test("IsUserMemberOfGroupWhenUserId") {
+    conf.set(
+      KyuubiConf.AUTHENTICATION_LDAP_USER_DN_PATTERN,
+      "CN=%s,OU=org1,DC=foo,DC=bar")
+    val validResult: NamingEnumeration[SearchResult] =
+      LdapTestUtils.mockNamingEnumeration("CN=User1")
+    val emptyResult: NamingEnumeration[SearchResult] = LdapTestUtils.mockEmptyNamingEnumeration
+    when(ctx.search(anyString, contains("(uid=usr1)"), any(classOf[SearchControls])))
+      .thenReturn(validResult)
+    when(ctx.search(anyString, contains("(uid=usr2)"), any(classOf[SearchControls])))
+      .thenReturn(emptyResult)
+    search = new LdapSearch(conf, ctx)
+    assert(search.isUserMemberOfGroup("usr1", "grp1"))
+    assert(!search.isUserMemberOfGroup("usr2", "grp2"))
+  }
+
+  test("IsUserMemberOfGroupWhenUserDn") {
+    conf.set(
+      KyuubiConf.AUTHENTICATION_LDAP_USER_DN_PATTERN,
+      "CN=%s,OU=org1,DC=foo,DC=bar")
+    val validResult: NamingEnumeration[SearchResult] =
+      LdapTestUtils.mockNamingEnumeration("CN=User1")
+    val emptyResult: NamingEnumeration[SearchResult] = LdapTestUtils.mockEmptyNamingEnumeration
+    when(ctx.search(anyString, contains("(uid=User1)"), any(classOf[SearchControls])))
+      .thenReturn(validResult)
+    when(ctx.search(anyString, contains("(uid=User2)"), any(classOf[SearchControls])))
+      .thenReturn(emptyResult)
+    search = new LdapSearch(conf, ctx)
+    assert(search.isUserMemberOfGroup("CN=User1,OU=org1,DC=foo,DC=bar", "grp1"))
+    assert(!search.isUserMemberOfGroup("CN=User2,OU=org1,DC=foo,DC=bar", "grp2"))
+  }
+}
diff --git a/kyuubi-common/src/test/scala/org/apache/kyuubi/service/authentication/ldap/LdapTestUtils.scala b/kyuubi-common/src/test/scala/org/apache/kyuubi/service/authentication/ldap/LdapTestUtils.scala
new file mode 100644
index 000000000..49340f2c4
--- /dev/null
+++ b/kyuubi-common/src/test/scala/org/apache/kyuubi/service/authentication/ldap/LdapTestUtils.scala
@@ -0,0 +1,116 @@
+/*
+ * 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 javax.naming.{NamingEnumeration, NamingException}
+import javax.naming.directory._
+
+import org.mockito.Mockito.when
+import org.mockito.stubbing.OngoingStubbing
+import org.scalatestplus.mockito.MockitoSugar
+
+case class NameValues(name: String, values: String*)
+case class Credentials(user: String, password: String)
+
+case class User(dn: String, id: String, password: String) {
+
+  def credentialsWithDn: Credentials = Credentials(dn, password)
+
+  def credentialsWithId: Credentials = Credentials(id, password)
+}
+
+object User {
+  def useIdForPassword(dn: String, id: String): User = User(dn, id, id)
+}
+
+object LdapTestUtils extends MockitoSugar {
+  @throws[NamingException]
+  def mockEmptyNamingEnumeration: NamingEnumeration[SearchResult] =
+    mockNamingEnumeration(new Array[SearchResult](0))
+
+  @throws[NamingException]
+  def mockNamingEnumeration(dns: String*): NamingEnumeration[SearchResult] =
+    mockNamingEnumeration(mockSearchResults(dns.toArray))
+
+  @throws[NamingException]
+  def mockNamingEnumeration(searchResults: Array[SearchResult]): NamingEnumeration[SearchResult] = {
+    val ne = mock[NamingEnumeration[SearchResult]]
+    mockHasMoreMethod(ne, searchResults.length)
+    if (searchResults.nonEmpty) {
+      val mockedResults = Array(searchResults: _*)
+      mockNextMethod(ne, mockedResults)
+    }
+    ne
+  }
+
+  @throws[NamingException]
+  def mockHasMoreMethod(ne: NamingEnumeration[SearchResult], length: Int): Unit = {
+    var hasMoreStub: OngoingStubbing[Boolean] = when(ne.hasMore)
+    (0 until length).foreach(_ => hasMoreStub = hasMoreStub.thenReturn(true))
+    hasMoreStub.thenReturn(false)
+  }
+
+  @throws[NamingException]
+  def mockNextMethod(
+      ne: NamingEnumeration[SearchResult],
+      searchResults: Array[SearchResult]): Unit = {
+    var nextStub: OngoingStubbing[SearchResult] = when(ne.next)
+    searchResults.foreach { searchResult =>
+      nextStub = nextStub.thenReturn(searchResult)
+    }
+  }
+
+  def mockSearchResults(dns: Array[String]): Array[SearchResult] = {
+    dns.map(mockSearchResult(_, null))
+  }
+
+  def mockSearchResult(dn: String, attributes: Attributes): SearchResult = {
+    val searchResult = mock[SearchResult]
+    when(searchResult.getNameInNamespace).thenReturn(dn)
+    when(searchResult.getAttributes).thenReturn(attributes)
+    searchResult
+  }
+
+  @throws[NamingException]
+  def mockEmptyAttributes(): Attributes = mockAttributes()
+
+  @throws[NamingException]
+  def mockAttributes(name: String, value: String): Attributes =
+    mockAttributes(NameValues(name, value))
+
+  @throws[NamingException]
+  def mockAttributes(name1: String, value1: String, name2: String, value2: String): Attributes =
+    if (name1 == name2) {
+      mockAttributes(NameValues(name1, value1, value2))
+    } else {
+      mockAttributes(
+        NameValues(name1, value1),
+        NameValues(name2, value2))
+    }
+
+  @throws[NamingException]
+  private def mockAttributes(namedValues: NameValues*): Attributes = {
+    val attributes = new BasicAttributes
+    namedValues.foreach { namedValue =>
+      val attr = new BasicAttribute(namedValue.name)
+      namedValue.values.foreach(attr.add)
+      attributes.put(attr)
+    }
+    attributes
+  }
+}
diff --git a/kyuubi-common/src/test/scala/org/apache/kyuubi/service/authentication/ldap/LdapUtilsSuite.scala b/kyuubi-common/src/test/scala/org/apache/kyuubi/service/authentication/ldap/LdapUtilsSuite.scala
new file mode 100644
index 000000000..1ef371051
--- /dev/null
+++ b/kyuubi-common/src/test/scala/org/apache/kyuubi/service/authentication/ldap/LdapUtilsSuite.scala
@@ -0,0 +1,88 @@
+/*
+ * 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 org.apache.kyuubi.KyuubiFunSuite
+import org.apache.kyuubi.config.KyuubiConf
+
+class LdapUtilsSuite extends KyuubiFunSuite {
+  test("CreateCandidatePrincipalsForUserDn") {
+    val conf = new KyuubiConf()
+    val userDn = "cn=user1,ou=CORP,dc=mycompany,dc=com"
+    val expected = Array(userDn)
+    val actual = LdapUtils.createCandidatePrincipals(conf, userDn)
+    assert(actual === expected)
+  }
+
+  test("CreateCandidatePrincipalsForUserWithDomain") {
+    val conf = new KyuubiConf()
+    val userWithDomain: String = "user1@mycompany.com"
+    val expected = Array(userWithDomain)
+    val actual = LdapUtils.createCandidatePrincipals(conf, userWithDomain)
+    assert(actual === expected)
+  }
+
+  test("CreateCandidatePrincipalsLdapDomain") {
+    val conf = new KyuubiConf()
+      .set(KyuubiConf.AUTHENTICATION_LDAP_DOMAIN, "mycompany.com")
+    val expected = Array("user1@mycompany.com")
+    val actual = LdapUtils.createCandidatePrincipals(conf, "user1")
+    assert(actual === expected)
+  }
+
+  test("CreateCandidatePrincipalsUserPatternsDefaultBaseDn") {
+    val conf = new KyuubiConf()
+      .set(KyuubiConf.AUTHENTICATION_LDAP_GUID_KEY, "sAMAccountName")
+      .set(KyuubiConf.AUTHENTICATION_LDAP_BASE_DN, "dc=mycompany,dc=com")
+    val expected = Array("sAMAccountName=user1,dc=mycompany,dc=com")
+    val actual = LdapUtils.createCandidatePrincipals(conf, "user1")
+    assert(actual === expected)
+  }
+
+  test("CreateCandidatePrincipals") {
+    val conf = new KyuubiConf()
+      .set(KyuubiConf.AUTHENTICATION_LDAP_BASE_DN, "dc=mycompany,dc=com")
+      .set(
+        KyuubiConf.AUTHENTICATION_LDAP_USER_DN_PATTERN,
+        "cn=%s,ou=CORP1,dc=mycompany,dc=com:cn=%s,ou=CORP2,dc=mycompany,dc=com")
+    val expected = Array(
+      "cn=user1,ou=CORP1,dc=mycompany,dc=com",
+      "cn=user1,ou=CORP2,dc=mycompany,dc=com")
+    val actual = LdapUtils.createCandidatePrincipals(conf, "user1")
+    assert(actual.sorted === expected.sorted)
+  }
+
+  test("ExtractFirstRdn") {
+    val dn = "cn=user1,ou=CORP1,dc=mycompany,dc=com"
+    val expected = "cn=user1"
+    val actual = LdapUtils.extractFirstRdn(dn)
+    assert(actual === expected)
+  }
+
+  test("ExtractBaseDn") {
+    val dn: String = "cn=user1,ou=CORP1,dc=mycompany,dc=com"
+    val expected = "ou=CORP1,dc=mycompany,dc=com"
+    val actual = LdapUtils.extractBaseDn(dn)
+    assert(actual === expected)
+  }
+
+  test("ExtractBaseDnNegative") {
+    val dn: String = "cn=user1"
+    assert(LdapUtils.extractBaseDn(dn) === null)
+  }
+}
diff --git a/kyuubi-common/src/test/scala/org/apache/kyuubi/service/authentication/ldap/QueryFactorySuite.scala b/kyuubi-common/src/test/scala/org/apache/kyuubi/service/authentication/ldap/QueryFactorySuite.scala
new file mode 100644
index 000000000..568009680
--- /dev/null
+++ b/kyuubi-common/src/test/scala/org/apache/kyuubi/service/authentication/ldap/QueryFactorySuite.scala
@@ -0,0 +1,97 @@
+/*
+ * 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 org.apache.kyuubi.KyuubiFunSuite
+import org.apache.kyuubi.config.KyuubiConf
+
+class QueryFactorySuite extends KyuubiFunSuite {
+  private var conf: KyuubiConf = _
+  private var queries: QueryFactory = _
+
+  override def beforeEach(): Unit = {
+    conf = new KyuubiConf()
+    conf.set(KyuubiConf.AUTHENTICATION_LDAP_GUID_KEY, "guid")
+    conf.set(KyuubiConf.AUTHENTICATION_LDAP_GROUP_CLASS_KEY, "superGroups")
+    conf.set(KyuubiConf.AUTHENTICATION_LDAP_GROUP_MEMBERSHIP_KEY, "member")
+    conf.set(KyuubiConf.AUTHENTICATION_LDAP_USER_MEMBERSHIP_KEY, "partOf")
+    queries = new QueryFactory(conf)
+    super.beforeEach()
+  }
+
+  test("FindGroupDnById") {
+    val q = queries.findGroupDnById("unique_group_id")
+    val expected = "(&(objectClass=superGroups)(guid=unique_group_id))"
+    val actual = q.filter
+    assert(expected === actual)
+  }
+
+  test("FindUserDnByRdn") {
+    val q = queries.findUserDnByRdn("cn=User1")
+    val expected =
+      "(&(|(objectClass=person)(objectClass=user)(objectClass=inetOrgPerson))(cn=User1))"
+    val actual = q.filter
+    assert(expected === actual)
+  }
+
+  test("FindDnByPattern") {
+    val q = queries.findDnByPattern("cn=User1")
+    val expected = "(cn=User1)"
+    val actual = q.filter
+    assert(expected === actual)
+  }
+
+  test("FindUserDnByName") {
+    val q = queries.findUserDnByName("unique_user_id")
+    val expected =
+      "(&(|(objectClass=person)(objectClass=user)(objectClass=inetOrgPerson))" +
+        "(|(uid=unique_user_id)(sAMAccountName=unique_user_id)))"
+    val actual = q.filter
+    assert(expected === actual)
+  }
+
+  test("FindGroupsForUser") {
+    val q = queries.findGroupsForUser("user_name", "user_Dn")
+    val expected = "(&(objectClass=superGroups)(|(member=user_Dn)(member=user_name)))"
+    val actual = q.filter
+    assert(expected === actual)
+  }
+
+  test("IsUserMemberOfGroup") {
+    val q = queries.isUserMemberOfGroup("unique_user", "cn=MyGroup,ou=Groups,dc=mycompany,dc=com")
+    val expected =
+      "(&(|(objectClass=person)(objectClass=user)(objectClass=inetOrgPerson))" +
+        "(partOf=cn=MyGroup,ou=Groups,dc=mycompany,dc=com)(guid=unique_user))"
+    val actual = q.filter
+    assert(expected === actual)
+  }
+
+  test("IsUserMemberOfGroupWhenMisconfigured") {
+    intercept[IllegalArgumentException] {
+      val misconfiguredQueryFactory = new QueryFactory(new KyuubiConf())
+      misconfiguredQueryFactory.isUserMemberOfGroup("user", "cn=MyGroup")
+    }
+  }
+
+  test("FindGroupDNByID") {
+    val q = queries.findGroupDnById("unique_group_id")
+    val expected = "(&(objectClass=superGroups)(guid=unique_group_id))"
+    val actual = q.filter
+    assert(expected === actual)
+  }
+}
diff --git a/kyuubi-common/src/test/scala/org/apache/kyuubi/service/authentication/ldap/QuerySuite.scala b/kyuubi-common/src/test/scala/org/apache/kyuubi/service/authentication/ldap/QuerySuite.scala
new file mode 100644
index 000000000..ffe330cce
--- /dev/null
+++ b/kyuubi-common/src/test/scala/org/apache/kyuubi/service/authentication/ldap/QuerySuite.scala
@@ -0,0 +1,54 @@
+/*
+ * 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 org.apache.kyuubi.KyuubiFunSuite
+
+class QuerySuite extends KyuubiFunSuite {
+
+  test("QueryBuilderFilter") {
+    val q = Query.builder
+      .filter("test <uid_attr>=<value> query")
+      .map("uid_attr", "uid")
+      .map("value", "Hello!")
+      .build
+    assert("test uid=Hello! query" === q.filter)
+    assert(0 === q.controls.getCountLimit)
+  }
+
+  test("QueryBuilderLimit") {
+    val q = Query.builder
+      .filter("<key1>,<key2>")
+      .map("key1", "value1")
+      .map("key2", "value2")
+      .limit(8)
+      .build
+    assert("value1,value2" === q.filter)
+    assert(8 === q.controls.getCountLimit)
+  }
+
+  test("QueryBuilderReturningAttributes") {
+    val q = Query.builder
+      .filter("(query)")
+      .returnAttribute("attr1")
+      .returnAttribute("attr2")
+      .build
+    assert("(query)" === q.filter)
+    assert(Array("attr1", "attr2") === q.controls.getReturningAttributes)
+  }
+}
diff --git a/kyuubi-common/src/test/scala/org/apache/kyuubi/service/authentication/ldap/SearchResultHandlerSuite.scala b/kyuubi-common/src/test/scala/org/apache/kyuubi/service/authentication/ldap/SearchResultHandlerSuite.scala
new file mode 100644
index 000000000..4e92f7f5f
--- /dev/null
+++ b/kyuubi-common/src/test/scala/org/apache/kyuubi/service/authentication/ldap/SearchResultHandlerSuite.scala
@@ -0,0 +1,203 @@
+/*
+ * 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.util
+import javax.naming.{NamingEnumeration, NamingException}
+import javax.naming.directory.SearchResult
+
+import scala.collection.mutable.ArrayBuffer
+
+import org.mockito.Mockito.{atLeastOnce, doThrow, verify}
+
+import org.apache.kyuubi.KyuubiFunSuite
+import org.apache.kyuubi.service.authentication.ldap.LdapTestUtils._
+
+class SearchResultHandlerSuite extends KyuubiFunSuite {
+  private var handler: SearchResultHandler = _
+
+  test("handle") {
+    val resultCollection = new MockResultCollectionBuilder()
+      .addSearchResultWithDns("1")
+      .addSearchResultWithDns("2", "3")
+      .build
+    handler = new SearchResultHandler(resultCollection)
+    val expected: util.List[String] = util.Arrays.asList("1", "2")
+    val actual: util.List[String] = new util.ArrayList[String]
+    handler.handle { record =>
+      actual.add(record.getNameInNamespace)
+      actual.size < 2
+    }
+    assert(expected === actual)
+    assertAllNamingEnumerationsClosed(resultCollection)
+  }
+
+  test("GetAllLdapNamesNoRecords") {
+    val resultCollection = new MockResultCollectionBuilder()
+      .addEmptySearchResult()
+      .build
+    handler = new SearchResultHandler(resultCollection)
+    val actual = handler.getAllLdapNames
+    assert(actual.isEmpty, "ResultSet size")
+    assertAllNamingEnumerationsClosed(resultCollection)
+  }
+
+  test("GetAllLdapNamesWithExceptionInNamingEnumerationClose") {
+    val resultCollection = new MockResultCollectionBuilder()
+      .addSearchResultWithDns("1")
+      .addSearchResultWithDns("2")
+      .build
+    doThrow(classOf[NamingException]).when(resultCollection.iterator.next).close()
+    handler = new SearchResultHandler(resultCollection)
+    val actual = handler.getAllLdapNames
+    assert(actual.length === 2, "ResultSet size")
+    assertAllNamingEnumerationsClosed(resultCollection)
+  }
+
+  test("GetAllLdapNames") {
+    val objectDn1: String = "cn=a1,dc=b,dc=c"
+    val objectDn2: String = "cn=a2,dc=b,dc=c"
+    val objectDn3: String = "cn=a3,dc=b,dc=c"
+    val resultCollection = new MockResultCollectionBuilder()
+      .addSearchResultWithDns(objectDn1)
+      .addSearchResultWithDns(objectDn2, objectDn3)
+      .build
+    handler = new SearchResultHandler(resultCollection)
+    val expected = Array(objectDn1, objectDn2, objectDn3)
+    val actual = handler.getAllLdapNames
+    assert(expected.sorted === actual.sorted)
+    assertAllNamingEnumerationsClosed(resultCollection)
+  }
+
+  test("GetAllLdapNamesAndAttributes") {
+    val searchResult1 = mockSearchResult(
+      "cn=a1,dc=b,dc=c",
+      mockAttributes("attr1", "attr1value1"))
+    val searchResult2 = mockSearchResult(
+      "cn=a2,dc=b,dc=c",
+      mockAttributes("attr1", "attr1value2", "attr2", "attr2value1"))
+    val searchResult3 = mockSearchResult(
+      "cn=a3,dc=b,dc=c",
+      mockAttributes("attr1", "attr1value3", "attr1", "attr1value4"))
+    val searchResult4 = mockSearchResult(
+      "cn=a4,dc=b,dc=c",
+      mockEmptyAttributes())
+    val resultCollection = new MockResultCollectionBuilder()
+      .addSearchResults(searchResult1)
+      .addSearchResults(searchResult2, searchResult3)
+      .addSearchResults(searchResult4)
+      .build
+    handler = new SearchResultHandler(resultCollection)
+    val expected = Array(
+      "cn=a1,dc=b,dc=c",
+      "attr1value1",
+      "cn=a2,dc=b,dc=c",
+      "attr1value2",
+      "attr2value1",
+      "cn=a3,dc=b,dc=c",
+      "attr1value3",
+      "attr1value4",
+      "cn=a4,dc=b,dc=c")
+    val actual = handler.getAllLdapNamesAndAttributes
+    assert(expected.sorted === actual.sorted)
+    assertAllNamingEnumerationsClosed(resultCollection)
+  }
+
+  test("HasSingleResultNoRecords") {
+    val resultCollection = new MockResultCollectionBuilder()
+      .addEmptySearchResult()
+      .build
+    handler = new SearchResultHandler(resultCollection)
+    assert(!handler.hasSingleResult)
+    assertAllNamingEnumerationsClosed(resultCollection)
+  }
+
+  test("HasSingleResult") {
+    val resultCollection = new MockResultCollectionBuilder()
+      .addSearchResultWithDns("1")
+      .build
+    handler = new SearchResultHandler(resultCollection)
+    assert(handler.hasSingleResult)
+    assertAllNamingEnumerationsClosed(resultCollection)
+  }
+
+  test("HasSingleResultManyRecords") {
+    val resultCollection = new MockResultCollectionBuilder()
+      .addSearchResultWithDns("1")
+      .addSearchResultWithDns("2")
+      .build
+    handler = new SearchResultHandler(resultCollection)
+    assert(!handler.hasSingleResult)
+    assertAllNamingEnumerationsClosed(resultCollection)
+  }
+
+  test("GetSingleLdapNameNoRecords") {
+    intercept[NamingException] {
+      val resultCollection = new MockResultCollectionBuilder()
+        .addEmptySearchResult()
+        .build
+      handler = new SearchResultHandler(resultCollection)
+      try handler.getSingleLdapName
+      finally {
+        assertAllNamingEnumerationsClosed(resultCollection)
+      }
+    }
+  }
+
+  test("GetSingleLdapName") {
+    val objectDn: String = "cn=a,dc=b,dc=c"
+    val resultCollection = new MockResultCollectionBuilder()
+      .addEmptySearchResult()
+      .addSearchResultWithDns(objectDn)
+      .build
+    handler = new SearchResultHandler(resultCollection)
+    val expected: String = objectDn
+    val actual: String = handler.getSingleLdapName
+    assert(expected === actual)
+    assertAllNamingEnumerationsClosed(resultCollection)
+  }
+
+  private def assertAllNamingEnumerationsClosed(
+      resultCollection: Array[NamingEnumeration[SearchResult]]): Unit = {
+    for (namingEnumeration <- resultCollection) {
+      verify(namingEnumeration, atLeastOnce).close()
+    }
+  }
+}
+
+class MockResultCollectionBuilder {
+
+  val results = new ArrayBuffer[NamingEnumeration[SearchResult]]
+
+  def addSearchResultWithDns(dns: String*): MockResultCollectionBuilder = {
+    results += mockNamingEnumeration(dns: _*)
+    this
+  }
+
+  def addSearchResults(dns: SearchResult*): MockResultCollectionBuilder = {
+    results += mockNamingEnumeration(dns.toArray)
+    this
+  }
+
+  def addEmptySearchResult(): MockResultCollectionBuilder = {
+    addSearchResults()
+    this
+  }
+
+  def build: Array[NamingEnumeration[SearchResult]] = results.toArray
+}
diff --git a/kyuubi-common/src/test/scala/org/apache/kyuubi/service/authentication/ldap/UserFilterSuite.scala b/kyuubi-common/src/test/scala/org/apache/kyuubi/service/authentication/ldap/UserFilterSuite.scala
new file mode 100644
index 000000000..4fc6cba49
--- /dev/null
+++ b/kyuubi-common/src/test/scala/org/apache/kyuubi/service/authentication/ldap/UserFilterSuite.scala
@@ -0,0 +1,60 @@
+/*
+ * 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 javax.security.sasl.AuthenticationException
+
+import org.scalatestplus.mockito.MockitoSugar.mock
+
+import org.apache.kyuubi.KyuubiFunSuite
+import org.apache.kyuubi.config.KyuubiConf
+
+class UserFilterSuite extends KyuubiFunSuite {
+  private val factory: FilterFactory = UserFilterFactory
+  private var conf: KyuubiConf = _
+  private var search: DirSearch = _
+
+  override def beforeEach(): Unit = {
+    conf = new KyuubiConf()
+    search = mock[DirSearch]
+    super.beforeEach()
+  }
+
+  test("Factory") {
+    conf.unset(KyuubiConf.AUTHENTICATION_LDAP_USER_FILTER)
+    assert(factory.getInstance(conf).isEmpty)
+    conf.set(KyuubiConf.AUTHENTICATION_LDAP_USER_FILTER.key, "User1")
+    assert(factory.getInstance(conf).isDefined)
+  }
+
+  test("ApplyPositive") {
+    conf.set(KyuubiConf.AUTHENTICATION_LDAP_USER_FILTER.key, "User1,User2,uSeR3")
+    val filter = factory.getInstance(conf).get
+    filter.apply(search, "User1")
+    filter.apply(search, "uid=user2,ou=People,dc=example,dc=com")
+    filter.apply(search, "User3@mydomain.com")
+  }
+
+  test("ApplyNegative") {
+    intercept[AuthenticationException] {
+      conf.set(KyuubiConf.AUTHENTICATION_LDAP_USER_FILTER.key, "User1,User2")
+      val filter = factory.getInstance(conf).get
+      filter.apply(search, "User3")
+    }
+  }
+}
diff --git a/kyuubi-common/src/test/scala/org/apache/kyuubi/service/authentication/ldap/UserSearchFilterSuite.scala b/kyuubi-common/src/test/scala/org/apache/kyuubi/service/authentication/ldap/UserSearchFilterSuite.scala
new file mode 100644
index 000000000..1a711a6d9
--- /dev/null
+++ b/kyuubi-common/src/test/scala/org/apache/kyuubi/service/authentication/ldap/UserSearchFilterSuite.scala
@@ -0,0 +1,79 @@
+/*
+ * 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 javax.naming.NamingException
+import javax.security.sasl.AuthenticationException
+
+import org.mockito.ArgumentMatchers.anyString
+import org.mockito.Mockito.when
+import org.scalatestplus.mockito.MockitoSugar.mock
+
+import org.apache.kyuubi.KyuubiFunSuite
+import org.apache.kyuubi.config.KyuubiConf
+
+class UserSearchFilterSuite extends KyuubiFunSuite {
+  private val factory: FilterFactory = UserSearchFilterFactory
+  private var conf: KyuubiConf = _
+  private var search: DirSearch = _
+
+  override def beforeEach(): Unit = {
+    conf = new KyuubiConf()
+    search = mock[DirSearch]
+    super.beforeEach()
+  }
+
+  test("FactoryWhenNoGroupOrUserFilters") {
+    assert(factory.getInstance(conf).isEmpty)
+  }
+
+  test("FactoryWhenGroupFilter") {
+    conf.set(KyuubiConf.AUTHENTICATION_LDAP_GROUP_FILTER.key, "Grp1,Grp2")
+    assert(factory.getInstance(conf).isDefined)
+  }
+
+  test("FactoryWhenUserFilter") {
+    conf.set(KyuubiConf.AUTHENTICATION_LDAP_USER_FILTER.key, "User1,User2")
+    assert(factory.getInstance(conf).isDefined)
+  }
+
+  test("ApplyPositive") {
+    conf.set(KyuubiConf.AUTHENTICATION_LDAP_USER_FILTER.key, "User1")
+    val filter = factory.getInstance(conf).get
+    when(search.findUserDn(anyString)).thenReturn("cn=User1,ou=People,dc=example,dc=com")
+    filter.apply(search, "User1")
+  }
+
+  test("ApplyWhenNamingException") {
+    intercept[AuthenticationException] {
+      conf.set(KyuubiConf.AUTHENTICATION_LDAP_USER_FILTER.key, "User1")
+      val filter = factory.getInstance(conf).get
+      when(search.findUserDn(anyString)).thenThrow(classOf[NamingException])
+      filter.apply(search, "User3")
+    }
+  }
+
+  test("ApplyWhenNotFound") {
+    intercept[AuthenticationException] {
+      conf.set(KyuubiConf.AUTHENTICATION_LDAP_USER_FILTER.key, "User1")
+      val filter = factory.getInstance(conf).get
+      when(search.findUserDn(anyString)).thenReturn(null)
+      filter.apply(search, "User3")
+    }
+  }
+}
diff --git a/kyuubi-server/src/test/scala/org/apache/kyuubi/RestClientTestHelper.scala b/kyuubi-server/src/test/scala/org/apache/kyuubi/RestClientTestHelper.scala
index 5b3627381..8344cdef0 100644
--- a/kyuubi-server/src/test/scala/org/apache/kyuubi/RestClientTestHelper.scala
+++ b/kyuubi-server/src/test/scala/org/apache/kyuubi/RestClientTestHelper.scala
@@ -54,7 +54,7 @@ trait RestClientTestHelper extends RestFrontendTestHelper with KerberizedTestHel
       .set(KyuubiConf.SERVER_SPNEGO_KEYTAB, testKeytab)
       .set(KyuubiConf.SERVER_SPNEGO_PRINCIPAL, testSpnegoPrincipal)
       .set(KyuubiConf.AUTHENTICATION_LDAP_URL, ldapUrl)
-      .set(KyuubiConf.AUTHENTICATION_LDAP_BASEDN, ldapBaseDn)
+      .set(KyuubiConf.AUTHENTICATION_LDAP_BASE_DN, ldapBaseDn.head)
       .set(
         KyuubiConf.AUTHENTICATION_CUSTOM_CLASS,
         classOf[UserDefineAuthenticationProviderImpl].getCanonicalName)
diff --git a/kyuubi-server/src/test/scala/org/apache/kyuubi/operation/KyuubiOperationKerberosAndPlainAuthSuite.scala b/kyuubi-server/src/test/scala/org/apache/kyuubi/operation/KyuubiOperationKerberosAndPlainAuthSuite.scala
index fc8e1ec70..31cde6397 100644
--- a/kyuubi-server/src/test/scala/org/apache/kyuubi/operation/KyuubiOperationKerberosAndPlainAuthSuite.scala
+++ b/kyuubi-server/src/test/scala/org/apache/kyuubi/operation/KyuubiOperationKerberosAndPlainAuthSuite.scala
@@ -63,11 +63,12 @@ class KyuubiOperationKerberosAndPlainAuthSuite extends WithKyuubiServer with Ker
     UserGroupInformation.setConfiguration(config)
     assert(UserGroupInformation.isSecurityEnabled)
 
-    KyuubiConf().set(KyuubiConf.AUTHENTICATION_METHOD, Seq("KERBEROS", "LDAP", "CUSTOM"))
+    KyuubiConf()
+      .set(KyuubiConf.AUTHENTICATION_METHOD, Seq("KERBEROS", "LDAP", "CUSTOM"))
       .set(KyuubiConf.SERVER_KEYTAB, testKeytab)
       .set(KyuubiConf.SERVER_PRINCIPAL, testPrincipal)
       .set(KyuubiConf.AUTHENTICATION_LDAP_URL, ldapUrl)
-      .set(KyuubiConf.AUTHENTICATION_LDAP_BASEDN, ldapBaseDn)
+      .set(KyuubiConf.AUTHENTICATION_LDAP_BASE_DN, ldapBaseDn.head)
       .set(
         KyuubiConf.AUTHENTICATION_CUSTOM_CLASS,
         classOf[UserDefineAuthenticationProviderImpl].getCanonicalName)
diff --git a/kyuubi-server/src/test/scala/org/apache/kyuubi/operation/thrift/http/KyuubiOperationThriftHttpKerberosAndPlainAuthSuite.scala b/kyuubi-server/src/test/scala/org/apache/kyuubi/operation/thrift/http/KyuubiOperationThriftHttpKerberosAndPlainAuthSuite.scala
index 4f6ae92f1..941e121a6 100644
--- a/kyuubi-server/src/test/scala/org/apache/kyuubi/operation/thrift/http/KyuubiOperationThriftHttpKerberosAndPlainAuthSuite.scala
+++ b/kyuubi-server/src/test/scala/org/apache/kyuubi/operation/thrift/http/KyuubiOperationThriftHttpKerberosAndPlainAuthSuite.scala
@@ -53,7 +53,7 @@ class KyuubiOperationThriftHttpKerberosAndPlainAuthSuite
       .set(KyuubiConf.SERVER_KEYTAB, testKeytab)
       .set(KyuubiConf.SERVER_PRINCIPAL, testPrincipal)
       .set(KyuubiConf.AUTHENTICATION_LDAP_URL, ldapUrl)
-      .set(KyuubiConf.AUTHENTICATION_LDAP_BASEDN, ldapBaseDn)
+      .set(KyuubiConf.AUTHENTICATION_LDAP_BASE_DN, ldapBaseDn.head)
       .set(
         KyuubiConf.AUTHENTICATION_CUSTOM_CLASS,
         classOf[UserDefineAuthenticationProviderImpl].getCanonicalName)
diff --git a/licenses-binary/LICENSE-antlr.txt b/licenses-binary/LICENSE-antlr.txt
new file mode 100644
index 000000000..3021ea043
--- /dev/null
+++ b/licenses-binary/LICENSE-antlr.txt
@@ -0,0 +1,8 @@
+[The BSD License]
+Copyright (c) 2012 Terence Parr and Sam Harwell
+All rights reserved.
+Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
+Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
+Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
+Neither the name of the author nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFI [...]
\ No newline at end of file
diff --git a/licenses-binary/LICENSE-antlr4.txt b/licenses-binary/LICENSE-antlr4.txt
new file mode 100644
index 000000000..21c16a132
--- /dev/null
+++ b/licenses-binary/LICENSE-antlr4.txt
@@ -0,0 +1,26 @@
+[The "BSD 3-clause license"]
+Copyright (c) 2012-2017 The ANTLR Project. All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions
+are met:
+
+ 1. Redistributions of source code must retain the above copyright
+    notice, this list of conditions and the following disclaimer.
+ 2. Redistributions in binary form must reproduce the above copyright
+    notice, this list of conditions and the following disclaimer in the
+    documentation and/or other materials provided with the distribution.
+ 3. Neither the name of the copyright holder nor the names of its contributors
+    may be used to endorse or promote products derived from this software
+    without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR
+IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
+OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
+IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT,
+INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT
+NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF
+THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
\ No newline at end of file
diff --git a/pom.xml b/pom.xml
index e4c6671c9..723123475 100644
--- a/pom.xml
+++ b/pom.xml
@@ -118,6 +118,7 @@
         <arrow.version>10.0.1</arrow.version>
         <!-- Please don't upgrade the version to 4.10+, it depends on JDK 11 -->
         <antlr4.version>4.9.3</antlr4.version>
+        <antlr.st4.version>4.3.4</antlr.st4.version>
         <apache.archive.dist>https://archive.apache.org/dist</apache.archive.dist>
         <bouncycastle.version>1.67</bouncycastle.version>
         <codahale.metrics.version>4.2.8</codahale.metrics.version>
@@ -252,6 +253,11 @@
                 <artifactId>antlr4-runtime</artifactId>
                 <version>${antlr4.version}</version>
             </dependency>
+            <dependency>
+                <groupId>org.antlr</groupId>
+                <artifactId>ST4</artifactId>
+                <version>${antlr.st4.version}</version>
+            </dependency>
             <dependency>
                 <groupId>org.apache.arrow</groupId>
                 <artifactId>arrow-vector</artifactId>