Enterprise Drivers
LDAP Driver
Log users in against LDAP and Active Directory, resolve their groups, and search and change the directory from your Kipchak API.
Introduction
Note: This driver is only available with Kipchak Enterprise.
An LDAP and Active Directory driver for Kipchak: log users in against the corporate directory, resolve their groups, and search and change the directory from your Kipchak API.
- Authentication:
authenticate()checks a username and password by binding as the user, and says why a login was refused (wrong password, disabled, locked, expired, must change password) without ever confusing an outage with a wrong password. - Groups: the user's groups, including nested ones (Active Directory's in-chain matching rule; followed level by level on OpenLDAP), membership checks, and group members.
- Search: filters built with every value escaped, paging through large result sets, single-entry reads.
- Writes and passwords: add, modify, rename and delete entries; password changes and administrator resets (Active Directory's
unicodePwdand the LDAP password-modify operation). - Production settings: LDAPS and StartTLS with certificate verification, CA files and client certificates, failover across several servers, short timeouts, and connections that survive FrankenPHP's long-lived workers (idle connections are replaced; a connection lost to a directory restart is retried once on a new one).
- Caching: user and group lookups can be cached in worker memory, with the Filecache driver, or with the Valkey driver. Passwords and login results are never cached.
Tested against OpenLDAP 2.6 and Active Directory (as reproduced by a Samba 4 domain controller).
Installation
composer require kipchak/driver-ldap
The driver needs the ldap PHP extension. The Kipchak Enterprise runtime image includes it; elsewhere, add it to your image (for example install-php-extensions ldap).
Initialise it in drivers/drivers.php, after the Logger driver (and after Filecache or Valkey, if you cache lookups with them):
use Kipchak\Driver\LDAP\LDAP;
LDAP::initialise($container);
Configuration
Create config/kipchak.ldap.php (the package ships a full sample.config.php):
return [
'connections' => [
'default' => [
'type' => 'active_directory',
'hosts' => ['ldaps://dc1.corp.example.com', 'ldaps://dc2.corp.example.com'],
'base_dn' => 'DC=corp,DC=example,DC=com',
'bind_dn' => env('LDAP_BIND_DN', ''),
'bind_password' => env('LDAP_BIND_PASSWORD', ''),
'users' => [
'attributes' => ['cn', 'displayName', 'mail', 'sAMAccountName', 'userPrincipalName'],
],
],
],
];
| Key | Default | Meaning |
|---|---|---|
type | openldap | active_directory or openldap. Sets the defaults for finding users and groups (below). |
hosts | required | One or more ldap:// or ldaps:// URIs, tried in order |
encryption | from hosts | ldaps for ldaps:// hosts, otherwise starttls. none sends passwords in the clear and must be asked for. |
base_dn | required | Where searches start |
bind_dn, bind_password | anonymous | The service account searches and writes run as. A DN with an empty password is refused. |
tls.ca | system CAs | CA file to verify the server's certificate |
tls.verify | true | Set false only for local development |
tls.cert, tls.key | none | Client certificate and key |
timeout | 5 | Seconds to connect |
operation_timeout | 10 | Seconds per operation |
idle_timeout | 300 | Replace a connection idle for longer than this. Active Directory drops idle connections after 900 seconds. |
page_size | 500 | Entries per page for large searches |
users.base_dn | base_dn | Where users are searched for |
users.filter | by type | Which entries are users |
users.username_attributes | by type | Attributes a username may match |
users.attributes | all | Attributes returned for users |
groups.base_dn | base_dn | Where groups are searched for |
groups.filter | by type | Which entries are groups |
groups.member_attributes | by type | Group attributes that list member DNs |
groups.name_attribute | cn | The attribute that names a group |
groups.nested | true | Follow groups within groups |
cache | none | memory, file (the Filecache driver) or a container key such as drivers.cache.valkey.cache |
cache_ttl | 300 | Seconds to cache user and group lookups |
The defaults by type:
| Active Directory | OpenLDAP | |
|---|---|---|
| Users | (&(objectCategory=person)(objectClass=user)) | (objectClass=inetOrgPerson) |
| Username | sAMAccountName or userPrincipalName | uid |
| Groups | (objectClass=group) | groupOfNames or groupOfUniqueNames |
| Members | member | member, uniqueMember |
Add more entries under connections for more directories, and fetch them by name: LDAP::get('partners').
Usage
Logging users in
use Kipchak\Driver\LDAP\LDAP;
use Kipchak\Driver\LDAP\Exception\LDAPConnectionException;
try {
$result = LDAP::get()->authenticate($username, $password);
} catch (LDAPConnectionException $e) {
// The directory is down or refused the service account: an outage, not a wrong password.
return Http\Response::json($response, ['error' => 'The directory is unavailable'], 503);
}
if (!$result->succeeded()) {
$this->logger->info('Login refused', ['reason' => $result->failure->value]);
return Http\Response::json($response, ['error' => 'Invalid credentials'], 401);
}
$user = $result->user; // the user's entry, with the connection's users.attributes
authenticate() finds the user with the service account, then binds as the user on a separate connection, so the service connection never changes identity. Usernames are escaped before they reach the filter, so a username of * matches nobody rather than everyone.
A refusal carries the reason in $result->failure:
AuthFailure | When |
|---|---|
EmptyPassword | The password was empty. Never sent to the directory, which would accept it as an "unauthenticated bind". |
UserNotFound, AmbiguousUser | No user, or more than one, has that username |
InvalidCredentials | Wrong password (and every refusal OpenLDAP gives) |
AccountDisabled, AccountExpired, AccountLocked | Active Directory account states |
PasswordExpired, PasswordMustChange | Active Directory password states |
LogonHoursRestricted, WorkstationRestricted | Active Directory logon restrictions |
Log the reason, but tell the client no more than "invalid credentials": naming it reveals which usernames exist.
Groups
$directory = LDAP::get();
$directory->groupNames('alice'); // ['developers', 'engineering']
$directory->groupNames('alice', nested: false); // ['developers']
$directory->isMemberOf($result->user, 'engineering'); // by name, case-insensitive
$directory->isMemberOf('alice', 'CN=Engineering,OU=Groups,DC=corp,DC=example,DC=com'); // or by DN
$directory->groups('alice', attributes: ['description']); // group entries
$directory->members('CN=Engineering,OU=Groups,DC=corp,DC=example,DC=com'); // users, nested too
On Active Directory, nested groups are resolved by the server in one search (LDAP_MATCHING_RULE_IN_CHAIN). On OpenLDAP they are followed level by level, with cycles detected. Active Directory's primary group (usually Domain Users) is not listed in member, so it does not appear.
Searching
Build filters with Filter, which escapes every value:
use Kipchak\Driver\LDAP\Query\Filter;
use Kipchak\Driver\LDAP\Query\Scope;
$people = $directory->search(
Filter::and('(objectClass=person)', Filter::startsWith('mail', $prefix)),
'OU=Staff,DC=corp,DC=example,DC=com',
['cn', 'mail'],
);
foreach ($people as $entry) {
$entry->dn;
$entry->get('mail'); // first value, or null
$entry->all('memberOf'); // every value
}
$directory->first(Filter::equals('mail', $email));
$directory->read($dn, ['cn']); // one entry by DN, or null
$directory->search('(objectClass=organizationalUnit)', scope: Scope::OneLevel);
foreach ($directory->each('(objectClass=user)') as $entry) { /* one page in memory at a time */ }
Filter also has present(), endsWith(), contains(), greaterOrEqual(), lessOrEqual(), or(), not(), matchingRule() and equalsBinary(). Dn::escape() and Dn::make() build DNs safely.
Searches page through results automatically. If the server's own size limit still cuts a search short, it throws rather than returning a partial list.
Active Directory attributes
use Kipchak\Driver\LDAP\Support\ActiveDirectory;
ActiveDirectory::guidToString($user->get('objectGUID')); // '0a1b2c3d-4e5f-...'
ActiveDirectory::sidToString($user->get('objectSid')); // 'S-1-5-21-...'
ActiveDirectory::fileTimeToDateTime($user->get('pwdLastSet'));
ActiveDirectory::isDisabled($user); // needs userAccountControl
$directory->first(Filter::equalsBinary('objectGUID', ActiveDirectory::guidToBytes($guid)));
Writing
The service account needs the rights in the directory.
use Kipchak\Driver\LDAP\Query\Dn;
use Kipchak\Driver\LDAP\Request\Modification;
$dn = Dn::make('uid', 'jdoe', 'ou=people,dc=example,dc=org');
$directory->add($dn, ['objectClass' => 'inetOrgPerson', 'uid' => 'jdoe', 'cn' => 'Jane Doe', 'sn' => 'Doe']);
$directory->modify($dn,
Modification::replace('mail', 'jane@example.org'),
Modification::add('description', 'Contractor'),
);
$directory->rename($dn, 'uid=jane.doe');
$directory->delete('uid=jane.doe,ou=people,dc=example,dc=org');
A modify() applies all its changes or none. Writes through the client clear its cache.
Passwords
// The user changes their own password, proving the current one.
$result = $directory->changePassword('alice', $current, $new); // AuthFailure::InvalidCredentials if wrong
// An administrator sets one.
$directory->resetPassword('alice', $new);
On Active Directory both need an encrypted connection, and changePassword() also works for accounts that must change their password at next login, which cannot log in until they do. A password the directory's policy rejects throws an LDAPException with resultCode 19.
Errors
LDAPConnectionException: the directory could not be reached, the TLS handshake failed, or it refused the service account. For logins, answer 503.LDAPException: the directory refused an operation.resultCodeis the LDAP result code (32 no such object, 50 insufficient access, 68 already exists, ...) anddiagnosticthe server's message.
Diagnostics
$directory->health(); // whether the directory answers; never throws
$directory->whoami(); // 'dn:cn=kipchak,...' or 'u:CORP\svc-kipchak'
$directory->rootDse(); // naming contexts, supported controls and extensions
FrankenPHP worker mode
One client per connection is kept for the life of each worker, with two links: one bound as the service account, one for checking passwords. A link idle for longer than idle_timeout is replaced before use, and an operation that fails because the directory went away is retried once on a new link (the retry is logged as a warning through the Logger driver).
libldap's TLS set-up is not safe when several threads connect at once, so the driver opens encrypted links one at a time across the process, using a lock file in the system temporary directory. Links are opened rarely, so the wait is short, but an unreachable server holds the lock for up to timeout seconds.
ldaps:// connections use libldap's process-wide TLS settings, which the driver sets, under that lock, as each link opens. StartTLS connections keep their own.