Remove all "FileHasObject" edge reads and writes
[phabricator.git] / src / applications / auth / adapter / PhutilLDAPAuthAdapter.php
blob14047c1761bebcc9d352cdbc67a32a0062c57af4
1 <?php
3 /**
4 * Retrieve identify information from LDAP accounts.
5 */
6 final class PhutilLDAPAuthAdapter extends PhutilAuthAdapter {
8 private $hostname;
9 private $port = 389;
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() {
30 return 'ldap';
33 public function setHostname($host) {
34 $this->hostname = $host;
35 return $this;
38 public function setPort($port) {
39 $this->port = $port;
40 return $this;
43 public function getAdapterDomain() {
44 return 'self';
47 public function setBaseDistinguishedName($base_distinguished_name) {
48 $this->baseDistinguishedName = $base_distinguished_name;
49 return $this;
52 public function setSearchAttributes(array $search_attributes) {
53 $this->searchAttributes = $search_attributes;
54 return $this;
57 public function setUsernameAttribute($username_attribute) {
58 $this->usernameAttribute = $username_attribute;
59 return $this;
62 public function setRealNameAttributes(array $attributes) {
63 $this->realNameAttributes = $attributes;
64 return $this;
67 public function setLDAPVersion($ldap_version) {
68 $this->ldapVersion = $ldap_version;
69 return $this;
72 public function setLDAPReferrals($ldap_referrals) {
73 $this->ldapReferrals = $ldap_referrals;
74 return $this;
77 public function setLDAPStartTLS($ldap_start_tls) {
78 $this->ldapStartTLS = $ldap_start_tls;
79 return $this;
82 public function setAnonymousUsername($anonymous_username) {
83 $this->anonymousUsername = $anonymous_username;
84 return $this;
87 public function setAnonymousPassword(
88 PhutilOpaqueEnvelope $anonymous_password) {
89 $this->anonymousPassword = $anonymous_password;
90 return $this;
93 public function setLoginUsername($login_username) {
94 $this->loginUsername = $login_username;
95 return $this;
98 public function setLoginPassword(PhutilOpaqueEnvelope $login_password) {
99 $this->loginPassword = $login_password;
100 return $this;
103 public function setActiveDirectoryDomain($domain) {
104 $this->activeDirectoryDomain = $domain;
105 return $this;
108 public function setAlwaysSearch($always_search) {
109 $this->alwaysSearch = $always_search;
110 return $this;
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;
131 if (!strlen($key)) {
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) {
142 $parts = array();
143 foreach ($this->realNameAttributes as $attribute) {
144 $parts[] = $this->readLDAPData($record, $attribute);
146 $parts = array_filter($parts);
148 if ($parts) {
149 return implode(' ', $parts);
152 return null;
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:
178 // array(
179 // 'count' => 1,
180 // 0 => 'actual-value-we-want',
181 // )
183 // However, in at least the case of 'dn', the property is a bare string.
185 if (is_scalar($list) && strlen($list)) {
186 return $list;
187 } else if (is_array($list)) {
188 return $list[0];
189 } else {
190 return $default;
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);
201 return $attribute;
204 // Otherwise, treat it as a simple attribute search.
206 return ldap_sprintf(
207 '%Q=%S',
208 $attribute,
209 $login_user);
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(
223 $attribute,
224 $login_user);
225 $record = $this->searchLDAPForRecord($search_query);
226 if ($record) {
227 $distinguished_name = $this->readLDAPData($record, 'dn');
228 break;
231 if ($distinguished_name === null) {
232 throw new PhutilAuthCredentialException();
234 } else {
235 $search_query = $this->formatLDAPAttributeSearch(
236 head($this->searchAttributes),
237 $login_user);
238 if ($this->activeDirectoryDomain) {
239 $distinguished_name = ldap_sprintf(
240 '%s@%Q',
241 $login_user,
242 $this->activeDirectoryDomain);
243 } else {
244 $distinguished_name = ldap_sprintf(
245 '%Q,%Q',
246 $search_query,
247 $this->baseDistinguishedName);
251 $this->bindLDAP($conn, $distinguished_name, $login_pass);
253 $result = $this->searchLDAPForRecord($search_query);
254 if (!$result) {
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.
268 throw new Exception(
269 pht(
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.',
273 $login_user));
274 } else {
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);
281 if (!$result) {
282 throw new Exception(
283 pht(
284 'LDAP: Failed to retrieve record for user "%s" when searching '.
285 'with both user and anonymous credentials.',
286 $login_user));
291 return $result;
294 private function establishConnection() {
295 if (!$this->ldapConnection) {
296 $host = $this->hostname;
297 $port = $this->port;
299 $profiler = PhutilServiceProfiler::getInstance();
300 $call_id = $profiler->beginServiceCall(
301 array(
302 'type' => 'ldap',
303 'call' => 'connect',
304 'host' => $host,
305 'port' => $this->port,
308 $conn = @ldap_connect($host, $this->port);
310 $profiler->endServiceCall(
311 $call_id,
312 array(
313 'ok' => (bool)$conn,
316 if (!$conn) {
317 throw new Exception(
318 pht('Unable to connect to LDAP server (%s:%d).', $host, $port));
321 $options = array(
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);
328 if (!$ok) {
329 $this->raiseConnectionException(
330 $conn,
331 pht(
332 "Unable to set LDAP option '%s' to value '%s'!",
333 $name,
334 $value));
338 if ($this->ldapStartTLS) {
339 $profiler = PhutilServiceProfiler::getInstance();
340 $call_id = $profiler->beginServiceCall(
341 array(
342 'type' => 'ldap',
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(
351 $call_id,
352 array());
354 if (!$ok) {
355 $this->raiseConnectionException(
356 $conn,
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);
379 if (!$results) {
380 return null;
383 if (count($results) > 1) {
384 throw new Exception(
385 pht(
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(
401 array(
402 'type' => 'ldap',
403 'call' => 'search',
404 'dn' => $this->baseDistinguishedName,
405 'query' => $query,
408 $result = @ldap_search($conn, $this->baseDistinguishedName, $query);
410 $profiler->endServiceCall($call_id, array());
412 if (!$result) {
413 $this->raiseConnectionException(
414 $conn,
415 pht('LDAP search failed.'));
418 $entries = @ldap_get_entries($conn, $result);
420 if (!$entries) {
421 $this->raiseConnectionException(
422 $conn,
423 pht('Failed to get LDAP entries from search result.'));
426 $results = array();
427 for ($ii = 0; $ii < $entries['count']; $ii++) {
428 $results[] = $entries[$ii];
431 return $results;
434 private function raiseConnectionException($conn, $message) {
435 $errno = @ldap_errno($conn);
436 $error = @ldap_error($conn);
438 // This is `LDAP_INVALID_CREDENTIALS`.
439 if ($errno == 49) {
440 throw new PhutilAuthCredentialException();
443 if ($errno || $error) {
444 $full_message = pht(
445 "LDAP Exception: %s\nLDAP Error #%d: %s",
446 $message,
447 $errno,
448 $error);
449 } else {
450 $full_message = pht(
451 'LDAP Exception: %s',
452 $message);
455 throw new Exception($full_message);
458 private function bindLDAP($conn, $user, PhutilOpaqueEnvelope $pass) {
459 $profiler = PhutilServiceProfiler::getInstance();
460 $call_id = $profiler->beginServiceCall(
461 array(
462 'type' => 'ldap',
463 'call' => 'bind',
464 'user' => $user,
467 // NOTE: ldap_bind() dumps cleartext passwords into logs by default. Keep
468 // it quiet.
469 if (strlen($user)) {
470 $ok = @ldap_bind($conn, $user, $pass->openEnvelope());
471 } else {
472 $ok = @ldap_bind($conn);
475 $profiler->endServiceCall($call_id, array());
477 if (!$ok) {
478 if (strlen($user)) {
479 $this->raiseConnectionException(
480 $conn,
481 pht('Failed to bind to LDAP server (as user "%s").', $user));
482 } else {
483 $this->raiseConnectionException(
484 $conn,
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);