Skip to content

LDAP Developer Guide

Ed Mozley edited this page Aug 16, 2026 · 6 revisions

LDAP β€” Developer Guide

How LDAP sign-in is built, why it's built that way, and the traps that will bite you if you change it. For the admin-facing guide see LDAP & Active Directory.

Shipped for issue #47.


πŸ“₯ Importing people is a separate subsystem with its own code and its own traps. This page covers sign-in only. For the import engine see Directory sync β€” Developer Guide, and to change it, Extending directory sync. They share ldap.php's connection and bind, and nothing else.

Where it lives

File Role
includes/ldap.php Everything directory-related: connect, bind, search, groups, the access gate, analyst provisioning
login.php The analyst login form β€” decides how to check a password (local / directory / refuse)
api/system/save_sso_provider.php Writes a provider; branches on protocol
api/system/get_sso_providers.php Lists providers; admin-gated, never returns secrets
api/system/test_ldap_connection.php The admin Test button β€” returns the real error, deliberately
system/sso/index.php System β†’ Authentication: the Type dropdown, LDAP fields, presets
system/help/sso.php The help page (registry entry: sso)

It reuses the SSO plumbing, deliberately

LDAP is not a parallel auth system. It's a second protocol value in the existing auth_providers table, beside 'oidc':

auth_providers.protocol = 'oidc' | 'ldap'

That means it inherits, for free: the provider admin UI, the auto_create_users JIT flag, default_modules, tenant_id scoping, the analyst_sso_identities link table, session handling, and the strict per-provider isolation rules. One auth story, not two.

Column groups are mutually exclusive. OIDC rows use issuer_url / client_id / client_secret / scopes; LDAP rows use the ldap_* columns. Saving one protocol blanks the other group's plain settings.

⚠️ The stored SECRET is not blanked. isMaskedNoChangeValue('') returns true (includes/encryption.php:105), so a blank secret means "keep what's stored" β€” on an OIDC save the old encrypted ldap_bind_password survives, and vice versa. Switching a provider's protocol therefore leaves the other protocol's secret in the row. Harmless while unused, but don't assume the row is clean.

issuer_url and client_id are NOT NULL, and LDAP rows store '' in them. A convention, not a necessity β€” '' reads unambiguously as "not applicable to this protocol". An earlier version of this page said the columns couldn't be relaxed because "db_verify only ever ADDS columns". That is true of the $schema array, not of db_verify: explicit probe-then-MODIFY blocks are a long-standing idiom in that file (api/system/db_verify.php:117-169 relaxes users.email and emails.from_address; there are five older precedents). The real rule is directional β€” relaxing a constraint is safe on the upgrade path because every existing row already satisfies it; tightening is not, because it can fail on live data and then silently diverge between fresh and upgraded installs.

Schema

All nullable, all added to both database/freeitsm.sql and the $schema array β€” which lives in includes/db_verify_schema.php (api/system/db_verify.php only requires it):

ldap_host, ldap_port, ldap_encryption            -- none | starttls | ldaps
ldap_bind_dn, ldap_bind_password                 -- service account; password encrypted at rest
ldap_base_dn, ldap_user_filter                   -- %s = what the user typed
ldap_attr_username, ldap_attr_email,
ldap_attr_name, ldap_attr_guid                   -- attribute mapping
ldap_group_base_dn, ldap_group_filter,           -- %s = the user's DN
ldap_analyst_group, ldap_user_group              -- the access gate

ldap_bind_password goes through encryptValue() / decryptValue() (AES-256-GCM), same as OIDC's client_secret. ldapGetProvider() decrypts on read. Neither ever leaves the server: the list endpoint exposes only has_secret / has_bind_password booleans.


The authentication flow

ldapAuthenticate($provider, $login, $password) β€” the two-step bind:

guard: empty/NUL password? -> reject BEFORE touching the network
guard: empty login?        -> reject BEFORE touching the network
ldapOpen()              -> connect, protocol v3, referrals OFF, timeouts, STARTTLS
ldapBindService()       -> bind as the read-only service account
ldapFindUser()          -> search; exactly one match or it's an error
ldap_bind($userDn, $pw) -> THE password check
ldapBindService()       -> re-bind as service to read groups
ldapUserGroups()        -> the groups they're in

ldapOpen()'s connect and read timeouts are what keep a dead domain controller from hanging the login page.

Returns ['ok' => bool, 'reason' => 'credentials'|'config'|'notfound', 'user' => [...]].

reason exists so callers can choose what to reveal: the login form says "invalid username or password" for everything, while the admin Test button shows the detail. Don't leak config errors to the login form.


The traps

1. Empty password = unauthenticated bind = log in as anyone

The single most dangerous thing here. RFC 4513 defines a bind with a DN but an empty password as an unauthenticated bind, and directories are permitted to answer it with success. A naive implementation:

if (ldap_bind($ds, $userDn, $password)) { /* logged in! */ }

...lets anyone in as anyone by leaving the password box blank.

// includes/ldap.php β€” never remove this, never rely on the server
if ($password === '') {
    return ['ok' => false, 'reason' => 'credentials', 'error' => 'A password is required.'];
}

Both test rigs REJECT an empty bind, so no end-to-end test against a directory proves this guard exists β€” delete the guard and a rig-based suite still passes.

It is covered by tests/ldap/01_empty_password_guard.php, which uses no directory at all. It points the provider at 192.0.2.1 (TEST-NET-1, guaranteed unroutable) and asserts on timing: under a millisecond means no socket was opened. Speed is the assertion. Verified able to fail by replacing the guard with if (false) β€” 6 assertions drop.

⚠️ This page previously claimed such a test existed when it did not. If you find yourself writing "this is covered by a test", go and look.

The guard also refuses a password containing a NUL byte. libldap takes a NUL-terminated C string, so "\0" or "\0hunter2" is truncated to "" at the library boundary β€” past a === '' check in PHP, and straight into the unauthenticated bind. Whitespace is deliberately not trimmed: " " has non-zero length, the directory checks it properly, and trimming would refuse a password some directory might legitimately hold. The test asserts that too, so nobody adds a "helpful" trim().

login.php also rejects empty input independently β€” two layers, on purpose.

2. "No such object" means "you can't read that", not "it isn't there"

OpenLDAP's default ACL ends by * none. A service account without an explicit read grant sees an empty directory and searches return No such object β€” not a permission error. This sends people hunting for a wrong base DN when the real problem is ACLs. ldapFindUser() deliberately says so in its error text.

Grant read to the service account, but not on userPassword β€” it never needs to see hashes.

3. memberOf shows DIRECT groups only

On AD, a user in IT-Support, which is itself a member of All-Staff, has only IT-Support in memberOf. Gate on All-Staff using memberOf and they're wrongly denied.

So groups are resolved by asking the groups who their members are, not by reading memberOf off the user:

AD:       (&(objectClass=group)(member:1.2.840.113556.1.4.1941:=%s))   <- LDAP_MATCHING_RULE_IN_CHAIN
OpenLDAP: (&(objectClass=groupOfNames)(member=%s))

1.2.840.113556.1.4.1941 is AD-only and walks the nesting. OpenLDAP has no equivalent, and no memberOf at all without the memberof overlay loaded β€” hence the different preset.

4. AD returns referrals

Subtree searches come back with ref: entries pointing at other naming contexts. Chasing them makes searches fail or hang. LDAP_OPT_REFERRALS, 0 is mandatory, not tuning.

5. objectGUID is binary; entryUUID is text

The identity link (analyst_sso_identities.subject) must be an immutable id β€” a DN changes on rename or OU move and would orphan the account. AD's objectGUID is raw binary, OpenLDAP's entryUUID is a readable string. ldapStringifyId() hex-encodes anything that isn't clean printable UTF-8, so both end up storable and comparable:

AD:        025812afe3a1324290a9b8b528280eb1      (hex-encoded binary)
OpenLDAP:  2ed219c2-1581-1041-9ec0-edc15902e9de  (as-is)

Also: ldap_get_entries() lowercases attribute names. Read them via ldapAttr(), which lowercases the key β€” don't index $entry['objectGUID'].

6. Filter injection

%s substitution happens after ldap_escape($v, '', LDAP_ESCAPE_FILTER). Substituting first would let * or )(|(uid=* rewrite the filter. Use str_replace, not sprintf β€” a filter may legitimately contain several %s.

7. Password expiry must not apply

A directory-backed analyst has an unusable random local hash. Letting local password expiry catch them sends them to force_password_change.php to change a password that doesn't exist. login.php sets $skipPasswordExpiry when a directory did the authenticating.


The access gate

ldapAccessRole($provider, $ldapUser) returns 'analyst' | 'user' | null.

both group fields blank  -> 'analyst'   (gate off; single-team default)
in ldap_analyst_group    -> 'analyst'   (wins if in both β€” the more capable role)
in ldap_user_group       -> 'user'
otherwise                -> null        -> refused

It fails closed. If the group read throws, the group list is empty, and empty never matches a configured gate. ldapAuthenticate() swallows group-read errors precisely so the gate β€” not an exception path β€” makes the decision. Never "helpfully" default to granting on error.

This gate is what makes JIT safe: without it, auto_create_users turns every employee in the directory into an analyst. It runs before provisioning, so a denied user leaves no trace in the database.


The Docker test rigs

Two directories, because building against one silently produces code shaped like that one. Both live in the repo at docker/ldap-test/ β€” compose file, seed scripts and a README with every credential:

docker compose -f docker/ldap-test/docker-compose.yml up -d

# Samba AD
bash docker/ldap-test/seed-ad.sh && bash docker/ldap-test/seed-ad-company.sh

⚠️ Those two lines seed Samba only. OpenLDAP is seeded separately with seed.ldif and acl.ldif (copied in, then ldapadd / ldapmodify) β€” the exact commands are in docker/ldap-test/README.md. Miss the ACL half and you get the "No such object" behaviour described in trap 3, which looks like a missing user rather than a permissions problem.

openldap:   osixia/openldap:1.5.0        # 3890 (ldap)                dc=freeitsm,dc=test
samba-ad:   nowsci/samba-domain:latest   # 3891 (ldap), 6361 (ldaps)  DC=ad,DC=freeitsm,DC=test
                                         # privileged: true, INSECURELDAP=true, NOCOMPLEXITY=true
phpldapadmin:                            # 8091 β€” browse either tree in a UI

NOCOMPLEXITY=true disables AD's password-complexity policy so the seed's fixture passwords are accepted; 6361 is there for testing LDAPS rather than plain 389.

Samba AD DC is the important one β€” it gives you real AD semantics (sAMAccountName, memberOf, nested groups, referrals, binary objectGUID, disabled-account refusal) without a Windows Server. INSECURELDAP=true relaxes ldap server require strong auth so simple binds work over plain 389 in testing.

The AD rig is seeded with a realistic company ("Northwind Trading") rather than flat users, because the awkward cases only exist in a realistic tree:

  • people in nested OUs (OU=IT,OU=Staff,OU=Northwind)
  • an account with no email (w.noemail) β†’ JIT must provision them anyway. She is in NW-Sales, the self-service group, so she also exercises the whole portal journey: sign in by username β†’ account created β†’ raise a ticket with no sender address. Until she was added to that group she belonged to none, which only ever exercised the gate-off path β€” and that is precisely why the gap between "can sign in" and "can raise a ticket" went unnoticed
  • a disabled leaver β†’ AD refuses the bind even with the right password
  • Siobhan O'Connor, JΓΌrgen MΓΌller, LucΓ­a GarcΓ­a β†’ apostrophes and UTF-8
  • NW-All-Staff containing the department groups, not people β†’ nested-group gating

Run against both rigs before shipping a change. If a change passes on OpenLDAP but you didn't try AD, you have tested nothing about nested groups, sAMAccountName or referrals.


Portal directory sign-in

πŸ“Š The end-to-end decision β€” including the OIDC-rejection branch and the two moments the routing happens β€” is drawn as a flowchart on LDAP & Active Directory Β§ How FreeITSM decides.

Where the two logins DIFFER

They answer the same four questions, but not with the same answers:

Analyst (login.php) Portal (api/self-service/login.php)
Account looked up by analysts.username only (:176) users.email or users.username
Providers tried for an unknown user every global one (:239, ldapAnalystProviders) scoped (ldapPortalProviders) β€” see below
Roles accepted 'analyst' only (:255) 'analyst' and 'user' β€” refuses only null
TOTP after a directory bind enforced (:299) enforced (fixed in #905 β€” it was skipped)
Company routing in the first box never multi-company only

⚠️ The analyst lookup is by username only. An LDAP-pinned analyst who types their email into the username field misses that lookup entirely and falls through to branch (c), which tries every provider rather than the one they are pinned to. It still works, but not for the reason you would expect.

The portal twin of the analyst path. Same includes/ldap.php authentication layer β€” ldapAuthenticate(), ldapAccessRole(), ldapUserGroups(), ldapStringifyId() are all portal-agnostic and reused unchanged. Two functions are new.

Function Analyst equivalent Why it isn't shared
ldapPortalProviders(PDO, ?string $identifier) ldapAnalystProviders(PDO) The analyst version hard-filters tenant_id IS NULL; the portal must reach company-owned directories β€” but not all of them at once (see below)
ldapResolveUser(PDO, array, array) ldapResolveAnalyst(...) Targets users, not analysts. Three deliberate behavioural differences

πŸ”΄ Why the candidate list is SCOPED

The analyst login tries every enabled LDAP provider for an unknown user. That is safe there: analyst directories are global and there is one set of them. Copying it to the portal would be a security bug that is invisible at N=1:

  1. A password typed for one employer gets offered to every other client company's domain controller.
  2. Every failed bind increments that directory's AD lockout counter β€” so one person mistyping their password could lock accounts belonging to a different company.

So:

Install Candidates tried
Single-company Every enabled LDAP provider (no boundary exists to cross)
Multi-company, identifier has a known domain That company's directories + global ones
Multi-company, bare username or unknown domain Global only

The three divergences in ldapResolveUser()

  1. πŸ”‘ No mailbox is NULL, never ''. The analyst side stores '' (#872) and gets away with it only because analysts.email is not unique. users.email IS unique, so the second mailbox-less person collides with the first. MySQL permits many NULLs in a unique index and exactly one empty string β€” so NULL is the representation that scales. Anything writing '' re-introduces the bug.

  2. πŸ”‘ An unclaimed existing account is CLAIMED, not refused. ldapResolveAnalyst() rejects a local-only match as strict isolation. The portal cannot: users rows are auto-created without a password all over the product β€” inbound email, web chat, WhatsApp, workflows, the API β€” so someone signing in for the first time usually already has a row holding their ticket history. Refusing would strand them beside their own tickets. Matches what portal OIDC already does. An account belonging to a different provider is still refused.

  3. πŸ”‘ Company comes from the provider first. resolveTenantForNewUser() works off the email domain, which returns nothing for someone with no address β€” precisely the people this exists for. A company-owned directory vouches for its people; the domain is the fallback.

A blank address also changes matching: the match-by-email step is skipped entirely for a directory entry with no address β€” otherwise the second mailbox-less starter would silently match (and take over) the first. That's safe because the identity link is the immutable directory id, never the email.

Both groups may use the portal

ldapAccessRole() returns 'analyst' for anyone in both groups, so testing for 'user' alone would lock out exactly the dual-membership people most likely to try. The portal refuses only null. An analyst raising their own ticket is a normal thing.

What the schema change touched

Column Was Now Why
users.username β€” VARCHAR(50) NULL, UNIQUE What they type. Nullable because local accounts have none; UNIQUE so two directory users can't share one
users.email NOT NULL UNIQUE NULL UNIQUE They may genuinely have none
emails.from_address NOT NULL NULL The one that made this more than a column. A mailbox-less requester could sign in and then fail to raise a ticket at all

⚠️ Deliberately not a synthesised address (w.noemail@company.local). The WhatsApp channel does exactly that β€” and its comment says it only does so because users.email was NOT NULL, the constraint this change removed. A fake address is indistinguishable from a real one, so an analyst replies to it and the reply bounces into nowhere.

Two things that broke elsewhere, both worth remembering

  • πŸ”΄ The portal privacy policy was silently voided. portalEmailInvolvesRequester() failed open on a blank requester address β€” right when blank meant "unknown", wrong once it can mean "has none", because then no email can ever have been addressed to them and every message on their ticket is third-party by definition. NULL and '' are now distinguished, and callers must preserve the difference rather than casting it away.
  • πŸ”΄ Reply had no address validation at all. null assigned to an <input>.value becomes the literal string "null", which is not empty, so it passed empty($to) and reached the mail provider. Guard client-side and validate server-side. send_email.php now validates the recipient β€” nothing on that path ever did.

Related, on the rendering side: one senderLabel() helper in inbox.js builds the sender label for the list, reading pane, thread, search and attachments, so nobody renders Wendy Warehouse <> β€” three further sites built the label by string concatenation and would have printed the literal word null. Reply pre-fills from the requester's address when there is one, and otherwise leaves the box empty and says a note is how you reach someone with no mailbox.


Known gaps / next

  • βœ… Self-service portal β€” DONE (#902). api/self-service/login.php branches on the same four outcomes as the analyst login β€” pinned to a directory β†’ bind, pinned to OIDC β†’ rejected with "use single sign-on", ordinary account β†’ password_verify, unknown β†’ ask the directories and JIT β€” but not identically; see the divergence table in Portal directory sign-in.
  • ⚠️ A bare username on a MULTI-COMPANY install can only be matched against a global directory. There is nothing in w.noemail to say which company they belong to, and guessing would mean trying their password against every client's directory. Fixing it properly needs a company hint the portal doesn't collect. Single-company installs are unaffected.
  • Per-group permission sets (gdoring's "read-only, and so on" in #47) β€” today groups pick which kind of account you get, not a role. Mapping a group onto an RBAC role is the natural follow-on; see Roles & Permissions.
  • Directory sync β€” deliberately out of scope. JIT-on-login only; nobody is created before they first sign in, and nobody is deactivated in FreeITSM when the directory disables them (they simply can't sign in). Full sync is a much bigger feature; see the non-goals in docs/design/sso-multi-tenant.md.
  • ldapCreateAnalyst() mirrors oidcCreateAnalyst() in api/auth/oidc_callback.php rather than sharing with it. Folding both into one provisioning helper is worthwhile β€” but it's a refactor of working auth code and deserves its own change.

See also

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Blue sky thinking

Bugs resolved

Links

Clone this wiki locally