diff --git a/plugins/kolab_2fa/kolab_2fa.php b/plugins/kolab_2fa/kolab_2fa.php index 8bef323e..6bc206ac 100644 --- a/plugins/kolab_2fa/kolab_2fa.php +++ b/plugins/kolab_2fa/kolab_2fa.php @@ -1,842 +1,839 @@ <?php /** * Kolab 2-Factor-Authentication plugin * * @author Thomas Bruederli <bruederli@kolabsys.com> * * Copyright (C) 2015, Kolab Systems AG <contact@kolabsys.com> * * 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 <http://www.gnu.org/licenses/>. */ class kolab_2fa extends rcube_plugin { public $task = '(login|settings)'; protected $login_verified = null; protected $login_factors = []; protected $drivers = []; protected $storage; /** * Plugin init */ public function init() { $this->load_config(); $this->add_hook('startup', [$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', [$this->home . '/lib']); if ($args['task'] === 'login' && $this->api->output) { $this->add_texts('localization/', false); $this->add_hook('authenticate', [$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); } } elseif ($args['task'] === 'settings') { $this->add_texts('localization/', !$this->api->output->ajax_call); $this->add_hook('settings_actions', [$this, 'settings_actions']); $this->register_action('plugin.kolab-2fa', [$this, 'settings_view']); $this->register_action('plugin.kolab-2fa-data', [$this, 'settings_data']); $this->register_action('plugin.kolab-2fa-save', [$this, 'settings_save']); $this->register_action('plugin.kolab-2fa-verify', [$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']; $username = !empty($_SESSION['kolab_auth_admin']) ? $_SESSION['kolab_auth_admin'] : $args['user']; // Check if we need to add/force domain to username $username_domain = $rcmail->config->get('username_domain'); if (!empty($username_domain)) { $domain = ''; if (is_array($username_domain)) { if (!empty($username_domain[$hostname])) { $domain = $username_domain[$hostname]; } } else { $domain = $username_domain; } if ($domain = rcube_utils::parse_host((string) $domain, $hostname)) { $pos = strpos($username, '@'); // force configured domains if ($pos !== false && $rcmail->config->get('username_domain_forced')) { $username = substr($username, 0, $pos) . '@' . $domain; } // just add domain if not specified elseif ($pos === false) { $username .= '@' . $domain; } } } // Convert username to lowercase. Copied from rcmail::login() $login_lc = $rcmail->config->get('login_lc', 2); if ($login_lc) { if ($login_lc == 2 || $login_lc === true) { $username = mb_strtolower($username); } elseif (strpos($username, '@')) { // lowercase domain name [$local, $domain] = explode('@', $username); $username = $local . '@' . mb_strtolower($domain); } } // 2a. let plugins provide the list of active authentication factors $lookup = $rcmail->plugins->exec_hook('kolab_2fa_lookup', [ 'user' => $username, 'host' => $hostname, 'factors' => null, 'check' => $rcmail->config->get('kolab_2fa_check', true), ]); $factors = []; if (isset($lookup['factors'])) { $factors = (array)$lookup['factors']; } // 2b. check storage if this user has 2FA enabled elseif ($lookup['check'] !== false && ($storage = $this->get_storage($username))) { $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'] = $username; $_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', [$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) { $this->login_verified = false; $rcmail = rcmail::get_instance(); $time = $_SESSION['kolab_2fa_time']; $nonce = $_SESSION['kolab_2fa_nonce']; $factors = (array) $_SESSION['kolab_2fa_factors']; $expired = $time < time() - $rcmail->config->get('kolab_2fa_timeout', 120); $username = !empty($_SESSION['kolab_auth_admin']) ? $_SESSION['kolab_auth_admin'] : $_SESSION['username']; if (!empty($factors) && !empty($nonce) && !$expired) { // TODO: check signature // try to verify each configured factor foreach ($factors as $factor) { [$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, $username); // 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']); if (!empty($_SESSION['kolab_auth_admin'])) { $_POST['_user'] = $_SESSION['kolab_auth_admin']; $_POST['_loginas'] = $_SESSION['username']; } } // 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, $username) { - if (strlen($code) && ($driver = $this->get_driver($method))) { - // set properties from login - $driver->username = $username; - + if (strlen($code) && ($driver = $this->get_driver($method, $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 = []) { $form_name = !empty($attrib['form']) ? $attrib['form'] : 'form'; $nonce = $_SESSION['kolab_2fa_nonce']; $methods = array_unique(array_map( function ($factor) { [$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(['name' => '_task', 'value' => 'login']); $input_action = new html_hiddenfield(['name' => '_action', 'value' => 'plugin.kolab-2fa-login']); $input_tzone = new html_hiddenfield(['name' => '_timezone', 'id' => 'rcmlogintz', 'value' => rcube_utils::get_input_value('_timezone', rcube_utils::INPUT_POST)]); $input_url = new html_hiddenfield(['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(['cols' => 2]); $required = count($methods) > 1 ? null : 'required'; $row = 0; // render input for each configured auth method foreach ($methods as $i => $method) { if ($row++ > 0) { $table->add( ['colspan' => 2, 'class' => 'title hint', 'style' => 'text-align:center'], $this->gettext('or') ); } $field_id = "rcmlogin2fa$method"; $input_code = new html_inputfield([ 'name' => "_${nonce}_${method}", 'class' => 'kolab2facode', 'id' => $field_id, 'required' => $required, 'autocomplete' => 'off', 'data-icon' => 'key', // for Elastic ] + $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'])) { $out .= html::p( 'formbuttons', html::tag('button', [ 'type' => 'submit', 'id' => 'rcmloginsubmit', 'class' => 'button mainaction save', ], $this->gettext('continue')) ); } // surround html output with a form tag if (empty($attrib['form'])) { $out = $this->api->output->form_tag(['name' => $form_name, 'method' => 'post'], $out); } return $out; } /** * Load driver class for the given authentication factor * - * @param string $factor Factor identifier (<method>:<id>) + * @param string $factor Factor identifier (<method>:<id>) + * @param string $username Username (email) + * * @return Kolab2FA\Driver\Base|false */ - public function get_driver($factor) + public function get_driver($factor, $username = null) { [$method] = explode(':', $factor, 2); $rcmail = rcmail::get_instance(); if (!empty($this->drivers[$factor])) { return $this->drivers[$factor]; } $config = $rcmail->config->get('kolab_2fa_' . $method, []); - // use product name as "issuer"" + // use product name as "issuer" if (empty($config['issuer'])) { $config['issuer'] = $rcmail->config->get('product_name'); } - try { - // TODO: use external auth service if configured + if (empty($username) && $rcmail->user->ID) { + $username = $rcmail->get_user_name(); + } - $driver = \Kolab2FA\Driver\Base::factory($factor, $config); + try { - // attach storage - $driver->storage = $this->get_storage(); + $storage = $this->get_storage($username); - if ($rcmail->user->ID) { - $driver->username = $rcmail->get_user_name(); - } + $driver = \Kolab2FA\Driver\Base::factory($storage, $factor, $config); $this->drivers[$factor] = $driver; return $driver; } catch (Exception $e) { $error = strval($e); } rcube::raise_error( [ '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', []) ); $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( [ 'code' => 600, 'type' => 'php', 'file' => __FILE__, 'line' => __LINE__, 'message' => $e->getMessage()], true, false ); } } return $this->storage; } /** * Handler for 'settings_actions' hook */ public function settings_actions($args) { // register as settings action $args['actions'][] = [ 'action' => 'plugin.kolab-2fa', 'class' => 'twofactorauth', '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', [$this, 'settings_form']); $this->register_handler('plugin.settingslist', [$this, 'settings_list']); $this->register_handler('plugin.factoradder', [$this, 'settings_factoradder']); $this->register_handler('plugin.highsecuritydialog', [$this, 'settings_highsecuritydialog']); $this->include_script('kolab2fa.js'); $this->include_stylesheet($this->local_skin_path() . '/kolab2fa.css'); $this->api->output->set_env('session_secured', $this->check_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(); $attrib['id'] = 'kolab2fa-add'; $select = new html_select($attrib); $select->add($this->gettext('addfactor') . '...', ''); foreach ((array)$rcmail->config->get('kolab_2fa_drivers', []) 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 = []) { $attrib['id'] = 'kolab2fa-factors'; $table = new html_table(['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 = []) { $rcmail = rcmail::get_instance(); $storage = $this->get_storage($rcmail->get_user_name()); $factors = $storage ? (array)$storage->enumerate() : []; $drivers = (array)$rcmail->config->get('kolab_2fa_drivers', []); $out = ''; $env_methods = []; foreach ($drivers as $j => $method) { $out .= $this->settings_factor($method, $attrib); $env_methods[$method] = [ 'name' => $this->gettext($method), 'active' => 0, ]; } $me = $this; $factors = array_combine( $factors, array_map(function ($id) use ($me, &$env_methods) { $props = ['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); $this->api->output->set_env('kolab_2fa_factors', !empty($factors) ? $factors : null); return html::div(['id' => 'kolab2fapropform'], $out); } /** * Render the settings UI for the given method/driver */ protected function settings_factor($method, $attrib) { $out = ''; $rcmail = rcmail::get_instance(); $attrib += ['class' => 'propform']; if ($driver = $this->get_driver($method)) { $table = new html_table(['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(['value' => '1']); break; case 'enum': case 'select': $input = new html_select(['disabled' => !empty($prop['readonly'])]); $input->add(array_map([$this, 'gettext'], $prop['options']), $prop['options']); break; default: $input = new html_inputfield([ 'size' => !empty($prop['size']) ? $prop['size'] : 30, 'disabled' => empty($prop['editable']), ]); } $explain_label = $field . 'explain' . $method; $explain_html = $rcmail->text_exists($explain_label, 'kolab_2fa') ? html::div('explain form-text', $this->gettext($explain_label)) : ''; $field_id = 'rcmk2fa' . $method . $field; $table->add('title', html::label($field_id, $this->gettext($field))); $table->add(null, $input->show('', ['id' => $field_id, 'name' => "_prop[$field]"]) . $explain_html); } // add row for displaying the QR code if (method_exists($driver, 'get_provisioning_uri')) { $gif = 'data:image/gif;base64,R0lGODlhDwAPAIAAAMDAwAAAACH5BAEAAAAALAAAAAAPAA8AQAINhI+py+0Po5y02otnAQA7'; $table->add('title', $this->gettext('qrcode')); $table->add( 'pl-3 pr-3', html::div('explain form-text', $this->gettext("qrcodeexplain$method")) . html::tag('img', ['src' => $gif, 'class' => 'qrcode mt-2', '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', ['type' => 'text', 'name' => '_verify_code', 'id' => $field_id, 'class' => 'k2fa-verify', 'size' => 20, 'required' => true]) . html::div('explain form-text', $this->gettext("verifycodeexplain$method")) ); } $input_id = new html_hiddenfield(['name' => '_prop[id]', 'value' => '']); $out .= html::tag( 'form', [ 'method' => 'post', 'action' => '#', 'id' => 'kolab2fa-prop-' . $method, 'style' => 'display:none', 'class' => 'propform', ], html::tag( 'fieldset', [], html::tag('legend', [], $this->gettext($method)) . html::div('factorprop', $table->show()) . $input_id->show() ) ); } return $out; } /** * Render the high-security-dialog content */ public function settings_highsecuritydialog($attrib = []) { $attrib += ['id' => 'kolab2fa-highsecuritydialog']; $field_id = 'rcmk2facode'; $input = new html_inputfield(['name' => '_code', 'id' => $field_id, 'class' => 'verifycode', 'size' => 20]); $label = html::label(['for' => $field_id, 'class' => 'col-form-label col-sm-4'], '$name'); return html::div( $attrib, html::div('explain form-text', $this->gettext('highsecuritydialog')) . html::div('propform row form-group', $label . html::div('col-sm-8', $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 = []; 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', [ '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()) : []; } 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', [ 'method' => $method, 'active' => $data !== false, 'id' => $driver->id] + $save_data); } elseif ($errors) { $this->api->output->show_message($this->gettext('factorsaveerror'), 'error'); $this->api->output->command('plugin.reset_form', $data !== false ? $method : null); } $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 = ['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(); // Some OTP apps have an issue with algorithm character case // So we make sure we use upper-case per the spec. $uri = str_replace('algorithm=sha', 'algorithm=SHA', $uri); $qr = new Endroid\QrCode\QrCode(); $qr->setText($uri) ->setSize(240) ->setPadding(10) ->setErrorCorrection('high') ->setForegroundColor(['r' => 0, 'g' => 0, 'b' => 0, 'a' => 0]) ->setBackgroundColor(['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; + $driver->set($key, $value, false); } } } $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', [ '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 = []; 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; } /** * Check whether the session is secured with 2FA (excluding the logon) */ protected function check_secure_mode() { // Allow admins that used kolab_auth's "login as" feature to act without // being asked for the user's second factor if (!empty($_SESSION['kolab_auth_admin']) && !empty($_SESSION['kolab_auth_password'])) { return true; } if (!empty($_SESSION['kolab_2fa_secure_mode']) && $_SESSION['kolab_2fa_secure_mode'] > time() - 180) { return $_SESSION['kolab_2fa_secure_mode']; } return false; } } diff --git a/plugins/kolab_2fa/lib/Kolab2FA/Driver/Base.php b/plugins/kolab_2fa/lib/Kolab2FA/Driver/Base.php index 1dd2cea9..273dc170 100644 --- a/plugins/kolab_2fa/lib/Kolab2FA/Driver/Base.php +++ b/plugins/kolab_2fa/lib/Kolab2FA/Driver/Base.php @@ -1,358 +1,338 @@ <?php /** * Kolab 2-Factor-Authentication Driver base class * * @author Thomas Bruederli <bruederli@kolabsys.com> * * Copyright (C) 2015, Kolab Systems AG <contact@kolabsys.com> * * 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 <http://www.gnu.org/licenses/>. */ namespace Kolab2FA\Driver; /** * Kolab 2-Factor-Authentication Driver base class - * - * @property string $username - * @property string $secret */ abstract class Base { public $method; public $id; public $storage; protected $config = []; + protected $config_keys = []; protected $props = []; protected $user_props = []; protected $pending_changes = false; protected $temporary = false; protected $allowed_props = ['username']; public $user_settings = [ 'active' => [ 'type' => 'boolean', 'editable' => false, 'hidden' => false, 'default' => false, ], 'label' => [ 'type' => 'text', 'editable' => true, 'label' => 'label', 'generator' => 'default_label', ], 'created' => [ 'type' => 'datetime', 'editable' => false, 'hidden' => false, 'label' => 'created', 'generator' => 'time', ], ]; /** * Static factory method */ - public static function factory($id, $config) + public static function factory($storage, $id, $config) { [$method] = explode(':', $id); $classmap = [ 'totp' => '\\Kolab2FA\\Driver\\TOTP', 'hotp' => '\\Kolab2FA\\Driver\\HOTP', 'yubikey' => '\\Kolab2FA\\Driver\\Yubikey', ]; $cls = $classmap[strtolower($method)]; if ($cls && class_exists($cls)) { - return new $cls($config, $id); + return new $cls($storage, $config, $id); } throw new Exception("Unknown 2FA driver '$method'"); } /** * Default constructor */ - public function __construct($config = null, $id = null) + public function __construct($storage, $config = null, $id = null) { - $this->init($config); + if (!is_array($config)) { + $config = []; + } + + $this->storage = $storage; + $this->props['username'] = (string) $storage->username; if (!empty($id) && $id != $this->method) { $this->id = $id; + if ($this->storage) { + $this->user_props = (array) $this->storage->read($this->id); + foreach ($this->config_keys as $key) { + if (isset($this->user_props[$key])) { + $config[$key] = $this->user_props[$key]; + } + } + } } else { // generate random ID $this->id = $this->method . ':' . bin2hex(openssl_random_pseudo_bytes(12)); $this->temporary = true; } + + $this->init($config); } /** * Initialize the driver with the given config options */ - public function init($config) + protected function init($config) { if (is_array($config)) { $this->config = array_merge($this->config, $config); } - - if (!empty($config['storage'])) { - $this->storage = \Kolab2FA\Storage\Base::factory($config['storage'], $config['storage_config']); - } } /** * Verify the submitted authentication code * * @param string $code The 2nd authentication factor to verify * @param int $timestamp Timestamp of authentication process (window start) * * @return bool True if valid, false otherwise */ abstract public function verify($code, $timestamp = null); + /** + * Implement this method if the driver can be provisioned via QR code + */ + /* abstract function get_provisioning_uri(); */ + /** * Getter for user-visible properties */ public function props($force = false) { $data = []; foreach ($this->user_settings as $key => $p) { if (!empty($p['private'])) { continue; } $data[$key] = [ 'type' => $p['type'], 'editable' => $p['editable'] ?? false, 'hidden' => $p['hidden'] ?? false, 'label' => $p['label'] ?? '', 'value' => $this->get($key, $force), ]; // format value into text switch ($p['type']) { case 'boolean': $data[$key]['value'] = (bool)$data[$key]['value']; $data[$key]['text'] = $data[$key]['value'] ? 'yes' : 'no'; break; case 'datetime': if (is_numeric($data[$key]['value'])) { $data[$key]['text'] = date('c', $data[$key]['value']); break; } // no break default: $data[$key]['text'] = $data[$key]['value']; } } return $data; } - /** - * Implement this method if the driver can be provisioned via QR code - */ - /* abstract function get_provisioning_uri(); */ - /** * Generate a random secret string */ public function generate_secret($length = 16) { // Base32 characters $chars = [ 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', // 7 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', // 15 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', // 23 'Y', 'Z', '2', '3', '4', '5', '6', '7', // 31 ]; $secret = ''; for ($i = 0; $i < $length; $i++) { $secret .= $chars[array_rand($chars)]; } return $secret; } /** * Generate the default label based on the method */ public function default_label() { if (class_exists('\\rcmail', false)) { return \rcmail::get_instance()->gettext($this->method, 'kolab_2fa'); } return strtoupper($this->method); } /** * Getter for read-only access to driver properties */ public function get($key, $force = false) { // this is a per-user property: get from persistent storage if (isset($this->user_settings[$key])) { $value = $this->get_user_prop($key); // generate property value if (!isset($value) && $force && !empty($this->user_settings[$key]['generator'])) { $func = $this->user_settings[$key]['generator']; if (is_string($func) && !is_callable($func)) { $func = [$this, $func]; } if (is_callable($func)) { $value = call_user_func($func); } if (isset($value)) { $this->set_user_prop($key, $value); } } } else { - $value = $this->props[$key] ?? null; + $value = $this->get_user_prop($key); + + if ($value === null) { + $value = $this->props[$key] ?? null; + } } return $value; } /** * Setter for restricted access to driver properties */ public function set($key, $value, $persistent = true) { // store as per-user property if (isset($this->user_settings[$key])) { if ($persistent) { return $this->set_user_prop($key, $value); } $this->user_props[$key] = $value; } $setter = 'set_' . $key; if (method_exists($this, $setter)) { call_user_func([$this, $setter], $value); } elseif (in_array($key, $this->allowed_props)) { $this->props[$key] = $value; } return true; } /** * Commit changes to storage */ public function commit() { if (!empty($this->user_props) && $this->storage && $this->pending_changes) { - if ($this->storage->write($this->id, $this->user_props)) { + $props = $this->user_props; + + // Remamber the driver config too. It will be used to verify the code. + // The configured one may be different than the one used on code creation. + foreach ($this->config_keys as $key) { + if (isset($this->config[$key])) { + $props[$key] = $this->config[$key]; + } + } + + if ($this->storage->write($this->id, $props)) { $this->pending_changes = false; $this->temporary = false; } } return !$this->pending_changes; } - /** - * Dedicated setter for the username property - */ - public function set_username($username) - { - $this->props['username'] = $username; - - if ($this->storage) { - $this->storage->set_username($username); - } - - return true; - } - /** * Clear data stored for this driver */ public function clear() { if ($this->storage) { return $this->storage->remove($this->id); } return false; } /** * Checks that a string contains a semicolon */ protected function hasSemicolon($value) { return preg_match('/(:|%3A)/i', (string) $value) > 0; } /** * Getter for per-user properties for this method */ protected function get_user_prop($key) { if (!isset($this->user_props[$key]) && $this->storage && !$this->pending_changes && !$this->temporary) { $this->user_props = (array)$this->storage->read($this->id); } return $this->user_props[$key] ?? null; } /** * Setter for per-user properties for this method */ protected function set_user_prop($key, $value) { $this->pending_changes |= (($this->user_props[$key] ?? null) !== $value); $this->user_props[$key] = $value; return true; } - - /** - * Magic getter for read-only access to driver properties - */ - public function __get($key) - { - // this is a per-user property: get from persistent storage - if (isset($this->user_settings[$key])) { - return $this->get_user_prop($key); - } - - return $this->props[$key]; - } - - /** - * Magic setter for restricted access to driver properties - */ - public function __set($key, $value) - { - $this->set($key, $value, false); - } - - /** - * Magic check if driver property is defined - */ - public function __isset($key) - { - return isset($this->props[$key]); - } } diff --git a/plugins/kolab_2fa/lib/Kolab2FA/Driver/HOTP.php b/plugins/kolab_2fa/lib/Kolab2FA/Driver/HOTP.php index e30b0f5f..dc7bb80d 100644 --- a/plugins/kolab_2fa/lib/Kolab2FA/Driver/HOTP.php +++ b/plugins/kolab_2fa/lib/Kolab2FA/Driver/HOTP.php @@ -1,148 +1,142 @@ <?php /** * Kolab 2-Factor-Authentication HOTP driver implementation * * @author Thomas Bruederli <bruederli@kolabsys.com> * * Copyright (C) 2015, Kolab Systems AG <contact@kolabsys.com> * * 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 <http://www.gnu.org/licenses/>. */ namespace Kolab2FA\Driver; class HOTP extends Base { public $method = 'hotp'; protected $config = [ 'digits' => 6, 'window' => 4, 'digest' => 'sha1', ]; + protected $config_keys = ['digits', 'digest']; protected $backend; /** * */ public function init($config) { parent::init($config); $this->user_settings += [ 'secret' => [ 'type' => 'text', 'private' => true, 'label' => 'secret', 'generator' => 'generate_secret', ], 'counter' => [ 'type' => 'integer', 'editable' => false, 'hidden' => true, 'generator' => 'random_counter', ], ]; if (!in_array($this->config['digest'], ['md5', 'sha1', 'sha256', 'sha512'])) { throw new \Exception("'{$this->config['digest']}' digest is not supported."); } if (!is_numeric($this->config['digits']) || $this->config['digits'] < 1) { throw new \Exception('Digits must be at least 1.'); } if ($this->hasSemicolon($this->config['issuer'])) { throw new \Exception('Issuer must not contain a semi-colon.'); } // copy config options $this->backend = \OTPHP\HOTP::create( null, // secret 0, // counter $this->config['digest'], // digest $this->config['digits'] // digits ); $this->backend->setIssuer($this->config['issuer']); $this->backend->setIssuerIncludedAsParameter(true); } /** * */ public function verify($code, $timestamp = null) { // get my secret from the user storage $secret = $this->get('secret'); - $counter = $this->get('counter'); if (!strlen($secret)) { - // LOG: "no secret set for user $this->username" - // rcube::console("VERIFY HOTP: no secret set for user $this->username"); return false; } try { - $this->backend->setLabel($this->username); + $this->backend->setLabel($this->get('username')); $this->backend->setSecret($secret); - $this->backend->setCounter(intval($this->get('counter'))); - $pass = $this->backend->verify($code, $counter, (int) $this->config['window']); + $pass = $this->backend->verify($code, $this->get('counter'), (int) $this->config['window']); // store incremented counter value $this->set('counter', $this->backend->getCounter()); $this->commit(); } catch (\Exception $e) { - // LOG: exception - // rcube::console("VERIFY HOTP: $this->id, " . strval($e)); $pass = false; } - // rcube::console('VERIFY HOTP', $this->username, $secret, $counter, $code, $pass); return $pass; } /** * Get the provisioning URI. */ public function get_provisioning_uri() { - if (!$this->secret) { + if (!$this->get('secret')) { // generate new secret and store it $this->set('secret', $this->get('secret', true)); $this->set('counter', $this->get('counter', true)); $this->set('created', $this->get('created', true)); $this->commit(); } // TODO: deny call if already active? - $this->backend->setLabel($this->username); - $this->backend->setSecret($this->secret); + $this->backend->setLabel($this->get('username')); + $this->backend->setSecret($this->get('secret')); $this->backend->setCounter(intval($this->get('counter'))); return $this->backend->getProvisioningUri(); } /** * Generate a random counter value */ public function random_counter() { return mt_rand(1, 999); } } diff --git a/plugins/kolab_2fa/lib/Kolab2FA/Driver/TOTP.php b/plugins/kolab_2fa/lib/Kolab2FA/Driver/TOTP.php index 6f6a173e..822c3efb 100644 --- a/plugins/kolab_2fa/lib/Kolab2FA/Driver/TOTP.php +++ b/plugins/kolab_2fa/lib/Kolab2FA/Driver/TOTP.php @@ -1,137 +1,133 @@ <?php /** * Kolab 2-Factor-Authentication TOTP driver implementation * * @author Thomas Bruederli <bruederli@kolabsys.com> * * Copyright (C) 2015, Kolab Systems AG <contact@kolabsys.com> * * 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 <http://www.gnu.org/licenses/>. */ namespace Kolab2FA\Driver; class TOTP extends Base { public $method = 'totp'; protected $config = [ 'digits' => 6, 'interval' => 30, 'digest' => 'sha1', ]; + protected $config_keys = ['digits', 'digest']; protected $backend; /** * */ public function init($config) { parent::init($config); $this->user_settings += [ 'secret' => [ 'type' => 'text', 'private' => true, 'label' => 'secret', 'generator' => 'generate_secret', ], ]; if (!in_array($this->config['digest'], ['md5', 'sha1', 'sha256', 'sha512'])) { throw new \Exception("'{$this->config['digest']}' digest is not supported."); } if (!is_numeric($this->config['digits']) || $this->config['digits'] < 1) { throw new \Exception('Digits must be at least 1.'); } if (!is_numeric($this->config['interval']) || $this->config['interval'] < 1) { throw new \Exception('Interval must be at least 1.'); } if ($this->hasSemicolon($this->config['issuer'])) { throw new \Exception('Issuer must not contain a semi-colon.'); } // copy config options $this->backend = \OTPHP\TOTP::create( null, //secret $this->config['interval'], // period $this->config['digest'], // digest $this->config['digits'] // digits ); $this->backend->setIssuer($this->config['issuer']); $this->backend->setIssuerIncludedAsParameter(true); } /** * */ public function verify($code, $timestamp = null) { // get my secret from the user storage $secret = $this->get('secret'); if (!strlen($secret)) { - // LOG: "no secret set for user $this->username" - // rcube::console("VERIFY TOTP: no secret set for user $this->username"); return false; } - $this->backend->setLabel($this->username); + $this->backend->setLabel($this->get('username')); $this->backend->setSecret($secret); // Pass a window to indicate the maximum timeslip between client (mobile // device) and server. $pass = $this->backend->verify($code, (int) $timestamp, 150); // try all codes from $timestamp till now if (!$pass && $timestamp) { $now = time(); while (!$pass && $timestamp < $now) { $pass = $code === $this->backend->at($timestamp); $timestamp += $this->config['interval']; } } - // rcube::console('VERIFY TOTP', $this->username, $secret, $code, $timestamp, $pass); return $pass; } /** * Get the provisioning URI. */ public function get_provisioning_uri() { - // rcube::console('PROV', $this->secret); - if (!$this->secret) { + if (!$this->get('secret')) { // generate new secret and store it $this->set('secret', $this->get('secret', true)); $this->set('created', $this->get('created', true)); - // rcube::console('PROV2', $this->secret); $this->commit(); } // TODO: deny call if already active? - $this->backend->setLabel($this->username); - $this->backend->setSecret($this->secret); + $this->backend->setLabel($this->get('username')); + $this->backend->setSecret($this->get('secret')); return $this->backend->getProvisioningUri(); } } diff --git a/plugins/kolab_2fa/lib/Kolab2FA/Driver/Yubikey.php b/plugins/kolab_2fa/lib/Kolab2FA/Driver/Yubikey.php index 579338e3..a2c3501b 100644 --- a/plugins/kolab_2fa/lib/Kolab2FA/Driver/Yubikey.php +++ b/plugins/kolab_2fa/lib/Kolab2FA/Driver/Yubikey.php @@ -1,128 +1,126 @@ <?php /** * Kolab 2-Factor-Authentication Yubikey driver implementation * * @author Thomas Bruederli <bruederli@kolabsys.com> * * Copyright (C) 2015, Kolab Systems AG <contact@kolabsys.com> * * 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 <http://www.gnu.org/licenses/>. */ namespace Kolab2FA\Driver; class Yubikey extends Base { public $method = 'yubikey'; protected $backend; /** * */ public function init($config) { parent::init($config); $this->user_settings += [ 'yubikeyid' => [ 'type' => 'text', 'editable' => true, 'label' => 'secret', ], ]; // initialize validator $this->backend = new \Yubikey\Validate($this->config['apikey'], $this->config['clientid']); // set configured validation hosts if (!empty($this->config['hosts'])) { $this->backend->setHosts((array)$this->config['hosts']); } if (isset($this->config['use_https'])) { $this->backend->setUseSecure((bool)$this->config['use_https']); } } /** * */ public function verify($code, $timestamp = null) { // get my secret from the user storage $keyid = $this->get('yubikeyid'); $pass = false; if (!strlen($keyid)) { - // LOG: "no key registered for user $this->username" return false; } // check key prefix with associated Yubikey ID if (strpos($code, $keyid) === 0) { try { $response = $this->backend->check($code); $pass = $response->success() === true; } catch (\Exception $e) { // TODO: log exception } } - // rcube::console('VERIFY Yubikey', $this->username, $keyid, $code, $pass); return $pass; } /** * @override */ public function set($key, $value, $persistent = true) { if ($key == 'yubikeyid' && strlen($value) > 12) { // verify the submitted code try { $response = $this->backend->check($value); if ($response->success() !== true) { // TODO: report error return false; } } catch (\Exception $e) { return false; } // truncate the submitted yubikey code to 12 characters $value = substr($value, 0, 12); } // invalid or no yubikey token provided elseif ($key == 'yubikeyid') { return false; } return parent::set($key, $value, $persistent); } /** * @override */ protected function set_user_prop($key, $value) { // set created timestamp if ($key !== 'created' && !isset($this->created)) { parent::set_user_prop('created', $this->get('created', true)); } return parent::set_user_prop($key, $value); } } diff --git a/plugins/kolab_2fa/lib/Kolab2FA/Storage/Base.php b/plugins/kolab_2fa/lib/Kolab2FA/Storage/Base.php index d068f2aa..bae8e41b 100644 --- a/plugins/kolab_2fa/lib/Kolab2FA/Storage/Base.php +++ b/plugins/kolab_2fa/lib/Kolab2FA/Storage/Base.php @@ -1,125 +1,126 @@ <?php /** * Abstract storage backend class for the Kolab 2-Factor-Authentication plugin * * @author Thomas Bruederli <bruederli@kolabsys.com> * * Copyright (C) 2015, Kolab Systems AG <contact@kolabsys.com> * * 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 <http://www.gnu.org/licenses/>. */ namespace Kolab2FA\Storage; use Kolab2FA\Log; abstract class Base { public $username = null; + protected $config = []; protected $logger; /** * */ public static function factory($backend, $config) { $classmap = [ '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 (!empty($this->config['debug'])) { $this->logger->set_level(LOG_DEBUG); } elseif (!empty($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); }