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

nic-6443 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/apisix.git


The following commit(s) were added to refs/heads/master by this push:
     new ec07ba5fc feat: add ldap-auth-advanced plugin (core authentication) 
(#13762)
ec07ba5fc is described below

commit ec07ba5fc59f71e10232b79489f2c2389df3601e
Author: Mohammad Izzraff Janius 
<[email protected]>
AuthorDate: Tue Aug 4 16:02:04 2026 +0800

    feat: add ldap-auth-advanced plugin (core authentication) (#13762)
---
 apisix-master-0.rockspec              |    2 +-
 apisix/cli/config.lua                 |    1 +
 apisix/consumer.lua                   |    1 +
 apisix/plugins/ldap-auth-advanced.lua |  400 ++++++++
 ci/pod/docker-compose.plugin.yml      |    4 +-
 ci/pod/openldap/ad.ldif               |  102 ++
 ci/pod/openldap/enable-anon-bind.sh   |   72 ++
 conf/config.yaml.example              |    1 +
 t/admin/consumers.t                   |   75 +-
 t/admin/plugins.t                     |    3 +-
 t/certs/localhost_slapd_cert.pem      |   43 +-
 t/plugin/ldap-auth-advanced.t         | 1647 +++++++++++++++++++++++++++++++++
 12 files changed, 2327 insertions(+), 24 deletions(-)

diff --git a/apisix-master-0.rockspec b/apisix-master-0.rockspec
index a15b57290..2b5b290e4 100644
--- a/apisix-master-0.rockspec
+++ b/apisix-master-0.rockspec
@@ -79,7 +79,7 @@ dependencies = {
     "net-url = 1.2-1",
     "xml2lua = 1.6-2",
     "lua-resty-mediador = 0.1.2-1",
-    "lua-resty-ldap = 0.1.0-0",
+    "lua-resty-ldap = 0.3.1-0",
     "lua-resty-t1k = 1.1.6-0",
     "brotli-ffi = 0.3-1",
     "lua-ffi-zlib = 0.6-0",
diff --git a/apisix/cli/config.lua b/apisix/cli/config.lua
index 374e259d8..98c3ff581 100644
--- a/apisix/cli/config.lua
+++ b/apisix/cli/config.lua
@@ -222,6 +222,7 @@ local _M = {
     "authz-casbin",
     "authz-casdoor",
     "wolf-rbac",
+    "ldap-auth-advanced",
     "ldap-auth",
     "hmac-auth",
     "basic-auth",
diff --git a/apisix/consumer.lua b/apisix/consumer.lua
index e8877495f..c13d3a73c 100644
--- a/apisix/consumer.lua
+++ b/apisix/consumer.lua
@@ -303,6 +303,7 @@ local plugin_unique_key_attrs = {
     ["jwt-auth"]   = "key",
     ["hmac-auth"]  = "key_id",
     ["ldap-auth"]  = "user_dn",
+    ["ldap-auth-advanced"] = "user_dn",
 }
 
 
diff --git a/apisix/plugins/ldap-auth-advanced.lua 
b/apisix/plugins/ldap-auth-advanced.lua
new file mode 100644
index 000000000..ecc6d78b7
--- /dev/null
+++ b/apisix/plugins/ldap-auth-advanced.lua
@@ -0,0 +1,400 @@
+--
+-- 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.
+--
+local core = require("apisix.core")
+local schema_def = require("apisix.schema_def")
+local auth_utils = require("apisix.utils.auth")
+local consumer_mod = require("apisix.consumer")
+local ldap_client = require("resty.ldap.client")
+local ldap_protocol = require("resty.ldap.protocol")
+local ldap_filter = require("resty.ldap.filter")
+local ngx = ngx
+local ipairs = ipairs
+local type = type
+local ngx_decode_base64 = ngx.decode_base64
+local ngx_re_match = ngx.re.match
+local str_find = string.find
+local str_sub = string.sub
+local parse_addr = core.utils.parse_addr
+
+-- RFC 4512 attribute-description: a descriptor ("cn", "sAMAccountName") or a
+-- numeric OID ("1.2.840.113556.1.4.656"), either optionally followed by
+-- ";option" suffixes ("cn;lang-en", "1.2.840.113556.1.4.656;binary").
+local ATTR_PATTERN = "^(?:[A-Za-z][A-Za-z0-9-]*"
+                     .. "|(?:0|[1-9][0-9]*)(?:\\.(?:0|[1-9][0-9]*))+)"
+                     .. "(?:;[A-Za-z0-9-]+)*$"
+
+local schema = {
+    type = "object",
+    title = "work with route or service object",
+    properties = {
+        -- connection
+        ldap_uri     = { type = "string",                          -- 
"host[:port]"
+                         minLength = 1, maxLength = 256 },
+        use_ldaps    = { type = "boolean", default = false },
+        use_starttls = { type = "boolean", default = false },
+        ssl_verify   = { type = "boolean", default = true },
+        timeout      = { type = "integer", minimum = 1, maximum = 60000,
+                         default = 10000 },                         -- 
milliseconds
+
+        -- connection pool
+        keepalive           = { type = "boolean", default = true },
+        keepalive_timeout   = { type = "integer", minimum = 1000, default = 
60000 },
+        keepalive_pool_size = { type = "integer", minimum = 1, default = 5 },
+        keepalive_pool_name = { type = "string", minLength = 1, maxLength = 
256 },
+
+        -- user resolution (search-then-bind)
+        base_dn       = { type = "string",                         -- search 
root
+                          minLength = 1, maxLength = 4096 },
+        attribute     = { type = "string", maxLength = 256,        -- filter: 
(attribute=username)
+                          default = "cn", pattern = ATTR_PATTERN },
+        bind_dn       = { type = "string",                         -- absent 
=> anonymous search
+                          minLength = 1, maxLength = 4096 },
+        ldap_password = { type = "string", minLength = 1, maxLength = 4096 },
+
+        -- search bounds
+        size_limit = { type = "integer", minimum = 2, default = 2 },
+        time_limit = { type = "integer", minimum = 0, default = 5 }, -- 
seconds; 0 = server default
+
+
+
+        -- consumer
+        consumer_required  = { type = "boolean", default = true },
+
+        -- request handling
+        header_type      = { type = "string", enum = {"ldap", "basic"}, 
default = "ldap" },
+        realm            = schema_def.get_realm_schema("ldap"),
+
+    },
+    encrypt_fields = {"ldap_password"},
+    required = {"ldap_uri", "base_dn"},
+}
+
+local consumer_schema = {
+    type = "object",
+    title = "work with consumer object",
+    properties = {
+        user_dn  = { type = "string", minLength = 1, maxLength = 4096 },
+    },
+    required = {"user_dn"},
+}
+
+local plugin_name = "ldap-auth-advanced"
+
+
+local _M = {
+    version = 0.1,
+    priority = 2541,
+    type = 'auth',
+    name = plugin_name,
+    schema = schema,
+    consumer_schema = consumer_schema,
+}
+
+function _M.check_schema(conf, schema_type)
+    if schema_type == core.schema.TYPE_CONSUMER then
+        return core.schema.check(consumer_schema, conf)
+    end
+
+    local ok, err = core.schema.check(schema, conf)
+    if not ok then
+        return false, err
+    end
+
+    if conf.use_ldaps and conf.use_starttls then
+        return false, "use_ldaps and use_starttls are mutually exclusive"
+    end
+
+    if conf.bind_dn and not conf.ldap_password then
+        return false, "ldap_password is required when bind_dn is set"
+    end
+
+    -- ldap_uri may omit ":port"; the effective port (636 with use_ldaps,
+    -- else 389) is resolved when the connection is opened.
+
+    return true
+end
+
+
+local CHALLENGE_SCHEME = {
+    ldap  = "ldap",
+    basic = "Basic",
+}
+
+
+-- Shared 401 helper for the authentication-failure paths.
+local function auth_failed(conf, ctx, reason)
+
+    -- under multi-auth, decline quietly and let the wrapper render the 401
+    if auth_utils.is_running_under_multi_auth(ctx) then
+        return 401
+    end
+
+    if reason then
+        core.log.warn(plugin_name, ": ", reason)
+    end
+    core.response.set_header("WWW-Authenticate",
+                             CHALLENGE_SCHEME[conf.header_type]
+                             .. " realm=\"" .. conf.realm .. "\"")
+    return 401, { message = "Authorization required" }
+end
+
+
+
+
+-- Parse one credential header value: the scheme word is conf.header_type
+-- ("ldap" or "basic", case-insensitive), the payload is
+-- base64("username:password").
+local function parse_credential_header(conf, auth_header)
+    local m, err = ngx_re_match(auth_header,
+                                "^(?i:" .. conf.header_type .. ")\\s+(.+)", 
"jo")
+    if err then
+        return nil, nil, "error matching authorization header: " .. err
+    end
+    if not m then
+        return nil, nil, "invalid authorization header format"
+    end
+
+    local decoded = ngx_decode_base64(m[1])
+    if not decoded then
+        return nil, nil, "failed to base64-decode authorization header"
+    end
+
+    -- split on the FIRST colon only: the password may itself contain ':'
+    local sep = str_find(decoded, ":", 1, true)
+    if not sep then
+        return nil, nil, "invalid credential: missing ':' separator"
+    end
+
+    local username = str_sub(decoded, 1, sep - 1)
+    local password = str_sub(decoded, sep + 1)
+
+    if password == "" then
+        return nil, nil, "empty password rejected before bind"
+    end
+    if username == "" then
+        return nil, nil, "empty username"
+    end
+
+    return username, password
+end
+
+
+-- Proxy-Authorization takes priority, but only when it parses into usable
+-- credentials for conf.header_type: a forward proxy may spend that header
+-- on its own credentials (e.g. "Basic ...") while the end user's ride in
+-- Authorization, so its mere presence must not mask a usable Authorization.
+local function extract_credentials(conf, ctx)
+    local proxy_err
+    local proxy_header = core.request.header(ctx, "Proxy-Authorization")
+    if proxy_header then
+        local username, password
+        username, password, proxy_err = parse_credential_header(conf, 
proxy_header)
+        if username then
+            return username, password
+        end
+    end
+
+    local auth_header = core.request.header(ctx, "Authorization")
+    if not auth_header then
+        return nil, nil, proxy_err or "missing authorization header"
+    end
+
+    return parse_credential_header(conf, auth_header)
+end
+
+
+-- resty.ldap reports a directory result-code failure as
+-- "<op> failed, error: <ERROR_MSG[code]>, details: <diagnostic>"; anything
+-- else is a socket/TLS/timeout error or a protocol violation. Match against
+-- the library's own message table so the strings cannot drift from it.
+local RESULT_INVALID_CREDENTIALS = ldap_protocol.ERROR_MSG[49]
+local RESULT_SIZE_LIMIT_EXCEEDED = ldap_protocol.ERROR_MSG[4]
+
+local function is_result_code(err, op, result_msg)
+    if type(err) ~= "string" then
+        return false
+    end
+    local prefix = op .. " failed, error: " .. result_msg .. ", details:"
+    return str_sub(err, 1, #prefix) == prefix
+end
+
+
+
+
+-- The LDAP round trip: resolve the user DN and authenticate the user's bind
+-- on ONE pinned connection. Returns (nil, nil, user_dn) on success, or
+-- (code, body) on failure. The socket is closed on every failure path and
+-- released to the pool only on success, so a poisoned socket is never pooled.
+local function ldap_resolve(conf, ctx, username, password)
+    -- The only client-controlled part of the search filter is the escaped
+    -- username. filter.escape leaves bytes the filter grammar rejects (e.g.
+    -- invalid UTF-8), and a grammar reject at search time would surface as a
+    -- 500 -- misclassifying a bad credential as a server fault. Pre-compile
+    -- the filter so any reject is a clean 401.
+    local search_filter = "(" .. conf.attribute .. "="
+                          .. ldap_filter.escape(username) .. ")"
+    if not ldap_filter.compile(search_filter) then
+        return auth_failed(conf, ctx, "invalid username")
+    end
+
+    -- ldap_uri is "host" or "host:port"; when the port is omitted it
+    -- defaults to 636 under LDAPS, else 389.
+    local host, port = parse_addr(conf.ldap_uri)
+    if not port then
+        port = conf.use_ldaps and 636 or 389
+    end
+
+    local client = ldap_client:new(host, port, {
+        socket_timeout      = conf.timeout,
+        keepalive_timeout   = conf.keepalive_timeout,
+        keepalive_pool_size = conf.keepalive_pool_size,
+        keepalive_pool_name = conf.keepalive_pool_name,
+        start_tls           = conf.use_starttls,
+        ldaps               = conf.use_ldaps,
+        ssl_verify          = conf.ssl_verify,
+    })
+
+    -- Bind before every search: a pooled socket may arrive bound as a
+    -- previous request's end user. Anonymous bind when bind_dn is unset.
+    -- The client connects lazily on the first operation, so a socket/TLS
+    -- failure surfaces here as (nil, err) -- an outage, never auth -- while
+    -- a directory rejection is (false, err).
+    local bind_ok, berr
+    if conf.bind_dn then
+        bind_ok, berr = client:simple_bind(conf.bind_dn, conf.ldap_password)
+    else
+        bind_ok, berr = client:simple_bind("", "")
+    end
+    if not bind_ok then
+        client:close()
+        if bind_ok == nil then
+            core.log.error(plugin_name, ": LDAP connect failed: ", berr)
+            return 500
+        end
+        -- a rejected search bind (e.g. a rotated service-account password)
+        -- is a misconfiguration, never the client's auth failure
+        core.log.error(plugin_name, ": LDAP search bind failed: ", berr)
+        return 500
+    end
+
+    -- Search for the user. size_limit floors at 2 (schema minimum) so a 2nd
+    -- match is observable.
+    local entries, serr = client:search(
+        conf.base_dn,
+        ldap_protocol.SEARCH_SCOPE_WHOLE_SUBTREE,
+        ldap_protocol.SEARCH_DEREF_ALIASES_ALWAYS,
+        conf.size_limit, conf.time_limit,
+        false,
+        search_filter)
+    if entries == false then
+        client:close()
+        if is_result_code(serr, "search", RESULT_SIZE_LIMIT_EXCEEDED) then
+            -- more than size_limit entries matched the login attribute: the
+            -- same ambiguity as match_count > 1 below; fail closed
+            return auth_failed(conf, ctx,
+                               "ambiguous user match (size limit exceeded); "
+                               .. "check attribute uniqueness")
+        end
+        core.log.error(plugin_name, ": LDAP user search failed: ", serr)
+        return 500
+    end
+
+    -- count SearchResultEntry rows (the library drops SearchResultDone)
+    local user_dn
+    local match_count = 0
+    for _, entry in ipairs(entries) do
+        if entry.entry_dn then
+            match_count = match_count + 1
+            user_dn = entry.entry_dn
+        end
+    end
+    if match_count == 0 then
+        client:close()
+        return auth_failed(conf, ctx, "user not found")
+    end
+    if match_count > 1 then
+        -- the login attribute is not unique under base_dn: a directory
+        -- misconfiguration. Fail closed rather than bind an arbitrary entry.
+        client:close()
+        return auth_failed(conf, ctx,
+                           "ambiguous user match (>1 entry); check attribute 
uniqueness")
+    end
+
+    -- Authenticate: bind as the resolved user. invalidCredentials is a wrong
+    -- password (401); any other result code (busy, unavailable, ...) or a
+    -- transport error is an outage (500).
+    local auth_ok, aerr = client:simple_bind(user_dn, password)
+    if not auth_ok then
+        client:close()
+        if is_result_code(aerr, "simple bind", RESULT_INVALID_CREDENTIALS) then
+            return auth_failed(conf, ctx, "user authentication failed")
+        end
+        core.log.error(plugin_name, ": LDAP authentication bind failed: ", 
aerr)
+        return 500
+    end
+
+
+    if conf.keepalive == false then
+        client:close()
+    else
+        client:set_keepalive()
+    end
+
+    return nil, nil, user_dn
+end
+
+
+function _M.rewrite(conf, ctx)
+    -- Strip the client-supplied identity headers before any auth work: only
+    -- attach_consumer() may set them, and with consumer_required=false it
+    -- never runs, so an inbound value would otherwise pass through untouched.
+    core.request.set_header(ctx, "X-Authenticated-Groups", nil)
+    core.request.set_header(ctx, "X-Consumer-Username", nil)
+    core.request.set_header(ctx, "X-Credential-Identifier", nil)
+    core.request.set_header(ctx, "X-Consumer-Custom-ID", nil)
+
+    -- Both fields are guaranteed non-empty: parse_credential_header rejects
+    -- an empty username or password as an unusable credential.
+    local username, password, err = extract_credentials(conf, ctx)
+    if err then
+        return auth_failed(conf, ctx, err)
+    end
+
+    local code, body, user_dn = ldap_resolve(conf, ctx, username, password)
+    if code then
+        return code, body
+    end
+
+    -- Associate a Consumer with the authenticated identity, unless
+    -- consumer_required is false. find_consumer() resolves secret references
+    -- in the Consumer's user_dn and skips unresolved ones fail-closed.
+    if conf.consumer_required ~= false then
+        local consumer, consumer_conf, err =
+            consumer_mod.find_consumer(plugin_name, "user_dn", user_dn)
+        if err then
+            return auth_failed(conf, ctx, "consumer_required but no Consumer 
is configured")
+        end
+        if not consumer then
+            return auth_failed(conf, ctx,
+                               "no Consumer maps to the authenticated user_dn")
+        end
+
+        consumer_mod.attach_consumer(ctx, consumer, consumer_conf)
+    end
+end
+
+return _M
diff --git a/ci/pod/docker-compose.plugin.yml b/ci/pod/docker-compose.plugin.yml
index 81b2da0df..53d1b6726 100644
--- a/ci/pod/docker-compose.plugin.yml
+++ b/ci/pod/docker-compose.plugin.yml
@@ -242,7 +242,7 @@ services:
   openldap:
     image: bitnamilegacy/openldap:2.5.8
     environment:
-      - LDAP_ADMIN_USERNAME=amdin
+      - LDAP_ADMIN_USERNAME=admin
       - LDAP_ADMIN_PASSWORD=adminpassword
       - LDAP_USERS=user01,user02
       - LDAP_PASSWORDS=password1,password2
@@ -255,6 +255,8 @@ services:
       - "1636:1636"
     volumes:
       - ./t/certs:/certs
+      - ./ci/pod/openldap/ad.ldif:/ldifs/ad.ldif
+      - 
./ci/pod/openldap/enable-anon-bind.sh:/docker-entrypoint-initdb.d/enable-anon-bind.sh
 
 
   ## Grafana Loki
diff --git a/ci/pod/openldap/ad.ldif b/ci/pod/openldap/ad.ldif
new file mode 100644
index 000000000..a782a83a8
--- /dev/null
+++ b/ci/pod/openldap/ad.ldif
@@ -0,0 +1,102 @@
+#
+# 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.
+#
+# Self-contained bootstrap tree: a non-empty /ldifs makes bitnami skip its
+# default tree and LDAP_USERS bootstrap, so this file rebuilds the base tree
+# and the stock user01/user02/readers entries (keeping the existing ldap-auth
+# tests passing) alongside the ldap-auth-advanced fixtures.
+
+dn: dc=example,dc=org
+objectClass: dcObject
+objectClass: organization
+dc: example
+o: example
+
+dn: ou=users,dc=example,dc=org
+objectClass: organizationalUnit
+ou: users
+
+dn: cn=user01,ou=users,dc=example,dc=org
+objectClass: inetOrgPerson
+objectClass: posixAccount
+objectClass: shadowAccount
+cn: User1
+cn: user01
+sn: Bar1
+uid: user01
+uidNumber: 1000
+gidNumber: 1000
+homeDirectory: /home/user01
+userPassword: password1
+
+dn: cn=user02,ou=users,dc=example,dc=org
+objectClass: inetOrgPerson
+objectClass: posixAccount
+objectClass: shadowAccount
+cn: User2
+cn: user02
+sn: Bar2
+uid: user02
+uidNumber: 1001
+gidNumber: 1001
+homeDirectory: /home/user02
+userPassword: password2
+
+dn: cn=readers,ou=users,dc=example,dc=org
+objectClass: groupOfNames
+cn: readers
+member: cn=user01,ou=users,dc=example,dc=org
+member: cn=user02,ou=users,dc=example,dc=org
+
+# AD shape: the RDN is cn ("Jane Doe") but the login attribute is uid (jdoe).
+dn: cn=Jane Doe,ou=users,dc=example,dc=org
+objectClass: inetOrgPerson
+objectClass: organizationalPerson
+objectClass: person
+cn: Jane Doe
+sn: Doe
+uid: jdoe
+userPassword: janesecret
+
+# Two entries share uid=dupuser: the ambiguous-match case.
+dn: cn=Dup User One,ou=users,dc=example,dc=org
+objectClass: inetOrgPerson
+objectClass: organizationalPerson
+objectClass: person
+cn: Dup User One
+sn: One
+uid: dupuser
+userPassword: duppass1
+
+dn: cn=Dup User Two,ou=users,dc=example,dc=org
+objectClass: inetOrgPerson
+objectClass: organizationalPerson
+objectClass: person
+cn: Dup User Two
+sn: Two
+uid: dupuser
+userPassword: duppass2
+
+# Hidden from anonymous searches by an ACL (enable-anon-bind.sh) yet readable
+# by any authenticated identity: the tripwire for the bind-state-leak probe.
+dn: cn=Secret User,ou=users,dc=example,dc=org
+objectClass: inetOrgPerson
+objectClass: organizationalPerson
+objectClass: person
+cn: Secret User
+sn: Secret
+uid: secretuser
+userPassword: secretpass
diff --git a/ci/pod/openldap/enable-anon-bind.sh 
b/ci/pod/openldap/enable-anon-bind.sh
new file mode 100755
index 000000000..073dfac8c
--- /dev/null
+++ b/ci/pod/openldap/enable-anon-bind.sh
@@ -0,0 +1,72 @@
+#!/bin/bash
+#
+# 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.
+#
+# Boot hook (docker-entrypoint-initdb.d).
+#
+# olcAllows bind_anon_dn: a simple bind with a DN and an EMPTY password
+# succeeds at the server (RFC 4513 5.1.2, result: anonymous) -- lets the
+# tests prove the plugin itself rejects empty passwords.
+#
+# The olcAccess rules make the bind identity observable:
+#   * cn=Secret User: invisible to an anonymous search, readable by any
+#     authenticated identity (userPassword keeps `auth` so the entry can
+#     still be bound once found) -- the bind-state-leak tripwire.
+#   * The catch-all keeps everything else readable, so the ldap-auth
+#     regression is unaffected.
+
+set -o errexit
+set -o nounset
+set -o pipefail
+
+. /opt/bitnami/scripts/liblog.sh
+. /opt/bitnami/scripts/libopenldap.sh
+
+eval "$(ldap_env)"
+
+info "Enabling RFC 4513 unauthenticated bind (olcAllows: bind_anon_dn)"
+
+ldap_start_bg
+
+ldapmodify -Y EXTERNAL -H "ldapi:///" <<EOF
+dn: cn=config
+changetype: modify
+add: olcAllows
+olcAllows: bind_anon_dn
+EOF
+
+info "Installing the bind-identity olcAccess rules on the data database"
+
+# Resolve the data database DN by suffix so we do not hard-code the {N} index.
+DATA_DB_DN="$(ldapsearch -Y EXTERNAL -H "ldapi:///" -b cn=config \
+    "(olcSuffix=dc=example,dc=org)" dn 2>/dev/null \
+    | awk '/^dn:/ { $1=""; sub(/^ /,""); print; exit }')"
+
+if [ -z "${DATA_DB_DN}" ]; then
+    error "cannot resolve the cn=config database DN for suffix 
dc=example,dc=org"
+    exit 1
+fi
+
+ldapmodify -Y EXTERNAL -H "ldapi:///" <<EOF
+dn: ${DATA_DB_DN}
+changetype: modify
+replace: olcAccess
+olcAccess: {0}to dn.exact="cn=Secret User,ou=users,dc=example,dc=org" 
attrs=userPassword by anonymous auth by users read by * none
+olcAccess: {1}to dn.exact="cn=Secret User,ou=users,dc=example,dc=org" by users 
read by * none
+olcAccess: {2}to * by * read
+EOF
+
+ldap_stop
diff --git a/conf/config.yaml.example b/conf/config.yaml.example
index d6714427a..bcc5e6acb 100644
--- a/conf/config.yaml.example
+++ b/conf/config.yaml.example
@@ -531,6 +531,7 @@ plugins:                           # plugin list (sorted by 
priority)
   - authz-casbin                   # priority: 2560
   - authz-casdoor                  # priority: 2559
   - wolf-rbac                      # priority: 2555
+  - ldap-auth-advanced             # priority: 2541
   - ldap-auth                      # priority: 2540
   - hmac-auth                      # priority: 2530
   - basic-auth                     # priority: 2520
diff --git a/t/admin/consumers.t b/t/admin/consumers.t
index a30a77cc5..fd958885d 100644
--- a/t/admin/consumers.t
+++ b/t/admin/consumers.t
@@ -702,12 +702,83 @@ GET /t
 
 
 
-=== TEST 21: clean up consumers
+=== TEST 21: add consumer adv_case_a with ldap-auth-advanced user_dn
 --- config
     location /t {
         content_by_lua_block {
             local t = require("lib.test_admin").test
-            for _, name in ipairs({"case_a", "case_b", "case_c", "enc_case_a", 
"ldap_case_a"}) do
+            local code, body = t('/apisix/admin/consumers',
+                ngx.HTTP_PUT,
+                [[{
+                     "username": "adv_case_a",
+                     "plugins": {
+                         "ldap-auth-advanced": {
+                             "user_dn": 
"cn=adv-duplicate-check,ou=users,dc=example,dc=org"
+                         }
+                     }
+                }]]
+                )
+
+            if code >= 300 then
+                ngx.status = code
+            end
+            ngx.say(body)
+        }
+    }
+--- request
+GET /t
+--- response_body
+passed
+
+
+
+=== TEST 22: add consumer adv_case_b with the same ldap-auth-advanced user_dn, 
should fail
+--- config
+    location /t {
+        content_by_lua_block {
+            local t = require("lib.test_admin").test
+            -- the duplicate check runs against the locally synced consumer
+            -- data, wait until the watcher catches up with the previous write
+            local find_consumer = require("apisix.consumer").find_consumer
+            for _ = 1, 100 do
+                if find_consumer("ldap-auth-advanced", "user_dn",
+                                 
"cn=adv-duplicate-check,ou=users,dc=example,dc=org") then
+                    break
+                end
+                ngx.sleep(0.05)
+            end
+
+            local code, body = t('/apisix/admin/consumers',
+                ngx.HTTP_PUT,
+                [[{
+                     "username": "adv_case_b",
+                     "plugins": {
+                         "ldap-auth-advanced": {
+                             "user_dn": 
"cn=adv-duplicate-check,ou=users,dc=example,dc=org"
+                         }
+                     }
+                }]]
+                )
+
+            ngx.status = code
+            ngx.print(body)
+        }
+    }
+--- request
+GET /t
+--- error_code: 400
+--- response_body
+{"error_msg":"duplicate user_dn of plugin ldap-auth-advanced found with 
consumer: adv_case_a"}
+
+
+
+=== TEST 23: clean up consumers
+--- config
+    location /t {
+        content_by_lua_block {
+            local t = require("lib.test_admin").test
+            for _, name in ipairs({"case_a", "case_b", "case_c", "enc_case_a", 
"ldap_case_a",
+                                   "adv_case_a"}) do
                 local code, body = t('/apisix/admin/consumers/' .. name, 
ngx.HTTP_DELETE)
                 if code >= 300 then
                     ngx.say("failed to delete consumer ", name, ": ", body)
diff --git a/t/admin/plugins.t b/t/admin/plugins.t
index f0a512069..ef70e46d9 100644
--- a/t/admin/plugins.t
+++ b/t/admin/plugins.t
@@ -83,6 +83,7 @@ cas-auth
 authz-casbin
 authz-casdoor
 wolf-rbac
+ldap-auth-advanced
 ldap-auth
 hmac-auth
 basic-auth
@@ -338,7 +339,7 @@ 
qr/\{"metadata_schema":\{"properties":\{"ikey":\{"minimum":0,"type":"number"\},"
         }
     }
 --- response_body eval
-qr/\[\{"name":"multi-auth","priority":2600\},\{"name":"wolf-rbac","priority":2555\},\{"name":"ldap-auth","priority":2540\},\{"name":"hmac-auth","priority":2530\},\{"name":"basic-auth","priority":2520\},\{"name":"jwt-auth","priority":2510\},\{"name":"jwe-decrypt","priority":2509\},\{"name":"key-auth","priority":2500\}\]/
+qr/\[\{"name":"multi-auth","priority":2600\},\{"name":"wolf-rbac","priority":2555\},\{"name":"ldap-auth-advanced","priority":2541\},\{"name":"ldap-auth","priority":2540\},\{"name":"hmac-auth","priority":2530\},\{"name":"basic-auth","priority":2520\},\{"name":"jwt-auth","priority":2510\},\{"name":"jwe-decrypt","priority":2509\},\{"name":"key-auth","priority":2500\}\]/
 
 
 
diff --git a/t/certs/localhost_slapd_cert.pem b/t/certs/localhost_slapd_cert.pem
index 0830e61b7..462a25f39 100644
--- a/t/certs/localhost_slapd_cert.pem
+++ b/t/certs/localhost_slapd_cert.pem
@@ -1,21 +1,26 @@
 -----BEGIN CERTIFICATE-----
-MIIDcjCCAdoCFCS4ndwl6lusO7yj4zxbngp17nNUMA0GCSqGSIb3DQEBCwUAMFYx
-CzAJBgNVBAYTAkNOMRIwEAYDVQQIDAlHdWFuZ0RvbmcxDzANBgNVBAcMBlpodUhh
-aTEPMA0GA1UECgwGaXJlc3R5MREwDwYDVQQDDAh0ZXN0LmNvbTAgFw0yMzA4MDMw
-NjM3NDhaGA8yMTA1MDkyMjA2Mzc0OFowEzERMA8GA1UEAwwIdGVzdC5jb20wggEi
-MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC4mQik/vxL1bdjXcXWNT6DBVGL
-A87CeVYleNE5Fx5KROU5Y388h80VgSTmV3ytu9ZuNEB8hcZqmAttZXcipMyNm9PI
-vTXaDFaQVclYNJ27hy7rpaJ29WkeLKlUx/pRUpOCg3lbafSmURf5C234LZL1qe5O
-0CIvY3uAzkGWMUE8ABYnef+ucULZPLa2+Y9wIx76oP5tfmcM/pQhDXt+GK/bZyat
-1sUmEHCVC2gjvHoZO8T7n4ccpi5v06Klj8BKVxRlGVkO2w4hlDbNyh6FWKK31nF8
-BLu/TKF70xSOzX4OFNT6/GJ8R9AyeK52f6OzNmlNUY3UsMEeX8Y2qL4hNrFhAgMB
-AAEwDQYJKoZIhvcNAQELBQADggGBAL6g7NZfVTEVklJPmTFSqwQfuu0YXmIvUQIF
-jvbNOmwC+nHSq6yuJFC+83R/on/IPWrkP489bEVwqaBQXnZnIMOTjIk8k9elBm/N
-1BBpzWUiKNp+HqRjPCanvRCIPUZ9hWpmJy6uzG6VodEtxZgJ7lsArj0AlOYlkHHa
-Ctmnl5g6H4m8sNACZaAixesb9wM8Slvn1zhpAeIYZsvIaBZOZnWuwHD1R7seh4ob
-BDhDaUfXOYb0VJaKNWnJ5ItPxh4/YMSuS7mG5o2ELnzWN6OeDEQrqKFW17qWLXko
-DXEfyrQnODDI+fXvasJhQ62hH33rQF/Q4yJQOEEr7gQUxtMYCxtGCumx2/5MFTuB
-E8sf8FykV5jGjwdwMHhPGAmhpMJwM6i65P9GwZguqVmeFv2l4eSTmMinURlkwaAw
-cx+LrigAxSKOCcnnnC6Uza1VShyDAuj+XKPglwwJd99UJlk1VG/9TXp3WZTOvSt+
-KttglpiMHyqzCYcMDTGbjPm/UsjFTw==
+MIIEeDCCAuCgAwIBAgIUBupn1MAsRm/H8tN0O9VQPiCdFNwwDQYJKoZIhvcNAQEL
+BQAwVjELMAkGA1UEBhMCQ04xEjAQBgNVBAgMCUd1YW5nRG9uZzEPMA0GA1UEBwwG
+Wmh1SGFpMQ8wDQYDVQQKDAZpcmVzdHkxETAPBgNVBAMMCHRlc3QuY29tMCAXDTI2
+MDczMDA2MjQwOFoYDzIxMDUwNjA2MDYyNDA4WjATMREwDwYDVQQDDAh0ZXN0LmNv
+bTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALiZCKT+/EvVt2NdxdY1
+PoMFUYsDzsJ5ViV40TkXHkpE5TljfzyHzRWBJOZXfK271m40QHyFxmqYC21ldyKk
+zI2b08i9NdoMVpBVyVg0nbuHLuulonb1aR4sqVTH+lFSk4KDeVtp9KZRF/kLbfgt
+kvWp7k7QIi9je4DOQZYxQTwAFid5/65xQtk8trb5j3AjHvqg/m1+Zwz+lCENe34Y
+r9tnJq3WxSYQcJULaCO8ehk7xPufhxymLm/ToqWPwEpXFGUZWQ7bDiGUNs3KHoVY
+orfWcXwEu79MoXvTFI7Nfg4U1Pr8YnxH0DJ4rnZ/o7M2aU1RjdSwwR5fxjaoviE2
+sWECAwEAAaOB/jCB+zAJBgNVHRMEAjAAMAsGA1UdDwQEAwIFoDATBgNVHSUEDDAK
+BggrBgEFBQcDATAdBgNVHQ4EFgQUAfpJAkVLndSIYBKd8BfitNA0XQowgYYGA1Ud
+IwR/MH2AFJm1K9XyYHCXRumS8W2DKI5RRxuzoVqkWDBWMQswCQYDVQQGEwJDTjES
+MBAGA1UECAwJR3VhbmdEb25nMQ8wDQYDVQQHDAZaaHVIYWkxDzANBgNVBAoMBmly
+ZXN0eTERMA8GA1UEAwwIdGVzdC5jb22CCQCtud6TIYApMTAkBgNVHREEHTAbggh0
+ZXN0LmNvbYIJbG9jYWxob3N0hwR/AAABMA0GCSqGSIb3DQEBCwUAA4IBgQAWSUj3
+j7dNGGPGSoqHsNOpiWeE7TfWGx+VNQRVysShoHysFn1qF7fVzkyz9FVhaeT/8RQw
+1l6q2IuIeqfnDhwsQxR4g8QM1mg0csMRB/MelJ2E8ZmjyVFuFxgJJ2YnKTPKdzYn
+PyCAC/H3iMK+l/0MdMK7S0uit9UOIkvXX5mjTmEYJdY+ttNhSXYHAk2LiIMWtSfq
+Jylkcvns6nWl8YtHuG5ntpD8ImQHvXClxl8XXA+0C7GYmeN3Gk501eDccHR2FIjU
+Um0oJmYOmwa8XGPGhA9d0OHsHkjyewMsPVRuTYpHlituoFK5ZyWASssT+PnnsyHy
+8P9HDl+FrD/9dKs5hyPCexbS7CxaxlAJi5J8JBOcZZk7ZA9YP1WYd6gP9syZ4pbd
+sbQgYWI7obPCBgStjnObTUey30JLf5+a4GVarEe1GBBauQ6eVegb6zB89QO3X+EB
+sAT2sPfEznf96ZhnYt2Y+H2nJ2ZN6bRpsjdNWmmXbklFnss1DhDmmjYgSJo=
 -----END CERTIFICATE-----
diff --git a/t/plugin/ldap-auth-advanced.t b/t/plugin/ldap-auth-advanced.t
new file mode 100644
index 000000000..e55f22712
--- /dev/null
+++ b/t/plugin/ldap-auth-advanced.t
@@ -0,0 +1,1647 @@
+#
+# 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.
+#
+use t::APISIX 'no_plan';
+
+repeat_each(1);
+no_long_string();
+no_root_location();
+no_shuffle();
+add_block_preprocessor(sub {
+    my ($block) = @_;
+
+    if (!$block->request) {
+        $block->set_value("request", "GET /t");
+    }
+});
+
+run_tests();
+
+
+__DATA__
+
+=== TEST 1: minimal valid conf (ldap_uri + base_dn) passes
+--- config
+    location /t {
+        content_by_lua_block {
+            local plugin = require("apisix.plugins.ldap-auth-advanced")
+            local ok, err = plugin.check_schema({
+                ldap_uri = "127.0.0.1:1389",
+                base_dn = "ou=users,dc=example,dc=org",
+            })
+            ngx.say(ok and "passed" or err)
+        }
+    }
+--- response_body
+passed
+
+
+
+=== TEST 2: missing ldap_uri is rejected
+--- config
+    location /t {
+        content_by_lua_block {
+            local plugin = require("apisix.plugins.ldap-auth-advanced")
+            local ok, err = plugin.check_schema({
+                base_dn = "ou=users,dc=example,dc=org",
+            })
+            ngx.say(ok and "passed" or err)
+        }
+    }
+--- response_body_like eval
+qr/property "ldap_uri" is required/
+
+
+
+=== TEST 3: missing base_dn is rejected
+--- config
+    location /t {
+        content_by_lua_block {
+            local plugin = require("apisix.plugins.ldap-auth-advanced")
+            local ok, err = plugin.check_schema({
+                ldap_uri = "127.0.0.1:1389",
+            })
+            ngx.say(ok and "passed" or err)
+        }
+    }
+--- response_body_like eval
+qr/property "base_dn" is required/
+
+
+
+=== TEST 4: use_ldaps and use_starttls are mutually exclusive
+--- config
+    location /t {
+        content_by_lua_block {
+            local plugin = require("apisix.plugins.ldap-auth-advanced")
+            local ok, err = plugin.check_schema({
+                ldap_uri = "127.0.0.1:1389",
+                base_dn = "ou=users,dc=example,dc=org",
+                use_ldaps = true,
+                use_starttls = true,
+            })
+            ngx.say(ok and "passed" or err)
+        }
+    }
+--- response_body
+use_ldaps and use_starttls are mutually exclusive
+
+
+
+=== TEST 5: use_ldaps alone (port-less uri) passes
+--- config
+    location /t {
+        content_by_lua_block {
+            local plugin = require("apisix.plugins.ldap-auth-advanced")
+            local ok, err = plugin.check_schema({
+                ldap_uri = "ldap.example.org",
+                base_dn = "ou=users,dc=example,dc=org",
+                use_ldaps = true,
+            })
+            ngx.say(ok and "passed" or err)
+        }
+    }
+--- response_body
+passed
+
+
+
+=== TEST 6: bind_dn set without ldap_password is rejected
+--- config
+    location /t {
+        content_by_lua_block {
+            local plugin = require("apisix.plugins.ldap-auth-advanced")
+            local ok, err = plugin.check_schema({
+                ldap_uri = "127.0.0.1:1389",
+                base_dn = "ou=users,dc=example,dc=org",
+                bind_dn = "cn=admin,dc=example,dc=org",
+            })
+            ngx.say(ok and "passed" or err)
+        }
+    }
+--- response_body
+ldap_password is required when bind_dn is set
+
+
+
+=== TEST 7: bind_dn with ldap_password passes
+--- config
+    location /t {
+        content_by_lua_block {
+            local plugin = require("apisix.plugins.ldap-auth-advanced")
+            local ok, err = plugin.check_schema({
+                ldap_uri = "127.0.0.1:1389",
+                base_dn = "ou=users,dc=example,dc=org",
+                bind_dn = "cn=admin,dc=example,dc=org",
+                ldap_password = "adminpassword",
+            })
+            ngx.say(ok and "passed" or err)
+        }
+    }
+--- response_body
+passed
+
+
+
+=== TEST 8: attribute with a bad pattern is rejected
+--- config
+    location /t {
+        content_by_lua_block {
+            local plugin = require("apisix.plugins.ldap-auth-advanced")
+            local ok, err = plugin.check_schema({
+                ldap_uri = "127.0.0.1:1389",
+                base_dn = "ou=users,dc=example,dc=org",
+                attribute = "1abc",
+            })
+            ngx.say(ok and "passed" or "rejected")
+        }
+    }
+--- response_body
+rejected
+
+
+
+=== TEST 9: attribute containing a space is rejected
+--- config
+    location /t {
+        content_by_lua_block {
+            local plugin = require("apisix.plugins.ldap-auth-advanced")
+            local ok, err = plugin.check_schema({
+                ldap_uri = "127.0.0.1:1389",
+                base_dn = "ou=users,dc=example,dc=org",
+                attribute = "a b",
+            })
+            ngx.say(ok and "passed" or "rejected")
+        }
+    }
+--- response_body
+rejected
+
+
+
+=== TEST 10: valid attribute (uid) passes
+--- config
+    location /t {
+        content_by_lua_block {
+            local plugin = require("apisix.plugins.ldap-auth-advanced")
+            local ok, err = plugin.check_schema({
+                ldap_uri = "127.0.0.1:1389",
+                base_dn = "ou=users,dc=example,dc=org",
+                attribute = "uid",
+            })
+            ngx.say(ok and "passed" or err)
+        }
+    }
+--- response_body
+passed
+
+
+
+=== TEST 11: size_limit below the floor of 2 is rejected
+--- config
+    location /t {
+        content_by_lua_block {
+            local plugin = require("apisix.plugins.ldap-auth-advanced")
+            local ok, err = plugin.check_schema({
+                ldap_uri = "127.0.0.1:1389",
+                base_dn = "ou=users,dc=example,dc=org",
+                size_limit = 1,
+            })
+            ngx.say(ok and "passed" or "rejected")
+        }
+    }
+--- response_body
+rejected
+
+
+
+=== TEST 12: consumer schema with user_dn passes
+--- config
+    location /t {
+        content_by_lua_block {
+            local core = require("apisix.core")
+            local plugin = require("apisix.plugins.ldap-auth-advanced")
+            local ok, err = plugin.check_schema(
+                { user_dn = "cn=user01,ou=users,dc=example,dc=org" },
+                core.schema.TYPE_CONSUMER)
+            ngx.say(ok and "passed" or err)
+        }
+    }
+--- response_body
+passed
+
+
+
+=== TEST 13: empty consumer schema is rejected
+--- config
+    location /t {
+        content_by_lua_block {
+            local core = require("apisix.core")
+            local plugin = require("apisix.plugins.ldap-auth-advanced")
+            local ok, err = plugin.check_schema({}, core.schema.TYPE_CONSUMER)
+            ngx.say(ok and "passed" or "rejected")
+        }
+    }
+--- response_body
+rejected
+
+
+
+=== TEST 14: set up a route protected by ldap-auth-advanced (live LDAP)
+--- config
+    location /t {
+        content_by_lua_block {
+            local t = require("lib.test_admin").test
+            local code, body = t('/apisix/admin/routes/1',
+                ngx.HTTP_PUT,
+                [[{
+                    "plugins": {
+                        "ldap-auth-advanced": {
+                            "ldap_uri": "127.0.0.1:1389",
+                            "base_dn": "ou=users,dc=example,dc=org",
+                            "attribute": "uid"
+                        }
+                    },
+                    "upstream": {
+                        "nodes": {
+                            "127.0.0.1:1980": 1
+                        },
+                        "type": "roundrobin"
+                    },
+                    "uri": "/hello"
+                }]]
+                )
+            if code >= 300 then
+                ngx.status = code
+            end
+            ngx.say(body)
+        }
+    }
+--- response_body
+passed
+
+
+
+=== TEST 15: no credential header -> 401 with WWW-Authenticate ldap realm
+--- request
+GET /hello
+--- error_code: 401
+--- response_headers
+WWW-Authenticate: ldap realm="ldap"
+
+
+
+=== TEST 16: malformed base64 payload ("aca_a" does not decode) -> 401
+--- request
+GET /hello
+--- more_headers
+Authorization: ldap aca_a
+--- error_code: 401
+
+
+
+=== TEST 17: base64 payload without a ':' separator (decodes to "useronly") -> 
401
+--- request
+GET /hello
+--- more_headers
+Authorization: ldap dXNlcm9ubHk=
+--- error_code: 401
+
+
+
+=== TEST 18: empty password (payload decodes to "user01:") rejected before any 
bind (RFC 4513 5.1.2 unauthenticated bind)
+--- request
+GET /hello
+--- more_headers
+Authorization: ldap dXNlcjAxOg==
+--- error_code: 401
+--- grep_error_log eval
+qr/empty password/
+--- grep_error_log_out
+empty password
+
+
+
+=== TEST 19: scheme word parsed case-insensitively (uppercase), empty password 
still rejected (creds: user01:)
+--- request
+GET /hello
+--- more_headers
+Authorization: LDAP dXNlcjAxOg==
+--- error_code: 401
+--- grep_error_log eval
+qr/empty password/
+--- grep_error_log_out
+empty password
+
+
+
+=== TEST 20: scheme word parsed case-insensitively (mixed case), empty 
password still rejected (creds: user01:)
+--- request
+GET /hello
+--- more_headers
+Authorization: lDaP dXNlcjAxOg==
+--- error_code: 401
+--- grep_error_log eval
+qr/empty password/
+--- grep_error_log_out
+empty password
+
+
+
+=== TEST 21: set up a route with header_type basic
+--- config
+    location /t {
+        content_by_lua_block {
+            local t = require("lib.test_admin").test
+            local code, body = t('/apisix/admin/routes/1',
+                ngx.HTTP_PUT,
+                [[{
+                    "plugins": {
+                        "ldap-auth-advanced": {
+                            "ldap_uri": "127.0.0.1:1389",
+                            "base_dn": "ou=users,dc=example,dc=org",
+                            "attribute": "uid",
+                            "header_type": "basic"
+                        }
+                    },
+                    "upstream": {
+                        "nodes": {
+                            "127.0.0.1:1980": 1
+                        },
+                        "type": "roundrobin"
+                    },
+                    "uri": "/hello"
+                }]]
+                )
+            if code >= 300 then
+                ngx.status = code
+            end
+            ngx.say(body)
+        }
+    }
+--- response_body
+passed
+
+
+
+=== TEST 22: header_type basic emits a Basic-scheme WWW-Authenticate
+--- request
+GET /hello
+--- error_code: 401
+--- response_headers
+WWW-Authenticate: Basic realm="ldap"
+
+
+
+=== TEST 23: inbound identity headers are cleared before any auth work, on 
every path
+--- config
+    location /t {
+        content_by_lua_block {
+            local core = require("apisix.core")
+            local plugin = require("apisix.plugins.ldap-auth-advanced")
+            local headers = {
+                "X-Authenticated-Groups", "X-Consumer-Username",
+                "X-Credential-Identifier", "X-Consumer-Custom-ID",
+            }
+            local ctx = { var = {} }
+            local before = {}
+            for i, h in ipairs(headers) do
+                before[i] = core.request.header(ctx, h) or "nil"
+            end
+            -- no credential header -> auth_failed path; the strip must still 
happen
+            plugin.rewrite({ header_type = "ldap", realm = "ldap" }, ctx)
+            local after = {}
+            for i, h in ipairs(headers) do
+                after[i] = core.request.header(ctx, h) or "nil"
+            end
+            ngx.say("before: ", table.concat(before, " "))
+            ngx.say("after: ", table.concat(after, " "))
+        }
+    }
+--- more_headers
+X-Authenticated-Groups: injected
+X-Consumer-Username: spoofed-user
+X-Credential-Identifier: spoofed-cred
+X-Consumer-Custom-ID: spoofed-id
+--- response_body
+before: injected spoofed-user spoofed-cred spoofed-id
+after: nil nil nil nil
+
+
+
+=== TEST 24: set up a route with multi-auth wrapping ldap-auth-advanced and 
basic-auth
+--- config
+    location /t {
+        content_by_lua_block {
+            local t = require("lib.test_admin").test
+            local code, body = t('/apisix/admin/routes/1',
+                ngx.HTTP_PUT,
+                [[{
+                    "plugins": {
+                        "multi-auth": {
+                            "auth_plugins": [
+                                {
+                                    "ldap-auth-advanced": {
+                                        "ldap_uri": "127.0.0.1:1389",
+                                        "base_dn": 
"ou=users,dc=example,dc=org",
+                                        "attribute": "uid"
+                                    }
+                                },
+                                {
+                                    "basic-auth": {}
+                                }
+                            ]
+                        }
+                    },
+                    "upstream": {
+                        "nodes": {
+                            "127.0.0.1:1980": 1
+                        },
+                        "type": "roundrobin"
+                    },
+                    "uri": "/hello"
+                }]]
+                )
+            if code >= 300 then
+                ngx.status = code
+            end
+            ngx.say(body)
+        }
+    }
+--- response_body
+passed
+
+
+
+=== TEST 25: under multi-auth ldap-auth-advanced declines quietly (creds: 
user01:)
+--- request
+GET /hello
+--- more_headers
+Authorization: ldap dXNlcjAxOg==
+--- error_code: 401
+--- response_body
+{"message":"Authorization Failed"}
+--- response_headers
+WWW-Authenticate: Basic realm="basic"
+--- no_error_log
+empty password
+
+
+
+=== TEST 26: set up the plain search-then-bind route (uid attribute)
+--- config
+    location /t {
+        content_by_lua_block {
+            local t = require("lib.test_admin").test
+            local code, body = t('/apisix/admin/routes/1',
+                ngx.HTTP_PUT,
+                [[{
+                    "plugins": {
+                        "ldap-auth-advanced": {
+                            "ldap_uri": "127.0.0.1:1389",
+                            "base_dn": "ou=users,dc=example,dc=org",
+                            "attribute": "uid",
+                            "keepalive_pool_size": 4
+                        }
+                    },
+                    "upstream": {
+                        "nodes": {
+                            "127.0.0.1:1980": 1
+                        },
+                        "type": "roundrobin"
+                    },
+                    "uri": "/hello"
+                }]]
+                )
+            if code >= 300 then
+                ngx.status = code
+            end
+            ngx.say(body)
+        }
+    }
+--- response_body
+passed
+
+
+
+=== TEST 27: consumer_required (default true) with NO matching Consumer -> 401 
(fails closed) (creds: user01:password1)
+--- request
+GET /hello
+--- more_headers
+Authorization: ldap dXNlcjAxOnBhc3N3b3JkMQ==
+--- error_code: 401
+--- grep_error_log eval
+qr/no Consumer is configured/
+--- grep_error_log_out
+no Consumer is configured
+
+
+
+=== TEST 28: create the ldap-auth-advanced Consumers (user_dn associations)
+--- config
+    location /t {
+        content_by_lua_block {
+            local core = require("apisix.core")
+            local t = require("lib.test_admin").test
+            local users = {
+                { name = "ldapadvuser01", dn = 
"cn=user01,ou=users,dc=example,dc=org" },
+                { name = "ldapadvuser02", dn = 
"cn=user02,ou=users,dc=example,dc=org" },
+                { name = "ldapadvjdoe",   dn = "cn=Jane 
Doe,ou=users,dc=example,dc=org" },
+                { name = "ldapadvsecret", dn = "cn=Secret 
User,ou=users,dc=example,dc=org" },
+            }
+            for _, u in ipairs(users) do
+                local code, body = t('/apisix/admin/consumers',
+                    ngx.HTTP_PUT,
+                    core.json.encode({
+                        username = u.name,
+                        plugins = {
+                            ["ldap-auth-advanced"] = { user_dn = u.dn },
+                        },
+                    }))
+                if code >= 300 then
+                    ngx.status = code
+                    ngx.say(body)
+                    return
+                end
+            end
+            ngx.say("passed")
+        }
+    }
+--- response_body
+passed
+
+
+
+=== TEST 29: happy path -- uid=user01 (cn=user01) matches Consumer 
ldapadvuser01 (200 + attached) (creds: user01:password1)
+--- request
+GET /hello
+--- more_headers
+Authorization: ldap dXNlcjAxOnBhc3N3b3JkMQ==
+--- error_code: 200
+--- response_body
+hello world
+--- error_log
+find consumer ldapadvuser01
+
+
+
+=== TEST 30: AD-shape happy path -- uid=jdoe (cn=Jane Doe) matches Consumer 
ldapadvjdoe (200) (creds: jdoe:janesecret)
+--- request
+GET /hello
+--- more_headers
+Authorization: ldap amRvZTpqYW5lc2VjcmV0
+--- error_code: 200
+--- response_body
+hello world
+--- error_log
+find consumer ldapadvjdoe
+
+
+
+=== TEST 31: wrong password -> 401 (a result-code failure, not a transport 
error) (creds: user01:wrong)
+--- request
+GET /hello
+--- more_headers
+Authorization: ldap dXNlcjAxOndyb25n
+--- error_code: 401
+
+
+
+=== TEST 32: unknown user -> 401 (the user search returns 0 entries) (creds: 
nouser:x)
+--- request
+GET /hello
+--- more_headers
+Authorization: ldap bm91c2VyOng=
+--- error_code: 401
+
+
+
+=== TEST 33: ambiguous match (two uid=dupuser entries) -> 401 + "ambiguous" 
warn (creds: dupuser:duppass1)
+--- request
+GET /hello
+--- more_headers
+Authorization: ldap ZHVwdXNlcjpkdXBwYXNzMQ==
+--- error_code: 401
+--- grep_error_log eval
+qr/ambiguous user match/
+--- grep_error_log_out
+ambiguous user match
+
+
+
+=== TEST 34: set up the search-then-bind route with consumer_required=false
+--- config
+    location /t {
+        content_by_lua_block {
+            local t = require("lib.test_admin").test
+            local code, body = t('/apisix/admin/routes/1',
+                ngx.HTTP_PUT,
+                [[{
+                    "plugins": {
+                        "ldap-auth-advanced": {
+                            "ldap_uri": "127.0.0.1:1389",
+                            "base_dn": "ou=users,dc=example,dc=org",
+                            "attribute": "uid",
+                            "consumer_required": false
+                        }
+                    },
+                    "upstream": {
+                        "nodes": {
+                            "127.0.0.1:1980": 1
+                        },
+                        "type": "roundrobin"
+                    },
+                    "uri": "/hello"
+                }]]
+                )
+            if code >= 300 then
+                ngx.status = code
+            end
+            ngx.say(body)
+        }
+    }
+--- response_body
+passed
+
+
+
+=== TEST 35: consumer_required=false -> user01 authenticated, no Consumer 
attached (200) (creds: user01:password1)
+--- request
+GET /hello
+--- more_headers
+Authorization: ldap dXNlcjAxOnBhc3N3b3JkMQ==
+--- error_code: 200
+--- response_body
+hello world
+--- no_error_log
+find consumer
+
+
+
+=== TEST 36: point the route at a dead LDAP port (transport-error case)
+--- config
+    location /t {
+        content_by_lua_block {
+            local t = require("lib.test_admin").test
+            local code, body = t('/apisix/admin/routes/1',
+                ngx.HTTP_PUT,
+                [[{
+                    "plugins": {
+                        "ldap-auth-advanced": {
+                            "ldap_uri": "127.0.0.1:1390",
+                            "base_dn": "ou=users,dc=example,dc=org",
+                            "attribute": "uid",
+                            "timeout": 1000
+                        }
+                    },
+                    "upstream": {
+                        "nodes": {
+                            "127.0.0.1:1980": 1
+                        },
+                        "type": "roundrobin"
+                    },
+                    "uri": "/hello"
+                }]]
+                )
+            if code >= 300 then
+                ngx.status = code
+            end
+            ngx.say(body)
+        }
+    }
+--- response_body
+passed
+
+
+
+=== TEST 37: LDAP unreachable -> 500 (a transport error is never an auth 
failure) (creds: user01:password1)
+--- request
+GET /hello
+--- more_headers
+Authorization: ldap dXNlcjAxOnBhc3N3b3JkMQ==
+--- error_code: 500
+--- error_log
+LDAP connect failed
+
+
+
+=== TEST 38: set up an LDAPS route on 1636 (use_ldaps, ssl_verify off)
+--- config
+    location /t {
+        content_by_lua_block {
+            local t = require("lib.test_admin").test
+            local code, body = t('/apisix/admin/routes/1',
+                ngx.HTTP_PUT,
+                [[{
+                    "plugins": {
+                        "ldap-auth-advanced": {
+                            "ldap_uri": "127.0.0.1:1636",
+                            "base_dn": "ou=users,dc=example,dc=org",
+                            "attribute": "uid",
+                            "use_ldaps": true,
+                            "ssl_verify": false
+                        }
+                    },
+                    "upstream": {
+                        "nodes": {
+                            "127.0.0.1:1980": 1
+                        },
+                        "type": "roundrobin"
+                    },
+                    "uri": "/hello"
+                }]]
+                )
+            if code >= 300 then
+                ngx.status = code
+            end
+            ngx.say(body)
+        }
+    }
+--- response_body
+passed
+
+
+
+=== TEST 39: happy path over LDAPS (200) (creds: user01:password1)
+--- request
+GET /hello
+--- more_headers
+Authorization: ldap dXNlcjAxOnBhc3N3b3JkMQ==
+--- error_code: 200
+--- response_body
+hello world
+
+
+
+=== TEST 40: set up a StartTLS route on 1389 (use_starttls, ssl_verify off)
+--- config
+    location /t {
+        content_by_lua_block {
+            local t = require("lib.test_admin").test
+            local code, body = t('/apisix/admin/routes/1',
+                ngx.HTTP_PUT,
+                [[{
+                    "plugins": {
+                        "ldap-auth-advanced": {
+                            "ldap_uri": "127.0.0.1:1389",
+                            "base_dn": "ou=users,dc=example,dc=org",
+                            "attribute": "uid",
+                            "use_starttls": true,
+                            "ssl_verify": false
+                        }
+                    },
+                    "upstream": {
+                        "nodes": {
+                            "127.0.0.1:1980": 1
+                        },
+                        "type": "roundrobin"
+                    },
+                    "uri": "/hello"
+                }]]
+                )
+            if code >= 300 then
+                ngx.status = code
+            end
+            ngx.say(body)
+        }
+    }
+--- response_body
+passed
+
+
+
+=== TEST 41: happy path over StartTLS (200) (creds: user01:password1)
+--- request
+GET /hello
+--- more_headers
+Authorization: ldap dXNlcjAxOnBhc3N3b3JkMQ==
+--- error_code: 200
+--- response_body
+hello world
+
+
+
+=== TEST 42: restore the plain search-then-bind route for the injection suite
+--- config
+    location /t {
+        content_by_lua_block {
+            local t = require("lib.test_admin").test
+            local code, body = t('/apisix/admin/routes/1',
+                ngx.HTTP_PUT,
+                [[{
+                    "plugins": {
+                        "ldap-auth-advanced": {
+                            "ldap_uri": "127.0.0.1:1389",
+                            "base_dn": "ou=users,dc=example,dc=org",
+                            "attribute": "uid"
+                        }
+                    },
+                    "upstream": {
+                        "nodes": {
+                            "127.0.0.1:1980": 1
+                        },
+                        "type": "roundrobin"
+                    },
+                    "uri": "/hello"
+                }]]
+                )
+            if code >= 300 then
+                ngx.status = code
+            end
+            ngx.say(body)
+        }
+    }
+--- response_body
+passed
+
+
+
+=== TEST 43: filter-injection usernames each 401 (none widens the search)
+--- config
+    location /t {
+        content_by_lua_block {
+            local http = require("resty.http")
+            local port = ngx.var.server_port
+            -- RFC 4515 s3 specials plus a NUL byte. Once escaped, every 
payload
+            -- is a literal that matches NO user, so each 401s via the "user 
not
+            -- found" path. The log assertions below are the real proof of
+            -- escaping: an UNescaped "*" would build the presence filter 
(uid=*),
+            -- match >1 entry, and 401 via the "ambiguous user match" path 
instead
+            -- -- so we assert "user not found" IS logged and "ambiguous user
+            -- match" is NOT (status 401 alone cannot tell the two paths 
apart).
+            local injections = { "*", "*)(objectClass=*", "(", ")", "\\", "\0" 
}
+            local all_401 = true
+            local statuses = {}
+            for _, u in ipairs(injections) do
+                local hc = http.new()
+                local cred = ngx.encode_base64(u .. ":x")
+                local res, err = hc:request_uri(
+                    "http://127.0.0.1:"; .. port .. "/hello",
+                    { headers = { ["Authorization"] = "ldap " .. cred } })
+                local st = res and res.status or ("ERR:" .. tostring(err))
+                statuses[#statuses + 1] = tostring(st)
+                if st ~= 401 then all_401 = false end
+            end
+            ngx.say("all_401: ", tostring(all_401))
+            ngx.say("statuses: ", table.concat(statuses, ","))
+        }
+    }
+--- response_body
+all_401: true
+statuses: 401,401,401,401,401,401
+--- error_log
+user not found
+--- no_error_log
+ambiguous user match
+
+
+
+=== TEST 44: username with an invalid UTF-8 byte -> clean 401, never a 500
+--- config
+    location /t {
+        content_by_lua_block {
+            local http = require("resty.http")
+            local port = ngx.var.server_port
+            -- 0xFF is a lone high byte: never valid UTF-8. filter.escape 
leaves it
+            -- untouched, so if it reached the search the library's filter 
grammar would
+            -- reject it as a "syntax error", which the plugin's 
non-result-code
+            -- branch turns into HTTP 500. A malformed username is a bad 
credential
+            -- (a client error), so it must be rejected up front as a clean 
401 --
+            -- never surfaced as a server-side 500.
+            local hc = http.new()
+            local cred = ngx.encode_base64(string.char(0xff) .. ":x")
+            local res, err = hc:request_uri(
+                "http://127.0.0.1:"; .. port .. "/hello",
+                { headers = { ["Authorization"] = "ldap " .. cred } })
+            ngx.say("status: ", res and res.status or ("ERR:" .. 
tostring(err)))
+        }
+    }
+--- response_body
+status: 401
+--- error_log
+invalid username
+--- no_error_log
+LDAP user search failed
+
+
+
+=== TEST 45: well-formed multibyte UTF-8 username reaches the search and 401s 
as not-found
+--- config
+    location /t {
+        content_by_lua_block {
+            local http = require("resty.http")
+            local port = ngx.var.server_port
+            -- "h" + U+00E9 (e-acute, bytes 0xC3 0xA9) + "llo": valid 2-byte 
UTF-8.
+            -- It must pass the encoding check and reach the search, where it
+            -- matches no user -> the "user not found" 401 path. This proves 
valid
+            -- multibyte input is NOT rejected as bad encoding (only invalid 
byte
+            -- sequences are).
+            local hc = http.new()
+            local username = "h" .. string.char(0xc3, 0xa9) .. "llo"
+            local cred = ngx.encode_base64(username .. ":x")
+            local res, err = hc:request_uri(
+                "http://127.0.0.1:"; .. port .. "/hello",
+                { headers = { ["Authorization"] = "ldap " .. cred } })
+            ngx.say("status: ", res and res.status or ("ERR:" .. 
tostring(err)))
+        }
+    }
+--- response_body
+status: 401
+--- error_log
+user not found
+--- no_error_log
+invalid username
+
+
+
+=== TEST 46: username with a trailing '~' reaches the search and 401s as 
not-found
+--- config
+    location /t {
+        content_by_lua_block {
+            local http = require("resty.http")
+            local port = ngx.var.server_port
+            -- RFC 4515 UTF1SUBSET (%x5D-7F) includes '~' (0x7e), so "admin~" 
is a
+            -- legal assertion value and a legal directory username. The filter
+            -- grammar only tolerates a raw '~' mid-value, so filter.escape 
emits
+            -- it as "\7e"; the compiler un-escapes that back to '~' and the 
search
+            -- goes out with the exact username. It must therefore reach the 
search
+            -- and take the "user not found" 401 path -- never be turned away 
by the
+            -- compile pre-check, which is reserved for input the grammar 
genuinely
+            -- cannot express (e.g. invalid UTF-8, TEST 44).
+            local hc = http.new()
+            local cred = ngx.encode_base64("admin~:x")
+            local res, err = hc:request_uri(
+                "http://127.0.0.1:"; .. port .. "/hello",
+                { headers = { ["Authorization"] = "ldap " .. cred } })
+            ngx.say("status: ", res and res.status or ("ERR:" .. 
tostring(err)))
+        }
+    }
+--- response_body
+status: 401
+--- error_log
+user not found
+--- no_error_log
+invalid username
+
+
+
+=== TEST 47: set up the three concurrent-probe routes (churn + anon-secret + 
svc-secret)
+--- config
+    location /t {
+        content_by_lua_block {
+            local t = require("lib.test_admin").test
+            -- route 1 (/hello): bind_dn UNSET, the pool-churn route.
+            local code = t('/apisix/admin/routes/1', ngx.HTTP_PUT, [[{
+                "plugins": { "ldap-auth-advanced": {
+                    "ldap_uri": "127.0.0.1:1389",
+                    "base_dn": "ou=users,dc=example,dc=org",
+                    "attribute": "uid",
+                    "keepalive_pool_size": 4
+                } },
+                "upstream": { "nodes": { "127.0.0.1:1980": 1 }, "type": 
"roundrobin" },
+                "uri": "/hello"
+            }]])
+            -- route 2 (/uri): bind_dn UNSET -- searches anonymously (Route A).
+            local code2 = t('/apisix/admin/routes/2', ngx.HTTP_PUT, [[{
+                "plugins": { "ldap-auth-advanced": {
+                    "ldap_uri": "127.0.0.1:1389",
+                    "base_dn": "ou=users,dc=example,dc=org",
+                    "attribute": "uid",
+                    "keepalive_pool_size": 4
+                } },
+                "upstream": { "nodes": { "127.0.0.1:1980": 1 }, "type": 
"roundrobin" },
+                "uri": "/uri"
+            }]])
+            -- route 3 (/hello1): bind_dn SET -- searches as the service 
account (Route B).
+            local code3 = t('/apisix/admin/routes/3', ngx.HTTP_PUT, [[{
+                "plugins": { "ldap-auth-advanced": {
+                    "ldap_uri": "127.0.0.1:1389",
+                    "base_dn": "ou=users,dc=example,dc=org",
+                    "attribute": "uid",
+                    "bind_dn": "cn=admin,dc=example,dc=org",
+                    "ldap_password": "adminpassword",
+                    "keepalive_pool_size": 4
+                } },
+                "upstream": { "nodes": { "127.0.0.1:1980": 1 }, "type": 
"roundrobin" },
+                "uri": "/hello1"
+            }]])
+            local ok = code < 300 and code2 < 300 and code3 < 300
+            ngx.say(ok and "passed" or "failed")
+        }
+    }
+--- response_body
+passed
+
+
+
+=== TEST 48: CONCURRENT bind-state-leak probe: anon re-bind must not leak
+--- config
+    location /probe {
+        content_by_lua_block {
+            local http = require("resty.http")
+            local args = ngx.req.get_uri_args()
+            local hc = http.new()
+            local cred = ngx.encode_base64(args.u .. ":" .. args.p)
+            local res, err = hc:request_uri(
+                "http://127.0.0.1:"; .. ngx.var.server_port .. args.path,
+                { headers = { ["Authorization"] = "ldap " .. cred } })
+            ngx.print(res and tostring(res.status) or ("ERR:" .. 
tostring(err)))
+        }
+    }
+    location /t {
+        content_by_lua_block {
+            -- Phase 1: CONCURRENTLY authenticate several end users on the
+            -- bind_dn-UNSET churn route so multiple pooled sockets end up
+            -- bound as DIFFERENT end users.
+            -- keepalive_pool_size=4 (>1) and all routes share pool 
127.0.0.1:1389.
+            local churn = {
+                {u="user01", p="password1"}, {u="user02", p="password2"},
+                {u="jdoe",   p="janesecret"},
+            }
+            local reqs = {}
+            for i = 1, 9 do
+                local c = churn[((i - 1) % 3) + 1]
+                reqs[i] = { "/probe", { args = { path = "/hello", u = c.u, p = 
c.p } } }
+            end
+            -- ngx.thread.spawn drives the concurrent capture_multi wave.
+            local th = assert(ngx.thread.spawn(function()
+                return { ngx.location.capture_multi(reqs) }
+            end))
+            local _, churn_resps = ngx.thread.wait(th)
+            local churn_all_200 = true
+            for _, r in ipairs(churn_resps) do
+                if r.body ~= "200" then churn_all_200 = false end
+            end
+
+            -- Phase 2: Route A (bind_dn UNSET) authenticating `secretuser`, 
which
+            -- is hidden from an anonymous search. Its anonymous 
simple_bind("","")
+            -- MUST reset any reused (end-user-bound) socket to anonymous -> 
the
+            -- search finds nothing -> 401. A leaked end-user bind would 
resolve
+            -- secretuser and 200.
+            local a = {}
+            for i = 1, 5 do
+                a[i] = { "/probe", { args = { path = "/uri", u = "secretuser", 
p = "secretpass" } } }
+            end
+            local ares = { ngx.location.capture_multi(a) }
+            local routeA_all_401 = true
+            for _, r in ipairs(ares) do
+                if r.body ~= "401" then routeA_all_401 = false end
+            end
+
+            -- Phase 3: Route B (bind_dn SET) authenticating `secretuser`. Its
+            -- search bind as the service account resolves secretuser -> 200.
+            local b = {}
+            for i = 1, 5 do
+                b[i] = { "/probe", { args = { path = "/hello1", u = 
"secretuser", p = "secretpass" } } }
+            end
+            local bres = { ngx.location.capture_multi(b) }
+            local routeB_all_200 = true
+            for _, r in ipairs(bres) do
+                if r.body ~= "200" then routeB_all_200 = false end
+            end
+
+            ngx.say("churn_all_200: ", tostring(churn_all_200))
+            ngx.say("routeA_anon_all_401: ", tostring(routeA_all_401))
+            ngx.say("routeB_svc_all_200: ", tostring(routeB_all_200))
+        }
+    }
+--- timeout: 15
+--- response_body
+churn_all_200: true
+routeA_anon_all_401: true
+routeB_svc_all_200: true
+
+
+
+=== TEST 49: swap in a Consumer whose user_dn is an $ENV:// secret reference
+--- main_config
+env ADV_LDAP_USER_DN=cn=user02,ou=users,dc=example,dc=org;
+--- config
+    location /t {
+        content_by_lua_block {
+            local t = require("lib.test_admin").test
+            local code, body = t('/apisix/admin/consumers/ldapadvuser02', 
ngx.HTTP_DELETE)
+            if code >= 300 then
+                ngx.status = code
+                return ngx.say(body)
+            end
+
+            code, body = t('/apisix/admin/consumers',
+                ngx.HTTP_PUT,
+                [[{
+                     "username": "ldapadvenvref",
+                     "plugins": {
+                         "ldap-auth-advanced": {
+                             "user_dn": "$ENV://ADV_LDAP_USER_DN"
+                         }
+                     }
+                }]]
+                )
+            if code >= 300 then
+                ngx.status = code
+                return ngx.say(body)
+            end
+
+            -- wait until the watcher has synced BOTH writes: the resolved
+            -- env-ref consumer must be the one the runtime map returns
+            local find_consumer = require("apisix.consumer").find_consumer
+            for _ = 1, 100 do
+                local consumer = find_consumer("ldap-auth-advanced", "user_dn",
+                                               
"cn=user02,ou=users,dc=example,dc=org")
+                if consumer and consumer.consumer_name == "ldapadvenvref" then
+                    break
+                end
+                ngx.sleep(0.05)
+            end
+            ngx.say("passed")
+        }
+    }
+--- response_body
+passed
+
+
+
+=== TEST 50: env-ref user_dn resolves and matches the Consumer (200) (creds: 
user02:password2)
+--- main_config
+env ADV_LDAP_USER_DN=cn=user02,ou=users,dc=example,dc=org;
+--- request
+GET /hello
+--- more_headers
+Authorization: ldap dXNlcjAyOnBhc3N3b3JkMg==
+--- error_code: 200
+--- response_body
+hello world
+--- error_log
+find consumer ldapadvenvref
+
+
+
+=== TEST 51: set the /uri header-printing route to consumer_required=false
+--- config
+    location /t {
+        content_by_lua_block {
+            local t = require("lib.test_admin").test
+            local code, body = t('/apisix/admin/routes/2',
+                ngx.HTTP_PUT,
+                [[{
+                    "plugins": {
+                        "ldap-auth-advanced": {
+                            "ldap_uri": "127.0.0.1:1389",
+                            "base_dn": "ou=users,dc=example,dc=org",
+                            "attribute": "uid",
+                            "consumer_required": false
+                        }
+                    },
+                    "upstream": {
+                        "nodes": {
+                            "127.0.0.1:1980": 1
+                        },
+                        "type": "roundrobin"
+                    },
+                    "uri": "/uri"
+                }]]
+                )
+            if code >= 300 then
+                ngx.status = code
+            end
+            ngx.say(body)
+        }
+    }
+--- response_body
+passed
+
+
+
+=== TEST 52: spoofed identity headers never reach the upstream without a 
Consumer (creds: user01:password1)
+--- request
+GET /uri
+--- more_headers
+Authorization: ldap dXNlcjAxOnBhc3N3b3JkMQ==
+X-Consumer-Username: spoofed-user
+X-Credential-Identifier: spoofed-cred
+X-Consumer-Custom-ID: spoofed-id
+X-Authenticated-Groups: spoofed-groups
+--- error_code: 200
+--- response_body
+uri: /uri
+authorization: ldap dXNlcjAxOnBhc3N3b3JkMQ==
+host: localhost
+x-real-ip: 127.0.0.1
+
+
+
+=== TEST 53: RFC 4512 attribute forms are accepted (descriptor, OID, options)
+--- config
+    location /t {
+        content_by_lua_block {
+            local plugin = require("apisix.plugins.ldap-auth-advanced")
+            local cases = {
+                "sAMAccountName",
+                "cn;lang-en",
+                "1.2.840.113556.1.4.656",
+                "0.9.2342.19200300.100.1.1;binary",
+            }
+            for _, attr in ipairs(cases) do
+                local ok = plugin.check_schema({
+                    ldap_uri = "127.0.0.1:1389",
+                    base_dn = "ou=users,dc=example,dc=org",
+                    attribute = attr,
+                })
+                ngx.say(attr, ": ", ok and "passed" or "rejected")
+            end
+        }
+    }
+--- response_body
+sAMAccountName: passed
+cn;lang-en: passed
+1.2.840.113556.1.4.656: passed
+0.9.2342.19200300.100.1.1;binary: passed
+
+
+
+=== TEST 54: malformed attribute forms are rejected
+--- config
+    location /t {
+        content_by_lua_block {
+            local plugin = require("apisix.plugins.ldap-auth-advanced")
+            local cases = {
+                "1",        -- a bare number is neither a descriptor nor an OID
+                "1.2.",     -- trailing dot
+                "01.2",     -- leading zero in an arc
+                "cn;",      -- empty option
+                ";binary",  -- missing attribute type
+            }
+            for _, attr in ipairs(cases) do
+                local ok = plugin.check_schema({
+                    ldap_uri = "127.0.0.1:1389",
+                    base_dn = "ou=users,dc=example,dc=org",
+                    attribute = attr,
+                })
+                ngx.say(attr, ": ", ok and "passed" or "rejected")
+            end
+        }
+    }
+--- response_body
+1: rejected
+1.2.: rejected
+01.2: rejected
+cn;: rejected
+;binary: rejected
+
+
+
+=== TEST 55: set up the search-then-bind route with the uid attribute's 
numeric OID
+--- config
+    location /t {
+        content_by_lua_block {
+            local t = require("lib.test_admin").test
+            local code, body = t('/apisix/admin/routes/1',
+                ngx.HTTP_PUT,
+                [[{
+                    "plugins": {
+                        "ldap-auth-advanced": {
+                            "ldap_uri": "127.0.0.1:1389",
+                            "base_dn": "ou=users,dc=example,dc=org",
+                            "attribute": "0.9.2342.19200300.100.1.1"
+                        }
+                    },
+                    "upstream": {
+                        "nodes": {
+                            "127.0.0.1:1980": 1
+                        },
+                        "type": "roundrobin"
+                    },
+                    "uri": "/hello"
+                }]]
+                )
+            if code >= 300 then
+                ngx.status = code
+            end
+            ngx.say(body)
+        }
+    }
+--- response_body
+passed
+
+
+
+=== TEST 56: OID attribute resolves the user end-to-end (200) (creds: 
user01:password1)
+--- request
+GET /hello
+--- more_headers
+Authorization: ldap dXNlcjAxOnBhc3N3b3JkMQ==
+--- error_code: 200
+--- response_body
+hello world
+--- error_log
+find consumer ldapadvuser01
+
+
+
+=== TEST 57: a proxy's own Basic Proxy-Authorization must not mask a valid 
Authorization
+--- request
+GET /hello
+--- more_headers
+Proxy-Authorization: Basic cHJveHk6aHVudGVyMg==
+Authorization: ldap dXNlcjAxOnBhc3N3b3JkMQ==
+--- error_code: 200
+--- response_body
+hello world
+--- error_log
+find consumer ldapadvuser01
+
+
+
+=== TEST 58: Proxy-Authorization alone carries the credential (200) (creds: 
user01:password1)
+--- request
+GET /hello
+--- more_headers
+Proxy-Authorization: ldap dXNlcjAxOnBhc3N3b3JkMQ==
+--- error_code: 200
+--- response_body
+hello world
+--- error_log
+find consumer ldapadvuser01
+
+
+
+=== TEST 59: when both headers parse, Proxy-Authorization wins (proxy: user01, 
auth: jdoe)
+--- request
+GET /hello
+--- more_headers
+Proxy-Authorization: ldap dXNlcjAxOnBhc3N3b3JkMQ==
+Authorization: ldap amRvZTpqYW5lc2VjcmV0
+--- error_code: 200
+--- response_body
+hello world
+--- error_log
+find consumer ldapadvuser01
+
+
+
+=== TEST 60: undecodable Proxy-Authorization payload falls back to 
Authorization
+--- request
+GET /hello
+--- more_headers
+Proxy-Authorization: ldap !!!not-base64!!!
+Authorization: ldap dXNlcjAxOnBhc3N3b3JkMQ==
+--- error_code: 200
+--- response_body
+hello world
+--- error_log
+find consumer ldapadvuser01
+
+
+
+=== TEST 61: unusable Proxy-Authorization with no Authorization still 401s
+--- request
+GET /hello
+--- more_headers
+Proxy-Authorization: Basic cHJveHk6aHVudGVyMg==
+--- error_code: 401
+
+
+
+=== TEST 62: Proxy-Authorization with an empty username falls back to 
Authorization (proxy: ":pass")
+--- request
+GET /hello
+--- more_headers
+Proxy-Authorization: ldap OnBhc3M=
+Authorization: ldap dXNlcjAxOnBhc3N3b3JkMQ==
+--- error_code: 200
+--- response_body
+hello world
+--- error_log
+find consumer ldapadvuser01
+
+
+
+=== TEST 63: Proxy-Authorization with an empty password falls back to 
Authorization (proxy: "user:")
+--- request
+GET /hello
+--- more_headers
+Proxy-Authorization: ldap dXNlcjo=
+Authorization: ldap dXNlcjAxOnBhc3N3b3JkMQ==
+--- error_code: 200
+--- response_body
+hello world
+--- error_log
+find consumer ldapadvuser01
+
+
+
+=== TEST 64: an empty-field Proxy-Authorization with no Authorization still 
401s (proxy: ":")
+--- request
+GET /hello
+--- more_headers
+Proxy-Authorization: ldap Og==
+--- error_code: 401
+--- grep_error_log eval
+qr/empty password/
+--- grep_error_log_out
+empty password
+
+
+
+=== TEST 65: set up a route whose service-account password is wrong
+--- config
+    location /t {
+        content_by_lua_block {
+            local t = require("lib.test_admin").test
+            local code, body = t('/apisix/admin/routes/1',
+                ngx.HTTP_PUT,
+                [[{
+                    "plugins": {
+                        "ldap-auth-advanced": {
+                            "ldap_uri": "127.0.0.1:1389",
+                            "base_dn": "ou=users,dc=example,dc=org",
+                            "attribute": "uid",
+                            "bind_dn": "cn=admin,dc=example,dc=org",
+                            "ldap_password": "rotated-away"
+                        }
+                    },
+                    "upstream": {
+                        "nodes": {
+                            "127.0.0.1:1980": 1
+                        },
+                        "type": "roundrobin"
+                    },
+                    "uri": "/hello"
+                }]]
+                )
+            if code >= 300 then
+                ngx.status = code
+            end
+            ngx.say(body)
+        }
+    }
+--- response_body
+passed
+
+
+
+=== TEST 66: rejected service bind -> 500, never a client auth failure (creds: 
user01:password1)
+--- request
+GET /hello
+--- more_headers
+Authorization: ldap dXNlcjAxOnBhc3N3b3JkMQ==
+--- error_code: 500
+--- error_log
+LDAP search bind failed
+
+
+
+=== TEST 67: set up a route with a nonexistent base_dn
+--- config
+    location /t {
+        content_by_lua_block {
+            local t = require("lib.test_admin").test
+            local code, body = t('/apisix/admin/routes/1',
+                ngx.HTTP_PUT,
+                [[{
+                    "plugins": {
+                        "ldap-auth-advanced": {
+                            "ldap_uri": "127.0.0.1:1389",
+                            "base_dn": "ou=nowhere,dc=example,dc=org",
+                            "attribute": "uid"
+                        }
+                    },
+                    "upstream": {
+                        "nodes": {
+                            "127.0.0.1:1980": 1
+                        },
+                        "type": "roundrobin"
+                    },
+                    "uri": "/hello"
+                }]]
+                )
+            if code >= 300 then
+                ngx.status = code
+            end
+            ngx.say(body)
+        }
+    }
+--- response_body
+passed
+
+
+
+=== TEST 68: search against a nonexistent base_dn -> 500 (noSuchObject is a 
misconfiguration) (creds: user01:password1)
+--- request
+GET /hello
+--- more_headers
+Authorization: ldap dXNlcjAxOnBhc3N3b3JkMQ==
+--- error_code: 500
+--- error_log
+LDAP user search failed
+
+
+
+=== TEST 69: set up a route whose login attribute matches many entries 
(objectClass)
+--- config
+    location /t {
+        content_by_lua_block {
+            local t = require("lib.test_admin").test
+            local code, body = t('/apisix/admin/routes/1',
+                ngx.HTTP_PUT,
+                [[{
+                    "plugins": {
+                        "ldap-auth-advanced": {
+                            "ldap_uri": "127.0.0.1:1389",
+                            "base_dn": "ou=users,dc=example,dc=org",
+                            "attribute": "objectClass"
+                        }
+                    },
+                    "upstream": {
+                        "nodes": {
+                            "127.0.0.1:1980": 1
+                        },
+                        "type": "roundrobin"
+                    },
+                    "uri": "/hello"
+                }]]
+                )
+            if code >= 300 then
+                ngx.status = code
+            end
+            ngx.say(body)
+        }
+    }
+--- response_body
+passed
+
+
+
+=== TEST 70: sizeLimitExceeded stays a fail-closed 401, not a 500 (creds: 
inetOrgPerson:x)
+--- request
+GET /hello
+--- more_headers
+Authorization: ldap aW5ldE9yZ1BlcnNvbjp4
+--- error_code: 401
+--- grep_error_log eval
+qr/ambiguous user match \(size limit exceeded\)/
+--- grep_error_log_out
+ambiguous user match (size limit exceeded)
+
+
+
+=== TEST 71: empty ldap_uri is rejected (minLength)
+--- config
+    location /t {
+        content_by_lua_block {
+            local plugin = require("apisix.plugins.ldap-auth-advanced")
+            local ok, err = plugin.check_schema({
+                ldap_uri = "",
+                base_dn = "ou=users,dc=example,dc=org",
+            })
+            ngx.say(ok and "passed" or err)
+        }
+    }
+--- response_body_like eval
+qr/property "ldap_uri" validation failed/
+
+
+
+=== TEST 72: overlong base_dn is rejected (maxLength)
+--- config
+    location /t {
+        content_by_lua_block {
+            local plugin = require("apisix.plugins.ldap-auth-advanced")
+            local ok, err = plugin.check_schema({
+                ldap_uri = "127.0.0.1:1389",
+                base_dn = "ou=" .. string.rep("x", 4094) .. ",dc=org",
+            })
+            ngx.say(ok and "passed" or err)
+        }
+    }
+--- response_body_like eval
+qr/property "base_dn" validation failed/
+
+
+
+=== TEST 73: empty consumer user_dn is rejected (minLength)
+--- config
+    location /t {
+        content_by_lua_block {
+            local core = require("apisix.core")
+            local plugin = require("apisix.plugins.ldap-auth-advanced")
+            local ok, err = plugin.check_schema({ user_dn = "" },
+                                                core.schema.TYPE_CONSUMER)
+            ngx.say(ok and "passed" or err)
+        }
+    }
+--- response_body_like eval
+qr/property "user_dn" validation failed/

Reply via email to