Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 0 additions & 11 deletions .cargo/config.toml

This file was deleted.

5 changes: 2 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,6 @@ jobs:
# Test::Nginx
sudo cpanm --notest Test::Nginx

# rust
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y

# luarocks
sudo apt -y install lua5.1 luarocks
luarocks make rockspec/lua-resty-ldap-local-0.rockspec --local --tree ./deps # install deps to local
Expand All @@ -56,5 +53,7 @@ jobs:
docker logs openldap || true
exit 1
fi
docker cp t/fixtures/ad.ldif openldap:/tmp/ad.ldif
docker exec openldap ldapadd -x -H ldap://127.0.0.1:1389 -D "cn=admin,dc=example,dc=org" -w adminpassword -f /tmp/ad.ldif
export PATH=$OPENRESTY_PREFIX/nginx/sbin:$OPENRESTY_PREFIX/luajit/bin:$PATH
make test
8 changes: 2 additions & 6 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,9 +1,5 @@
# dev
t/servroot
deps/


# Added by cargo

/target
/Cargo.lock
# luarocks --tree ./deps build output (see .github/workflows/ci.yml)
deps/
17 changes: 0 additions & 17 deletions Cargo.toml

This file was deleted.

51 changes: 9 additions & 42 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,52 +1,19 @@
UNAME ?= $(shell uname)
INSTALL ?= install
ECHO ?= echo
C_SO_NAME := librasn.so

ifeq ($(UNAME),Darwin)
C_SO_NAME := librasn.dylib
endif

all:
@echo --- Build
@echo CFLAGS: $(CFLAGS)
@echo LIBFLAG: $(LIBFLAG)
@echo LUA_LIBDIR: $(LUA_LIBDIR)
@echo LUA_BINDIR: $(LUA_BINDIR)
@echo LUA_INCDIR: $(LUA_INCDIR)

@echo --- Check Rust toolchain
@utils/check-rust.sh

@echo --- Build Rust cdylib
cargo build --release

### install: Install the library to runtime
.PHONY: install
install:
@echo --- Install
@echo INST_PREFIX: $(INST_PREFIX)
@echo INST_BINDIR: $(INST_BINDIR)
@echo INST_LIBDIR: $(INST_LIBDIR)
@echo INST_LUADIR: $(INST_LUADIR)
@echo INST_CONFDIR: $(INST_CONFDIR)
$(INSTALL) -d $(INST_LUADIR)/resty/ldap/
$(INSTALL) lib/resty/ldap/*.lua $(INST_LUADIR)/resty/ldap/
$(INSTALL) target/release/${C_SO_NAME} $(INST_LIBDIR)/rasn.so

### dev: Create a development ENV
### dev: Install runtime dependencies locally
.PHONY: dev
dev:
luarocks install rockspec/lua-resty-ldap-main-0.rockspec --only-deps --local

### test: Run the test suite
.PHONY: test
test:
git apply t/patch/unknown_op.patch
trap 'git apply t/patch/unknown_op.patch -R' EXIT; \
trap 'exit 130' INT TERM HUP; \
prove -r t/

### help: Show Makefile rules
.PHONY: help
help:
@echo Makefile rules:
@echo
@grep -E '^### [-A-Za-z0-9_]+:' Makefile | sed 's/###/ /'

test:
git apply t/patch/unknown_op.patch
prove -r t/
git apply t/patch/unknown_op.patch -R
65 changes: 60 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ local client = ldap_client:new("127.0.0.1", 1389, {
keepalive_timeout = 60000,
start_tls = false,
ldaps = false,
ssl_verify = false
ssl_verify = true
})
local err = client:simple_bind("cn=user01,ou=users,dc=example,dc=org", "password1")
```
Expand All @@ -49,7 +49,7 @@ To load this module:
| `keepalive_timeout` | number | 60000 | An optional value in milliseconds that defines how long an idle connection to LDAP server will live before being closed. |
| `start_tls` | boolean | false | Set it to `true` to issue StartTLS (Transport Layer Security) extended operation over ldap connection. If the start_tls setting is enabled, ensure the ldaps setting is disabled. |
| `ldaps` | boolean | false | Set to `true` to connect using the LDAPS protocol (LDAP over TLS). When ldaps is configured, you must use port 636. If the ldap setting is enabled, ensure the start_tls setting is disabled. |
| `ssl_verify` | boolean | false | Set to true to authenticate LDAP server. The server certificate will be verified according to the CA certificates specified by the `lua_ssl_trusted_certificate` directive. |
| `ssl_verify` | boolean | true | Verify the LDAP server's certificate against the CA certificates specified by the `lua_ssl_trusted_certificate` directive. Set to `false` to skip verification. |
| `keepalive_pool_name` | string | host:ip | Set and override the default connection pool name for scenarios where the same connection parameters are used but with a different authentication method. The default value is the same as the OpenResty rule, and the value is the `host:port` of the LDAP server. |
| `keepalive_pool_size` | number | 2 | Set the size of a certain connection pool. According to OpenResty's rule, it can only be set when the pool is created and cannot be changed dynamically. |

Expand All @@ -63,13 +63,13 @@ To load this module:

`bind_dn` and `password` can be `nil` values, that means the client is instructed to do anonymous bind.

`res` is a boolean type value that will be true when authentication is successful, when it is false, `err` will contain errors.
`res` is `true` when authentication succeeds and `false` when the server rejects the bind (or the request cannot be encoded); on a transport or decoding failure `res` is `nil`. In both failure cases `err` carries the error.

#### search

**syntax:** *res, err = client:search(base_dn?, scope?, deref_aliases?, size_limit?, time_limit?, types_only?, filter?, attributes?)*
**syntax:** *res, err = client:search(base_dn, scope?, deref_aliases?, size_limit?, time_limit?, types_only?, filter?, attributes?)*

`base_dn` is the base DN you need to search. Default is `dc=example,dc=org`.
`base_dn` is the base DN you need to search. It is required; a `nil` value is rejected before any request is sent.

`scope` is a flag field in the search protocol that specifies how the LDAP server performs the search, such as baseDN only, all subtrees, etc. You can import those values from protocol.lua, `SEARCH_SCOPE_BASE_OBJECT`, `SEARCH_SCOPE_SINGLE_LEVEL` and `SEARCH_SCOPE_WHOLE_SUBTREE`. Default is `SEARCH_SCOPE_WHOLE_SUBTREE`.

Expand All @@ -84,3 +84,58 @@ To load this module:
`filter` is an LDAP filter expression in string. Default is `(objectClass=*)`.

`attributes` is an array table that contains one to more query fields that you need to have the LDAP server return. Default is `["objectClass"]`.

#### Connection lifecycle

The client opens its connection on the first operation and holds it, so a `simple_bind` followed by a `search` runs on that one connection. Nothing is released implicitly: the caller ends the session with `set_keepalive` (return it to the pool) or `close` (drop it), once it is done issuing operations.

> **Note:** this changed in v0.3.0. Operations used to return the connection to the pool on their own, so a caller that never released it still got connection reuse. Such a caller now holds the connection until it is garbage collected at the end of the request, and gets no reuse — add a `set_keepalive` or `close` call when you are done with the client.

An unrecoverable socket error drops the connection, so the next operation opens a new one instead of reusing the dead one.

Connection pools are partitioned by transport mode (plain, STARTTLS, LDAPS) and TLS verification policy; bind state is not part of the pool key. A released connection keeps whatever bind state its session left, so a drawn connection's identity is unknown until the session establishes its own: issue a `simple_bind` first whenever identity matters — each bind resets the LDAP session (RFC 4513 section 4). A connection that hits a transport or protocol error is closed, never pooled.

#### set_keepalive

**syntax:** *ok, err = client:set_keepalive()*

Returns the connection to the connection pool and detaches it from the client. The client remains usable; the next operation opens or draws another connection. Returns `true` when there is no connection to release.

#### close

**syntax:** *ok, err = client:close()*

Closes the connection without returning it to the pool, and detaches it from the client.

### resty.ldap

Compatibility entrypoint kept for APISIX's `ldap-auth` plugin, preserving the v0.1.0 `ldap_authenticate` contract as a thin wrapper over `resty.ldap.client`. New code should use `resty.ldap.client` instead.

```lua
local ldap = require "resty.ldap"
```

#### ldap_authenticate

**syntax:** *ok, err, user_dn = ldap.ldap_authenticate(username, password, conf)*

Binds against the directory as `<attribute>=<username>,<base_dn>` with the given password. `ok` is `true` when authentication succeeds, `false` when the credentials are rejected — an empty or missing password is refused before any request is sent, as it would form an unauthenticated bind (RFC 4513 section 5.1.2) — and `nil` on an invalid `conf` or a connect/TLS/transport failure; in both failure cases `err` carries the error. The connection is released into the pool after the bind attempt — whatever its outcome — and the next call reuses it and rebinds.

`username` is escaped per RFC 4514 when the bind DN is built, so DN metacharacters (`, + " \ < > ; =`, a leading space or `#`, a trailing space, or a NUL) are treated as literal characters of the RDN value instead of injecting extra RDN components. Any username string is accepted, as in v0.1.0.

`user_dn` is the canonical (escaped) DN the bind was performed with; callers that key identity on the DN (e.g. the APISIX `ldap-auth` consumer lookup) should use it instead of rebuilding the DN from the raw username. It is returned whenever a bind was attempted, and is `nil` on connect/STARTTLS/handshake failures.

`conf` is a table of below items (note the key names differ from `resty.ldap.client`'s `client_config`; they follow the v0.1.0 / `ldap-auth` contract):

| key | type | default value | Description |
| ----------- | ----------- | ----------- | ----------- |
| `ldap_host` | string | *required* | LDAP server host. |
| `ldap_port` | number | 389 | LDAP server port. |
| `timeout` | number | 10000 | Socket timeout in milliseconds. |
| `keepalive` | number | 60000 | How long an idle connection lives in the pool, in milliseconds. |
| `start_tls` | boolean | false | Issue the StartTLS extended operation before binding. |
| `ldaps` | boolean | false | Connect using LDAP over TLS. |
| `tls_verify` | boolean | true | Verify the server certificate during the TLS handshake. This is the key APISIX's `ldap-auth` passes. |
| `base_dn` | string | *required* | Base DN the username is appended to. |
| `attribute` | string | cn | RDN attribute the username is bound as. |

108 changes: 108 additions & 0 deletions lib/resty/ldap.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
local log = ngx.log
local ERR = ngx.ERR
local client = require "resty.ldap.client"


local tostring = tostring

local default_conf = {
timeout = 10000,
start_tls = false,
ldap_port = 389,
ldaps = false,
tls_verify = true,
attribute = "cn",
keepalive = 60000,
}

local function set_conf_default_values(conf)
for k, v in pairs(default_conf) do
if conf[k] == nil then
conf[k] = v
end
end
end


-- RFC 4514 s2.4 escaping for an RDN value; the bind DN below is built from a
-- client-supplied username. Not filter.escape(), which is RFC 4515 and leaves
-- ',' and '+' alone.
local function escape_dn_value(value)
local lead = value:sub(1, 1)
-- a lone leading space is covered by the lead rule alone
local trail = #value > 1 and value:sub(-1) or ""

local out = value:gsub('([\\",+<>;=])', "\\%1"):gsub("%z", "\\00")

if trail == " " then
out = out:sub(1, -2) .. "\\ "
end
if lead == " " or lead == "#" then
out = "\\" .. out
end

return out
end


local _M = {}


function _M.ldap_authenticate(given_username, given_password, conf)
set_conf_default_values(conf)

if conf.ldap_host == nil or conf.ldap_host == "" then
return nil, "ldap_host is required"
end
if conf.base_dn == nil or conf.base_dn == "" then
return nil, "base_dn is required"
end

-- same coercion contract as protocol.simple_bind_request
if type(given_username) == "number" then
given_username = tostring(given_username)
end
if type(given_username) ~= "string" then
return false, "bind username must be a string"
end

-- RFC 4513 s5.1.2: a non-empty DN with an empty password is an
-- unauthenticated bind, which some servers accept as an anonymous
-- success -- an authentication bypass, so it must never reach the wire.
-- Other invalid types are rejected by protocol.simple_bind_request.
if given_password == nil or given_password == "" then
return false, "bind password is missing or empty"
end

local cli = client:new(conf.ldap_host, conf.ldap_port, {
socket_timeout = conf.timeout,
keepalive_timeout = conf.keepalive,
start_tls = conf.start_tls,
ldaps = conf.ldaps,
ssl_verify = conf.tls_verify,
})

local who = conf.attribute .. "=" .. escape_dn_value(given_username) ..
"," .. conf.base_dn
local is_authenticated, bind_err = cli:simple_bind(who, given_password)

-- pooled even when the bind was rejected: a failed bind leaves the
-- session anonymous (RFC 4513 s4), and the next call rebinds anyway
local released, release_err = cli:set_keepalive()
if not released then
log(ERR, "failed to return the connection to the pool: ", release_err)
end

if is_authenticated == nil then
-- connect, TLS or transport failure; the client already dropped the socket
log(ERR, "failed to bind to ", conf.ldap_host, ":",
tostring(conf.ldap_port), ": ", bind_err)
return nil, bind_err
end

-- who: the canonical escaped bind DN, for callers that key identity on it
return is_authenticated, bind_err, who
end


return _M
Loading