Path: blob/master/src/applications/auth/adapter/PhutilLDAPAuthAdapter.php
12256 views
<?php12/**3* Retrieve identify information from LDAP accounts.4*/5final class PhutilLDAPAuthAdapter extends PhutilAuthAdapter {67private $hostname;8private $port = 389;910private $baseDistinguishedName;11private $searchAttributes = array();12private $usernameAttribute;13private $realNameAttributes = array();14private $ldapVersion = 3;15private $ldapReferrals;16private $ldapStartTLS;17private $anonymousUsername;18private $anonymousPassword;19private $activeDirectoryDomain;20private $alwaysSearch;2122private $loginUsername;23private $loginPassword;2425private $ldapUserData;26private $ldapConnection;2728public function getAdapterType() {29return 'ldap';30}3132public function setHostname($host) {33$this->hostname = $host;34return $this;35}3637public function setPort($port) {38$this->port = $port;39return $this;40}4142public function getAdapterDomain() {43return 'self';44}4546public function setBaseDistinguishedName($base_distinguished_name) {47$this->baseDistinguishedName = $base_distinguished_name;48return $this;49}5051public function setSearchAttributes(array $search_attributes) {52$this->searchAttributes = $search_attributes;53return $this;54}5556public function setUsernameAttribute($username_attribute) {57$this->usernameAttribute = $username_attribute;58return $this;59}6061public function setRealNameAttributes(array $attributes) {62$this->realNameAttributes = $attributes;63return $this;64}6566public function setLDAPVersion($ldap_version) {67$this->ldapVersion = $ldap_version;68return $this;69}7071public function setLDAPReferrals($ldap_referrals) {72$this->ldapReferrals = $ldap_referrals;73return $this;74}7576public function setLDAPStartTLS($ldap_start_tls) {77$this->ldapStartTLS = $ldap_start_tls;78return $this;79}8081public function setAnonymousUsername($anonymous_username) {82$this->anonymousUsername = $anonymous_username;83return $this;84}8586public function setAnonymousPassword(87PhutilOpaqueEnvelope $anonymous_password) {88$this->anonymousPassword = $anonymous_password;89return $this;90}9192public function setLoginUsername($login_username) {93$this->loginUsername = $login_username;94return $this;95}9697public function setLoginPassword(PhutilOpaqueEnvelope $login_password) {98$this->loginPassword = $login_password;99return $this;100}101102public function setActiveDirectoryDomain($domain) {103$this->activeDirectoryDomain = $domain;104return $this;105}106107public function setAlwaysSearch($always_search) {108$this->alwaysSearch = $always_search;109return $this;110}111112public function getAccountID() {113return $this->readLDAPRecordAccountID($this->getLDAPUserData());114}115116public function getAccountName() {117return $this->readLDAPRecordAccountName($this->getLDAPUserData());118}119120public function getAccountRealName() {121return $this->readLDAPRecordRealName($this->getLDAPUserData());122}123124public function getAccountEmail() {125return $this->readLDAPRecordEmail($this->getLDAPUserData());126}127128public function readLDAPRecordAccountID(array $record) {129$key = $this->usernameAttribute;130if (!strlen($key)) {131$key = head($this->searchAttributes);132}133return $this->readLDAPData($record, $key);134}135136public function readLDAPRecordAccountName(array $record) {137return $this->readLDAPRecordAccountID($record);138}139140public function readLDAPRecordRealName(array $record) {141$parts = array();142foreach ($this->realNameAttributes as $attribute) {143$parts[] = $this->readLDAPData($record, $attribute);144}145$parts = array_filter($parts);146147if ($parts) {148return implode(' ', $parts);149}150151return null;152}153154public function readLDAPRecordEmail(array $record) {155return $this->readLDAPData($record, 'mail');156}157158private function getLDAPUserData() {159if ($this->ldapUserData === null) {160$this->ldapUserData = $this->loadLDAPUserData();161}162163return $this->ldapUserData;164}165166private function readLDAPData(array $data, $key, $default = null) {167$list = idx($data, $key);168if ($list === null) {169// At least in some cases (and maybe in all cases) the results from170// ldap_search() are keyed in lowercase. If we missed on the first171// try, retry with a lowercase key.172$list = idx($data, phutil_utf8_strtolower($key));173}174175// NOTE: In most cases, the property is an array, like:176//177// array(178// 'count' => 1,179// 0 => 'actual-value-we-want',180// )181//182// However, in at least the case of 'dn', the property is a bare string.183184if (is_scalar($list) && strlen($list)) {185return $list;186} else if (is_array($list)) {187return $list[0];188} else {189return $default;190}191}192193private function formatLDAPAttributeSearch($attribute, $login_user) {194// If the attribute contains the literal token "${login}", treat it as a195// query and substitute the user's login name for the token.196197if (strpos($attribute, '${login}') !== false) {198$escaped_user = ldap_sprintf('%S', $login_user);199$attribute = str_replace('${login}', $escaped_user, $attribute);200return $attribute;201}202203// Otherwise, treat it as a simple attribute search.204205return ldap_sprintf(206'%Q=%S',207$attribute,208$login_user);209}210211private function loadLDAPUserData() {212$conn = $this->establishConnection();213214$login_user = $this->loginUsername;215$login_pass = $this->loginPassword;216217if ($this->shouldBindWithoutIdentity()) {218$distinguished_name = null;219$search_query = null;220foreach ($this->searchAttributes as $attribute) {221$search_query = $this->formatLDAPAttributeSearch(222$attribute,223$login_user);224$record = $this->searchLDAPForRecord($search_query);225if ($record) {226$distinguished_name = $this->readLDAPData($record, 'dn');227break;228}229}230if ($distinguished_name === null) {231throw new PhutilAuthCredentialException();232}233} else {234$search_query = $this->formatLDAPAttributeSearch(235head($this->searchAttributes),236$login_user);237if ($this->activeDirectoryDomain) {238$distinguished_name = ldap_sprintf(239'%s@%Q',240$login_user,241$this->activeDirectoryDomain);242} else {243$distinguished_name = ldap_sprintf(244'%Q,%Q',245$search_query,246$this->baseDistinguishedName);247}248}249250$this->bindLDAP($conn, $distinguished_name, $login_pass);251252$result = $this->searchLDAPForRecord($search_query);253if (!$result) {254// This is unusual (since the bind succeeded) but we've seen it at least255// once in the wild, where the anonymous user is allowed to search but256// the credentialed user is not.257258// If we don't have anonymous credentials, raise an explicit exception259// here since we'll fail a typehint if we don't return an array anyway260// and this is a more useful error.261262// If we do have anonymous credentials, we'll rebind and try the search263// again below. Doing this automatically means things work correctly more264// often without requiring additional configuration.265if (!$this->shouldBindWithoutIdentity()) {266// No anonymous credentials, so we just fail here.267throw new Exception(268pht(269'LDAP: Failed to retrieve record for user "%s" when searching. '.270'Credentialed users may not be able to search your LDAP server. '.271'Try configuring anonymous credentials or fully anonymous binds.',272$login_user));273} else {274// Rebind as anonymous and try the search again.275$user = $this->anonymousUsername;276$pass = $this->anonymousPassword;277$this->bindLDAP($conn, $user, $pass);278279$result = $this->searchLDAPForRecord($search_query);280if (!$result) {281throw new Exception(282pht(283'LDAP: Failed to retrieve record for user "%s" when searching '.284'with both user and anonymous credentials.',285$login_user));286}287}288}289290return $result;291}292293private function establishConnection() {294if (!$this->ldapConnection) {295$host = $this->hostname;296$port = $this->port;297298$profiler = PhutilServiceProfiler::getInstance();299$call_id = $profiler->beginServiceCall(300array(301'type' => 'ldap',302'call' => 'connect',303'host' => $host,304'port' => $this->port,305));306307$conn = @ldap_connect($host, $this->port);308309$profiler->endServiceCall(310$call_id,311array(312'ok' => (bool)$conn,313));314315if (!$conn) {316throw new Exception(317pht('Unable to connect to LDAP server (%s:%d).', $host, $port));318}319320$options = array(321LDAP_OPT_PROTOCOL_VERSION => (int)$this->ldapVersion,322LDAP_OPT_REFERRALS => (int)$this->ldapReferrals,323);324325foreach ($options as $name => $value) {326$ok = @ldap_set_option($conn, $name, $value);327if (!$ok) {328$this->raiseConnectionException(329$conn,330pht(331"Unable to set LDAP option '%s' to value '%s'!",332$name,333$value));334}335}336337if ($this->ldapStartTLS) {338$profiler = PhutilServiceProfiler::getInstance();339$call_id = $profiler->beginServiceCall(340array(341'type' => 'ldap',342'call' => 'start-tls',343));344345// NOTE: This boils down to a function call to ldap_start_tls_s() in346// C, which is a service call.347$ok = @ldap_start_tls($conn);348349$profiler->endServiceCall(350$call_id,351array());352353if (!$ok) {354$this->raiseConnectionException(355$conn,356pht('Unable to start TLS connection when connecting to LDAP.'));357}358}359360if ($this->shouldBindWithoutIdentity()) {361$user = $this->anonymousUsername;362$pass = $this->anonymousPassword;363$this->bindLDAP($conn, $user, $pass);364}365366$this->ldapConnection = $conn;367}368369return $this->ldapConnection;370}371372373private function searchLDAPForRecord($dn) {374$conn = $this->establishConnection();375376$results = $this->searchLDAP('%Q', $dn);377378if (!$results) {379return null;380}381382if (count($results) > 1) {383throw new Exception(384pht(385'LDAP record query returned more than one result. The query must '.386'uniquely identify a record.'));387}388389return head($results);390}391392public function searchLDAP($pattern /* ... */) {393$args = func_get_args();394$query = call_user_func_array('ldap_sprintf', $args);395396$conn = $this->establishConnection();397398$profiler = PhutilServiceProfiler::getInstance();399$call_id = $profiler->beginServiceCall(400array(401'type' => 'ldap',402'call' => 'search',403'dn' => $this->baseDistinguishedName,404'query' => $query,405));406407$result = @ldap_search($conn, $this->baseDistinguishedName, $query);408409$profiler->endServiceCall($call_id, array());410411if (!$result) {412$this->raiseConnectionException(413$conn,414pht('LDAP search failed.'));415}416417$entries = @ldap_get_entries($conn, $result);418419if (!$entries) {420$this->raiseConnectionException(421$conn,422pht('Failed to get LDAP entries from search result.'));423}424425$results = array();426for ($ii = 0; $ii < $entries['count']; $ii++) {427$results[] = $entries[$ii];428}429430return $results;431}432433private function raiseConnectionException($conn, $message) {434$errno = @ldap_errno($conn);435$error = @ldap_error($conn);436437// This is `LDAP_INVALID_CREDENTIALS`.438if ($errno == 49) {439throw new PhutilAuthCredentialException();440}441442if ($errno || $error) {443$full_message = pht(444"LDAP Exception: %s\nLDAP Error #%d: %s",445$message,446$errno,447$error);448} else {449$full_message = pht(450'LDAP Exception: %s',451$message);452}453454throw new Exception($full_message);455}456457private function bindLDAP($conn, $user, PhutilOpaqueEnvelope $pass) {458$profiler = PhutilServiceProfiler::getInstance();459$call_id = $profiler->beginServiceCall(460array(461'type' => 'ldap',462'call' => 'bind',463'user' => $user,464));465466// NOTE: ldap_bind() dumps cleartext passwords into logs by default. Keep467// it quiet.468if (strlen($user)) {469$ok = @ldap_bind($conn, $user, $pass->openEnvelope());470} else {471$ok = @ldap_bind($conn);472}473474$profiler->endServiceCall($call_id, array());475476if (!$ok) {477if (strlen($user)) {478$this->raiseConnectionException(479$conn,480pht('Failed to bind to LDAP server (as user "%s").', $user));481} else {482$this->raiseConnectionException(483$conn,484pht('Failed to bind to LDAP server (without username).'));485}486}487}488489490/**491* Determine if this adapter should attempt to bind to the LDAP server492* without a user identity.493*494* Generally, we can bind directly if we have a username/password, or if the495* "Always Search" flag is set, indicating that the empty username and496* password are sufficient.497*498* @return bool True if the adapter should perform binds without identity.499*/500private function shouldBindWithoutIdentity() {501return $this->alwaysSearch || strlen($this->anonymousUsername);502}503504}505506507