-
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.
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.
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.
- β
Self-service portal β DONE (#902).
api/self-service/login.phpnow branches exactly like the analyst login: pinned β bind, local βpassword_verify, unknown β ask the directories and JIT. See Portal directory sign-in below. β οΈ 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
- β³ π 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)