From 39d1cb2905c5cf39c6d3c277b38bd66e1f3dae4a Mon Sep 17 00:00:00 2001 From: Jared Hancock Date: Wed, 30 Sep 2015 10:38:29 -0500 Subject: [PATCH 1/6] ldap: Fix autodiscovery for Active Directory This patch adds support for automatically discovering the closest domain controller (LDAP server) by asynchronously connecting to all domain controllers advertised in DNS, sorted by priority and weight, in parallel and using the first server to respond. The protocol is described by Microsoft at https://technet.microsoft.com/en-us/library/cc978016.aspx --- auth-ldap/authentication.php | 138 ++++++++++++++++++++++++++++++++++- auth-ldap/config.php | 38 +++++++++- 2 files changed, 170 insertions(+), 6 deletions(-) diff --git a/auth-ldap/authentication.php b/auth-ldap/authentication.php index 34d78ea..a5c20a8 100644 --- a/auth-ldap/authentication.php +++ b/auth-ldap/authentication.php @@ -76,9 +76,8 @@ function getConfig() { return $this->config; } - function autodiscover($domain, $dns=array()) { + static function lookupDnsWithServers($domain, $dns=array()) { require_once(PEAR_DIR.'Net/DNS2.php'); - // TODO: Lookup DNS server from hosts file if not set $q = new Net_DNS2_Resolver(); if ($dns) $q->setServers($dns); @@ -98,20 +97,146 @@ function autodiscover($domain, $dns=array()) { 'weight' => $srv->weight, ); } - // Sort servers by priority ASC, then weight DESC + return $servers; + } + + static function lookupDnsWindows($domain) { + $servers = array(); + if (!($answers = dns_get_record('_ldap._tcp.'.$domain, DNS_SRV))) + return $servers; + + foreach ($answers as $srv) { + $servers[] = array( + 'host' => "{$srv['target']}:{$srv['port']}", + 'priority' => $srv['pri'], + 'weight' => $srv['weight'], + ); + } + return $servers; + } + + /** + * Discover Active Directory LDAP servers using DNS. + * + * Parameters: + * $domain - AD domain + * $dns - DNS server hints (optional) + * $closestOnly - Return at most one server which is definitely + * available and represents the first to respond of all the servers + * discovered in DNS. + * + * References: + * "DNS-Based Discovery" (Microsoft) + * https://msdn.microsoft.com/en-us/library/cc717360.aspx + */ + static function autodiscover($domain, $dns=array(), $closestOnly=false) { + if (!$dns && stripos(PHP_OS, 'WIN') === 0) { + // Net_DNS2_Resolver won't work on windows servers without DNS + // specified + // TODO: Lookup DNS server from hosts file if not set + $servers = self::lookupDnsWindows($domain); + } + else { + $servers = self::lookupDnsWithServers($domain, $dns); + } + // Sort by priority and weight + // priority ASC, then weight DESC usort($servers, function($a, $b) { return ($a['priority'] << 15) - $a['weight'] - ($b['priority'] << 15) + $b['weight']; }); + // Locate closest domain controller (if requested) + if ($closestOnly) { + if ($idx = self::findClosestLdapServer($servers)) { + return array($servers[$idx]); + } + } return $servers; } + /** + * Discover the closest LDAP server based on apparent TCP connect + * timing. This method will attempt parallel, asynchronous connections + * to all received LDAP servers and return the array index of the + * first-respodning server. + * + * Returns: + * (int|false|null) - index into the received servers list for the + * first-responding server. NULL if no servers responded in a few + * seconds, and FALSE if the socket extension is not loaded for this + * PHP setup. + * + * References: + * "Finding a Domain Controller in the Closest Site" (Microsoft) + * https://technet.microsoft.com/en-us/library/cc978016.aspx + * + * "here's how you can implement timeouts with the socket functions" + * (PHP, rbarnes at fake dot com) + * http://us3.php.net/manual/en/function.socket-connect.php#84465 + */ + static function findClosestLdapServer($servers, $defl_port=389) { + if (!function_exists('socket_create')) + return false; + + $sockets = array(); + reset($servers); + $closest = null; + $loops = 60; # 6 seconds max + while (!$closest && $loops--) { + list($i, $S) = each($servers); + if ($S) { + // Add another socket to the list + // Lookup IP address for host + list($host, $port) = explode(':', $S['host'], 2); + if (!@inet_pton($host)) { + if ($host == ($ip = gethostbyname($host))) { + continue; + } + $host = $ip; + } + // Start an async connect to this server + if (!($sk = socket_create(AF_INET, SOCK_STREAM, SOL_TCP))) + continue; + + socket_set_nonblock($sk); + + $sockets[$i] = array($sk, $host, $port ?: $defl_port); + } + // Look for a successful connection + foreach ($sockets as $i=>$X) { + list($sk, $host, $port) = $X; + if (@socket_connect($sk, $host, $port)) { + // Connected!!! + $closest = $i; + break; + } + else { + $error = socket_last_error(); + if (!in_array($error, array(SOCKET_EINPROGRESS, SOCKET_EALREADY))) { + // Bad mojo + socket_close($sk); + unset($sockets,$i); + } + } + } + // Microsoft recommends waiting 0.1s; however, we're in the + // business of providing quick response times + usleep(2000); + } + foreach ($sockets as $X) { + list($sk) = $X; + socket_close($sk); + } + return $closest; + } + function getServers() { if (!($servers = $this->getConfig()->get('servers')) || !($servers = preg_split('/\s+/', $servers))) { if ($domain = $this->getConfig()->get('domain')) { $dns = preg_split('/,?\s+/', $this->getConfig()->get('dns')); - return $this->autodiscover($domain, array_filter($dns)); + return self::autodiscover($domain, array_filter($dns), + true); } } if ($servers) { @@ -150,6 +275,11 @@ function getConnection($force_reconnect=false) { } foreach ($this->getServers() as $s) { + @list($host, $port) = explode(':', $s['host'], 2); + if ($port) { + $s['port'] = $port; + $s['host'] = $host; + } $params = $defaults + $s; $c = new Net_LDAP2($params); $r = $c->bind(); diff --git a/auth-ldap/config.php b/auth-ldap/config.php index 84fd83e..0580f61 100644 --- a/auth-ldap/config.php +++ b/auth-ldap/config.php @@ -62,7 +62,10 @@ function($self, $val) use ($__) { 'servers' => new TextareaField(array( 'id' => 'servers', 'label' => $__('LDAP servers'), - 'configuration' => array('html'=>false, 'rows'=>2, 'cols'=>40), + 'configuration' => array('html'=>false, 'rows'=>2, + 'cols'=>40, + 'placeholder' => $__('Auto detect (recommended for Active Directory)'), + ), 'hint' => $__('Use "server" or "server:port". Place one server entry per line'), )), 'tls' => new BooleanField(array( @@ -141,13 +144,37 @@ function pre_save(&$config, &$errors) { return; } + // Discover LDAP servers for this domain if ($config['domain'] && !$config['servers']) { if (!($servers = LDAPAuthentication::autodiscover($config['domain'], - preg_split('/,?\s+/', $config['dns'])))) + array_filter(preg_split('/,?\s+/', $config['dns'])))) + ) { $this->getForm()->getField('servers')->addError( $__("Unable to find LDAP servers for this domain. Try giving an address of one of the DNS servers or manually specify the LDAP servers for this domain below.")); + } + // Attemt to discover the closest server + elseif (false !== ($idx = + LDAPAuthentication::findClosestLdapServer($servers)) + ) { + if (class_exists('Messages')) { # added in v1.10 + Messages::info(sprintf( + $__('%s was detected as the closest LDAP server. If this is not true, set the servers manually'), + $servers[$idx]['host'])); + } + $servers = array($servers[$idx]); + } + elseif ($idx === null) { + $this->getForm()->getField('servers')->addError( + sprintf($__( + "No autodiscovered servers (%s) seem to be responding" + ), implode(', ', array_map( + function($a) { return $a['host']; }, + $servers)) + )); + } + // else scanning might not be supported by PHP } else { if (!$config['servers']) @@ -160,6 +187,7 @@ function pre_save(&$config, &$errors) { $servers[] = array('host' => $host); } } + $connection_error = false; foreach ($servers as $info) { // Assume MSAD @@ -181,6 +209,12 @@ function pre_save(&$config, &$errors) { 'LDAP_OPT_TIMELIMIT' => 5, 'LDAP_OPT_NETWORK_TIMEOUT' => 5, ); + // Break 'port' to a separate option + @list($host, $port) = explode(':', $info['host'], 2); + if ($port) { + $info['port'] = $port; + $info['host'] = $host; + } $c = new Net_LDAP2($info); $r = $c->bind(); if (PEAR::isError($r)) { From 6f96a34efc49f9993e1e327f3a9974146ab998eb Mon Sep 17 00:00:00 2001 From: Jared Hancock Date: Fri, 2 Oct 2015 11:45:42 -0500 Subject: [PATCH 2/6] ldap: Store and prefer the last-known closest server --- auth-ldap/authentication.php | 73 ++++++++++++++++++++++++------------ 1 file changed, 49 insertions(+), 24 deletions(-) diff --git a/auth-ldap/authentication.php b/auth-ldap/authentication.php index a5c20a8..f70245e 100644 --- a/auth-ldap/authentication.php +++ b/auth-ldap/authentication.php @@ -129,7 +129,9 @@ static function lookupDnsWindows($domain) { * "DNS-Based Discovery" (Microsoft) * https://msdn.microsoft.com/en-us/library/cc717360.aspx */ - static function autodiscover($domain, $dns=array(), $closestOnly=false) { + static function autodiscover($domain, $dns=array(), $closestOnly=false, + $config=null + ) { if (!$dns && stripos(PHP_OS, 'WIN') === 0) { // Net_DNS2_Resolver won't work on windows servers without DNS // specified @@ -147,7 +149,7 @@ static function autodiscover($domain, $dns=array(), $closestOnly=false) { }); // Locate closest domain controller (if requested) if ($closestOnly) { - if ($idx = self::findClosestLdapServer($servers)) { + if ($idx = self::findClosestLdapServer($servers, $config)) { return array($servers[$idx]); } } @@ -174,15 +176,51 @@ static function autodiscover($domain, $dns=array(), $closestOnly=false) { * (PHP, rbarnes at fake dot com) * http://us3.php.net/manual/en/function.socket-connect.php#84465 */ - static function findClosestLdapServer($servers, $defl_port=389) { + static function findClosestLdapServer($servers, $config=false, + $defl_port=389 + ) { if (!function_exists('socket_create')) return false; - $sockets = array(); + // If there's only one selection, then it must be the fastest reset($servers); + if (count($servers) < 2) + return key($servers); + + // Start with last-used closest server + if ($config && ($T = $config->get('closest'))) { + foreach ($servers as $i=>$S) { + if ($T == $S['host']) { + // Move this server to the front of the list (but don't + // change the indexing + $servers = array($i=>$S) + $servers; + break; + } + } + } + + $sockets = array(); $closest = null; - $loops = 60; # 6 seconds max + $loops = 100; # ~50ms seconds max while (!$closest && $loops--) { + // Look for a successful connection + foreach ($sockets as $i=>$X) { + list($sk, $host, $port) = $X; + if (@socket_connect($sk, $host, $port)) { + // Connected!!! + $closest = $i; + break; + } + else { + $error = socket_last_error(); + if (!in_array($error, array(SOCKET_EINPROGRESS, SOCKET_EALREADY))) { + // Bad mojo + socket_close($sk); + unset($sockets,$i); + } + } + } + // Look for anothe rserver list($i, $S) = each($servers); if ($S) { // Add another socket to the list @@ -202,31 +240,18 @@ static function findClosestLdapServer($servers, $defl_port=389) { $sockets[$i] = array($sk, $host, $port ?: $defl_port); } - // Look for a successful connection - foreach ($sockets as $i=>$X) { - list($sk, $host, $port) = $X; - if (@socket_connect($sk, $host, $port)) { - // Connected!!! - $closest = $i; - break; - } - else { - $error = socket_last_error(); - if (!in_array($error, array(SOCKET_EINPROGRESS, SOCKET_EALREADY))) { - // Bad mojo - socket_close($sk); - unset($sockets,$i); - } - } - } // Microsoft recommends waiting 0.1s; however, we're in the // business of providing quick response times - usleep(2000); + usleep(500); } foreach ($sockets as $X) { list($sk) = $X; socket_close($sk); } + // Save closest server for faster response next time + if ($config) { + $config->set('closest', $servers[$closest]['host']); + } return $closest; } @@ -236,7 +261,7 @@ function getServers() { if ($domain = $this->getConfig()->get('domain')) { $dns = preg_split('/,?\s+/', $this->getConfig()->get('dns')); return self::autodiscover($domain, array_filter($dns), - true); + true, $this->getConfig()); } } if ($servers) { From 9af56804fcad1293be30b0f8d88a8395b09c89e9 Mon Sep 17 00:00:00 2001 From: Jared Hancock Date: Fri, 15 Feb 2019 15:03:45 -0600 Subject: [PATCH 3/6] ldap: Support specifying a server in the config --- auth-ldap/authentication.php | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/auth-ldap/authentication.php b/auth-ldap/authentication.php index f70245e..398bceb 100644 --- a/auth-ldap/authentication.php +++ b/auth-ldap/authentication.php @@ -149,7 +149,14 @@ static function autodiscover($domain, $dns=array(), $closestOnly=false, }); // Locate closest domain controller (if requested) if ($closestOnly) { - if ($idx = self::findClosestLdapServer($servers, $config)) { + // If there are no servers from DNS, but there is one saved in the + // config, return that one + if (count($servers) === 0 + && $config && ($T = $config->get('closest')) + ) { + return array($T); + } + if (is_int($idx = self::findClosestLdapServer($servers, $config))) { return array($servers[$idx]); } } From 5eb258f61d07da7625b38ba598f797804289f5c9 Mon Sep 17 00:00:00 2001 From: Jared Hancock Date: Fri, 15 Feb 2019 15:04:19 -0600 Subject: [PATCH 4/6] ldap: oops: Fix couple typos --- auth-ldap/authentication.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/auth-ldap/authentication.php b/auth-ldap/authentication.php index 398bceb..8027676 100644 --- a/auth-ldap/authentication.php +++ b/auth-ldap/authentication.php @@ -223,11 +223,11 @@ static function findClosestLdapServer($servers, $config=false, if (!in_array($error, array(SOCKET_EINPROGRESS, SOCKET_EALREADY))) { // Bad mojo socket_close($sk); - unset($sockets,$i); + unset($sockets[$i]); } } } - // Look for anothe rserver + // Look for another server list($i, $S) = each($servers); if ($S) { // Add another socket to the list From 90ee5d66f19597e003a92e0d6cfe0cea61dd001a Mon Sep 17 00:00:00 2001 From: Jared Hancock Date: Tue, 19 Jan 2021 10:23:18 -0600 Subject: [PATCH 5/6] ldap: Add support for avatars from Active Directory --- auth-ldap/authentication.php | 91 ++++++++++++++++++++++++++++++++++++ 1 file changed, 91 insertions(+) diff --git a/auth-ldap/authentication.php b/auth-ldap/authentication.php index 8027676..e2d978b 100644 --- a/auth-ldap/authentication.php +++ b/auth-ldap/authentication.php @@ -37,6 +37,7 @@ class LDAPAuthentication { 'phone' => 'telephoneNumber', 'mobile' => false, 'username' => 'sAMAccountName', + 'avatar' => array('jpegPhoto', 'thumbnailPhoto'), 'dn' => '{username}@{domain}', 'search' => '(&(objectCategory=person)(objectClass=user)(|(sAMAccountName={q}*)(firstName={q}*)(lastName={q}*)(displayName={q}*)))', 'lookup' => '(&(objectCategory=person)(objectClass=user)({attr}={q}))', @@ -58,6 +59,7 @@ class LDAPAuthentication { 'phone' => 'telephoneNumber', 'mobile' => 'mobileTelephoneNumber', 'username' => 'uid', + 'avatar' => 'jpegPhoto', 'dn' => 'uid={username},{search_base}', 'search' => '(&(objectClass=inetOrgPerson)(|(uid={q}*)(displayName={q}*)(cn={q}*)))', 'lookup' => '(&(objectClass=inetOrgPerson)({attr}={q}))', @@ -642,6 +644,91 @@ function authenticate($username, $password=false, $errors=array()) { } } +if (defined('MAJOR_VERSION') && version_compare(MAJOR_VERSION, '1.10', '>=')) { + require_once INCLUDE_DIR . 'class.avatar.php'; + + class LdapAvatarSource + extends AvatarSource { + static $id = 'ldap'; + static $name = 'LDAP and Active Directory'; + + static $config; + + function getAvatar($user) { + return new LdapAvatar($user); + } + + static function registerUrl($config) { + static::$config = $config; + Signal::connect('api', function($dispatcher) { + $dispatcher->append( + url_get('^/ldap/avatar$', array('LdapAvatarSource', 'tryFetchAvatar')) + ); + }); + } + + static function tryFetchAvatar() { + static::fetchAvatar(); + // if fetchAvatar is successful, then it won't return + Http::redirect(ROOT_PATH.'images/mystery-oscar.png'); + } + + static function fetchAvatar() { + $ldap = new LDAPAuthentication(static::$config); + + if (!($c = $ldap->getConnection())) + return null; + + // This requires a search user to be defined + if (!$ldap->_bind($c)) + return null; + + $schema_type = $ldap->getSchema($c); + $schema = $ldap::$schemas[$schema_type]['user']; + list($email, $username) = + Net_LDAP2_Util::escape_filter_value(array( + $_GET['email'], $_GET['username'])); + + $r = $c->search( + $ldap->getSearchBase(), + sprintf('(|(%s=%s)(%s=%s))', $schema['email'], $email, + $schema['username'], $username), + array( + 'sizelimit' => 1, + 'attributes' => array_filter(flatten(array( + $schema['avatar'] + ))), + ) + ); + if (PEAR::isError($r) || !$r->count()) + return null; + + if (!($avatar = $ldap->_getValue($r->current(), $schema['avatar']))) + return null; + + // Ensure the value is cacheable + $etag = md5($avatar); + Http::cacheable($etag, false, 86400); + Http::response(200, $avatar, 'image/jpeg', false); + } + } + + class LdapAvatar + extends Avatar { + function getUrl($size) { + $user = $this->user; + $acct = $user instanceof User + ? $this->user->getAccount() + : $user; + return ROOT_PATH . 'api/ldap/avatar?' + .Http::build_query(array( + 'email' => $this->user->getEmail(), + 'username' => $acct ? $acct->username : '', + )); + } + } +} + require_once(INCLUDE_DIR.'class.plugin.php'); require_once('config.php'); class LdapAuthPlugin extends Plugin { @@ -653,5 +740,9 @@ function bootstrap() { StaffAuthenticationBackend::register(new StaffLDAPAuthentication($config)); if ($config->get('auth-client')) UserAuthenticationBackend::register(new ClientLDAPAuthentication($config)); + if (class_exists('LdapAvatarSource')) { + AvatarSource::register('LdapAvatarSource'); + LdapAvatarSource::registerUrl($config); + } } } From 24a02fd7ea258b27b29a7d1f9d140ac016e3e31e Mon Sep 17 00:00:00 2001 From: Jared Hancock Date: Tue, 19 Jan 2021 10:36:45 -0600 Subject: [PATCH 6/6] ldap: Perform authenticated bind when possible --- auth-ldap/authentication.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/auth-ldap/authentication.php b/auth-ldap/authentication.php index e2d978b..84d3645 100644 --- a/auth-ldap/authentication.php +++ b/auth-ldap/authentication.php @@ -316,8 +316,7 @@ function getConnection($force_reconnect=false) { } $params = $defaults + $s; $c = new Net_LDAP2($params); - $r = $c->bind(); - if (!PEAR::isError($r)) { + if ($this->_bind($c)) { $connection = $c; return $c; }