Page MenuHomec4science

PhutilAuthAdapterLDAP.php
No OneTemporary

File Metadata

Created
Fri, Nov 29, 00:05

PhutilAuthAdapterLDAP.php

<?php
/**
* Retrieve identify information from LDAP accounts.
*/
final class PhutilAuthAdapterLDAP extends PhutilAuthAdapter {
private $hostname;
private $port = 389;
private $baseDistinguishedName;
private $searchAttributes = array();
private $usernameAttribute;
private $realNameAttributes = array();
private $ldapVersion = 3;
private $ldapReferrals;
private $ldapStartTLS;
private $anonymousUsername;
private $anonymousPassword;
private $activeDirectoryDomain;
private $alwaysSearch;
private $loginUsername;
private $loginPassword;
private $ldapUserData;
private $ldapConnection;
public function getAdapterType() {
return 'ldap';
}
public function setHostname($host) {
$this->hostname = $host;
return $this;
}
public function setPort($port) {
$this->port = $port;
return $this;
}
public function getAdapterDomain() {
return 'self';
}
public function setBaseDistinguishedName($base_distinguished_name) {
$this->baseDistinguishedName = $base_distinguished_name;
return $this;
}
public function setSearchAttributes(array $search_attributes) {
$this->searchAttributes = $search_attributes;
return $this;
}
public function setUsernameAttribute($username_attribute) {
$this->usernameAttribute = $username_attribute;
return $this;
}
public function setRealNameAttributes(array $attributes) {
$this->realNameAttributes = $attributes;
return $this;
}
public function setLDAPVersion($ldap_version) {
$this->ldapVersion = $ldap_version;
return $this;
}
public function setLDAPReferrals($ldap_referrals) {
$this->ldapReferrals = $ldap_referrals;
return $this;
}
public function setLDAPStartTLS($ldap_start_tls) {
$this->ldapStartTLS = $ldap_start_tls;
return $this;
}
public function setAnonymousUsername($anonymous_username) {
$this->anonymousUsername = $anonymous_username;
return $this;
}
public function setAnonymousPassword(
PhutilOpaqueEnvelope $anonymous_password) {
$this->anonymousPassword = $anonymous_password;
return $this;
}
public function setLoginUsername($login_username) {
$this->loginUsername = $login_username;
return $this;
}
public function setLoginPassword(PhutilOpaqueEnvelope $login_password) {
$this->loginPassword = $login_password;
return $this;
}
public function setActiveDirectoryDomain($domain) {
$this->activeDirectoryDomain = $domain;
return $this;
}
public function setAlwaysSearch($always_search) {
$this->alwaysSearch = $always_search;
return $this;
}
public function getAccountID() {
return $this->readLDAPRecordAccountID($this->getLDAPUserData());
}
public function getAccountName() {
return $this->readLDAPRecordAccountName($this->getLDAPUserData());
}
public function getAccountRealName() {
return $this->readLDAPRecordRealName($this->getLDAPUserData());
}
public function getAccountEmail() {
return $this->readLDAPRecordEmail($this->getLDAPUserData());
}
public function readLDAPRecordAccountID(array $record) {
$key = $this->usernameAttribute;
if (!strlen($key)) {
$key = head($this->searchAttributes);
}
return $this->readLDAPData($record, $key);
}
public function readLDAPRecordAccountName(array $record) {
return $this->readLDAPRecordAccountID($record);
}
public function readLDAPRecordRealName(array $record) {
$parts = array();
foreach ($this->realNameAttributes as $attribute) {
$parts[] = $this->readLDAPData($record, $attribute);
}
$parts = array_filter($parts);
if ($parts) {
return implode(' ', $parts);
}
return null;
}
public function readLDAPRecordEmail(array $record) {
return $this->readLDAPData($record, 'mail');
}
private function getLDAPUserData() {
if ($this->ldapUserData === null) {
$this->ldapUserData = $this->loadLDAPUserData();
}
return $this->ldapUserData;
}
private function readLDAPData(array $data, $key, $default = null) {
$list = idx($data, $key);
if ($list === null) {
// At least in some cases (and maybe in all cases) the results from
// ldap_search() are keyed in lowercase. If we missed on the first
// try, retry with a lowercase key.
$list = idx($data, phutil_utf8_strtolower($key));
}
// NOTE: In most cases, the property is an array, like:
//
// array(
// 'count' => 1,
// 0 => 'actual-value-we-want',
// )
//
// However, in at least the case of 'dn', the property is a bare string.
if (is_scalar($list) && strlen($list)) {
return $list;
} else if (is_array($list)) {
return $list[0];
} else {
return $default;
}
}
private function formatLDAPAttributeSearch($attribute, $login_user) {
// If the attribute contains the literal token "${login}", treat it as a
// query and substitute the user's login name for the token.
if (strpos($attribute, '${login}') !== false) {
$escaped_user = ldap_sprintf('%S', $login_user);
$attribute = str_replace('${login}', $escaped_user, $attribute);
return $attribute;
}
// Otherwise, treat it as a simple attribute search.
return ldap_sprintf(
'%Q=%S',
$attribute,
$login_user);
}
private function loadLDAPUserData() {
$conn = $this->establishConnection();
$login_user = $this->loginUsername;
$login_pass = $this->loginPassword;
if ($this->shouldBindWithoutIdentity()) {
$distinguished_name = null;
$search_query = null;
foreach ($this->searchAttributes as $attribute) {
$search_query = $this->formatLDAPAttributeSearch(
$attribute,
$login_user);
$record = $this->searchLDAPForRecord($search_query);
if ($record) {
$distinguished_name = $this->readLDAPData($record, 'dn');
break;
}
}
if ($distinguished_name === null) {
throw new PhutilAuthCredentialException();
}
} else {
$search_query = $this->formatLDAPAttributeSearch(
head($this->searchAttributes),
$login_user);
if ($this->activeDirectoryDomain) {
$distinguished_name = ldap_sprintf(
'%s@%Q',
$login_user,
$this->activeDirectoryDomain);
} else {
$distinguished_name = ldap_sprintf(
'%Q,%Q',
$search_query,
$this->baseDistinguishedName);
}
}
$this->bindLDAP($conn, $distinguished_name, $login_pass);
$result = $this->searchLDAPForRecord($search_query);
if (!$result) {
// This is unusual (since the bind succeeded) but we've seen it at least
// once in the wild, where the anonymous user is allowed to search but
// the credentialed user is not.
// If we don't have anonymous credentials, raise an explicit exception
// here since we'll fail a typehint if we don't return an array anyway
// and this is a more useful error.
// If we do have anonymous credentials, we'll rebind and try the search
// again below. Doing this automatically means things work correctly more
// often without requiring additional configuration.
if (!$this->shouldBindWithoutIdentity()) {
// No anonymous credentials, so we just fail here.
throw new Exception(
pht(
'LDAP: Failed to retrieve record for user "%s" when searching. '.
'Credentialed users may not be able to search your LDAP server. '.
'Try configuring anonymous credentials or fully anonymous binds.',
$login_user));
} else {
// Rebind as anonymous and try the search again.
$user = $this->anonymousUsername;
$pass = $this->anonymousPassword;
$this->bindLDAP($conn, $user, $pass);
$result = $this->searchLDAPForRecord($search_query);
if (!$result) {
throw new Exception(
pht(
'LDAP: Failed to retrieve record for user "%s" when searching '.
'with both user and anonymous credentials.',
$login_user));
}
}
}
return $result;
}
private function establishConnection() {
if (!$this->ldapConnection) {
$host = $this->hostname;
$port = $this->port;
$profiler = PhutilServiceProfiler::getInstance();
$call_id = $profiler->beginServiceCall(
array(
'type' => 'ldap',
'call' => 'connect',
'host' => $host,
'port' => $this->port,
));
$conn = @ldap_connect($host, $this->port);
$profiler->endServiceCall(
$call_id,
array(
'ok' => (bool)$conn,
));
if (!$conn) {
throw new Exception(
"Unable to connect to LDAP server ({$host}:{$port}).");
}
$options = array(
LDAP_OPT_PROTOCOL_VERSION => (int)$this->ldapVersion,
LDAP_OPT_REFERRALS => (int)$this->ldapReferrals,
);
foreach ($options as $name => $value) {
$ok = @ldap_set_option($conn, $name, $value);
if (!$ok) {
$this->raiseConnectionException(
$conn,
pht(
"Unable to set LDAP option '%s' to value '%s'!",
$name,
$value));
}
}
if ($this->ldapStartTLS) {
$profiler = PhutilServiceProfiler::getInstance();
$call_id = $profiler->beginServiceCall(
array(
'type' => 'ldap',
'call' => 'start-tls',
));
// NOTE: This boils down to a function call to ldap_start_tls_s() in
// C, which is a service call.
$ok = @ldap_start_tls($conn);
$profiler->endServiceCall(
$call_id,
array());
if (!$ok) {
$this->raiseConnectionException(
$conn,
pht('Unable to start TLS connection when connecting to LDAP.'));
}
}
if ($this->shouldBindWithoutIdentity()) {
$user = $this->anonymousUsername;
$pass = $this->anonymousPassword;
$this->bindLDAP($conn, $user, $pass);
}
$this->ldapConnection = $conn;
}
return $this->ldapConnection;
}
private function searchLDAPForRecord($dn) {
$conn = $this->establishConnection();
$results = $this->searchLDAP('%Q', $dn);
if (!$results) {
return null;
}
if (count($results) > 1) {
throw new Exception(
pht(
'LDAP record query returned more than one result. The query must '.
'uniquely identify a record.'));
}
return head($results);
}
public function searchLDAP($pattern /* ... */) {
$args = func_get_args();
$query = call_user_func_array('ldap_sprintf', $args);
$conn = $this->establishConnection();
$profiler = PhutilServiceProfiler::getInstance();
$call_id = $profiler->beginServiceCall(
array(
'type' => 'ldap',
'call' => 'search',
'dn' => $this->baseDistinguishedName,
'query' => $query,
));
$result = @ldap_search($conn, $this->baseDistinguishedName, $query);
$profiler->endServiceCall($call_id, array());
if (!$result) {
$this->raiseConnectionException(
$conn,
pht('LDAP search failed.'));
}
$entries = @ldap_get_entries($conn, $result);
if (!$entries) {
$this->raiseConnectionException(
$conn,
pht('Failed to get LDAP entries from search result.'));
}
$results = array();
for ($ii = 0; $ii < $entries['count']; $ii++) {
$results[] = $entries[$ii];
}
return $results;
}
private function raiseConnectionException($conn, $message) {
$errno = @ldap_errno($conn);
$error = @ldap_error($conn);
// This is `LDAP_INVALID_CREDENTIALS`.
if ($errno == 49) {
throw new PhutilAuthCredentialException();
}
if ($errno || $error) {
$full_message = pht(
"LDAP Exception: %s\nLDAP Error #%d: %s",
$message,
$errno,
$error);
} else {
$full_message = pht(
'LDAP Exception: %s',
$message);
}
throw new Exception($full_message);
}
private function bindLDAP($conn, $user, PhutilOpaqueEnvelope $pass) {
$profiler = PhutilServiceProfiler::getInstance();
$call_id = $profiler->beginServiceCall(
array(
'type' => 'ldap',
'call' => 'bind',
'user' => $user,
));
// NOTE: ldap_bind() dumps cleartext passwords into logs by default. Keep
// it quiet.
if (strlen($user)) {
$ok = @ldap_bind($conn, $user, $pass->openEnvelope());
} else {
$ok = @ldap_bind($conn);
}
$profiler->endServiceCall($call_id, array());
if (!$ok) {
if (strlen($user)) {
$this->raiseConnectionException(
$conn,
pht('Failed to bind to LDAP server (as user "%s").', $user));
} else {
$this->raiseConnectionException(
$conn,
pht('Failed to bind to LDAP server (without username).'));
}
}
}
/**
* Determine if this adapter should attempt to bind to the LDAP server
* without a user identity.
*
* Generally, we can bind directly if we have a username/password, or if the
* "Always Search" flag is set, indicating that the empty username and
* password are sufficient.
*
* @return bool True if the adapter should perform binds without identity.
*/
private function shouldBindWithoutIdentity() {
return $this->alwaysSearch || strlen($this->anonymousUsername);
}
}

Event Timeline