diff --git a/apisix-master-0.rockspec b/apisix-master-0.rockspec index a15b57290fcb..c56dab4141a6 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.0-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 374e259d8161..98c3ff581218 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 e8877495f1a9..c13d3a73c4b1 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 000000000000..00dd29bc7d04 --- /dev/null +++ b/apisix/plugins/ldap-auth-advanced.lua @@ -0,0 +1,399 @@ +-- +-- 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 + + return str_sub(decoded, 1, sep - 1), str_sub(decoded, sep + 1) +end + + +-- Proxy-Authorization takes priority, but only when it parses into +-- 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 +-- " failed, error: , details: "; 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) + + local username, password, err = extract_credentials(conf, ctx) + if err then + return auth_failed(conf, ctx, err) + end + + -- A zero-length password would be an RFC 4513 5.1.2 unauthenticated bind, + -- which authenticates anyone whose username resolves. Reject before any + -- bind can be attempted. + if password == "" then + return auth_failed(conf, ctx, "empty password rejected before bind") + end + + if username == "" then + return auth_failed(conf, ctx, "empty username") + 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 81b2da0df3dc..53d1b6726014 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 000000000000..a782a83a8b02 --- /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 000000000000..073dfac8c152 --- /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:///" </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:///" <= 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 f0a512069894..ef70e46d9175 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 0830e61b761d..462a25f39d7a 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 000000000000..1bbaaf613fc5 --- /dev/null +++ b/t/plugin/ldap-auth-advanced.t @@ -0,0 +1,1605 @@ +# +# 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 grammar-reserved ASCII byte (trailing '~') -> clean 401, never a 500 +--- config + location /t { + content_by_lua_block { + local http = require("resty.http") + local port = ngx.var.server_port + -- "admin~" is valid ASCII (it passes any UTF-8 check) but filter.escape + -- leaves '~' untouched and the library's filter grammar rejects a trailing + -- '~' as a "syntax error". Before the compile pre-check this reached the + -- search and the non-result-code branch turned that "syntax error" into + -- HTTP 500. A malformed username is a bad credential (a client error), + -- so it must be rejected up front as a clean 401 with "invalid username" + -- -- never surfaced as a 500 and never logged as an LDAP search failure. + 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 +invalid username +--- no_error_log +LDAP user search failed + + + +=== 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: 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 63: 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 64: 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 65: 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 66: 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 67: 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 68: 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 69: 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 70: 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/