Skip to content

LDAP Server

Many organisations keep their users in an LDAP directory such as OpenLDAP or Microsoft Active Directory. Stalwart can use that directory as the source of its accounts and passwords. Users then sign in to mail with the credentials they already use elsewhere, and the server accepts mail for the addresses recorded in the directory. Stalwart works with directories that implement RFC 4511.

The server queries the directory at two moments: when a user signs in, and when mail arrives for an address in a domain the directory serves. For each entry it finds, it keeps a local account (Directories).

To connect a directory, create a Directory object of type Ldap (in the WebUI under Settings › Authentication › Directories). Then set its id as directoryId on the Authentication singleton or, in the Enterprise edition, on the domains the directory serves.

The server does not copy accounts from the directory in advance. It creates a local account the first time it needs one: when the user signs in, or when a message arrives for an address and the directory returns the entry. An account can therefore receive mail before its user has ever signed in.

Each later lookup updates the local account from the directory. The description and group memberships are refreshed, and any new aliases are added. Aliases removed from the directory are kept, however.

SCIM changes this behaviour. In a domain opened to SCIM provisioning, the directory neither creates nor updates accounts. A sign-in by an account that SCIM has not provisioned then fails with Accounts in this domain are provisioned through SCIM, just-in-time provisioning is disabled.

Nothing is ever removed automatically. An entry deleted from the directory stops appearing in query results, so mail for its address is no longer accepted. The server cannot observe the deletion itself, however. The local account and its mailbox therefore remain, and keep counting against the licensed account limit, until an administrator deletes them. For the identity provider to delete and suspend accounts explicitly, enable SCIM provisioning for the domain. Provisioning models describes how the two interact.

url is the address of the directory server. Its scheme decides how the connection is encrypted:

  • With an ldap:// URL, the connection starts unencrypted. Enabling useTls upgrades it with STARTTLS before anything is sent.
  • With an ldaps:// URL, conventionally on port 636, the connection uses TLS from the start, so useTls has no effect.

Use one of the two whenever the directory is not on the same host. Both authentication modes send secrets over this connection: in password-comparison mode the directory sends password hashes, and in bind mode the server sends the user’s password. allowInvalidCerts accepts certificates that fail validation and is meant for testing only.

timeout bounds both the establishment of a connection and each LDAP operation. It is 30 seconds by default. When several servers are configured (see Secondary servers), a connection attempt can take that long for each server before it fails.

The server does not open a new connection for every request. It keeps a pool of connections to the directory, and poolMaxConnections caps the number of simultaneous connections. Three timeouts bound the steps of handing out a connection from the pool:

Occasional reconnects by the directory do not cause failed sign-ins. When a login fails because a pooled connection was dropped or had gone stale, the server retries once on a fresh connection before reporting the failure.

A directory often runs on several replicated servers, so that sign-ins keep working when one of them is down. List the backups in urlFailover. When the server in url cannot be reached, the backups are tried in order, and the first one that accepts a connection becomes the active server.

The server returns to the primary as soon as it can. While a backup is active, the primary is probed at most once a minute, and the server switches back as soon as the primary responds. Each switch is logged as a store.ldap-warning event with the message Switched active LDAP server. A transport error during an operation is retried once on a new connection, as it is without failover.

The following settings make ldap1 the primary server and ldap2 and ldap3 its backups:

{
"url": "ldaps://ldap1.example.org",
"urlFailover": {"ldaps://ldap2.example.org": true, "ldaps://ldap3.example.org": true}
}

The server needs its own account in the directory to search it. This service account is set in bindDn and bindSecret. Every search starts at baseDn, for example dc=example,dc=org, and the service account needs read access to the user and group entries under it.

The service account is needed in both authentication modes, because recipient lookups and group resolution always use it. When bindDn is empty, the server searches anonymously, which most directories do not allow.

bindSecret can hold the password itself, or read it from an environment variable or a file, which keeps the password out of the configuration. The following settings read it from the LDAP_BIND_PASSWORD environment variable:

{
"baseDn": "dc=example,dc=org",
"bindDn": "cn=stalwart,ou=services,dc=example,dc=org",
"bindSecret": {"@type": "EnvironmentVariable", "variableName": "LDAP_BIND_PASSWORD"}
}

A password can be checked in two places: by the directory, or by the server against the hash stored in the directory. bindAuthentication chooses between them. Bind authentication is enabled by default.

With bind authentication, the directory decides whether the password is correct. The server uses the service account to search for the user’s entry with filterLogin. It then binds to the directory as that entry, with the password the user supplied. Because the directory performs the check, this mode works with directories that do not reveal password hashes, which includes Active Directory and most hardened OpenLDAP installations.

Bind mode has one drawback. Since no hash is read, the server cannot tell from the password itself that it has changed. It detects a change through attrSecretChanged, an attribute whose value changes whenever the password does, and then signs out the user’s OAuth applications. If the attribute is not configured, or not present on the directory entries, OAuth tokens stay valid after a password change in the directory.

When bindAuthentication is set to false, the server uses password comparison instead. It reads the password hash with the service account, from the attribute named in attrSecret, and verifies the supplied password against it. Two conditions follow: the hash must be in one of the supported formats, and the service account must be allowed to read it.

In both modes, a wrong password is reported as invalid credentials. Sign-in failures do not reveal whether the account exists in the directory.

The server locates entries with three LDAP search filters. Each filter contains placeholders, which the server replaces with the value being looked up after LDAP escaping:

  • {user} stands for the whole value. It can also be written {username}, {email} or, as in older configurations, ?.
  • {local} stands for the part before the @.
  • {domain} stands for the part after the @.

A filter without a placeholder fails to load with Missing parameter placeholders in value .... A filter with an unknown placeholder fails with Unknown LDAP filter placeholder: <name>.

Each filter serves one lookup:

  • filterLogin finds the entry for the name a user signs in with. By default it is (&(objectClass=inetOrgPerson)(mail=?)).
  • filterRecipient finds the user or group entry for a recipient address. By default it is (|(&(objectClass=inetOrgPerson)(|(mail=?)(mailAlias=?)))(&(objectClass=groupOfNames)(|(mail=?)(mailAlias=?)))).
  • filterMemberOf finds the groups of an entry, with ? replaced by the entry’s DN. By default it is (&(objectClass=groupOfNames)(member=?)). It is needed only when user entries do not carry their group memberships.

The defaults suit directories built on inetOrgPerson and groupOfNames. A directory that uses posixAccount and posixGroup entries needs other filters. The following filters find users by uid when they sign in, and users or groups by mail or mailAlias when mail arrives:

{
"filterLogin": "(&(objectClass=posixAccount)(uid=?))",
"filterRecipient": "(&(|(objectClass=posixAccount)(objectClass=posixGroup))(|(mail=?)(mailAlias=?)))"
}

Once an entry is found, the server reads the account’s information from its attributes. The following fields name those attributes. Each field is a set, so several attributes can be listed for it:

FieldInformationDefault
attrEmailPrimary addressmail
attrEmailAliasAliasesmailAlias
attrDescriptionDisplay namedescription
attrMemberOfGroups the entry belongs tomemberOf
attrClassObject class, to tell users from groupsobjectClass
attrSecretPassword hash, in password-comparison modeuserPassword
attrSecretChangedValue that changes with the password, in bind modepwdChangeTime

The server tells users from groups by their class. An entry whose class equals groupClass is a group, and any other entry is a user. groupClass is groupOfNames by default.

Quotas, roles and permissions are not read from the directory. They are set on the local account in Stalwart (Quotas).

Active Directory needs settings that differ from the defaults in three respects. It does not reveal password hashes, so bind authentication is required. It uses its own object classes. It also records group membership in memberOf.

The following object is a starting point for the domain example.org, where users sign in with their user principal name and receive mail at the address in mail. It keeps bind authentication, matches user and group entries, reads the display name from displayName and detects password changes through pwdLastSet:

{
"@type": "Ldap",
"description": "Active Directory",
"url": "ldaps://dc1.example.org",
"baseDn": "dc=example,dc=org",
"bindDn": "CN=Stalwart,OU=Service Accounts,DC=example,DC=org",
"bindSecret": {"@type": "EnvironmentVariable", "variableName": "LDAP_BIND_PASSWORD"},
"bindAuthentication": true,
"filterLogin": "(&(objectClass=user)(userPrincipalName=?))",
"filterRecipient": "(|(&(objectClass=user)(mail=?))(&(objectClass=group)(mail=?)))",
"filterMemberOf": "(&(objectClass=group)(member=?))",
"attrEmail": {"mail": true},
"attrDescription": {"displayName": true},
"attrMemberOf": {"memberOf": true},
"attrSecretChanged": {"pwdLastSet": true},
"groupClass": "group"
}