-
Notifications
You must be signed in to change notification settings - Fork 15
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.
| 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) |
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('')returnstrue(includes/encryption.php:105), so a blank secret means "keep what's stored" β on an OIDC save the old encryptedldap_bind_passwordsurvives, 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_urlandclient_idareNOT 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$schemaarray, not of db_verify: explicit probe-then-MODIFYblocks are a long-standing idiom in that file (api/system/db_verify.php:117-169relaxesusers.emailandemails.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.
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.
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 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 at192.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 withif (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.
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.
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.
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.
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'].
%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.
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.
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.
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.shseed.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 UINOCOMPLEXITY=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 inNW-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-Staffcontaining 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.
π 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.
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 |
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:
- A password typed for one employer gets offered to every other client company's domain controller.
- 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 |
-
π No mailbox is
NULL, never''. The analyst side stores''(#872) and gets away with it only becauseanalysts.emailis not unique.users.emailIS 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. -
π An unclaimed existing account is CLAIMED, not refused.
ldapResolveAnalyst()rejects a local-only match as strict isolation. The portal cannot:usersrows 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. -
π 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.
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.
| 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 becauseusers.emailwas 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.
- π΄ 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.NULLand''are now distinguished, and callers must preserve the difference rather than casting it away. - π΄ Reply had no address validation at all.
nullassigned to an<input>.valuebecomes the literal string"null", which is not empty, so it passedempty($to)and reached the mail provider. Guard client-side and validate server-side.send_email.phpnow 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.
- β
Self-service portal β DONE (#902).
api/self-service/login.phpbranches 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 inw.noemailto 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()mirrorsoidcCreateAnalyst()inapi/auth/oidc_callback.phprather 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.
- LDAP & Active Directory β the admin-facing guide
- Setting up OpenLDAP & Samba AD in Docker β the rigs, step by step, with the seeded company
- Single Sign-On (SSO) Β· Admin Access Control
FreeITSM β an open-source IT Service Management platform Β· github.com/edmozley/freeitsm Β· MIT licence
- Installation
- β° Scheduled tasks (cron jobs)
- Architecture
- AI Providers
- Internationalisation (i18n)
- Timezones & Time Handling
- Theming & Dark Mode
- β¨οΈ Command palette (βK)
- π Searching inside tickets
- π Attached documents
- MobileβFriendly
-
Security
- Layer 1 β which modules you can enter
- β³ π§© Module Access Control
- β³ π οΈ Module Access β Developer Guide
- Layer 2 β what you can administer
- β³ π Roles & Permissions
- β³ π οΈ Roles β Developer Guide
- β³ π€ Why capabilities are constants
- Layer 3 β the System module
- β³ π Admin Access Control
- Hardening
- β³ π Security review response 2026-08
- β³ π‘οΈ Security hardening 2026-08
- β³ π οΈ Security hardening 2026-08 β Developer Guide
- β³ π‘οΈ Round three β plain English
- β³ π οΈ Round three β Developer Guide
- Single Sign-On (SSO)
- ποΈ LDAP & Active Directory
- Browser Extension
- API Reference
-
π REST API β how it works
- β³ π« REST API: Tickets
- β³ π» REST API: Assets
- β³ π΄ REST API: Problems
- β³ π REST API: Changes
- β³ π REST API: Knowledge
- β³ β REST API: Tasks
- β³ ποΈ REST API: CMDB
- β³ π REST API: Contracts
- β³ ποΈ REST API: Calendar
- β³ πΏ REST API: Software
- β³ π¦ REST API: Service Status
- β³ βοΈ REST API: Morning Checks
- β³ π REST API: Forms
- β³ βοΈ REST API: Workflow
- β³ πΊοΈ REST API: Network Mapper
- β³ π§ Using the API docs page
- β³ π OpenAPI specification
- β³ β OpenAPI: kept correct
- β³ π οΈ Maintaining the catalogue
- Watchtower
-
Tickets
- β³ Mailbox Authentication
- β³ π€ Email send log
- β³ Basic IMAP mailboxes
- β³ Email rendering & images
- β³ SLA Management
- β³ WhatsApp channel
- β³ π¬ Web chat channel
- β³ π£ Slack channel
- β³ π Linking tickets
- β³ ποΈ Canned responses
- β³ βοΈ Limiting replies to particular senders
- β³ βοΈ Email signatures
- β³ π The public web address
- β³ π’ Ticket numbering
- β³ π Raising a ticket for someone else
- β³ π Merging tickets
- β³ β Splitting tickets
- β³ β Selecting several tickets
- β³ π οΈ Snoozing tickets β Developer Guide
- β³ π₯ Collision detection
- β³ β±οΈ Time tracking
- Problem Management
- Tasks
- Assets
- Knowledge
- Change Management
- Calendar
- Morning Checks
- Reporting
- Software
- Forms
- Contracts
- Service Status
- π Notifications
- π¨ War Room
- Self-Service Portal
- LMS
- Process Mapper
- CMDB
- Network Mapper
- Workflows
- Issue trackers (Jira, Azure DevOps)
- System
-
Overview
- β³ π Progress tracker
- β³ Concepts & vocabulary
- β³ Email routing & mailboxes
- β³ Settings: global vs per-company
- β³ Users & self-service
- β³ Staff cross-company access
- β³ Worked examples
- β³ Pitfalls & gotchas
- β³ Scope: what it's for
- β³ π οΈ Developer Guide (make a module multi-company)
- β³ ποΈ Case study: CMDB (a linked graph)
- β³ π§ͺ Test harness (prove it's isolated)