@recaptime-dev's working patches + fork for Phorge, a community fork of Phabricator. (Upstream dev and stable branches are at upstream/main and upstream/stable respectively.) hq.recaptime.dev/wiki/Phorge
phorge phabricator
at recaptime-dev/main 505 lines 14 kB view raw
1<?php 2 3/** 4 * Retrieve identify information from LDAP accounts. 5 */ 6final class PhutilLDAPAuthAdapter extends PhutilAuthAdapter { 7 8 private $hostname; 9 private $port = 389; 10 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; 22 23 private $loginUsername; 24 private $loginPassword; 25 26 private $ldapUserData; 27 private $ldapConnection; 28 29 public function getAdapterType() { 30 return 'ldap'; 31 } 32 33 public function setHostname($host) { 34 $this->hostname = $host; 35 return $this; 36 } 37 38 public function setPort($port) { 39 $this->port = $port; 40 return $this; 41 } 42 43 public function getAdapterDomain() { 44 return 'self'; 45 } 46 47 public function setBaseDistinguishedName($base_distinguished_name) { 48 $this->baseDistinguishedName = $base_distinguished_name; 49 return $this; 50 } 51 52 public function setSearchAttributes(array $search_attributes) { 53 $this->searchAttributes = $search_attributes; 54 return $this; 55 } 56 57 public function setUsernameAttribute($username_attribute) { 58 $this->usernameAttribute = $username_attribute; 59 return $this; 60 } 61 62 public function setRealNameAttributes(array $attributes) { 63 $this->realNameAttributes = $attributes; 64 return $this; 65 } 66 67 public function setLDAPVersion($ldap_version) { 68 $this->ldapVersion = $ldap_version; 69 return $this; 70 } 71 72 public function setLDAPReferrals($ldap_referrals) { 73 $this->ldapReferrals = $ldap_referrals; 74 return $this; 75 } 76 77 public function setLDAPStartTLS($ldap_start_tls) { 78 $this->ldapStartTLS = $ldap_start_tls; 79 return $this; 80 } 81 82 public function setAnonymousUsername($anonymous_username) { 83 $this->anonymousUsername = $anonymous_username; 84 return $this; 85 } 86 87 public function setAnonymousPassword( 88 PhutilOpaqueEnvelope $anonymous_password) { 89 $this->anonymousPassword = $anonymous_password; 90 return $this; 91 } 92 93 public function setLoginUsername($login_username) { 94 $this->loginUsername = $login_username; 95 return $this; 96 } 97 98 public function setLoginPassword(PhutilOpaqueEnvelope $login_password) { 99 $this->loginPassword = $login_password; 100 return $this; 101 } 102 103 public function setActiveDirectoryDomain($domain) { 104 $this->activeDirectoryDomain = $domain; 105 return $this; 106 } 107 108 public function setAlwaysSearch($always_search) { 109 $this->alwaysSearch = $always_search; 110 return $this; 111 } 112 113 public function getAccountID() { 114 return $this->readLDAPRecordAccountID($this->getLDAPUserData()); 115 } 116 117 public function getAccountName() { 118 return $this->readLDAPRecordAccountName($this->getLDAPUserData()); 119 } 120 121 public function getAccountRealName() { 122 return $this->readLDAPRecordRealName($this->getLDAPUserData()); 123 } 124 125 public function getAccountEmail() { 126 return $this->readLDAPRecordEmail($this->getLDAPUserData()); 127 } 128 129 public function readLDAPRecordAccountID(array $record) { 130 $key = $this->usernameAttribute; 131 if (!strlen($key)) { 132 $key = head($this->searchAttributes); 133 } 134 return $this->readLDAPData($record, $key); 135 } 136 137 public function readLDAPRecordAccountName(array $record) { 138 return $this->readLDAPRecordAccountID($record); 139 } 140 141 public function readLDAPRecordRealName(array $record) { 142 $parts = array(); 143 foreach ($this->realNameAttributes as $attribute) { 144 $parts[] = $this->readLDAPData($record, $attribute); 145 } 146 $parts = array_filter($parts); 147 148 if ($parts) { 149 return implode(' ', $parts); 150 } 151 152 return null; 153 } 154 155 public function readLDAPRecordEmail(array $record) { 156 return $this->readLDAPData($record, 'mail'); 157 } 158 159 private function getLDAPUserData() { 160 if ($this->ldapUserData === null) { 161 $this->ldapUserData = $this->loadLDAPUserData(); 162 } 163 164 return $this->ldapUserData; 165 } 166 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)); 174 } 175 176 // NOTE: In most cases, the property is an array, like: 177 // 178 // array( 179 // 'count' => 1, 180 // 0 => 'actual-value-we-want', 181 // ) 182 // 183 // However, in at least the case of 'dn', the property is a bare string. 184 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; 191 } 192 } 193 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. 197 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; 202 } 203 204 // Otherwise, treat it as a simple attribute search. 205 206 return ldap_sprintf( 207 '%Q=%S', 208 $attribute, 209 $login_user); 210 } 211 212 private function loadLDAPUserData() { 213 $conn = $this->establishConnection(); 214 215 $login_user = $this->loginUsername; 216 $login_pass = $this->loginPassword; 217 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; 229 } 230 } 231 if ($distinguished_name === null) { 232 throw new PhutilAuthCredentialException(); 233 } 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); 248 } 249 } 250 251 $this->bindLDAP($conn, $distinguished_name, $login_pass); 252 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. 258 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. 262 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); 279 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)); 287 } 288 } 289 } 290 291 return $result; 292 } 293 294 private function establishConnection() { 295 if (!$this->ldapConnection) { 296 $host = $this->hostname; 297 $port = $this->port; 298 299 $profiler = PhutilServiceProfiler::getInstance(); 300 $call_id = $profiler->beginServiceCall( 301 array( 302 'type' => 'ldap', 303 'call' => 'connect', 304 'host' => $host, 305 'port' => $this->port, 306 )); 307 308 $conn = @ldap_connect($host, $this->port); 309 310 $profiler->endServiceCall( 311 $call_id, 312 array( 313 'ok' => (bool)$conn, 314 )); 315 316 if (!$conn) { 317 throw new Exception( 318 pht('Unable to connect to LDAP server (%s:%d).', $host, $port)); 319 } 320 321 $options = array( 322 LDAP_OPT_PROTOCOL_VERSION => (int)$this->ldapVersion, 323 LDAP_OPT_REFERRALS => (int)$this->ldapReferrals, 324 ); 325 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)); 335 } 336 } 337 338 if ($this->ldapStartTLS) { 339 $profiler = PhutilServiceProfiler::getInstance(); 340 $call_id = $profiler->beginServiceCall( 341 array( 342 'type' => 'ldap', 343 'call' => 'start-tls', 344 )); 345 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); 349 350 $profiler->endServiceCall( 351 $call_id, 352 array()); 353 354 if (!$ok) { 355 $this->raiseConnectionException( 356 $conn, 357 pht('Unable to start TLS connection when connecting to LDAP.')); 358 } 359 } 360 361 if ($this->shouldBindWithoutIdentity()) { 362 $user = $this->anonymousUsername; 363 $pass = $this->anonymousPassword; 364 $this->bindLDAP($conn, $user, $pass); 365 } 366 367 $this->ldapConnection = $conn; 368 } 369 370 return $this->ldapConnection; 371 } 372 373 374 private function searchLDAPForRecord($dn) { 375 $conn = $this->establishConnection(); 376 377 $results = $this->searchLDAP('%Q', $dn); 378 379 if (!$results) { 380 return null; 381 } 382 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.')); 388 } 389 390 return head($results); 391 } 392 393 public function searchLDAP($pattern /* ... */) { 394 $args = func_get_args(); 395 $query = call_user_func_array('ldap_sprintf', $args); 396 397 $conn = $this->establishConnection(); 398 399 $profiler = PhutilServiceProfiler::getInstance(); 400 $call_id = $profiler->beginServiceCall( 401 array( 402 'type' => 'ldap', 403 'call' => 'search', 404 'dn' => $this->baseDistinguishedName, 405 'query' => $query, 406 )); 407 408 $result = @ldap_search($conn, $this->baseDistinguishedName, $query); 409 410 $profiler->endServiceCall($call_id, array()); 411 412 if (!$result) { 413 $this->raiseConnectionException( 414 $conn, 415 pht('LDAP search failed.')); 416 } 417 418 $entries = @ldap_get_entries($conn, $result); 419 420 if (!$entries) { 421 $this->raiseConnectionException( 422 $conn, 423 pht('Failed to get LDAP entries from search result.')); 424 } 425 426 $results = array(); 427 for ($ii = 0; $ii < $entries['count']; $ii++) { 428 $results[] = $entries[$ii]; 429 } 430 431 return $results; 432 } 433 434 private function raiseConnectionException($conn, $message) { 435 $errno = @ldap_errno($conn); 436 $error = @ldap_error($conn); 437 438 // This is `LDAP_INVALID_CREDENTIALS`. 439 if ($errno == 49) { 440 throw new PhutilAuthCredentialException(); 441 } 442 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); 453 } 454 455 throw new Exception($full_message); 456 } 457 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, 465 )); 466 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); 473 } 474 475 $profiler->endServiceCall($call_id, array()); 476 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).')); 486 } 487 } 488 } 489 490 491 /** 492 * Determine if this adapter should attempt to bind to the LDAP server 493 * without a user identity. 494 * 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. 498 * 499 * @return bool True if the adapter should perform binds without identity. 500 */ 501 private function shouldBindWithoutIdentity() { 502 return $this->alwaysSearch || strlen($this->anonymousUsername); 503 } 504 505}