4 * Retrieve identify information from LDAP accounts.
6 final class PhutilLDAPAuthAdapter
extends PhutilAuthAdapter
{
11 private $baseDistinguishedName;
12 private $searchAttributes = array();
13 private $usernameAttribute;
14 private $realNameAttributes = array();
15 private $ldapVersion = 3;
16 private $ldapReferrals;
17 private $ldapStartTLS;
18 private $anonymousUsername;
19 private $anonymousPassword;
20 private $activeDirectoryDomain;
21 private $alwaysSearch;
23 private $loginUsername;
24 private $loginPassword;
26 private $ldapUserData;
27 private $ldapConnection;
29 public function getAdapterType() {
33 public function setHostname($host) {
34 $this->hostname
= $host;
38 public function setPort($port) {
43 public function getAdapterDomain() {
47 public function setBaseDistinguishedName($base_distinguished_name) {
48 $this->baseDistinguishedName
= $base_distinguished_name;
52 public function setSearchAttributes(array $search_attributes) {
53 $this->searchAttributes
= $search_attributes;
57 public function setUsernameAttribute($username_attribute) {
58 $this->usernameAttribute
= $username_attribute;
62 public function setRealNameAttributes(array $attributes) {
63 $this->realNameAttributes
= $attributes;
67 public function setLDAPVersion($ldap_version) {
68 $this->ldapVersion
= $ldap_version;
72 public function setLDAPReferrals($ldap_referrals) {
73 $this->ldapReferrals
= $ldap_referrals;
77 public function setLDAPStartTLS($ldap_start_tls) {
78 $this->ldapStartTLS
= $ldap_start_tls;
82 public function setAnonymousUsername($anonymous_username) {
83 $this->anonymousUsername
= $anonymous_username;
87 public function setAnonymousPassword(
88 PhutilOpaqueEnvelope
$anonymous_password) {
89 $this->anonymousPassword
= $anonymous_password;
93 public function setLoginUsername($login_username) {
94 $this->loginUsername
= $login_username;
98 public function setLoginPassword(PhutilOpaqueEnvelope
$login_password) {
99 $this->loginPassword
= $login_password;
103 public function setActiveDirectoryDomain($domain) {
104 $this->activeDirectoryDomain
= $domain;
108 public function setAlwaysSearch($always_search) {
109 $this->alwaysSearch
= $always_search;
113 public function getAccountID() {
114 return $this->readLDAPRecordAccountID($this->getLDAPUserData());
117 public function getAccountName() {
118 return $this->readLDAPRecordAccountName($this->getLDAPUserData());
121 public function getAccountRealName() {
122 return $this->readLDAPRecordRealName($this->getLDAPUserData());
125 public function getAccountEmail() {
126 return $this->readLDAPRecordEmail($this->getLDAPUserData());
129 public function readLDAPRecordAccountID(array $record) {
130 $key = $this->usernameAttribute
;
132 $key = head($this->searchAttributes
);
134 return $this->readLDAPData($record, $key);
137 public function readLDAPRecordAccountName(array $record) {
138 return $this->readLDAPRecordAccountID($record);
141 public function readLDAPRecordRealName(array $record) {
143 foreach ($this->realNameAttributes
as $attribute) {
144 $parts[] = $this->readLDAPData($record, $attribute);
146 $parts = array_filter($parts);
149 return implode(' ', $parts);
155 public function readLDAPRecordEmail(array $record) {
156 return $this->readLDAPData($record, 'mail');
159 private function getLDAPUserData() {
160 if ($this->ldapUserData
=== null) {
161 $this->ldapUserData
= $this->loadLDAPUserData();
164 return $this->ldapUserData
;
167 private function readLDAPData(array $data, $key, $default = null) {
168 $list = idx($data, $key);
169 if ($list === null) {
170 // At least in some cases (and maybe in all cases) the results from
171 // ldap_search() are keyed in lowercase. If we missed on the first
172 // try, retry with a lowercase key.
173 $list = idx($data, phutil_utf8_strtolower($key));
176 // NOTE: In most cases, the property is an array, like:
180 // 0 => 'actual-value-we-want',
183 // However, in at least the case of 'dn', the property is a bare string.
185 if (is_scalar($list) && strlen($list)) {
187 } else if (is_array($list)) {
194 private function formatLDAPAttributeSearch($attribute, $login_user) {
195 // If the attribute contains the literal token "${login}", treat it as a
196 // query and substitute the user's login name for the token.
198 if (strpos($attribute, '${login}') !== false) {
199 $escaped_user = ldap_sprintf('%S', $login_user);
200 $attribute = str_replace('${login}', $escaped_user, $attribute);
204 // Otherwise, treat it as a simple attribute search.
212 private function loadLDAPUserData() {
213 $conn = $this->establishConnection();
215 $login_user = $this->loginUsername
;
216 $login_pass = $this->loginPassword
;
218 if ($this->shouldBindWithoutIdentity()) {
219 $distinguished_name = null;
220 $search_query = null;
221 foreach ($this->searchAttributes
as $attribute) {
222 $search_query = $this->formatLDAPAttributeSearch(
225 $record = $this->searchLDAPForRecord($search_query);
227 $distinguished_name = $this->readLDAPData($record, 'dn');
231 if ($distinguished_name === null) {
232 throw new PhutilAuthCredentialException();
235 $search_query = $this->formatLDAPAttributeSearch(
236 head($this->searchAttributes
),
238 if ($this->activeDirectoryDomain
) {
239 $distinguished_name = ldap_sprintf(
242 $this->activeDirectoryDomain
);
244 $distinguished_name = ldap_sprintf(
247 $this->baseDistinguishedName
);
251 $this->bindLDAP($conn, $distinguished_name, $login_pass);
253 $result = $this->searchLDAPForRecord($search_query);
255 // This is unusual (since the bind succeeded) but we've seen it at least
256 // once in the wild, where the anonymous user is allowed to search but
257 // the credentialed user is not.
259 // If we don't have anonymous credentials, raise an explicit exception
260 // here since we'll fail a typehint if we don't return an array anyway
261 // and this is a more useful error.
263 // If we do have anonymous credentials, we'll rebind and try the search
264 // again below. Doing this automatically means things work correctly more
265 // often without requiring additional configuration.
266 if (!$this->shouldBindWithoutIdentity()) {
267 // No anonymous credentials, so we just fail here.
270 'LDAP: Failed to retrieve record for user "%s" when searching. '.
271 'Credentialed users may not be able to search your LDAP server. '.
272 'Try configuring anonymous credentials or fully anonymous binds.',
275 // Rebind as anonymous and try the search again.
276 $user = $this->anonymousUsername
;
277 $pass = $this->anonymousPassword
;
278 $this->bindLDAP($conn, $user, $pass);
280 $result = $this->searchLDAPForRecord($search_query);
284 'LDAP: Failed to retrieve record for user "%s" when searching '.
285 'with both user and anonymous credentials.',
294 private function establishConnection() {
295 if (!$this->ldapConnection
) {
296 $host = $this->hostname
;
299 $profiler = PhutilServiceProfiler
::getInstance();
300 $call_id = $profiler->beginServiceCall(
305 'port' => $this->port
,
308 $conn = @ldap_connect
($host, $this->port
);
310 $profiler->endServiceCall(
318 pht('Unable to connect to LDAP server (%s:%d).', $host, $port));
322 LDAP_OPT_PROTOCOL_VERSION
=> (int)$this->ldapVersion
,
323 LDAP_OPT_REFERRALS
=> (int)$this->ldapReferrals
,
326 foreach ($options as $name => $value) {
327 $ok = @ldap_set_option
($conn, $name, $value);
329 $this->raiseConnectionException(
332 "Unable to set LDAP option '%s' to value '%s'!",
338 if ($this->ldapStartTLS
) {
339 $profiler = PhutilServiceProfiler
::getInstance();
340 $call_id = $profiler->beginServiceCall(
343 'call' => 'start-tls',
346 // NOTE: This boils down to a function call to ldap_start_tls_s() in
347 // C, which is a service call.
348 $ok = @ldap_start_tls
($conn);
350 $profiler->endServiceCall(
355 $this->raiseConnectionException(
357 pht('Unable to start TLS connection when connecting to LDAP.'));
361 if ($this->shouldBindWithoutIdentity()) {
362 $user = $this->anonymousUsername
;
363 $pass = $this->anonymousPassword
;
364 $this->bindLDAP($conn, $user, $pass);
367 $this->ldapConnection
= $conn;
370 return $this->ldapConnection
;
374 private function searchLDAPForRecord($dn) {
375 $conn = $this->establishConnection();
377 $results = $this->searchLDAP('%Q', $dn);
383 if (count($results) > 1) {
386 'LDAP record query returned more than one result. The query must '.
387 'uniquely identify a record.'));
390 return head($results);
393 public function searchLDAP($pattern /* ... */) {
394 $args = func_get_args();
395 $query = call_user_func_array('ldap_sprintf', $args);
397 $conn = $this->establishConnection();
399 $profiler = PhutilServiceProfiler
::getInstance();
400 $call_id = $profiler->beginServiceCall(
404 'dn' => $this->baseDistinguishedName
,
408 $result = @ldap_search
($conn, $this->baseDistinguishedName
, $query);
410 $profiler->endServiceCall($call_id, array());
413 $this->raiseConnectionException(
415 pht('LDAP search failed.'));
418 $entries = @ldap_get_entries
($conn, $result);
421 $this->raiseConnectionException(
423 pht('Failed to get LDAP entries from search result.'));
427 for ($ii = 0; $ii < $entries['count']; $ii++
) {
428 $results[] = $entries[$ii];
434 private function raiseConnectionException($conn, $message) {
435 $errno = @ldap_errno
($conn);
436 $error = @ldap_error
($conn);
438 // This is `LDAP_INVALID_CREDENTIALS`.
440 throw new PhutilAuthCredentialException();
443 if ($errno ||
$error) {
445 "LDAP Exception: %s\nLDAP Error #%d: %s",
451 'LDAP Exception: %s',
455 throw new Exception($full_message);
458 private function bindLDAP($conn, $user, PhutilOpaqueEnvelope
$pass) {
459 $profiler = PhutilServiceProfiler
::getInstance();
460 $call_id = $profiler->beginServiceCall(
467 // NOTE: ldap_bind() dumps cleartext passwords into logs by default. Keep
470 $ok = @ldap_bind
($conn, $user, $pass->openEnvelope());
472 $ok = @ldap_bind
($conn);
475 $profiler->endServiceCall($call_id, array());
479 $this->raiseConnectionException(
481 pht('Failed to bind to LDAP server (as user "%s").', $user));
483 $this->raiseConnectionException(
485 pht('Failed to bind to LDAP server (without username).'));
492 * Determine if this adapter should attempt to bind to the LDAP server
493 * without a user identity.
495 * Generally, we can bind directly if we have a username/password, or if the
496 * "Always Search" flag is set, indicating that the empty username and
497 * password are sufficient.
499 * @return bool True if the adapter should perform binds without identity.
501 private function shouldBindWithoutIdentity() {
502 return $this->alwaysSearch ||
strlen($this->anonymousUsername
);