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/