diff --git a/doc/kolab-freebusy.config.ini b/doc/kolab-freebusy.config.ini index 79b31eb..cba70fa 100644 --- a/doc/kolab-freebusy.config.ini +++ b/doc/kolab-freebusy.config.ini @@ -1,96 +1,98 @@ ;; Kolab Free/Busy Service configuration ;; Require HTTP authentication to access this service [httpauth] ;; Example for static auth credentials ; type = static ; username = "" ; password = "" ;; Example for LDAP-based authentication ; type = ldap ; host = ldap://localhost:389 ; bind_dn = "uid=kolab-service,ou=Special Users,dc=yourdomain,dc=com" ; bind_pw = "" ; base_dn = "dc=yourdomain,dc=com" ; filter = "(&(|(mail=%s)(alias=%s)(uid=%s))(objectclass=inetorgperson))" ; optional, %s is replaced by the username +;; Enables session token authentication +; allow_token = true ;; Allow privileged access from these IPs [trustednetworks] allow = 127.0.0.1, 192.168.0.0/16, 10.10.*, ::1 ;; Logging configuration [log] driver = file ; supported drivers: file, syslog path = ./log name = freebusy level = 300 ; (100 = Debug, 200 = Info, 300 = Warn, 400 = Error, 500 = Critical) ;; Directories to resolve email addresses and their f/b source locations ;; try local filesystem first [directory "local"] type = static filter = "@yourdomain" fbsource = file:/var/lib/kolab-freebusy/%s.ifb ;; check if primary email address hits a cache file (saves LDAP lookups) [directory "local-cache"] type = static fbsource = file:/var/cache/kolab-freebusy/%s.ifb expires = 10m ;; local Kolab directory server [directory "kolab-ldap"] type = ldap host = ldap://localhost:389 bind_dn = "uid=kolab-service,ou=Special Users,dc=yourdomain,dc=com" bind_pw = "" base_dn = "ou=People,dc=yourdomain,dc=com" ; use %dc as placeholder for the domain part extracted from the request string filter = "(&(objectClass=kolabInetOrgPerson)(|(uid=%s)(mail=%s)(alias=%s)))" attributes = mail, sn, alias lc_attributes = sn mail_attributes = mail, alias fbsource = file:/var/lib/kolab-freebusy/%mail.ifb loglevel = 200 ; Info ;; resolve Kolab resources from LDAP and fetch calendar from IMAP [directory "kolab-resources"] type = ldap host = ldap://localhost:389 bind_dn = "uid=kolab-service,ou=Special Users,dc=yourdomain,dc=com" bind_pw = "" base_dn = "ou=Resources,dc=yourdomain,dc=com" filter = "(&(objectClass=kolabsharedfolder)(mail=%s))" attributes = mail, kolabtargetfolder fbsource = "fbdaemon://localhost:?folder=%kolabtargetfolder" timeout = 10 ; abort after 10 seconds cacheto = /var/cache/kolab-freebusy/%mail.ifb expires = 10m loglevel = 100 ; Debug ;; external MS Exchange 2010 server [directory "exchange"] type = static filter = "@microsoft.com$" fbsource = https://externalhost/free-busy/%s.ics format = Exchange2010 ;; further examples of fbsource URIs ;; - fetch data from another server by HTTP(s) ; fbsource = "https://fb-service-user:imap-password@kolab-server/freebusy/%mail.ifb" ;; - read data from a users calendars (all) using IMAP proxy authentication ; fbsource = "imap://%mail:@localhost/?proxy_auth=cyrus-admin" ;; - read data from a shared IMAP folder with cyrus-admin privileges ; fbsource = "imap://cyrus-admin:@localhost/%kolabtargetfolder?acl=lrs" ;; - trigger kolab-freebusyd daemon (folder= for shared folders, user= for user mailboxes) ; fbsource = "fbdaemon://localhost:?folder=%kolabtargetfolder&user=%mail" diff --git a/lib/Kolab/FreeBusy/HTTPAuth.php b/lib/Kolab/FreeBusy/HTTPAuth.php index 368aa72..9d10927 100644 --- a/lib/Kolab/FreeBusy/HTTPAuth.php +++ b/lib/Kolab/FreeBusy/HTTPAuth.php @@ -1,141 +1,185 @@ * * Copyright (C) 2013, Kolab Systems AG * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero 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 Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ namespace Kolab\FreeBusy; +use \Kolab\Config; use \Net_LDAP3; use \Monolog\Logger as Monolog; /** * Static class to process HTTP authentication to this service */ class HTTPAuth { private static $logger; /** * Validate HTTP basic auth against the configured backend */ public static function check($config) { $logger = Logger::get('httpauth'); + // First try token authentication if enabled and user/token detected in the URL + if (!empty($_SERVER['FREEBUSY_URI']) + && Config::boolean($config['allow_token']) + && preg_match('|([^@/]+@[^@/]+)/([a-f0-9]{32})/[^/]+$|', $_SERVER['FREEBUSY_URI'], $matches) + && self::checkToken($config, $matches[1], $matches[2]) + ) { + return true; + } + // no http auth submitted, abort! if (empty($_SERVER['PHP_AUTH_USER']) || !isset($_SERVER['PHP_AUTH_PW'])) { $logger->addDebug('No HTTP auth submitted'); return false; } switch ($config['type']) { case 'static': return self::checkStatic($config, $_SERVER['PHP_AUTH_USER'], $_SERVER['PHP_AUTH_PW']); case 'ldap': return self::checkLDAP($config, $_SERVER['PHP_AUTH_USER'], $_SERVER['PHP_AUTH_PW']); default: $logger->addWarning('Unsupported auth type ' . $config['type']); break; } return false; } /** * Validate static user credentials from config */ private static function checkStatic($config, $user, $pass) { $valid = $user == $config['username'] && $pass == $config['password']; Logger::get('httpauth')->addInfo("Static: authenticating user '$user': " . ($valid ? 'SUCCESS' : 'FAILURE')); return $valid; } /** * Validate user credentials against the configured LDAP backend */ private static function checkLDAP($config, $user, $pass) { self::$logger = Logger::get('httpauth', intval($config['loglevel'])); list($u, $d) = explode('@', $user); $replaces = array('%dc' => 'dc=' . str_replace('.', ',dc=', $d), '%u' => $u); $config['base_dn'] = strtr($config['base_dn'], $replaces); $config['filter'] = strtr($config['filter'], $replaces); $host = parse_url($config['host']); $ldap_config = array( 'hosts' => array($config['host']), 'port' => $host['port'] ?: 389, 'use_tls' => $host['scheme'] == 'tls', 'root_dn' => $config['base_dn'], 'filter' => $config['filter'], 'service_bind_dn' => $config['bind_dn'], 'service_bind_pw' => $config['bind_pw'], 'log_hook' => 'Kolab\FreeBusy\HTTPAuth::ldapLog', ); // instantiate Net_LDAP3 and connect with logger $ldap = new Net_LDAP3($ldap_config); // connect + bind to LDAP server if ($ldap->connect()) { self::$logger->addDebug("LDAP: connected to $config[host] with '$config[bind_dn]'"); // extract domain part from base_dn $dn_domain = ldap_explode_dn($config['base_dn'], 1); unset($dn_domain['count']); $domain = join('.', $dn_domain); $valid = (bool)$ldap->login($user, $pass, $domain); } else { self::$logger->addWarning("LDAP: connectiion to $config[host] with '$config[bind_dn]' failed!"); } self::$logger->addInfo("LDAP: authenticating user '$user': " . ($valid ? 'SUCCESS' : 'FAILURE')); return $valid; } + /** + * Validate user token and credentials from freebusy_auth cache + */ + private static function checkToken($config, $user, $token) + { + // See 'ready' hook handler in kolab_auth plugin + // for details on how the token auth (cache) entries are created + + // load the Roundcube framework with its autoloader + require_once KOLAB_FREEBUSY_ROOT . '/lib/Roundcube/bootstrap.php'; + + $rcube = \rcube::get_instance(\rcube::INIT_WITH_DB | \rcube::INIT_WITH_PLUGINS); + $ip = \rcube_utils::remote_addr(); + $key = md5("$token:$ip:$user"); + $valid = false; + + $rcube->config->set('freebusy_auth_cache', 'db'); + $cache = $rcube->get_cache_shared('freebusy_auth', false); + + if ($cache && ($deadline = $cache->get($key))) { + $now = new \DateTime('now', new \DateTimeZone('UTC')); + $deadline = new \DateTime($deadline); + + if ($deadline >= $now) { + $valid = true; + } + } + + $status = $valid ? 'SUCCESS' : 'FAILURE'; + Logger::get('httpauth')->addInfo("Token: authenticating user $user/$token/$ip: $status"); + + return $valid; + } + /** * Callback for Net_LDAP3 logging */ public static function ldapLog($level, $msg) { // map PHP log levels to Monolog levels static $loglevels = array( LOG_DEBUG => Monolog::DEBUG, LOG_NOTICE => Monolog::NOTICE, LOG_INFO => Monolog::INFO, LOG_WARNING => Monolog::WARNING, LOG_ERR => Monolog::ERROR, LOG_CRIT => Monolog::CRITICAL, LOG_ALERT => Monolog::ALERT, LOG_EMERG => Monolog::EMERGENCY, ); $msg = is_array($msg) ? join('; ', $msg) : strval($msg); self::$logger->addRecord($loglevels[$level], $msg); } } \ No newline at end of file diff --git a/lib/Kolab/FreeBusy/SourceIMAP.php b/lib/Kolab/FreeBusy/SourceIMAP.php index 7e9daaf..251d6aa 100644 --- a/lib/Kolab/FreeBusy/SourceIMAP.php +++ b/lib/Kolab/FreeBusy/SourceIMAP.php @@ -1,365 +1,359 @@ * * Copyright (C) 2013-2015, Kolab Systems AG * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero 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 Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ namespace Kolab\FreeBusy; use Kolab\Config; use Sabre\VObject; use Sabre\VObject\Component\VCalendar; use Sabre\VObject\FreeBusyGenerator; use Sabre\VObject\ParseException; -// configure env for Roundcube framework -define('RCUBE_INSTALL_PATH', KOLAB_FREEBUSY_ROOT . '/'); -define('RCUBE_CONFIG_DIR', KOLAB_FREEBUSY_ROOT . '/config/'); -define('RCUBE_PLUGINS_DIR', KOLAB_FREEBUSY_ROOT . '/lib/plugins/'); - - /** * Implementation of a Free/Busy data source reading from IMAP * (not yet implemented!) */ class SourceIMAP extends Source { private $folders = array(); public function __construct($config) { parent::__construct($config + array('mail_attributes' => 'mail')); // load the Roundcube framework with its autoloader require_once KOLAB_FREEBUSY_ROOT . '/lib/Roundcube/bootstrap.php'; $rcube = \rcube::get_instance(\rcube::INIT_WITH_DB | \rcube::INIT_WITH_PLUGINS); // Load plugins $rcube->plugins->init($rcube); $rcube->plugins->load_plugins(array(), array('libkolab','libcalendaring')); // get libvcalendar instance $this->libvcal = \libcalendaring::get_ical(); } /** * @see Source::getFreeBusyData() */ public function getFreeBusyData($user, $extended) { $log = Logger::get('imap', intval($this->config['loglevel'])); $config = $this->getUserConfig($user); parse_str(strval($config['query']), $param); $config += $param; // log this... $log->addInfo("Fetching data for ", $config); // caching is enabled if (!empty($config['cacheto'])) { // check for cached data if ($cached = $this->getCached($config)) { $log->addInfo("Deliver cached data from " . $config['cacheto']); return $cached; } // touch cache file to avoid multiple requests generating the same data if (file_exists($config['cacheto'])) { touch($config['cacheto']); } else { file_put_contents($config['cacheto'], Utils::dummyVFreebusy($user['mail'])); $tempfile = $config['cacheto']; } } // compose a list of user email addresses $user_email = array(); foreach (Config::convert($this->config['mail_attributes'], Config::ARR) as $key) { if (!empty($user[$key])) { $user_email = array_merge($user_email, (array)$user[$key]); } } // synchronize with IMAP and read Kolab event objects if ($imap = $this->imap_login($config)) { // target folder is specified in source URI if ($config['path'] && $config['path'] != '/') { $folders = array(\kolab_storage::get_folder(substr($config['path'], 1))); $read_all = true; } else { // list all folders of type 'event' $folders = \kolab_storage::get_folders('event', false); $read_all = false; } $utc = new \DateTimezone('UTC'); $dtstart = Utils::periodStartDT(); $dtend = Utils::periodEndDT(); $calendar = new VObject\Component\VCalendar(); $seen = array(); $this->libvcal->set_timezone($utc); $log->addInfo("Getting events from IMAP in range", array($dtstart->format('c'), $dtend->format('c'))); $query = array(array('dtstart','<=',$dtend), array('dtend','>=',$dtstart)); foreach ($folders as $folder) { $count = 0; $log->debug('Reading Kolab folder: ' . $folder->name, $folder->get_folder_info()); // skip other user's shared calendars if (!$read_all && $folder->get_namespace() == 'other') { continue; } // set ACL (temporarily) if ($config['acl']) { $folder->_old_acl = $folder->get_myrights(); $imap->set_acl($folder->name, $config['user'], $config['acl']); } foreach ($folder->select($query) as $event) { //$log->debug('Processing event', $event); if ($event['cancelled']) { continue; } // only consider shared namespace events if user is a confirmed participant (or organizer) // skip declined events if (!$this->check_participation($event, $user_email, $status) || ($status != 'ACCEPTED' && $status != 'TENTATIVE') ) { $log->debug('Skip shared/declined event', array($event['uid'], $event['title'])); continue; } // translate all-day dates into absolute UTC times // FIXME: use server timezone? if ($event['allday']) { $utc = new \DateTimeZone('UTC'); if (!empty($event['start'])) { $event['start']->setTimeZone($utc); $event['start']->setTime(0,0,0); } if (!empty($event['end'])) { $event['end']->setTimeZone($utc); $event['end']->setTime(23,59,59); } } // avoid duplicate entries $key = $event['start']->format('c') . '/' . $event['end']->format('c'); if ($seen[$key]++) { $log->debug('Skipping duplicate event at ' . $key, array($event['uid'], $event['title'])); continue; } // copied from libvcalendar::_to_ical() $ve = $this->to_vevent($event, $calendar, $user_email); if ($event['recurrence']) { if ($exdates = $event['recurrence']['EXDATE']) unset($event['recurrence']['EXDATE']); if ($rdates = $event['recurrence']['RDATE']) unset($event['recurrence']['RDATE']); if ($event['recurrence']['FREQ']) $ve->add('RRULE', \libcalendaring::to_rrule($event['recurrence'])); // consider recurrence exceptions if (is_array($event['recurrence']['EXCEPTIONS'])) { foreach ($event['recurrence']['EXCEPTIONS'] as $i => $exception) { // register exdate for this occurrence if ($exception['recurrence_date'] instanceof \DateTime) { $exdates[] = $exception['recurrence_date']; } // add exception to vcalendar container if (!$exception['cancelled'] && $this->check_participation($exception, $user_email, $status) && $status != 'DECLINED') { $vex = $this->to_vevent($exception, $calendar, $user_email); $vex->UID = $event['uid'] . '-' . $i; $calendar->add($vex); $log->debug("Adding event exception for processing:\n" . $vex->serialize()); } } } // add EXDATEs each one per line (for Thunderbird Lightning) if ($exdates) { foreach ($exdates as $ex) { if ($ex instanceof \DateTime) { $exd = clone $event['start']; $exd->setDate($ex->format('Y'), $ex->format('n'), $ex->format('j')); $exd->setTimeZone($utc); $ve->add($this->libvcal->datetime_prop($calendar, 'EXDATE', $exd, true)); } } } // add RDATEs if (!empty($rdates)) { foreach ((array)$rdates as $rdate) { $ve->add($this->libvcal->datetime_prop($calendar, 'RDATE', $rdate)); } } } // append to vcalendar container $calendar->add($ve); $count++; $log->debug("Adding event for processing:\n" . $ve->serialize()); } $log->addInfo("Added $count events from folder" . $folder->name); } $this->imap_disconnect($imap, $config, $folders); // feed the calendar object into the free/busy generator // we must specify a start and end date, because recurring events are expanded. nice! $fbgen = new FreeBusyGenerator($dtstart, $dtend, $calendar); // get the freebusy report $freebusy = $fbgen->getResult(); $freebusy->PRODID = Utils::PRODID; $freebusy->METHOD = 'PUBLISH'; $freebusy->VFREEBUSY->UID = date('YmdHi') . '-' . substr(md5($user_email[0]), 0, 16); $freebusy->VFREEBUSY->ORGANIZER = 'mailto:' . $user_email[0]; // serialize to VCALENDAR format return $freebusy->serialize(); } // remove (temporary) cache file again else if ($tempfile) { unlink($tempfile); } return false; } /** * Helper method to establish connection to the configured IMAP backend */ private function imap_login($config) { $rcube = \rcube::get_instance(); $imap = $rcube->get_storage(); $host = $config['host']; $port = $config['port'] ?: ($config['scheme'] == 'imaps' ? 993 : 143); // detect ssl|tls method if ($config['scheme'] == 'imaps' || $port == 993) { $ssl = 'imaps'; } elseif ($config['scheme'] == 'tls') { $ssl = 'tls'; } else { $ssl = false; } // enable proxy authentication if (!empty($config['proxy_auth'])) { $imap->set_options(array('auth_cid' => $config['proxy_auth'], 'auth_pw' => $config['pass'])); } // authenticate user in IMAP if (!$imap->connect($host, $config['user'], $config['pass'], $port, $ssl)) { Logger::get('imap')->addWarning("Failed to connect to IMAP server: " . $imap->get_error_code(), $config); return false; } // fake user object to rcube framework $rcube->set_user(new \rcube_user('0', array('username' => $config['user']))); return $imap; } /** * Cleanup and close IMAP connection */ private function imap_disconnect($imap, $config, $folders) { // reset ACL if ($config['acl'] && !empty($folders)) { foreach ($folders as $folder) { $imap->set_acl($folder->name, $config['user'], $folder->_old_acl); } } $imap->close(); } /** * Helper method to build a Sabre/VObject from the gieven event data */ private function to_vevent($event, $cal, $user_email) { // copied from libvcalendar::_to_ical() $ve = $cal->create('VEVENT'); $ve->UID = $event['uid']; if (!empty($event['start'])) $ve->add($this->libvcal->datetime_prop($cal, 'DTSTART', $event['start'], false, false)); if (!empty($event['end'])) $ve->add($this->libvcal->datetime_prop($cal, 'DTEND', $event['end'], false, false)); if (!empty($event['free_busy'])) $ve->add('TRANSP', $event['free_busy'] == 'free' ? 'TRANSPARENT' : 'OPAQUE'); if ($this->check_participation($event, $user_email, $status) && $status) { $ve->add('STATUS', $status); } return $ve; } /** * Helper method to check the participation status of the requested user */ private function check_participation($event, $user_email, &$status = null) { if (is_array($event['organizer']) && !empty($event['organizer']['email'])) { if (in_array($event['organizer']['email'], $user_email)) { $status = 'ACCEPTED'; if ($event['free_busy'] == 'tentative') { $status = 'TENTATIVE'; } else if (!empty($event['status'])) { $status = $event['status']; } return true; } } if (is_array($event['attendees'])) { foreach ($event['attendees'] as $attendee) { if (in_array($attendee['email'], $user_email)) { $status = $attendee['status']; return true; } } } return false; } } diff --git a/public_html/index.php b/public_html/index.php index 49e67e8..eb74d5e 100644 --- a/public_html/index.php +++ b/public_html/index.php @@ -1,118 +1,124 @@ * * Copyright (C) 2014, Kolab Systems AG * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero 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 Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ define('KOLAB_FREEBUSY_ROOT', realpath('../')); +// configure env for Roundcube framework +define('RCUBE_INSTALL_PATH', KOLAB_FREEBUSY_ROOT . '/'); +define('RCUBE_CONFIG_DIR', KOLAB_FREEBUSY_ROOT . '/config/'); +define('RCUBE_PLUGINS_DIR', KOLAB_FREEBUSY_ROOT . '/lib/plugins/'); + // suppress error notices ini_set('error_reporting', E_ALL &~ E_NOTICE); // use composer's autoloader for both dependencies and local lib $loader = require_once(KOLAB_FREEBUSY_ROOT . '/vendor/autoload.php'); $loader->set('Kolab', array(KOLAB_FREEBUSY_ROOT . '/lib')); // register Kolab namespace $loader->setUseIncludePath(true); // enable searching the include_path (e.g. for PEAR packages) use Kolab\Config; use Kolab\FreeBusy\Utils; use Kolab\FreeBusy\Logger; use Kolab\FreeBusy\Directory; use Kolab\FreeBusy\HTTPAuth; // load config $config = Config::get_instance(KOLAB_FREEBUSY_ROOT . '/config'); if ($config->valid()) { // check for trusted IP first $remote_ip = Utils::remoteIP(); $trusted_ip = $config->trustednetworks ? Utils::checkIPRange($remote_ip, $config->get('trustednetworks.allow', array(), Config::ARR)) : false; $log = Logger::get('web'); $uri = $_SERVER['REDIRECT_URL']; // we're not always redirected here if (empty($uri)) { $uri = $_SERVER['REQUEST_URI']; $log->addDebug('Request (direct): ' . $uri, array('ip' => $remote_ip, 'trusted' => $trusted_ip)); } else { $log->addDebug('Request (redirect): ' . $uri, array('ip' => $remote_ip, 'trusted' => $trusted_ip)); } + list($uri, $args) = explode('?', $uri); + // check HTTP authentication if (!$trusted_ip && $config->httpauth) { + $_SERVER['FREEBUSY_URI'] = urldecode(rtrim($uri, '/')); + if (!HTTPAuth::check($config->httpauth)) { $log->addDebug("Abort with 401 Unauthorized"); header('WWW-Authenticate: Basic realm="Kolab Free/Busy Service"'); header($_SERVER['SERVER_PROTOCOL'] . " 401 Unauthorized", true); exit; } } #header('Content-type: text/calendar; charset=utf-8', true); header('Content-type: text/plain; charset=utf-8', true); - list($uri, $args) = explode('?', $uri); - // analyse request - $url = array_filter(explode('/', $uri)); - $user = strtolower(array_pop($url)); - $action = strtolower(array_pop($url)); + $url = array_filter(explode('/', $uri)); + $user = strtolower(array_pop($url)); $extended = false; // remove file extension if (preg_match('/^(.+)\.([ipx]fb)$/i', $user, $m)) { $user = urldecode($m[1]); $extended = $m[2] == 'xfb'; } // iterate over directories foreach ($config->directory as $key => $dirconfig) { $log->addDebug("Trying directory $key", $dirconfig); $directory = Directory::factory($dirconfig); if ($directory && ($fbdata = $directory->getFreeBusyData($user, $extended))) { $log->addInfo("Found valid data for user $user in directory $key"); echo $fbdata; exit; } } // return 404 if request was sent from a trusted IP if ($trusted_ip) { $log->addDebug("Returning '404 Not Found' for user $user"); header($_SERVER['SERVER_PROTOCOL'] . " 404 Not found", true); } else { $log->addInfo("Returning empty Free/Busy list for user $user"); // Return an apparent empty Free/Busy list. print Utils::dummyVFreebusy($user); } } // exit with error # header($_SERVER['SERVER_PROTOCOL'] . " 500 Internal Server Error", true);