-
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.
| 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, so switching a provider's type can't leave stale settings quietly in force.
β οΈ issuer_urlandclient_idareNOT NULL, and LDAP rows store''in them. This looks like a wart; it isn't optional.db_verifyonly ever ADDS missing columns (db_verify.php:2614) β it neverMODIFYs an existing one. Relaxing those columns toNULLwould apply on fresh installs and silently not apply on upgraded ones, so LDAP rows would insert fine on one and fatal on the other. Empty string works identically on both.
All nullable, all added to both database/freeitsm.sql and the $schema array in api/system/db_verify.php:
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 password? -> 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
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 amount of local testing proves this guard exists. Delete it and every test still passes. It is covered by an explicit unit-style assertion that the function short-circuits before any network call.
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
bash docker/ldap-test/seed-ad.sh && bash docker/ldap-test/seed-ad-company.shopenldap: osixia/openldap:1.5.0 # port 3890 dc=freeitsm,dc=test
samba-ad: nowsci/samba-domain:latest # port 3891 DC=ad,DC=freeitsm,DC=test
# privileged: true, INSECURELDAP=trueSamba 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 β JIT must refuse cleanly
- 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.
-
Self-service portal.
ldap_user_groupresolves to'user', butapi/self-service/login.phpdoes not yet do an LDAP bind β so a user-group member is correctly refused analyst access but cannot yet sign in to the portal via LDAP either. That's the next slice. - 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
- 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
- β³ π 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)