icingaweb2/library/Icinga/Protocol/Ldap/Connection.php

469 lines
14 KiB
PHP

<?php
/**
* LDAP connection handler
*/
namespace Icinga\Protocol\Ldap;
use Icinga\Application\Platform;
use \Icinga\Application\Config;
use Icinga\Application\Logger as Log;
/**
* Backend class managing all the LDAP stuff for you.
*
* Usage example:
*
* <code>
* $lconf = new Connection((object) array(
* 'hostname' => 'localhost',
* 'root_dn' => 'dc=monitoring,dc=...',
* 'bind_dn' => 'cn=Mangager,dc=monitoring,dc=...',
* 'bind_pw' => '***'
* ));
* </code>
*
* @copyright Copyright (c) 2013 Icinga-Web Team <info@icinga.org>
* @author Icinga-Web Team <info@icinga.org>
* @license http://www.gnu.org/copyleft/gpl.html GNU General Public License
*/
class Connection
{
const LDAP_NO_SUCH_OBJECT = 0x20;
protected $ds;
protected $hostname;
protected $port = 389;
protected $bind_dn;
protected $bind_pw;
protected $root_dn;
protected $count;
protected $ldap_extension = array(
'1.3.6.1.4.1.1466.20037' => 'STARTTLS',
// '1.3.6.1.4.1.4203.1.11.1' => '11.1', // PASSWORD_MODIFY
// '1.3.6.1.4.1.4203.1.11.3' => '11.3', // Whoami
// '1.3.6.1.1.8' => '8', // Cancel Extended Request
);
protected $ms_capability = array(
// Prefix LDAP_CAP_
// Source: http://msdn.microsoft.com/en-us/library/cc223359.aspx
// Running Active Directory as AD DS:
'1.2.840.113556.1.4.800' => 'ACTIVE_DIRECTORY_OID',
// Capable of signing and sealing on an NTLM authenticated connection
// and of performing subsequent binds on a signed or sealed connection.
'1.2.840.113556.1.4.1791' => 'ACTIVE_DIRECTORY_LDAP_INTEG_OID',
// If AD DS: running at least W2K3, if AD LDS running at least W2K8
'1.2.840.113556.1.4.1670' => 'ACTIVE_DIRECTORY_V51_OID',
// If AD LDS: accepts DIGEST-MD5 binds for AD LDSsecurity principals
'1.2.840.113556.1.4.1880' => 'ACTIVE_DIRECTORY_ADAM_DIGEST',
// Running Active Directory as AD LDS
'1.2.840.113556.1.4.1851' => 'ACTIVE_DIRECTORY_ADAM_OID',
// If AD DS: it's a Read Only DC (RODC)
'1.2.840.113556.1.4.1920' => 'ACTIVE_DIRECTORY_PARTIAL_SECRETS_OID',
// Running at least W2K8
'1.2.840.113556.1.4.1935' => 'ACTIVE_DIRECTORY_V60_OID',
// Running at least W2K8r2
'1.2.840.113556.1.4.2080' => 'ACTIVE_DIRECTORY_V61_R2_OID',
// Running at least W2K12
'1.2.840.113556.1.4.2237' => 'ACTIVE_DIRECTORY_W8_OID',
);
protected $root;
protected $supports_v3 = false;
protected $supports_tls = false;
/**
* Constructor
*
* TODO: Allow to pass port and SSL options
*
* @param array LDAP connection credentials
*/
public function __construct($config)
{
$this->hostname = $config->hostname;
$this->bind_dn = $config->bind_dn;
$this->bind_pw = $config->bind_pw;
$this->root_dn = $config->root_dn;
}
public function getDN()
{
return $this->root_dn;
}
public function root()
{
if ($this->root === null) {
$this->root = Root::forConnection($this);
}
return $this->root;
}
public function select()
{
return new Query($this);
}
public function fetchOne($query, $fields = array())
{
$row = (array) $this->fetchRow($query, $fields);
return array_shift($row);
}
public function fetchDN($query, $fields = array())
{
$rows = $this->fetchAll($query, $fields);
if (count($rows) !== 1) {
throw new \Exception(
sprintf(
'Cannot fetch single DN for %s',
$query
)
);
}
return key($rows);
}
public function fetchRow($query, $fields = array())
{
// TODO: This is ugly, make it better!
$results = $this->fetchAll($query, $fields);
return array_shift($results);
}
public function count(Query $query)
{
$results = $this->runQuery($query, '+');
if (! $results) {
return 0;
}
return ldap_count_entries($this->ds, $results);
}
public function fetchAll($query, $fields = array())
{
$offset = null;
$limit = null;
if ($query->hasLimit()) {
$offset = $query->getOffset();
$limit = $query->getLimit();
}
$entries = array();
$results = $this->runQuery($query, $fields);
if (! $results) {
return array();
}
$entry = ldap_first_entry($this->ds, $results);
$count = 0;
while ($entry) {
if (($offset === null || $offset <= $count)
&& ($limit === null || ($offset + $limit) >= $count)
) {
$attrs = ldap_get_attributes($this->ds, $entry);
$entries[ldap_get_dn($this->ds, $entry)]
= $this->cleanupAttributes($attrs);
}
$count++;
$entry = ldap_next_entry($this->ds, $entry);
}
ldap_free_result($results);
return $entries;
}
public function cleanupAttributes(& $attrs)
{
$clean = (object) array();
for ($i = 0; $i < $attrs['count']; $i++) {
$attr_name = $attrs[$i];
if ($attrs[$attr_name]['count'] === 1) {
$clean->$attr_name = $attrs[$attr_name][0];
} else {
for ($j = 0; $j < $attrs[$attr_name]['count']; $j++) {
$clean->{$attr_name}[] = $attrs[$attr_name][$j];
}
}
}
return $clean;
}
protected function runQuery($query, $fields)
{
$this->connect();
if ($query instanceof Query) {
$fields = $query->listFields();
}
// WARNING:
// We do not support pagination right now, and there is no chance to
// do so for PHP < 5.4. Warnings about "Sizelimit exceeded" will
// therefore not be hidden right now.
$results = @ldap_search(
$this->ds,
$this->root_dn,
(string) $query,
$fields,
0, // Attributes and values
0 // No limit - at least where possible
);
if ($results === false) {
if (ldap_errno($this->ds) === self::LDAP_NO_SUCH_OBJECT) {
return false;
}
throw new \Exception(
sprintf(
'LDAP query "%s" (root %s) failed: %s',
$query,
$this->root_dn,
ldap_error($this->ds)
)
);
die('Query failed');
}
$list = array();
if ($query instanceof Query) {
foreach ($query->getSortColumns() as $col) {
ldap_sort($this->ds, $results, $col[0]);
}
}
return $results;
}
public function testCredentials($username, $password)
{
$ds = $this->prepareNewConnection();
$r = @ldap_bind($ds, $username, $password);
if ($r) {
log::debug(
'Successfully tested LDAP credentials (%s / %s)',
$username,
'***'
);
return true;
} else {
log::debug(
'Testing LDAP credentials (%s / %s) failed: %s',
$username,
'***',
ldap_error($ds)
);
return false;
}
}
protected function getConfigDir($sub = null)
{
$dir = Config::getInstance()->getConfigDir() . '/ldap';
if ($sub !== null) {
$dir .= '/' . $sub;
}
return $dir;
}
protected function discoverServerlistForDomain($domain)
{
$ldaps_records = dns_get_record('_ldaps._tcp.' . $domain, DNS_SRV);
$ldap_records = dns_get_record('_ldap._tcp.' . $domain, DNS_SRV);
}
protected function prepareNewConnection()
{
$use_tls = false;
$force_tls = true;
$force_tls = false;
if ($use_tls) {
$this->prepareTlsEnvironment();
}
$ds = ldap_connect($this->hostname, $this->port);
$cap = $this->discoverCapabilities($ds);
if ($use_tls) {
if ($cap->starttls) {
if (@ldap_start_tls($ds)) {
Log::debug('LDAP STARTTLS succeeded');
} else {
Log::debug('LDAP STARTTLS failed: %s', ldap_error($ds));
throw new \Exception(
sprintf(
'LDAP STARTTLS failed: %s',
ldap_error($ds)
)
);
}
} elseif ($force_tls) {
throw new \Exception(
sprintf(
'TLS is required but not announced by %s',
$this->host_name
)
);
} else {
// TODO: Log noticy -> TLS enabled but not announced
}
}
// ldap_rename requires LDAPv3:
if ($cap->ldapv3) {
if (! ldap_set_option($ds, LDAP_OPT_PROTOCOL_VERSION, 3)) {
throw new Exception('LDAPv3 is required');
}
} else {
// TODO: remove this -> FORCING v3 for now
ldap_set_option($ds, LDAP_OPT_PROTOCOL_VERSION, 3);
Log::warn('No LDAPv3 support detected');
}
// Not setting this results in "Operations error" on AD when using the
// whole domain as search base:
ldap_set_option($ds, LDAP_OPT_REFERRALS, 0);
// ldap_set_option($ds, LDAP_OPT_DEREF, LDAP_DEREF_NEVER);
return $ds;
}
protected function prepareTlsEnvironment()
{
$strict_tls = true;
// TODO: allow variable known CA location (system VS Icinga)
if (Platform::isWindows()) {
// putenv('LDAP...')
} else {
if ($strict_tls) {
$ldap_conf = $this->getConfigDir('ldap_ca.conf');
} else {
$ldap_conf = $this->getConfigDir('ldap_nocert.conf');
}
putenv('LDAPRC=' . $ldap_conf);
if (getenv('LDAPRC') !== $ldap_conf) {
throw new Exception('putenv failed');
}
}
}
protected function discoverCapabilities($ds)
{
$query = $this->select()->from(
'*',
array(
'defaultNamingContext',
'namingContexts',
'vendorName',
'vendorVersion',
'supportedSaslMechanisms',
'dnsHostName',
'schemaNamingContext',
'supportedLDAPVersion', // => array(3, 2)
'supportedCapabilities',
'supportedExtension',
'+'
)
);
$fields = $query->listFields();
$result = @ldap_read(
$ds,
'',
(string) $query,
$query->listFields()
);
if (! $result) {
throw new Exception(
sprintf(
'Capability query failed: %s',
ldap_error($ds)
)
);
}
$entry = ldap_first_entry($ds, $result);
$cap = (object) array(
'ldapv3' => false,
'starttls' => false,
);
if ($entry === false) {
// TODO: Is it OK to have no capabilities?
return $cap;
}
$ldapAttributes = ldap_get_attributes($ds, $entry);
$result = $this->cleanupAttributes(
$ldapAttributes
);
/*
if (isset($result->dnsHostName)) {
ldap_set_option($ds, LDAP_OPT_HOST_NAME, $result->dnsHostName);
}
*/
if ((is_string($result->supportedLDAPVersion)
&& (int) $result->supportedLDAPVersion === 3)
|| (is_array($result->supportedLDAPVersion)
&& in_array(3, $result->supportedLDAPVersion)
)) {
$cap->ldapv3 = true;
}
if (isset($result->supportedCapabilities)) {
foreach ($result->supportedCapabilities as $oid) {
if (array_key_exists($oid, $this->ms_capability)) {
// echo $this->ms_capability[$oid] . "\n";
}
}
}
if (isset($result->supportedExtension)) {
foreach ($result->supportedExtension as $oid) {
if (array_key_exists($oid, $this->ldap_extension)) {
if ($this->ldap_extension[$oid] === 'STARTTLS') {
$cap->starttls = true;
}
}
}
}
return $cap;
}
public function connect()
{
if ($this->ds !== null) {
return;
}
$this->ds = $this->prepareNewConnection();
$r = @ldap_bind($this->ds, $this->bind_dn, $this->bind_pw);
if (! $r) {
throw new \Exception(
sprintf(
'LDAP connection to %s:%s (%s / %s) failed: %s',
$this->hostname,
$this->port,
$this->bind_dn,
'***' /* $this->bind_pw */,
ldap_error($this->ds)
)
);
}
}
public function __destruct()
{
putenv('LDAPRC');
}
}