diff --git a/lib/Autodiscover.php b/lib/Autodiscover.php index 33cda73..53cc909 100644 --- a/lib/Autodiscover.php +++ b/lib/Autodiscover.php @@ -1,342 +1,392 @@ | | | | This program is free software: you can redistribute it and/or modify | | it under the terms of the GNU General Public License as published by | | the Free Software Foundation, either version 3 of the License, or | | (at your option) any later version. | | | | This program is distributed in the hope that it will be useful, | | but WITHOUT ANY WARRANTY; without even the implied warranty of | | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | | GNU General Public License for more details. | | | | You should have received a copy of the GNU General Public License | | along with this program. If not, see http://www.gnu.org/licenses/. | +--------------------------------------------------------------------------+ | Author: Aleksander Machniak | +--------------------------------------------------------------------------+ */ /** * Main application class */ class Autodiscover { const CHARSET = 'UTF-8'; protected $conf; protected $config = array(); /** * Autodiscover main execution path */ public static function run() { $uris = array($_SERVER['REQUEST_URI'], $_SERVER['SCRIPT_NAME']); $type = ''; // Detect request type foreach ($uris as $uri) { // Outlook/Activesync if (stripos($uri, 'autodiscover.xml') !== false) { $type = 'Microsoft'; break; } + // Microsoft Autodiscover V2 + elseif (stripos($uri, 'autodiscover.json') !== false) { + $type = 'Json'; + break; + } // Mozilla Thunderbird (Kmail/Kontact/Evolution) else if (strpos($uri, 'config-v1.1.xml') !== false) { $type = 'Mozilla'; break; } } if (!$type) { header("HTTP/1.0 404 Not Found"); exit; } $class = "Autodiscover$type"; require_once __DIR__ . '/' . $class . '.php'; $engine = new $class; $engine->handle(); // fallback to 404 header("HTTP/1.0 404 Not Found"); exit; } /** * Initialization of class instance */ public function __construct() { require_once __DIR__ . '/Conf.php'; require_once __DIR__ . '/Log.php'; $this->conf = Conf::get_instance(); } /** * Handle request */ public function handle() { // read request parameters $this->handle_request(); // validate requested email address if (empty($this->email)) { $this->error("Email address not provided"); } if (!strpos($this->email, '@')) { $this->error("Invalid email address"); } // find/set services parameters $this->configure(); // send response $this->handle_response(); } /** * Send error to the client and exit */ protected function error($msg) { header("HTTP/1.0 500 $msg"); exit; } + /** + * Send 401 Unauthorized to the client end exit + */ + protected function unauthorized($basicauth = true) + { + if ($basicauth) { + header('WWW-Authenticate: Basic realm="'.$_SERVER['HTTP_HOST'].'"'); + } + header('HTTP/1.0 401 Unauthorized'); + exit; + } + /** * Get services configuration */ protected function configure() { $pos = strrpos($this->email, '@'); $this->config = array( 'email' => $this->email, 'domain' => strtolower(substr($this->email, $pos + 1)), 'displayName' => $this->conf->get('autodiscover', 'service_name'), 'displayShortName' => $this->conf->get('autodiscover', 'service_short'), ); // get user form LDAP, set domain/login/user in $this->config $user = $this->get_user($this->email, $this->config['domain']); $proto_map = array('tls' => 'STARTTLS', 'ssl' => 'SSL'); foreach (array('imap', 'pop3', 'smtp') as $type) { if ($value = $this->conf->get('autodiscover', $type)) { $params = explode(';', $value); $pass_secure = in_array($params[1], array('CRAM-MD5', 'DIGEST-MD5')); $host = $params[0]; $host = str_replace('%d', $this->config['domain'], $host); $url = parse_url($host); $this->config[$type] = array( 'hostname' => $url['host'], 'port' => $url['port'], 'socketType' => $proto_map[$url['scheme']] ?: 'plain', 'username' => $this->config['login'] ?: $this->config['email'], 'authentication' => 'password-' . ($pass_secure ? 'encrypted' : 'cleartext'), ); } } if ($host = $this->conf->get('autodiscover', 'activesync')) { $host = str_replace('%d', $this->config['domain'], $host); $this->config['activesync'] = $host; } // Log::debug(print_r($this->config, true)); } /** * Get user record from LDAP */ protected function get_user($email, $domain) { // initialize LDAP connection $result = $this->init_ldap(); if (!$result) { $this->config = array_merge( $this->config, Array('mail' => $email, 'domain' => $domain) ); return; } // find domain if (!$this->ldap->find_domain($domain)) { $this->error("Unknown domain"); } // find user $user = $this->find_user($email, $domain); // update config $this->config = array_merge($this->config, (array)$user, array('domain' => $domain)); } /** * Initialize LDAP connection */ protected function init_ldap() { $ldap_uri = $this->conf->get('ldap_uri', false); if (!$ldap_uri) { return false; } $uri = parse_url($ldap_uri); $this->_ldap_server = ($uri['scheme'] === 'ldaps' ? 'ldaps://' : '') . $uri['host']; $this->_ldap_port = $uri['port']; $this->_ldap_scheme = $uri['scheme']; $this->_ldap_bind_dn = $this->conf->get('ldap', 'service_bind_dn'); $this->_ldap_bind_pw = $this->conf->get('ldap', 'service_bind_pw'); // Catch cases in which the ldap server port has not been explicitely defined if (!$this->_ldap_port) { $this->_ldap_port = $this->_ldap_scheme == 'ldaps' ? 636 : 389; } require_once 'Net/LDAP3.php'; $this->ldap = new Net_LDAP3(array( 'debug' => in_array(strtolower($this->conf->get('autodiscover', 'debug_mode')), array('trace', 'debug')), 'log_hook' => array($this, 'ldap_log'), 'vlv' => $this->conf->get('ldap', 'vlv', Conf::AUTO), 'config_root_dn' => "cn=config", 'hosts' => array($this->_ldap_server), 'port' => $this->_ldap_port, 'use_tls' => $this->_ldap_scheme == 'tls', 'domain_base_dn' => $this->conf->get('ldap', 'domain_base_dn'), 'domain_filter' => $this->conf->get('ldap', 'domain_filter'), 'domain_name_attribute' => $this->conf->get('ldap', 'domain_name_attribute'), )); $this->_ldap_domain = $this->conf->get('primary_domain'); // connect to LDAP if (!$this->ldap->connect()) { $this->error("Storage connection failed"); return false; } // bind as the service user if (!$this->ldap->bind($this->_ldap_bind_dn, $this->_ldap_bind_pw)) { $this->error("Storage connection failed"); return false; } return true; } /** * Find user in LDAP */ private function find_user($email, $domain) { $filter = $this->conf->get('login_filter'); if (empty($filter)) { $filter = $this->conf->get('filter'); } if (empty($filter)) { $filter = "(&(|(mail=%s)(mail=%U@%d)(alias=%s)(alias=%U@%d)(uid=%s))(objectclass=inetorgperson))"; } $_parts = explode('@', $email); $localpart = $_parts[0]; $replace_patterns = array( '/%s/' => $email, '/%d/' => $domain, '/%U/' => $localpart, '/%r/' => $domain, ); $attributes = array( 'login' => $this->conf->get('autodiscover', 'login_attribute') ?: 'mail', 'username' => $this->conf->get('autodiscover', 'name_attribute') ?: 'cn', ); $filter = preg_replace(array_keys($replace_patterns), array_values($replace_patterns), $filter); $base_dn = $this->ldap->domain_root_dn($domain); $result = $this->ldap->search($base_dn, $filter, 'sub', array_values($attributes)); if (!$result) { Log::debug("Could not search $base_dn with $filter"); return; } if ($result->count() > 1) { Log::debug("Multiple entries found."); return; } else if ($result->count() < 1) { Log::debug("No entries found."); return; } // parse result $entries = $result->entries(true); $dn = key($entries); $entry = $entries[$dn]; - $result = array(); + $result = array('dn' => $dn); foreach ($attributes as $idx => $attr) { $result[$idx] = is_array($entry[$attr]) ? current($entry[$attr]) : $entry[$attr]; } return $result; } + /** + * authenticate a user by his given dn and password + */ + protected function authenticate($dn, $password) + { + if (empty($this->_ldap_server)) { + return false; + } + + $ldap = new Net_LDAP3(array( + 'debug' => in_array(strtolower($this->conf->get('autodiscover', 'debug_mode')), array('trace', 'debug')), + 'log_hook' => array($this, 'ldap_log'), + 'hosts' => array($this->_ldap_server), + 'port' => $this->_ldap_port, + 'use_tls' => $this->_ldap_scheme == 'tls' + )); + + // connect to LDAP + if (!$ldap->connect()) { + $this->error("Storage connection failed"); + return false; + } + + // bind as given userdn + if (!$ldap->bind($dn, $password)) { + $this->unauthorized(); + return false; + } + + $ldap->close(); + return true; + } + /** * LDAP logging handler */ public function ldap_log($level, $msg) { if (is_array($msg)) { $msg = implode("\n", $msg); } switch ($level) { case LOG_DEBUG: Log::debug($str . $msg); break; case LOG_ERR: Log::error($str . $msg); break; case LOG_INFO: Log::info($str . $msg); break; case LOG_WARNING: Log::warning($str . $msg); break; case LOG_ALERT: case LOG_CRIT: case LOG_EMERG: case LOG_NOTICE: default: Log::trace($str . $msg); break; } } } diff --git a/lib/AutodiscoverJson.php b/lib/AutodiscoverJson.php new file mode 100644 index 0000000..75a7065 --- /dev/null +++ b/lib/AutodiscoverJson.php @@ -0,0 +1,82 @@ + | + | | + | This program is free software: you can redistribute it and/or modify | + | it under the terms of the GNU General Public License as published by | + | the Free Software Foundation, either version 3 of the License, or | + | (at your option) any later version. | + | | + | This program is distributed in the hope that it will be useful, | + | but WITHOUT ANY WARRANTY; without even the implied warranty of | + | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | + | GNU General Public License for more details. | + | | + | You should have received a copy of the GNU General Public License | + | along with this program. If not, see http://www.gnu.org/licenses/. | + +--------------------------------------------------------------------------+ + | Author: Daniel Hoffend | + +--------------------------------------------------------------------------+ +*/ + +/** + * Autodiscover Service class for Microsoft Autodiscover V2 + */ +class AutodiscoverJson extends Autodiscover +{ + + public function handle_request() + { + if (preg_match('|autodiscover.json/v1.0/([^\?]+)|', $_SERVER['REQUEST_URI'], $regs)) { + $this->email = $regs[1]; + } + + Log::debug('Request [json]: ' . $_SERVER['REQUEST_URI']); + } + + /** + * Generates JSON response + */ + protected function handle_response() + { + if (strtolower($_GET['Protocol']) == 'activesync' + && !empty($this->config['activesync']) + ) { + if (!preg_match('/^https?:/i', $this->config['activesync'])) { + $this->config['activesync'] = 'https://' . $this->config['activesync'] . '/Microsoft-Server-ActiveSync'; + } + $json = array( + 'Protocol' => 'ActiveSync', + 'Url' => $this->config['activesync'] + ); + } + elseif (strtolower($_GET['Protocol']) == 'autodiscoverv1') { + $json = array( + 'Protocol' => 'ActiveSync', + 'Url' => 'https://' . $_SERVER['HTTP_HOST'] . '/Autodiscover/Autodiscover.xml' + ); + } + else { + http_response_code(400); + $json = array( + 'ErrorCore' => 'InvalidProtocol', + 'ErrorMessage' => 'The given protocol value \u0027' + . $_GET['Protocol'] + . '\u0027 is invalid. Supported values are \u0027' + . (!empty($this->config['activesync']) ? 'ActiveSync,' : '') + . 'AutodiscoverV1\u0027' + ); + } + + $response = json_encode($json, JSON_PRETTY_PRINT); + Log::debug('Response [json]: ' . $response); + + header('Content-Type: application/json; charset=' . Autodiscover::CHARSET); + echo $response; + exit; + } +} diff --git a/lib/AutodiscoverMicrosoft.php b/lib/AutodiscoverMicrosoft.php index 4a3e2db..9459098 100644 --- a/lib/AutodiscoverMicrosoft.php +++ b/lib/AutodiscoverMicrosoft.php @@ -1,246 +1,263 @@ | | | | This program is free software: you can redistribute it and/or modify | | it under the terms of the GNU General Public License as published by | | the Free Software Foundation, either version 3 of the License, or | | (at your option) any later version. | | | | This program is distributed in the hope that it will be useful, | | but WITHOUT ANY WARRANTY; without even the implied warranty of | | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | | GNU General Public License for more details. | | | | You should have received a copy of the GNU General Public License | | along with this program. If not, see http://www.gnu.org/licenses/. | +--------------------------------------------------------------------------+ | Author: Aleksander Machniak | +--------------------------------------------------------------------------+ */ /** * Autodiscover Service class for Microsoft Outlook and Activesync devices */ class AutodiscoverMicrosoft extends Autodiscover { const NS = "http://schemas.microsoft.com/exchange/autodiscover/responseschema/2006"; const RESPONSE_NS = "http://schemas.microsoft.com/exchange/autodiscover/outlook/responseschema/2006a"; const MOBILESYNC_NS = "http://schemas.microsoft.com/exchange/autodiscover/mobilesync/responseschema/2006"; private $type = 'outlook'; + private $password; /** * Handle request parameters (find email address) */ protected function handle_request() { $post = $_SERVER['REQUEST_METHOD'] == 'POST' ? file_get_contents('php://input') : null; - Log::debug('Request [microsoft]: ' . $post); + // check for basic authentication + Log::debug('Request [microsoft]: Basic Auth Username: ' . ($_SERVER['PHP_AUTH_USER'] ?: 'none')); + if (empty($_SERVER['PHP_AUTH_USER']) || empty($_SERVER['PHP_AUTH_PW'])) { + $this->unauthorized(); + } + $this->password = $_SERVER['PHP_AUTH_PW']; + // check for request object + Log::debug('Request [microsoft]: ' . $post); if (empty($post)) { $this->error("Invalid input"); } // parse XML try { $xml = new SimpleXMLElement($post); $ns = $xml->getDocNamespaces(); if (empty($ns) || empty($ns[''])) { $this->error("Invalid input. Missing XML request schema"); } $xml->registerXPathNamespace('request', $ns['']); if ($email = $xml->xpath('//request:EMailAddress')) { $this->email = (string) array_shift($email); } if ($schema = $xml->xpath('//request:AcceptableResponseSchema')) { $schema = (string) array_shift($schema); if (strpos($schema, 'mobilesync')) { $this->type = 'mobilesync'; } } } catch (Exception $e) { $this->error("Invalid input"); } + + // basic auth username must match with given email address + if ($_SERVER['PHP_AUTH_USER'] != $this->email) { + $this->unauthorized(); + } } /** * Handle response */ public function handle_response() { - $method = $this->type . '_response'; + // authenticate the user found during configure() against ldap + if (empty($this->config['dn']) || !$this->authenticate($this->config['dn'], $this->password)) { + $this->unauthorized(); + } + $method = $this->type . '_response'; $xml = $this->$method(); $xml->formatOutput = true; $response = $xml->saveXML(); Log::debug('Response [microsoft]: ' . $response); header('Content-type: text/xml; charset=' . Autodiscover::CHARSET); echo $response; exit; } /** * Generates XML response for Activesync */ protected function mobilesync_response() { if (empty($this->config['activesync'])) { $this->error("Activesync not supported"); } if (!preg_match('/^https?:/i', $this->config['activesync'])) { $this->config['activesync'] = 'https://' . $this->config['activesync'] . '/Microsoft-Server-ActiveSync'; } $xml = new DOMDocument('1.0', Autodiscover::CHARSET); // create main elements (tree) $doc = $xml->createElementNS(self::NS, 'Autodiscover'); $doc = $xml->appendChild($doc); $response = $xml->createElementNS(self::MOBILESYNC_NS, 'Response'); $response = $doc->appendChild($response); $user = $xml->createElement('User'); $user = $response->appendChild($user); $action = $xml->createElement('Action'); $action = $response->appendChild($action); $settings = $xml->createElement('Settings'); $settings = $action->appendChild($settings); $server = $xml->createElement('Server'); $server = $settings->appendChild($server); // configuration $dispname = $xml->createElement('DisplayName'); $dispname = $user->appendChild($dispname); $dispname->appendChild($xml->createTextNode($this->config['username'])); $email = $xml->createElement('EMailAddress'); $email = $user->appendChild($email); $email->appendChild($xml->createTextNode($this->config['login'] ?: $this->config['email'])); $element = $xml->createElement('Type'); $element = $server->appendChild($element); $element->appendChild($xml->createTextNode('MobileSync')); $element = $xml->createElement('Url'); $element = $server->appendChild($element); $element->appendChild($xml->createTextNode($this->config['activesync'])); $element = $xml->createElement('Name'); $element = $server->appendChild($element); $element->appendChild($xml->createTextNode($this->config['activesync'])); return $xml; } /** * Generates XML response for Outlook */ protected function outlook_response() { $xml = new DOMDocument('1.0', Autodiscover::CHARSET); // create main elements (tree) $doc = $xml->createElementNS(self::NS, 'Autodiscover'); $doc = $xml->appendChild($doc); $response = $xml->createElementNS(self::RESPONSE_NS, 'Response'); $response = $doc->appendChild($response); $user = $xml->createElement('User'); $user = $response->appendChild($user); $account = $xml->createElement('Account'); $account = $response->appendChild($account); $accountType = $xml->createElement('AccountType'); $accountType = $account->appendChild($accountType); $accountType->appendChild($xml->createTextNode('email')); $action = $xml->createElement('Action'); $action = $account->appendChild($action); $action->appendChild($xml->createTextNode('settings')); // configuration $dispname = $xml->createElement('DisplayName'); $dispname = $user->appendChild($dispname); $dispname->appendChild($xml->createTextNode($this->config['username'])); $email = $xml->createElement('AutoDiscoverSMTPAddress'); $email = $user->appendChild($email); $email->appendChild($xml->createTextNode($this->config['email'])); // @TODO: Microsoft supports also DAV protocol here foreach (array('imap', 'pop3', 'smtp') as $type) { if (!empty($this->config[$type])) { $protocol = $this->add_protocol_element($xml, $type, $this->config[$type]); $account->appendChild($protocol); } } return $xml; } /** * Creates Protocol element for XML response */ private function add_protocol_element($xml, $type, $config) { $protocol = $xml->createElement('Protocol'); $element = $xml->createElement('Type'); $element = $protocol->appendChild($element); $element->appendChild($xml->createTextNode(strtoupper($type))); // @TODO: TTL/ExpirationDate tags // server attributes map $server_attributes = array( 'Server' => 'hostname', 'Port' => 'port', 'LoginName' => 'username', ); foreach ($server_attributes as $tag_name => $conf_name) { $value = $this->config[$type][$conf_name]; if (!empty($value)) { $element = $xml->createElement($tag_name); $element->appendChild($xml->createTextNode($value)); $protocol->appendChild($element); } } $spa = $this->config[$type]['authentication'] == 'password-encrypted' ? 'on' : 'off'; $element = $xml->createElement('SPA'); $element->appendChild($xml->createTextNode($spa)); $protocol->appendChild($element); $map = array('STARTTLS' => 'TLS', 'SSL' => 'SSL', 'plain' => 'None'); $element = $xml->createElement('Encryption'); $element->appendChild($xml->createTextNode($map[$this->config[$type]['socketType']] ?: 'Auto')); $protocol->appendChild($element); return $protocol; } }