diff --git a/plugins/kolab_2fa/kolab_2fa.php b/plugins/kolab_2fa/kolab_2fa.php index 906b2b7d..3fe87412 100644 --- a/plugins/kolab_2fa/kolab_2fa.php +++ b/plugins/kolab_2fa/kolab_2fa.php @@ -1,755 +1,756 @@ * * Copyright (C) 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 . */ class kolab_2fa extends rcube_plugin { public $task = '(login|settings)'; protected $login_verified = null; protected $login_factors = array(); protected $drivers = array(); protected $storage; /** * Plugin init */ public function init() { $this->load_config(); $this->add_hook('startup', array($this, 'startup')); } /** * Startup hook */ public function startup($args) { $rcmail = rcmail::get_instance(); // register library namespace to autoloader $loader = include(INSTALL_PATH . 'vendor/autoload.php'); $loader->set('Kolab2FA', array($this->home . '/lib')); if ($args['task'] === 'login' && $this->api->output) { $this->add_texts('localization/', false); $this->add_hook('authenticate', array($this, 'authenticate')); // process 2nd factor auth step after regular login if ($args['action'] === 'plugin.kolab-2fa-login' /* || !empty($_SESSION['kolab_2fa_factors']) */) { return $this->login_verify($args); } } else if ($args['task'] === 'settings') { $this->add_texts('localization/', !$this->api->output->ajax_call); $this->add_hook('settings_actions', array($this, 'settings_actions')); $this->register_action('plugin.kolab-2fa', array($this, 'settings_view')); $this->register_action('plugin.kolab-2fa-data', array($this, 'settings_data')); $this->register_action('plugin.kolab-2fa-save', array($this, 'settings_save')); $this->register_action('plugin.kolab-2fa-verify', array($this, 'settings_verify')); } return $args; } /** * Handler for 'authenticate' plugin hook. * * ATTENTION: needs to be called *after* kolab_auth::authenticate() */ public function authenticate($args) { // nothing to be done for me if ($args['abort'] || $this->login_verified !== null) { return $args; } $rcmail = rcmail::get_instance(); // parse $host URL $a_host = parse_url($args['host']); $hostname = $_SESSION['hostname'] = $a_host['host'] ?: $args['host']; // 1. find user record (and its prefs) before IMAP login if ($user = rcube_user::query($args['user'], $hostname)) { $rcmail->config->set_user_prefs($user->get_prefs()); } // 2a. let plugins provide the list of active authentication factors $lookup = $rcmail->plugins->exec_hook('kolab_2fa_lookup', array( 'user' => $args['user'], 'host' => $hostname, 'factors' => $rcmail->config->get('kolab_2fa_factors'), 'check' => $rcmail->config->get('kolab_2fa_check', true), )); if (isset($lookup['factors'])) { $factors = (array)$lookup['factors']; } // 2b. check storage if this user has 2FA enabled else if ($lookup['check'] !== false && ($storage = $this->get_storage($args['user']))) { $factors = (array)$storage->enumerate(); } if (count($factors) > 0) { $args['abort'] = true; $factors = array_unique($factors); // 3. flag session for 2nd factor verification $_SESSION['kolab_2fa_time'] = time(); $_SESSION['kolab_2fa_nonce'] = bin2hex(openssl_random_pseudo_bytes(32)); $_SESSION['kolab_2fa_factors'] = $factors; $_SESSION['username'] = $args['user']; $_SESSION['host'] = $args['host']; $_SESSION['password'] = $rcmail->encrypt($args['pass']); // 4. render to 2nd auth step $this->login_step($factors); } return $args; } /** * Handler for the additional login step requesting the 2FA verification code */ public function login_step($factors) { // replace handler for login form $this->login_factors = array_values($factors); $this->api->output->add_handler('loginform', array($this, 'auth_form')); // focus the code input field on load $this->api->output->add_script('$("input.kolab2facode").first().select();', 'docready'); $this->api->output->send('login'); } /** * Process the 2nd factor code verification form submission */ public function login_verify($args) { $rcmail = rcmail::get_instance(); $time = $_SESSION['kolab_2fa_time']; $nonce = $_SESSION['kolab_2fa_nonce']; $factors = (array)$_SESSION['kolab_2fa_factors']; $this->login_verified = false; $expired = $time < time() - $rcmail->config->get('kolab_2fa_timeout', 120); if (!empty($factors) && !empty($nonce) && !$expired) { // TODO: check signature // try to verify each configured factor foreach ($factors as $factor) { list($method) = explode(':', $factor, 2); // verify the submitted code $code = rcube_utils::get_input_value("_${nonce}_${method}", rcube_utils::INPUT_POST); $this->login_verified = $this->verify_factor_auth($factor, $code); // accept first successful method if ($this->login_verified) { break; } } } if ($this->login_verified) { // restore POST data from session $_POST['_user'] = $_SESSION['username']; $_POST['_host'] = $_SESSION['host']; $_POST['_pass'] = $rcmail->decrypt($_SESSION['password']); } // proceed with regular login ... $args['action'] = 'login'; // session data will be reset in index.php thus additional // auth attempts with intercepted data will be rejected // $rcmail->kill_session(); // we can't display any custom error messages on failed login // but that's actually desired to expose as little information as possible return $args; } /** * Helper method to verify the given method/code tuple */ protected function verify_factor_auth($method, $code) { if (strlen($code) && ($driver = $this->get_driver($method))) { // set properties from login $driver->username = $_SESSION['username']; try { // verify the submitted code return $driver->verify($code, $_SESSION['kolab_2fa_time']); } catch (Exception $e) { rcube::raise_error($e, true, false); } } return false; } /** * Render 2nd factor authentication form in place of the regular login form */ public function auth_form($attrib = array()) { $form_name = !empty($attrib['form']) ? $attrib['form'] : 'form'; $nonce = $_SESSION['kolab_2fa_nonce']; $methods = array_unique(array_map(function($factor) { list($method, $id) = explode(':', $factor); return $method; }, $this->login_factors )); // forward these values as the regular login screen would submit them $input_task = new html_hiddenfield(array('name' => '_task', 'value' => 'login')); $input_action = new html_hiddenfield(array('name' => '_action', 'value' => 'plugin.kolab-2fa-login')); $input_tzone = new html_hiddenfield(array('name' => '_timezone', 'id' => 'rcmlogintz', 'value' => rcube_utils::get_input_value('_timezone', rcube_utils::INPUT_POST))); $input_url = new html_hiddenfield(array('name' => '_url', 'id' => 'rcmloginurl', 'value' => rcube_utils::get_input_value('_url', rcube_utils::INPUT_POST))); // create HTML table with two cols $table = new html_table(array('cols' => 2)); $required = count($methods) > 1 ? null : 'required'; // render input for each configured auth method foreach ($methods as $i => $method) { if ($row++ > 0) { $table->add(array('colspan' => 2, 'class' => 'title hint', 'style' => 'text-align:center'), $this->gettext('or')); } $field_id = "rcmlogin2fa$method"; $input_code = new html_inputfield(array('name' => "_${nonce}_${method}", 'class' => 'kolab2facode', 'id' => $field_id, 'required' => $required, 'autocomplete' => 'off') + $attrib); $table->add('title', html::label($field_id, html::quote($this->gettext($method)))); $table->add('input', $input_code->show('')); } $out = $input_task->show(); $out .= $input_action->show(); $out .= $input_tzone->show(); $out .= $input_url->show(); $out .= $table->show(); // add submit button if (rcube_utils::get_boolean($attrib['submit'])) { $submit = new html_inputfield(array('type' => 'submit', 'id' => 'rcmloginsubmit', 'class' => 'button mainaction', 'value' => $this->gettext('continue'))); $out .= html::p('formbuttons', $submit->show()); } // surround html output with a form tag if (empty($attrib['form'])) { $out = $this->api->output->form_tag(array('name' => $form_name, 'method' => 'post'), $out); } return $out; } /** * Load driver class for the given method */ public function get_driver($method) { $rcmail = rcmail::get_instance(); if ($this->drivers[$method]) { return $this->drivers[$method]; } $config = $rcmail->config->get('kolab_2fa_' . $method, array()); // use product name as "issuer"" if (empty($config['issuer'])) { $config['issuer'] = $rcmail->config->get('product_name'); } try { // TODO: use external auth service if configured $driver = \Kolab2FA\Driver\Base::factory($method, $config); // attach storage $driver->storage = $this->get_storage(); if ($rcmail->user->ID) { $driver->username = $rcmail->get_user_name(); } $this->drivers[$method] = $driver; return $driver; } catch (Exception $e) { $error = strval($e); } rcube::raise_error(array( 'code' => 600, 'type' => 'php', 'file' => __FILE__, 'line' => __LINE__, 'message' => $error), true, false); return false; } /** * Getter for a storage instance singleton */ public function get_storage($for = null) { if (!isset($this->storage) || (!empty($for) && $this->storage->username !== $for)) { $rcmail = rcmail::get_instance(); try { $this->storage = \Kolab2FA\Storage\Base::factory( $rcmail->config->get('kolab_2fa_storage', 'roundcube'), $rcmail->config->get('kolab_2fa_storage_config', array()) ); $this->storage->set_username($for); + $this->storage->set_logger(new \Kolab2FA\Log\RcubeLogger()); // set user properties from active session if (!empty($_SESSION['kolab_dn'])) { $this->storage->userdn = $_SESSION['kolab_dn']; } } catch (Exception $e) { $this->storage = false; rcube::raise_error(array( 'code' => 600, 'type' => 'php', 'file' => __FILE__, 'line' => __LINE__, 'message' => $error), true, false); } } return $this->storage; } /** * Handler for 'settings_actions' hook */ public function settings_actions($args) { // register as settings action $args['actions'][] = array( 'action' => 'plugin.kolab-2fa', 'class' => '2factorauth', 'label' => 'settingslist', 'title' => 'settingstitle', 'domain' => 'kolab_2fa', ); return $args; } /** * Handler for settings/plugin.kolab-2fa requests */ public function settings_view() { $this->register_handler('plugin.settingsform', array($this, 'settings_form')); $this->register_handler('plugin.settingslist', array($this, 'settings_list')); $this->register_handler('plugin.factoradder', array($this, 'settings_factoradder')); $this->register_handler('plugin.highsecuritydialog', array($this, 'settings_highsecuritydialog')); $this->include_script('kolab2fa.js'); $this->include_stylesheet($this->local_skin_path() . '/kolab2fa.css'); if ($this->check_secure_mode()) { $this->api->output->set_env('session_secured', $_SESSION['kolab_2fa_secure_mode']); } $this->api->output->add_label('save','cancel'); $this->api->output->set_pagetitle($this->gettext('settingstitle')); $this->api->output->send('kolab_2fa.config'); } /** * Render the menu to add another authentication factor */ public function settings_factoradder($attrib) { $rcmail = rcmail::get_instance(); $select = new html_select(array('id' => 'kolab2fa-add')); $select->add($this->gettext('addfactor') . '...', ''); foreach ((array)$rcmail->config->get('kolab_2fa_drivers', array()) as $method) { $select->add($this->gettext($method), $method); } return $select->show(); } /** * Render a list of active factor this user has configured */ public function settings_list($attrib = array()) { $attrib['id'] = 'kolab2fa-factors'; $table = new html_table(array('cols' => 3)); $table->add_header('name', $this->gettext('factor')); $table->add_header('created', $this->gettext('created')); $table->add_header('actions', ''); return $table->show($attrib); } /** * Render the settings form template object */ public function settings_form($attrib = array()) { $rcmail = rcmail::get_instance(); $storage = $this->get_storage($rcmail->get_user_name()); $factors = $storage ? (array)$storage->enumerate() : array(); $drivers = (array)$rcmail->config->get('kolab_2fa_drivers', array()); $env_methods = array(); foreach ($drivers as $j => $method) { $out .= $this->settings_factor($method, $attrib); $env_methods[$method] = array( 'name' => $this->gettext($method), 'active' => 0, ); } $me = $this; $this->api->output->set_env('kolab_2fa_factors', array_combine( $factors, array_map(function($id) use ($me, &$env_methods) { $props = array('id' => $id); if ($driver = $me->get_driver($id)) { $props += $this->format_props($driver->props()); $props['method'] = $driver->method; $props['name'] = $me->gettext($driver->method); $env_methods[$driver->method]['active']++; } return $props; }, $factors) )); $this->api->output->set_env('kolab_2fa_methods', $env_methods); return html::div(array('id' => 'kolab2fapropform'), $out); } /** * Render the settings UI for the given method/driver */ protected function settings_factor($method, $attrib) { $out = ''; $rcmail = rcmail::get_instance(); $attrib += array('class' => 'propform'); if ($driver = $this->get_driver($method)) { $table = new html_table(array('cols' => 2, 'class' => $attrib['class'])); foreach ($driver->props() as $field => $prop) { if (!$prop['editable']) { continue; } switch ($prop['type']) { case 'boolean': case 'checkbox': $input = new html_checkbox(array('value' => '1')); break; case 'enum': case 'select': $input = new html_select(array('disabled' => $prop['readonly'])); $input->add(array_map(array($this, 'gettext'), $prop['options']), $prop['options']); break; default: $input = new html_inputfield(array('size' => $prop['size'] ?: 30, 'disabled' => !$prop['editable'])); } $explain_label = $field . 'explain' . $method; $explain_html = $rcmail->text_exists($explain_label, 'kolab_2fa') ? html::p('explain', $this->gettext($explain_label)) : ''; $field_id = 'rcmk2fa' . $method . $field; $table->add('title', html::label($field_id, $this->gettext($field))); $table->add(null, $input->show('', array('id' => $field_id, 'name' => "_prop[$field]")) . $explain_html); } // add row for displaying the QR code if (method_exists($driver, 'get_provisioning_uri')) { $table->add('title', $this->gettext('qrcode')); $table->add(null, html::p('explain', $this->gettext("qrcodeexplain$method") ) . html::p(null, html::tag('img', array('src' => 'data:image/gif;base64,R0lGODlhDwAPAIAAAMDAwAAAACH5BAEAAAAALAAAAAAPAA8AQAINhI+py+0Po5y02otnAQA7', 'class' => 'qrcode', 'rel' => $method)) ) ); // add row for testing the factor $field_id = 'rcmk2faverify' . $method; $table->add('title', html::label($field_id, $this->gettext('verifycode'))); $table->add(null, html::tag('input', array('type' => 'text', 'name' => '_verify_code', 'id' => $field_id, 'class' => 'k2fa-verify', 'size' => 20, 'required' => true)) . html::p('explain', $this->gettext("verifycodeexplain$method")) ); } $input_id = new html_hiddenfield(array('name' => '_prop[id]', 'value' => '')); $out .= html::tag('form', array( 'method' => 'post', 'action' => '#', 'id' => 'kolab2fa-prop-' . $method, 'style' => 'display:none', ), html::tag('fieldset', array(), html::tag('legend', array(), $this->gettext($method)) . html::div('factorprop', $table->show()) . $input_id->show() ) ); } return $out; } /** * Render th */ public function settings_highsecuritydialog($attrib = array()) { $attrib += array('id' => 'kolab2fa-highsecuritydialog'); $field_id = 'rcmk2facode'; $input = new html_inputfield(array('name' => '_code', 'id' => $field_id, 'class' => 'verifycode', 'size' => 20)); return html::div($attrib, html::p('explain', $this->gettext('highsecuritydialog')) . html::div('propform', html::label($field_id, '$name') . $input->show('')) ); } /** * Handler for settings/plugin.kolab-2fa-save requests */ public function settings_save() { $method = rcube_utils::get_input_value('_method', rcube_utils::INPUT_POST); $data = @json_decode(rcube_utils::get_input_value('_data', rcube_utils::INPUT_POST), true); $rcmail = rcmail::get_instance(); $storage = $this->get_storage($rcmail->get_user_name()); $success = false; $errors = 0; $save_data = array(); if ($driver = $this->get_driver($method)) { if ($data === false) { if ($this->check_secure_mode()) { // remove method from active factors and clear stored settings $success = $driver->clear(); } else { $errors++; } } else { // verify the submitted code before saving $verify_code = rcube_utils::get_input_value('_verify_code', rcube_utils::INPUT_POST); $timestamp = intval(rcube_utils::get_input_value('_timestamp', rcube_utils::INPUT_POST)); if (!empty($verify_code)) { if (!$driver->verify($verify_code, $timestamp)) { $this->api->output->command('plugin.verify_response', array( 'id' => $driver->id, 'method' => $driver->method, 'success' => false, 'message' => str_replace('$method', $this->gettext($driver->method), $this->gettext('codeverificationfailed')) )); $this->api->output->send(); } } foreach ($data as $prop => $value) { if (!$driver->set($prop, $value)) { $errors++; } } $driver->set('active', true); } // commit changes to the user properties if (!$errors) { if ($success = $driver->commit()) { $save_data = $data !== false ? $this->format_props($driver->props()) : array(); } else { $errors++; } } } if ($success) { $this->api->output->show_message($data === false ? $this->gettext('factorremovesuccess') : $this->gettext('factorsavesuccess'), 'confirmation'); $this->api->output->command('plugin.save_success', array('method' => $method, 'active' => $data !== false) + $save_data); } else if ($errors) { $this->api->output->show_message($this->gettext('factorsaveerror'), 'error'); $this->api->output->command('plugin.reset_form', $method); } $this->api->output->send(); } /** * Handler for settings/plugin.kolab-2fa-data requests */ public function settings_data() { $method = rcube_utils::get_input_value('_method', rcube_utils::INPUT_POST); if ($driver = $this->get_driver($method)) { $data = array('method' => $method, 'id' => $driver->id); foreach ($driver->props(true) as $field => $prop) { $data[$field] = $prop['text'] ?: $prop['value']; } // generate QR code for provisioning URI if (method_exists($driver, 'get_provisioning_uri')) { try { $uri = $driver->get_provisioning_uri(); $qr = new Endroid\QrCode\QrCode(); $qr->setText($uri) ->setSize(240) ->setPadding(10) ->setErrorCorrection('high') ->setForegroundColor(array('r' => 0, 'g' => 0, 'b' => 0, 'a' => 0)) ->setBackgroundColor(array('r' => 255, 'g' => 255, 'b' => 255, 'a' => 0)); $data['qrcode'] = base64_encode($qr->get()); } catch (Exception $e) { rcube::raise_error($e, true, false); } } $this->api->output->command('plugin.render_data', $data); } $this->api->output->send(); } /** * Handler for settings/plugin.kolab-2fa-verify requests */ public function settings_verify() { $method = rcube_utils::get_input_value('_method', rcube_utils::INPUT_POST); $timestamp = intval(rcube_utils::get_input_value('_timestamp', rcube_utils::INPUT_POST)); $success = false; if ($driver = $this->get_driver($method)) { $data = @json_decode(rcube_utils::get_input_value('_data', rcube_utils::INPUT_POST), true); if (is_array($data)) { foreach ($data as $key => $value) { if ($value !== '******') { $driver->$key = $value; } } } $success = $driver->verify(rcube_utils::get_input_value('_code', rcube_utils::INPUT_POST), $timestamp); $method = $driver->method; } // put session into high-security mode if ($success && !empty($_POST['_session'])) { $_SESSION['kolab_2fa_secure_mode'] = time(); } $this->api->output->command('plugin.verify_response', array( 'method' => $method, 'success' => $success, 'message' => str_replace('$method', $this->gettext($method), $this->gettext($success ? 'codeverificationpassed' : 'codeverificationfailed')) )); $this->api->output->send(); } /** * */ protected function format_props($props) { $rcmail = rcmail::get_instance(); $values = array(); foreach ($props as $key => $prop) { switch ($prop['type']) { case 'datetime': $value = $rcmail->format_date($prop['value']); break; default: $value = $prop['value']; } $values[$key] = $value; } return $values; } /** * */ protected function check_secure_mode() { $valid = ($_SESSION['kolab_2fa_secure_mode'] && $_SESSION['kolab_2fa_secure_mode'] > time() - 180); return $valid; } } \ No newline at end of file diff --git a/plugins/kolab_2fa/lib/Kolab2FA/Storage/Base.php b/plugins/kolab_2fa/lib/Kolab2FA/Storage/Base.php index f79f2d57..b4f36115 100644 --- a/plugins/kolab_2fa/lib/Kolab2FA/Storage/Base.php +++ b/plugins/kolab_2fa/lib/Kolab2FA/Storage/Base.php @@ -1,95 +1,127 @@ * * Copyright (C) 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 Kolab2FA\Storage; +use \Kolab2FA\Log; + + abstract class Base { public $username = null; protected $config = array(); + protected $logger; /** * */ public static function factory($backend, $config) { $classmap = array( 'ldap' => '\\Kolab2FA\\Storage\\LDAP', 'roundcube' => '\\Kolab2FA\\Storage\\RcubeUser', 'rcubeuser' => '\\Kolab2FA\\Storage\\RcubeUser', ); $cls = $classmap[strtolower($backend)]; if ($cls && class_exists($cls)) { return new $cls($config); } throw new Exception("Unknown storage backend '$backend'"); } /** * Default constructor */ public function __construct($config = null) { if (is_array($config)) { $this->init($config); } } /** * Initialize the driver with the given config options */ public function init(array $config) { $this->config = array_merge($this->config, $config); + + // use syslog logger by default + $this->set_logger(new Log\Syslog()); + } + + /** + * + */ + public function set_logger(Log\Logger $logger) + { + $this->logger = $logger; + + if ($this->config['debug']) { + $this->logger->set_level(LOG_DEBUG); + } + else if ($this->config['loglevel']) { + $this->logger->set_level($this->config['loglevel']); + } } /** * Set username to store data for */ public function set_username($username) { $this->username = $username; } + /** + * Send messager to the logging system + */ + protected function log($level, $message) + { + if ($this->logger) { + $this->logger->log($level, $message); + } + } + /** * List keys holding settings for 2-factor-authentication */ abstract public function enumerate(); /** * Read data for the given key */ abstract public function read($key); /** * Save data for the given key */ abstract public function write($key, $value); /** * Remove the data stored for the given key */ abstract public function remove($key); } diff --git a/plugins/kolab_2fa/lib/Kolab2FA/Storage/LDAP.php b/plugins/kolab_2fa/lib/Kolab2FA/Storage/LDAP.php index 67871adf..074c2cbd 100644 --- a/plugins/kolab_2fa/lib/Kolab2FA/Storage/LDAP.php +++ b/plugins/kolab_2fa/lib/Kolab2FA/Storage/LDAP.php @@ -1,374 +1,349 @@ * * Copyright (C) 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 Kolab2FA\Storage; use \Net_LDAP3; +use \Kolab2FA\Log\Logger; class LDAP extends Base { public $userdn; private $cache = array(); private $ldapcache = array(); private $conn; private $error; public function init(array $config) { parent::init($config); $this->conn = new Net_LDAP3($config); $this->conn->config_set('log_hook', array($this, 'log')); $this->conn->connect(); $bind_pass = $this->config['bind_pass']; $bind_user = $this->config['bind_user']; $bind_dn = $this->config['bind_dn']; $this->ready = $this->conn->bind($bind_dn, $bind_pass); if (!$this->ready) { throw new Exception("LDAP storage not ready: " . $this->error); } } /** * List/set methods activated for this user */ public function enumerate($active = true) { $filter = $this->parse_vars($this->config['filter'], '*'); $base_dn = $this->parse_vars($this->config['base_dn'], '*'); $scope = $this->config['scope'] ?: 'sub'; $ids = array(); if ($this->ready && ($result = $this->conn->search($base_dn, $filter, $scope, array($this->config['fieldmap']['id'], $this->config['fieldmap']['active'])))) { foreach ($result as $dn => $entry) { $rec = $this->field_mapping($dn, Net_LDAP3::normalize_entry($entry, true)); if (!empty($rec['id']) && ($active === null || $active == $rec['active'])) { $ids[] = $rec['id']; } } } // TODO: cache this in memory return $ids; } /** * Read data for the given key */ public function read($key) { if (!isset($this->cache[$key])) { $this->cache[$key] = $this->get_ldap_record($this->username, $key); } return $this->cache[$key]; } /** * Save data for the given key */ public function write($key, $value) { $success = false; $ldap_attrs = array(); if (is_array($value)) { // add some default values $value += (array)$this->config['defaults'] + array('active' => false, 'username' => $this->username, 'userdn' => $this->userdn); foreach ($value as $k => $val) { if ($attr = $this->config['fieldmap'][$k]) { $ldap_attrs[$attr] = $this->value_mapping($k, $val, false); } } } else { // invalid data structure return false; } // update existing record if ($rec = $this->get_ldap_record($this->username, $key)) { $old_attrs = $rec['_raw']; $new_attrs = array_merge($old_attrs, $ldap_attrs); $result = $this->conn->modify_entry($rec['_dn'], $old_attrs, $new_attrs); $success = !empty($result); } // insert new record else if ($this->ready) { $entry_dn = $this->get_entry_dn($this->username, $key); // add object class attribute $me = $this; $ldap_attrs['objectclass'] = array_map(function($cls) use ($me, $key) { return $me->parse_vars($cls, $key); }, (array)$this->config['objectclass']); $success = $this->conn->add_entry($entry_dn, $ldap_attrs); } if ($success) { $this->cache[$key] = $value; $this->ldapcache = array(); // cleanup: remove disabled/inactive/temporary entries if ($value['active']) { foreach ($this->enumerate(false) as $id) { if ($id != $key) { $this->remove($id); } } // set user roles according to active factors $this->set_user_roles(); } } return $success; } /** * Remove the data stored for the given key */ public function remove($key) { if ($this->ready) { $entry_dn = $this->get_entry_dn($this->username, $key); $success = $this->conn->delete_entry($entry_dn); // set user roles according to active factors if ($success) { $this->set_user_roles(); } return $success; } return false; } /** * Set username to store data for */ public function set_username($username) { parent::set_username($username); // reset cached values $this->cache = array(); $this->ldapcache = array(); } /** * */ protected function set_user_roles() { if (!$this->ready || !$this->userdn || empty($this->config['user_roles'])) { return false; } $auth_roles = array(); foreach ($this->enumerate(true) as $id) { foreach ($this->config['user_roles'] as $prefix => $role) { if (strpos($id, $prefix) === 0) { $auth_roles[] = $role; } } } $role_attr = $this->config['fieldmap']['roles'] ?: 'nsroledn'; if ($user_attrs = $this->conn->get_entry($this->userdn, array($role_attr))) { $internals = array_values($this->config['user_roles']); $new_attrs = $old_attrs = Net_LDAP3::normalize_entry($user_attrs); $new_attrs[$role_attr] = array_merge( array_unique($auth_roles), array_filter((array)$old_attrs[$role_attr], function($f) use ($internals) { return !in_array($f, $internals); }) ); $result = $this->conn->modify_entry($this->userdn, $old_attrs, $new_attrs); return !empty($result); } return false; } /** * Fetches user data from LDAP addressbook */ protected function get_ldap_record($user, $key) { $entry_dn = $this->get_entry_dn($user, $key); if (!isset($this->ldapcache[$entry_dn])) { $this->ldapcache[$entry_dn] = array(); if ($this->ready && ($entry = $this->conn->get_entry($entry_dn, array_values($this->config['fieldmap'])))) { $this->ldapcache[$entry_dn] = $this->field_mapping($entry_dn, Net_LDAP3::normalize_entry($entry, true)); } } return $this->ldapcache[$entry_dn]; } /** * Compose a full DN for the given record identifier */ protected function get_entry_dn($user, $key) { $base_dn = $this->parse_vars($this->config['base_dn'], $key); return sprintf('%s=%s,%s', $this->config['rdn'], Net_LDAP3::quote_string($key, true), $base_dn); } /** * Maps LDAP attributes to defined fields */ protected function field_mapping($dn, $entry) { $entry['_dn'] = $dn; $entry['_raw'] = $entry; // fields mapping foreach ($this->config['fieldmap'] as $field => $attr) { $attr_lc = strtolower($attr); if (isset($entry[$attr_lc])) { $entry[$field] = $this->value_mapping($field, $entry[$attr_lc], true); } else if (isset($entry[$attr])) { $entry[$field] = $this->value_mapping($field, $entry[$attr], true); } } return $entry; } /** * */ protected function value_mapping($attr, $value, $reverse = false) { if ($map = $this->config['valuemap'][$attr]) { if ($reverse) { $map = array_flip($map); } if (is_array($value)) { $value = array_filter(array_map(function($val) use ($map) { return $map[$val]; }, $value)); } else { $value = $map[$value]; } } // convert (date) type switch ($this->config['attrtypes'][$attr]) { case 'datetime': $ts = is_numeric($value) ? $value : strtotime($value); if ($ts) { $value = gmdate($reverse ? 'U' : 'YmdHi\Z', $ts); } break; case 'integer': $value = intval($value); break; } return $value; } /** * Prepares filter query for LDAP search */ protected function parse_vars($str, $key) { $user = $this->username; if (strpos($user, '@') > 0) { list($u, $d) = explode('@', $user); } else if ($this->userdn) { $u = $this->userdn; $d = trim(str_replace(',dc=', '.', substr($u, strpos($u, ',dc='))), '.'); } if ($this->userdn) { $user = $this->userdn; } // build hierarchal domain string $dc = $this->conn->domain_root_dn($d); $class = $this->config['classmap'] ? $this->config['classmap']['*'] : '*'; // map key to objectclass if (is_array($this->config['classmap'])) { foreach ($this->config['classmap'] as $k => $c) { if (strpos($key, $k) === 0) { $class = $c; break; } } } $replaces = array('%dc' => $dc, '%d' => $d, '%fu' => $user, '%u' => $u, '%c' => $class); return strtr($str, $replaces); } - /** - * Prints debug/error info to the log - */ - public function log($level, $msg) - { - $msg = implode("\n", $msg); - - switch ($level) { - case LOG_DEBUG: - case LOG_INFO: - case LOG_NOTICE: - if ($this->config['debug'] && class_exists('\\rcube', false)) { - \rcube::write_log('ldap', $msg); - } - break; - - case LOG_EMERGE: - case LOG_ALERT: - case LOG_CRIT: - case LOG_ERR: - case LOG_WARNING: - $this->error = $msg; - // throw new Exception("LDAP storage error: " . $msg); - break; - } - } } diff --git a/plugins/kolab_2fa/lib/Kolab2FA/Storage/RcubeUser.php b/plugins/kolab_2fa/lib/Kolab2FA/Storage/RcubeUser.php index 5f614704..99f89cd6 100644 --- a/plugins/kolab_2fa/lib/Kolab2FA/Storage/RcubeUser.php +++ b/plugins/kolab_2fa/lib/Kolab2FA/Storage/RcubeUser.php @@ -1,183 +1,193 @@ * * Copyright (C) 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 Kolab2FA\Storage; use \rcmail; use \rcube_user; class RcubeUser extends Base { // sefault config protected $config = array( 'keymap' => array(), ); private $cache = array(); private $user; public function init(array $config) { parent::init($config); $rcmail = rcmail::get_instance(); $this->config['hostname'] = $rcmail->user->ID ? $rcmail->user->data['mail_host'] : $_SESSION['hostname']; } /** * List/set methods activated for this user */ public function enumerate() { if ($factors = $this->get_factors()) { return array_keys(array_filter($factors, function($prop) { return !empty($prop['active']); })); } return array(); } /** * Read data for the given key */ public function read($key) { if (!isset($this->cache[$key])) { $factors = $this->get_factors(); - console('READ', $key, $factors); + $this->log(LOG_DEBUG, 'RcubeUser::read() ' . $key); $this->cache[$key] = $factors[$key]; } return $this->cache[$key]; } /** * Save data for the given key */ public function write($key, $value) { + $this->log(LOG_DEBUG, 'RcubeUser::write() ' . @json_encode($value)); + if ($user = $this->get_user($this->username)) { $this->cache[$key] = $value; $factors = $this->get_factors(); $factors[$key] = $value; $pkey = $this->key2property('blob'); $save_data = array($pkey => $factors); $update_index = false; // remove entry if ($value === null) { unset($factors[$key]); $update_index = true; } // remove non-active entries else if (!empty($value['active'])) { $factors = array_filter($factors, function($prop) { return !empty($prop['active']); }); $update_index = true; } // update the index of active factors if ($update_index) { $save_data[$this->key2property('factors')] = array_keys( array_filter($factors, function($prop) { return !empty($prop['active']); }) ); } - return $user->save_prefs($save_data, true); + $success = $user->save_prefs($save_data, true); + + if (!$success) { + $this->log(LOG_WARNING, sprintf('Failed to save prefs for user %s', $this->username)); + } } return false; } /** * Remove the data stored for the given key */ public function remove($key) { return $this->write($key, null); } /** * Set username to store data for */ public function set_username($username) { parent::set_username($username); // reset cached values $this->cache = array(); $this->user = null; } /** * Helper method to get a rcube_user instance for storing prefs */ private function get_user($username) { // use global instance if we have a valid Roundcube session $rcmail = rcmail::get_instance(); if ($rcmail->user->ID && $rcmail->user->get_username() == $username) { return $rcmail->user; } if (!$this->user) { $this->user = rcube_user::query($username, $this->config['hostname']); } + if (!$this->user) { + $this->log(LOG_WARNING, sprintf('No user record found for %s @ %s', $username, $this->config['hostname'])); + } + return $this->user; } /** * */ private function get_factors() { if ($user = $this->get_user($this->username)) { $prefs = $user->get_prefs(); return (array)$prefs[$this->key2property('blob')]; } return null; } /** * */ private function key2property($key) { // map key to configured property name if (is_array($this->config['keymap']) && isset($this->config['keymap'][$key])) { return $this->config['keymap'][$key]; } // default return 'kolab_2fa_' . $key; } }