From b409fbb72591b43df7431e83e30d6c00ea633f21 Mon Sep 17 00:00:00 2001 From: Simon Rettberg Date: Thu, 5 Apr 2018 15:08:52 +0200 Subject: [locationinfo] Add exchange backend Closes #3170 --- .../jamesiarmes/PhpEws/Autodiscover.php | 896 +++++++++++++++++++++ 1 file changed, 896 insertions(+) create mode 100644 modules-available/locationinfo/exchange-includes/jamesiarmes/PhpEws/Autodiscover.php (limited to 'modules-available/locationinfo/exchange-includes/jamesiarmes/PhpEws/Autodiscover.php') diff --git a/modules-available/locationinfo/exchange-includes/jamesiarmes/PhpEws/Autodiscover.php b/modules-available/locationinfo/exchange-includes/jamesiarmes/PhpEws/Autodiscover.php new file mode 100644 index 00000000..8198137d --- /dev/null +++ b/modules-available/locationinfo/exchange-includes/jamesiarmes/PhpEws/Autodiscover.php @@ -0,0 +1,896 @@ +setCAInfo('/path/to/your/cacert.pem'); + * $ews = $auto->newEWS(); + * + * @link http://technet.microsoft.com/en-us/library/bb332063(EXCHG.80).aspx + * @link https://www.testexchangeconnectivity.com/ + * + * @package php-ews\AutoDiscovery + */ +class Autodiscover +{ + /** + * The path appended to the various schemes and hostnames used during + * autodiscovery. + * + * @var string + */ + const AUTODISCOVER_PATH = '/autodiscover/autodiscover.xml'; + + /** + * Server was discovered using the TLD method. + * + * @var integer + */ + const AUTODISCOVERED_VIA_TLD = 10; + + /** + * Server was discovered using the subdomain method. + * + * @var integer + */ + const AUTODISCOVERED_VIA_SUBDOMAIN = 11; + + /** + * Server was discovered using the unauthenticated GET method. + * + * @var integer + */ + const AUTODISCOVERED_VIA_UNAUTHENTICATED_GET = 12; + + /** + * Server was discovered using the DNS SRV redirect method. + * + * @var integer + */ + const AUTODISCOVERED_VIA_SRV_RECORD = 13; + + /** + * Server was discovered using the HTTP redirect method. + * + * @var integer + * + * @todo We do not currently support this. + */ + const AUTODISCOVERED_VIA_RESPONSE_REDIRECT = 14; + + /** + * The email address to attempt autodiscovery against. + * + * @var string + */ + protected $email; + + /** + * The password to present during autodiscovery. + * + * @var string + */ + protected $password; + + /** + * The Exchange username to use during authentication. If unspecified, + * the provided email address will be used as the username. + * + * @var string + */ + protected $username; + + /** + * The top-level domain name, extracted from the provided email address. + * + * @var string + */ + protected $tld; + + /** + * The Autodiscover XML request. Since it's used repeatedly, it's cached + * in this property to avoid redundant re-generation. + * + * @var string + */ + protected $requestxml; + + /** + * The Certificate Authority path. Should point to a directory containing + * one or more certificates to use in SSL verification. + * + * @var string + */ + protected $capath; + + /** + * The path to a specific Certificate Authority file. Get one and use it + * for full Autodiscovery compliance. + * + * @var string + * + * @link http://curl.haxx.se/ca/cacert.pem + * @link http://curl.haxx.se/ca/ + */ + protected $cainfo; + + /** + * Skip SSL verification. Bad idea, and violates the strict Autodiscover + * protocol. But, here in case you have no other option. + * Defaults to FALSE. + * + * @var boolean + */ + protected $skip_ssl_verification = false; + + /** + * The body of the last response. + * + * @var string + */ + public $last_response; + + /** + * An associative array of response headers that resulted from the + * last request. Keys are lowercased for easy checking. + * + * @var array + */ + public $last_response_headers; + + /** + * The output of curl_info() relating to the most recent cURL request. + * + * @var array + */ + public $last_info; + + /** + * The cURL error code associated with the most recent cURL request. + * + * @var integer + */ + public $last_curl_errno; + + /** + * Human-readable description of the most recent cURL error. + * + * @var string + */ + public $last_curl_error; + + /** + * The value in seconds to use for Autodiscover host connection timeouts. + * Default connection timeout is 2 seconds, so that unresponsive methods + * can be bypassed quickly. + * + * @var integer + */ + public $connection_timeout = 2; + + /** + * Information about an Autodiscover Response containing an error will + * be stored here. + * + * @var mixed + */ + public $error = false; + + /** + * Information about an Autodiscover Response with a redirect will be + * retained here. + * + * @var mixed + */ + public $redirect = false; + + /** + * A successful, non-error and non-redirect parsed Autodiscover response + * will be stored here. + * + * @var mixed + */ + public $discovered = null; + + /** + * Constructor for the EWSAutodiscover class. + * + * @param string $email + * @param string $password + * @param string $username + * If left blank, the email provided will be used. + */ + public function __construct($email, $password, $username = null) + { + $this->email = $email; + $this->password = $password; + if ($username === null) { + $this->username = $email; + } else { + $this->username = $username; + } + + $this->setTLD(); + } + + /** + * Execute the full discovery chain of events in the correct sequence + * until a valid response is received, or all methods have failed. + * + * @return integer + * One of the AUTODISCOVERED_VIA_* constants. + * + * @throws \RuntimeException + * When all autodiscovery methods fail. + */ + public function discover() + { + $result = $this->tryTLD(); + + if ($result === false) { + $result = $this->trySubdomain(); + } + + if ($result === false) { + $result = $this->trySubdomainUnauthenticatedGet(); + } + + if ($result === false) { + $result = $this->trySRVRecord(); + } + + if ($result === false) { + throw new \RuntimeException('Autodiscovery failed.'); + } + + return $result; + } + + /** + * Return the settings discovered from the Autodiscover process. + * + * NULL indicates discovery has not completed (or been attempted) + * FALSE indicates discovery was not successful. Check for errors + * or redirects. + * An array will be returned with discovered settings on success. + * + * @return mixed + */ + public function discoveredSettings() + { + return $this->discovered; + } + + /** + * Toggle skipping of SSL verification in cURL requests. + * + * @param boolean $skip + * Whether or not to skip SSL certificate verification. + * @return self + */ + public function skipSSLVerification($skip = true) + { + $this->skip_ssl_verification = (bool) $skip; + + return $this; + } + + /** + * Parse the hex ServerVersion value and return a valid + * Client::VERSION_* constant. + * + * @return string|boolean A known version constant, or FALSE if it could not + * be determined. + * + * @link http://msdn.microsoft.com/en-us/library/bb204122(v=exchg.140).aspx + * @link http://blogs.msdn.com/b/pcreehan/archive/2009/09/21/parsing-serverversion-when-an-int-is-really-5-ints.aspx + * @link http://office.microsoft.com/en-us/outlook-help/determine-the-version-of-microsoft-exchange-server-my-account-connects-to-HA001191800.aspx + * + * @param string $version_hex + * Hexadecimal version string. + */ + public function parseServerVersion($version_hex) + { + $svbinary = base_convert($version_hex, 16, 2); + if (strlen($svbinary) == 31) { + $svbinary = '0' . $svbinary; + } + + $majorversion = base_convert(substr($svbinary, 4, 6), 2, 10); + $minorversion = base_convert(substr($svbinary, 10, 6), 2, 10); + $majorbuild = base_convert(substr($svbinary, 17, 15), 2, 10); + + switch ($majorversion) { + case 8: + return $this->parseVersion2007($minorversion); + case 14: + return $this->parseVersion2010($minorversion); + case 15: + if ($minorversion == 0) { + return $this->parseVersion2013($majorbuild); + } + + return $this->parseVersion2016(); + } + + // Guess we didn't find a known version. + return false; + } + + /** + * Method to return a new Client object, auto-configured + * with the proper hostname. + * + * @return mixed Client object on success, FALSE on failure. + */ + public function newEWS() + { + // Discovery not yet attempted. + if ($this->discovered === null) { + $this->discover(); + } + + // Discovery not successful. + if ($this->discovered === false) { + return false; + } + + $server = false; + $version = null; + + // Pick out the host from the EXPR (Exchange RPC over HTTP). + foreach ($this->discovered['Account']['Protocol'] as $protocol) { + if ( + ($protocol['Type'] == 'EXCH' || $protocol['Type'] == 'EXPR') + && isset($protocol['ServerVersion']) + ) { + if ($version === null) { + $sv = $this->parseServerVersion($protocol['ServerVersion']); + if ($sv !== false) { + $version = $sv; + } + } + } + + if ($protocol['Type'] == 'EXPR' && isset($protocol['Server'])) { + $server = $protocol['Server']; + } + } + + if ($server) { + if ($version === null) { + // EWS class default. + $version = Client::VERSION_2007; + } + return new Client( + $server, + (!empty($this->username) ? $this->username : $this->email), + $this->password, + $version + ); + } + + return false; + } + + /** + * Static method may fail if there are issues surrounding SSL certificates. + * In such cases, set up the object as needed, and then call newEWS(). + * + * @param string $email + * @param string $password + * @param string $username + * If left blank, the email provided will be used. + * @return mixed + */ + public static function getEWS($email, $password, $username = null) + { + $auto = new Autodiscover($email, $password, $username); + return $auto->newEWS(); + } + + /** + * Perform an NTLM authenticated HTTPS POST to the top-level + * domain of the email address. + * + * @return integer|boolean + * One of the AUTODISCOVERED_VIA_* constants or false on failure. + */ + public function tryTLD() + { + $url = 'https://' . $this->tld . self::AUTODISCOVER_PATH; + return ($this->tryViaUrl($url) ? self::AUTODISCOVERED_VIA_TLD : false); + } + + /** + * Perform an NTLM authenticated HTTPS POST to the 'autodiscover' + * subdomain of the email address' TLD. + * + * @return integer|boolean + * One of the AUTODISCOVERED_VIA_* constants or false on failure. + */ + public function trySubdomain() + { + $url = 'https://autodiscover.' . $this->tld . self::AUTODISCOVER_PATH; + return ($this->tryViaUrl($url) + ? self::AUTODISCOVERED_VIA_SUBDOMAIN + : false); + } + + /** + * Perform an unauthenticated HTTP GET in an attempt to get redirected + * via 302 to the correct location to perform the HTTPS POST. + * + * @return integer|boolean + * One of the AUTODISCOVERED_VIA_* constants or false on failure. + */ + public function trySubdomainUnauthenticatedGet() + { + $this->reset(); + $url = 'http://autodiscover.' . $this->tld . self::AUTODISCOVER_PATH; + $ch = curl_init(); + $opts = array( + CURLOPT_URL => $url, + CURLOPT_HTTPGET => true, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 4, + CURLOPT_CONNECTTIMEOUT => $this->connection_timeout, + CURLOPT_FOLLOWLOCATION => false, + CURLOPT_HEADER => false, + CURLOPT_HEADERFUNCTION => array($this, 'readHeaders'), + CURLOPT_HTTP200ALIASES => array(301, 302), + CURLOPT_IPRESOLVE => CURL_IPRESOLVE_V4 + ); + curl_setopt_array($ch, $opts); + $this->last_response = curl_exec($ch); + $this->last_info = curl_getinfo($ch); + $this->last_curl_errno = curl_errno($ch); + $this->last_curl_error = curl_error($ch); + + if ( + $this->last_info['http_code'] == 302 + || $this->last_info['http_code'] == 301 + ) { + if ($this->tryViaUrl($this->last_response_headers['location'])) { + return self::AUTODISCOVERED_VIA_UNAUTHENTICATED_GET; + } + } + + return false; + } + + /** + * Attempt to retrieve the autodiscover host from an SRV DNS record. + * + * @link http://support.microsoft.com/kb/940881 + * + * @return integer|boolean + * The value of self::AUTODISCOVERED_VIA_SRV_RECORD or false. + */ + public function trySRVRecord() + { + $srvhost = '_autodiscover._tcp.' . $this->tld; + $lookup = dns_get_record($srvhost, DNS_SRV); + if (sizeof($lookup) > 0) { + $host = $lookup[0]['target']; + $url = 'https://' . $host . self::AUTODISCOVER_PATH; + if ($this->tryViaUrl($url)) { + return self::AUTODISCOVERED_VIA_SRV_RECORD; + } + } + + return false; + } + + /** + * Set the path to the file to be used by CURLOPT_CAINFO. + * + * @param string $path + * Path to a certificate file such as cacert.pem + * @return self + */ + public function setCAInfo($path) + { + if (file_exists($path) && is_file($path)) { + $this->cainfo = $path; + } + + return $this; + } + + /** + * Set the path to the file to be used by CURLOPT_CAPATH. + * + * @param string $path + * Path to a directory containing one or more CA certificates. + * @return self + */ + public function setCAPath($path) + { + if (is_dir($path)) { + $this->capath = $path; + } + + return $this; + } + + /** + * Set a connection timeout for the POST methods. + * + * @param integer $seconds + * Seconds to wait for a connection. + * @return self + */ + public function setConnectionTimeout($seconds) + { + $this->connection_timeout = intval($seconds); + + return $this; + } + + /** + * Perform the NTLM authenticated post against one of the chosen + * endpoints. + * + * @param string $url + * URL to try posting to. + * @param integer $timeout + * Number of seconds before the request should timeout. + * @return boolean + */ + public function doNTLMPost($url, $timeout = 6) + { + $this->reset(); + + $ch = curl_init(); + $opts = array( + CURLOPT_URL => $url, + CURLOPT_HTTPAUTH => CURLAUTH_BASIC | CURLAUTH_NTLM, + CURLOPT_CUSTOMREQUEST => 'POST', + CURLOPT_POSTFIELDS => $this->getAutoDiscoverRequest(), + CURLOPT_RETURNTRANSFER => true, + CURLOPT_USERPWD => $this->username . ':' . $this->password, + CURLOPT_TIMEOUT => $timeout, + CURLOPT_CONNECTTIMEOUT => $this->connection_timeout, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_HEADER => false, + CURLOPT_HEADERFUNCTION => array($this, 'readHeaders'), + CURLOPT_IPRESOLVE => CURL_IPRESOLVE_V4, + CURLOPT_SSL_VERIFYPEER => true, + CURLOPT_SSL_VERIFYHOST => 2, + ); + + // Set the appropriate content-type. + curl_setopt($ch, CURLOPT_HTTPHEADER, array('Content-Type: text/xml; charset=utf-8')); + + if (!empty($this->cainfo)) { + $opts[CURLOPT_CAINFO] = $this->cainfo; + } + + if (!empty($this->capath)) { + $opts[CURLOPT_CAPATH] = $this->capath; + } + + if ($this->skip_ssl_verification) { + $opts[CURLOPT_SSL_VERIFYPEER] = false; + } + + curl_setopt_array($ch, $opts); + $this->last_response = curl_exec($ch); + $this->last_info = curl_getinfo($ch); + $this->last_curl_errno = curl_errno($ch); + $this->last_curl_error = curl_error($ch); + + if ($this->last_curl_errno != CURLE_OK) { + return false; + } + + $discovered = $this->parseAutodiscoverResponse(); + + return $discovered; + } + + /** + * Parse the Autoresponse Payload, particularly to determine if an + * additional request is necessary. + * + * @return boolean|array FALSE if response isn't XML or parsed response + * array. + */ + protected function parseAutodiscoverResponse() + { + // Content-type isn't trustworthy, unfortunately. Shame on Microsoft. + if (substr($this->last_response, 0, 5) !== 'responseToArray($this->last_response); + + if (isset($response['Error'])) { + $this->error = $response['Error']; + return false; + } + + // Check the account action for redirect. + switch ($response['Account']['Action']) { + case 'redirectUrl': + $this->redirect = array( + 'redirectUrl' => $response['Account']['RedirectUrl'] + ); + return false; + case 'redirectAddr': + $this->redirect = array( + 'redirectAddr' => $response['Account']['RedirectAddr'] + ); + return false; + case 'settings': + default: + $this->discovered = $response; + return true; + } + } + + /** + * Set the top-level domain to be used with autodiscover attempts based + * on the provided email address. + * + * @return boolean + */ + protected function setTLD() + { + $pos = strpos($this->email, '@'); + if ($pos !== false) { + $this->tld = trim(substr($this->email, $pos + 1)); + return true; + } + + return false; + } + + /** + * Reset the response-related structures. Called before making a new + * request. + * + * @return self + */ + public function reset() + { + $this->last_response_headers = array(); + $this->last_info = array(); + $this->last_curl_errno = 0; + $this->last_curl_error = ''; + + return $this; + } + + /** + * Return the generated Autodiscover XML request body. + * + * @return string + */ + public function getAutodiscoverRequest() + { + if (!empty($this->requestxml)) { + return $this->requestxml; + } + + $xml = new \XMLWriter(); + $xml->openMemory(); + $xml->setIndent(true); + $xml->startDocument('1.0', 'UTF-8'); + $xml->startElementNS( + null, + 'Autodiscover', + 'http://schemas.microsoft.com/exchange/autodiscover/outlook/requestschema/2006' + ); + + $xml->startElement('Request'); + $xml->writeElement('EMailAddress', $this->email); + $xml->writeElement( + 'AcceptableResponseSchema', + 'http://schemas.microsoft.com/exchange/autodiscover/outlook/responseschema/2006a' + ); + $xml->endElement(); + $xml->endElement(); + + $this->requestxml = $xml->outputMemory(); + return $this->requestxml; + } + + /** + * Utility function to pick headers off of the incoming cURL response. + * Used with CURLOPT_HEADERFUNCTION. + * + * @param resource $_ch + * cURL handle. + * @param string $str + * Header string to read. + * @return integer + * Bytes read. + */ + public function readHeaders($_ch, $str) + { + $pos = strpos($str, ':'); + if ($pos !== false) { + $key = strtolower(substr($str, 0, $pos)); + $val = trim(substr($str, $pos + 1)); + $this->last_response_headers[$key] = $val; + } + + return strlen($str); + } + + /** + * Utility function to parse XML payloads from the response into easier + * to manage associative arrays. + * + * @param string $xml + * XML to parse. + * @return array + */ + public function responseToArray($xml) + { + $doc = new \DOMDocument(); + $doc->loadXML($xml); + $out = $this->nodeToArray($doc->documentElement); + + return $out['Response']; + } + + /** + * Recursive method for parsing DOM nodes. + * + * @param \DOMElement $node + * DOMNode object. + * @return mixed + * + * @link https://github.com/gaarf/XML-string-to-PHP-array + */ + protected function nodeToArray($node) + { + $output = array(); + switch ($node->nodeType) { + case XML_CDATA_SECTION_NODE: + case XML_TEXT_NODE: + $output = trim($node->textContent); + break; + case XML_ELEMENT_NODE: + for ($i = 0, $m = $node->childNodes->length; $i < $m; $i++) { + $child = $node->childNodes->item($i); + $v = $this->nodeToArray($child); + if (isset($child->tagName)) { + $t = $child->tagName; + if (!isset($output[$t])) { + $output[$t] = array(); + } + $output[$t][] = $v; + } elseif ($v || $v === '0') { + $output = (string) $v; + } + } + + // Edge case of a node containing a text node, which also has + // attributes. this way we'll retain text and attributes for + // this node. + if (is_string($output) && $node->attributes->length) { + $output = array('@text' => $output); + } + + if (is_array($output)) { + if ($node->attributes->length) { + $a = array(); + foreach ($node->attributes as $attrName => $attrNode) { + $a[$attrName] = (string) $attrNode->value; + } + $output['@attributes'] = $a; + } + foreach ($output as $t => $v) { + if (is_array($v) && count($v) == 1 && $t != '@attributes') { + $output[$t] = $v[0]; + } + } + } + break; + } + + return $output; + } + + /** + * Parses the version of an Exchange 2007 server. + * + * @param integer $minorversion + * Minor server version. + * @return string Server version. + */ + protected function parseVersion2007($minorversion) { + switch ($minorversion) { + case 0: + return Client::VERSION_2007; + case 1: + case 2: + case 3: + return Client::VERSION_2007_SP1; + default: + return Client::VERSION_2007; + } + } + + /** + * Parses the version of an Exchange 2010 server. + * + * @param integer $minorversion + * Minor server version. + * @return string Server version. + */ + protected function parseVersion2010($minorversion) { + switch ($minorversion) { + case 0: + return Client::VERSION_2010; + case 1: + return Client::VERSION_2010_SP1; + case 2: + return Client::VERSION_2010_SP2; + default: + return Client::VERSION_2010; + } + } + + /** + * Parses the version of an Exchange 2013 server. + * + * @param integer $majorbuild + * Major build version. + * @return string Server version. + */ + protected function parseVersion2013($majorbuild) { + return ($majorbuild == 847 + ? Client::VERSION_2013_SP1 + : Client::VERSION_2013); + } + + /** + * Parses the version of an Exchange 2016 server. + * + * @return string Server version. + */ + protected function parseVersion2016() { + return Client::VERSION_2016; + } + + /** + * Attempts an autodiscover via a URL. + * + * @param string $url + * Url to attempt an autodiscover. + * @param integer $timeout + * Number of seconds before the request should timeout. + * @return boolean + */ + protected function tryViaUrl($url, $timeout = 6) + { + $result = $this->doNTLMPost($url, $timeout); + return ($result ? true : false); + } +} -- cgit v1.2.3-55-g7522