diff --git a/plugins/kolab_2fa/kolab2fa.js b/plugins/kolab_2fa/kolab2fa.js index d471aecd..dd0bf8b8 100644 --- a/plugins/kolab_2fa/kolab2fa.js +++ b/plugins/kolab_2fa/kolab2fa.js @@ -1,358 +1,370 @@ /** * Kolab 2-Factor-Authentication plugin client functions * * @author Thomas Bruederli * * @licstart The following is the entire license notice for the * JavaScript code in this page. * * 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 . * * @licend The above is the entire license notice * for the JavaScript code in this page. */ window.rcmail && rcmail.addEventListener('init', function(evt) { var highsec_call_stack = []; var highsec_dialog; var factor_dialog; /** * Equivalend of PHP time() */ function time() { return Math.round(new Date().getTime() / 1000); } /** * Render the settings UI */ function render() { var table = $('#kolab2fa-factors tbody'); table.html(''); var rows = 0; - $.each(rcmail.env.kolab_2fa_factors, function(method, props) { + $.each(rcmail.env.kolab_2fa_factors, function(id, props) { if (props.active) { - var tr = $('').addClass(method).appendTo(table); - $('').addClass('name').text(props.name || method).appendTo(tr); + var tr = $('').addClass(props.method).appendTo(table); + $('').addClass('name').text(props.label || props.name).appendTo(tr); $('').addClass('created').text(props.created || '??').appendTo(tr); - $('').addClass('actions').html('' + rcmail.get_label('remove','kolab_2fa') + '').appendTo(tr); + $('').addClass('actions').html('' + rcmail.get_label('remove','kolab_2fa') + '').appendTo(tr); rows++; } }); table.parent()[(rows > 0 ? 'show' : 'hide')](); - +/* var remaining = 0; $('#kolab2fa-add option').each(function(i, elem) { var method = elem.value; - if (rcmail.env.kolab_2fa_factors[method]) { - $(elem).prop('disabled', rcmail.env.kolab_2fa_factors[method].active); - if (!rcmail.env.kolab_2fa_factors[method].active) { - remaining++; - } + $(elem).prop('disabled', active[method]); + if (!active[method]) { + remaining++; } }); $('#kolab2fa-add').prop('disabled', !remaining).get(0).selectedIndex = 0; +*/ } /** * Open dialog to add the given authentication factor */ function add_factor(method) { var lock, form = $('#kolab2fa-prop-' + method), props = rcmail.env.kolab_2fa_factors[method]; if (form.length) { form.get(0).reset(); form.find('img.qrcode').attr('src', 'data:image/gif;base64,R0lGODlhDwAPAIAAAMDAwAAAACH5BAEAAAAALAAAAAAPAA8AQAINhI+py+0Po5y02otnAQA7'); form.off('submit'); factor_dialog = rcmail.show_popup_dialog( form.show(), rcmail.get_label('addfactor', 'kolab_2fa'), [ { text: rcmail.gettext('save', 'kolab_2fa'), 'class': 'mainaction', click: function(e) { save_data(method); } }, { text: rcmail.gettext('cancel'), click: function() { factor_dialog.dialog('close'); } } ], { open: function(event, ui) { - + $(event.target).find('input[name="_verify_code"]').keypress(function(e) { + if (e.which == 13) { + $(e.target).closest('.ui-dialog').find('.ui-button.mainaction').click(); + } + }); }, close: function(event, ui) { form.hide().appendTo(document.body); factor_dialog = null; } } ) .addClass('propform') .data('method', method) .data('timestamp', time()); form.on('submit', function(e) { save_data(method); return false; }); // load generated data lock = rcmail.set_busy(true, 'loading'); rcmail.http_post('plugin.kolab-2fa-data', { _method: method }, lock); } } /** * Remove the given factor from the account */ - function remove_factor(method) { - if (rcmail.env.kolab_2fa_factors[method]) { - rcmail.env.kolab_2fa_factors[method].active = false; + function remove_factor(id) { + if (rcmail.env.kolab_2fa_factors[id]) { + rcmail.env.kolab_2fa_factors[id].active = false; } render(); var lock = rcmail.set_busy(true, 'saving'); - rcmail.http_post('plugin.kolab-2fa-save', { _method: method, _data: 'false' }, lock); + rcmail.http_post('plugin.kolab-2fa-save', { _method: id, _data: 'false' }, lock); } /** * Submit factor settings form */ function save_data(method) { - var lock, form = $('#kolab2fa-prop-' + method), + var lock, data, form = $('#kolab2fa-prop-' + method), verify = form.find('input[name="_verify_code"]'); if (verify.length && !verify.val().length) { alert(rcmail.get_label('verifycodemissing','kolab_2fa')); verify.select(); return false; } + data = form_data(form); lock = rcmail.set_busy(true, 'saving'); rcmail.http_post('plugin.kolab-2fa-save', { - _method: method, - _data: JSON.stringify(form_data(form)), + _method: data.id || method, + _data: JSON.stringify(data), _verify_code: verify.val(), _timestamp: factor_dialog ? factor_dialog.data('timestamp') : null }, lock); } /** * Collect all factor properties from the form */ function form_data(form) { var data = {}; form.find('input, select').each(function(i, elem) { if (elem.name.indexOf('_prop') === 0) { k = elem.name.match(/\[([a-z0-9_.-]+)\]$/i) ? RegExp.$1 : null; if (k) { data[k] = elem.tagName == 'SELECT' ? $('option:selected', elem).val() : $(elem).val(); } } }); return data; } /** * Execute the given function after the user authorized the session with a 2nd factor */ - function require_high_security(func) + function require_high_security(func, exclude) { // request 2nd factor auth if (!rcmail.env.session_secured || rcmail.env.session_secured < time() - 120) { var method, name; // find an active factor - $.each(rcmail.env.kolab_2fa_factors, function(m, prop) { - if (prop.active) { - method = m; - name = prop.name; - return true; + $.each(rcmail.env.kolab_2fa_factors, function(id, prop) { + if (prop.active && !method || method == exclude) { + method = id; + name = prop.label || prop.name; + if (!exclude || id !== exclude) { + return true; + } } }); // we have a registered factor, use it if (method) { highsec_call_stack.push(func); var html = String($('#kolab2fa-highsecuritydialog').html()).replace('$name', name); highsec_dialog = rcmail.show_popup_dialog( html, rcmail.get_label('highsecurityrequired', 'kolab_2fa'), [ { text: rcmail.gettext('enterhighsecurity', 'kolab_2fa'), click: function(e) { var lock, code = highsec_dialog.find('input[name="_code"]').val(); if (code && code.length) { lock = rcmail.set_busy(true, 'verifying'); rcmail.http_post('plugin.kolab-2fa-verify', { _method: method, _code: code, _session: 1, _timestamp: highsec_dialog.data('timestamp') }, lock); } else { highsec_dialog.find('input[name="_code"]').select(); } }, 'class': 'mainaction' }, { text: rcmail.gettext('cancel'), click: function() { highsec_dialog.dialog('close'); } } ], { open: function(event, ui) { // submit code on $(event.target).find('input[name="_code"]').keypress(function(e) { if (e.which == 13) { $(e.target).closest('.ui-dialog').find('.ui-button.mainaction').click(); } }).select(); }, close: function(event, ui) { $(this).remove(); highsec_dialog = null; highsec_call_stack.pop(); } } ).data('timestamp', time()); return false; } } // just trigger the callback func.call(this); }; // callback for factor data provided by the server rcmail.addEventListener('plugin.render_data', function(data) { - var method = data._method, + var method = data.method, form = $('#kolab2fa-prop-' + method); if (form.length) { $.each(data, function(field, value) { - form.find('[name="_prop[' + method + '][' + field + ']"]').val(value); + form.find('[name="_prop[' + field + ']"]').val(value); }); if (data.qrcode) { $('img.qrcode[rel='+method+']').attr('src', "data:image/png;base64," + data.qrcode); } } else if (window.console) { console.error("Cannot assign auth data", data); } }); // callback for save action rcmail.addEventListener('plugin.save_success', function(data) { - if (rcmail.env.kolab_2fa_factors[data.method]) { - $.extend(rcmail.env.kolab_2fa_factors[data.method], data); + if (!data.active && rcmail.env.kolab_2fa_factors[data.id]) { + delete rcmail.env.kolab_2fa_factors[data.id]; + } + else if (rcmail.env.kolab_2fa_factors[data.id]) { + $.extend(rcmail.env.kolab_2fa_factors[data.id], data); + } + else { + rcmail.env.kolab_2fa_factors[data.id] = data; } if (factor_dialog) { factor_dialog.dialog('close'); } render(); }); // callback for verify action rcmail.addEventListener('plugin.verify_response', function(data) { // execute high-security call stack and close dialog if (data.success && highsec_dialog && highsec_dialog.is(':visible')) { var func; while (highsec_call_stack.length) { func = highsec_call_stack.pop(); func(); } highsec_dialog.dialog('close'); rcmail.env.session_secured = time(); } else { rcmail.display_message(data.message, data.success ? 'confirmation' : 'warning'); if (highsec_dialog && highsec_dialog.is(':visible')) { highsec_dialog.find('input[name="_code"]').val('').select(); } else { $('#kolab2fa-prop-' + data.method + ' input.k2fa-verify').val('').select(); } } }); // callback for save failure rcmail.addEventListener('plugin.reset_form', function(method) { if (rcmail.env.kolab_2fa_factors[method]) { rcmail.env.kolab_2fa_factors[method].active = false; } render(); }); // handler for selections $('#kolab2fa-add').change(function() { var method = $('option:selected', this).val(); // require high security? add_factor(method); this.selectedIndex = 0; }); // handler for delete button clicks $('#kolab2fa-factors tbody').on('click', '.button.delete', function(e) { - var method = $(this).attr('rel'); + var id = $(this).attr('rel'); // require auth verification require_high_security(function() { if (confirm(rcmail.get_label('authremoveconfirm', 'kolab_2fa'))) { - remove_factor(method); + remove_factor(id); } - }); + }, id); return false; }); // submit verification code on $('.propform input.k2fa-verify').keypress(function(e) { if (e.which == 13) { $(this).closest('.propform').find('.button.verify').click(); } }); // render list initially render(); }); \ No newline at end of file diff --git a/plugins/kolab_2fa/kolab_2fa.php b/plugins/kolab_2fa/kolab_2fa.php index 43c0397a..4ee7e8d8 100644 --- a/plugins/kolab_2fa/kolab_2fa.php +++ b/plugins/kolab_2fa/kolab_2fa.php @@ -1,752 +1,746 @@ * * 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_factors', array( 'user' => $args['user'], 'host' => $hostname, 'active' => $rcmail->config->get('kolab_2fa_factors'), )); if (isset($lookup['active'])) { $factors = (array)$lookup['active']; } // 2b. check storage if this user has 2FA enabled else if ($storage = $this->get_storage($args['user'])) { - $factors = (array)$storage->read('active'); + $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']; - $sign = rcube_utils::get_input_value('_sign', rcube_utils::INPUT_POST); $this->login_verified = false; $expired = $time < time() - $rcmail->config->get('kolab_2fa_timeout', 120); - if (!empty($sign) && !empty($factors) && !empty($nonce) && !$expired) { + if (!empty($factors) && !empty($nonce) && !$expired) { // TODO: check signature // try to verify each configured factor - foreach ($factors as $method) { + 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($method, $code); + $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))); - // TODO: generate request signature - $input_sign = new html_hiddenfield(array('name' => '_sign', 'id' => 'rcmloginsign', 'value' => 'XXX')); // create HTML table with two cols $table = new html_table(array('cols' => 2)); - $required = count($this->login_factors) > 1 ? null : 'required'; + $required = count($methods) > 1 ? null : 'required'; // render input for each configured auth method - foreach ($this->login_factors as $i => $method) { - if ($i > 0) { + 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 .= $input_sign->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(); - $method = strtolower($method); - $valid = in_array($method, $rcmail->config->get('kolab_2fa_drivers', array())); - - if (!$valid) { - return false; - } 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(); // set user properties from active session 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); } 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(); - $storage = $this->get_storage($rcmail->get_user_name()); - $active = $storage ? (array)$storage->read('active') : array(); $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, array('disabled' => in_array($method, $active))); + $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->read('active') : array(); + $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( - $drivers, - array_map(function($method) use ($me, $factors) { - $props = array( - 'name' => $me->gettext($method), - 'active' => in_array($method, $factors), - ); + $factors, + array_map(function($id) use ($me, &$env_methods) { + $props = array('id' => $id); - if ($props['active'] && ($driver = $me->get_driver($method))) { + 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; - }, $drivers) + }, $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()) + 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); + $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()); - $active = $storage ? (array)$storage->read('active') : array(); $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 - $active = array_filter($active, function($f) use ($method) { return $f != $method; }); - $driver->clear(); + $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( - 'method' => $method, + 'id' => $driver->id, + 'method' => $driver->method, 'success' => false, - 'message' => str_replace('$method', $this->gettext($method), $this->gettext('codeverificationfailed')) + '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++; } } - if (!in_array($method, $active)) { - $active[] = $method; - } + $driver->set('active', true); } // update list of active factors for this user if (!$errors) { - $success = $storage && $storage->write('active', $active); + $success = $driver->commit(); $save_data = $data !== false ? $this->format_props($driver->props()) : array(); } } 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); - - // abort if session is not authorized - /* - if ($driver->active && !$this->check_secure_mode()) { - $this->api->output->send(); - } - */ + $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; - case 'boolean': - $value = $this->gettext($prop['value'] ? 'yes' : 'no'); - 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/Driver/Base.php b/plugins/kolab_2fa/lib/Kolab2FA/Driver/Base.php index 77bd2240..89488231 100644 --- a/plugins/kolab_2fa/lib/Kolab2FA/Driver/Base.php +++ b/plugins/kolab_2fa/lib/Kolab2FA/Driver/Base.php @@ -1,291 +1,352 @@ * * 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\Driver; abstract class Base { public $method = null; + public $id = null; public $storage; protected $config = array(); protected $props = array(); protected $user_props = array(); + protected $pending_changes = false; protected $allowed_props = array('username'); - public $user_settings = array(); + public $user_settings = array( + 'active' => array( + 'type' => 'boolean', + 'editable' => false, + 'hidden' => false, + 'default' => false, + ), + 'label' => array( + 'type' => 'text', + 'editable' => true, + 'label' => 'label', + 'generator' => 'default_label', + ), + 'created' => array( + 'type' => 'datetime', + 'editable' => false, + 'hidden' => false, + 'label' => 'created', + 'generator' => 'time', + ), + ); /** * Static factory method */ - public static function factory($method, $config) + public static function factory($id, $config) { + list($method) = explode(':', $id); + $classmap = array( '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); + return new $cls($config, $id); } throw new Exception("Unknown 2FA driver '$method'"); } /** * Default constructor */ - public function __construct($config = null) + public function __construct($config = null, $id = null) { - if (is_array($config)) { - $this->init($config); + $this->init($config); + + if (!empty($id) && $id != $this->method) { + $this->id = $id; + } + else { // generate random ID + $this->id = $this->method . ':' . bin2hex(openssl_random_pseudo_bytes(12)); } } /** * Initialize the driver with the given config options */ - public function init(array $config) + public function init($config) { - $this->config = array_merge($this->config, $config); + if (is_array($config)) { + $this->config = array_merge($this->config, $config); + } if ($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 boolean True if valid, false otherwise */ abstract function verify($code, $timestamp = null); /** * Getter for user-visible properties */ public function props($force = false) { $data = array(); foreach ($this->user_settings as $key => $p) { if ($p['private']) { continue; } $data[$key] = array( 'type' => $p['type'], 'editable' => $p['editable'], 'hidden' => $p['hidden'], '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; } default: $data[$key]['text'] = $data[$key]['value']; } } return $data; } /** * Implement this method if the driver can be prpvisioned via QR code */ /* abstract function get_provisioning_uri(); */ /** * Generate a random secret string */ public function generate_secret($length = 16) { // Base32 characters $chars = array( '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 && $this->user_settings[$key]['generator']) { $func = $this->user_settings[$key]['generator']; if (is_string($func) && !is_callable($func)) { $func = array($this, $func); } if (is_callable($func)) { $value = call_user_func($func); } - if (!isset($value)) { + if (isset($value)) { $this->set_user_prop($key, $value); } } } else { $value = $this->props[$key]; } 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(array($this, $setter), $value); } else if (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)) { + $this->pending_changes = 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) { - $this->storage->remove($this->method); + return $this->storage->remove($this->id); } + + return false; } /** * Getter for per-user properties for this method */ protected function get_user_prop($key) { - if (!isset($this->user_props[$key]) && $this->storage) { - $this->user_props = (array)$this->storage->read($this->method); + if (!isset($this->user_props[$key]) && $this->storage && !$this->pending_changes) { + $this->user_props = (array)$this->storage->read($this->id); } return $this->user_props[$key]; } /** * Setter for per-user properties for this method */ protected function set_user_prop($key, $value) { - $success = false; + $success = true; + $this->pending_changes |= ($this->user_props[$key] !== $value); $this->user_props[$key] = $value; +/* if ($this->user_settings[$key] && $this->storage) { - $props = (array)$this->storage->read($this->method); + $props = (array)$this->storage->read($this->id); $props[$key] = $value; - $success = $this->storage->write($this->method, $props); + $success = $this->storage->write($this->id, $props); } - +*/ return $success; } /** * 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]); } } \ No newline at end of file diff --git a/plugins/kolab_2fa/lib/Kolab2FA/Driver/HOTP.php b/plugins/kolab_2fa/lib/Kolab2FA/Driver/HOTP.php index dc1e0a25..89542242 100644 --- a/plugins/kolab_2fa/lib/Kolab2FA/Driver/HOTP.php +++ b/plugins/kolab_2fa/lib/Kolab2FA/Driver/HOTP.php @@ -1,127 +1,121 @@ * * 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\Driver; class HOTP extends Base { public $method = 'hotp'; protected $config = array( 'digits' => 6, 'window' => 4, 'digest' => 'sha1', ); - public $user_settings = array( - 'secret' => array( - 'type' => 'text', - 'private' => true, - 'label' => 'secret', - 'generator' => 'generate_secret', - ), - 'created' => array( - 'type' => 'datetime', - 'editable' => false, - 'hidden' => false, - 'label' => 'created', - 'generator' => 'time', - ), - 'counter' => array( - 'type' => 'integer', - 'editable' => false, - 'hidden' => true, - 'generator' => 'random_counter', - ), - ); - protected $backend; /** * */ - public function init(array $config) + public function init($config) { parent::init($config); + $this->user_settings += array( + 'secret' => array( + 'type' => 'text', + 'private' => true, + 'label' => 'secret', + 'generator' => 'generate_secret', + ), + 'counter' => array( + 'type' => 'integer', + 'editable' => false, + 'hidden' => true, + 'generator' => 'random_counter', + ), + ); + // copy config options $this->backend = new \Kolab2FA\OTP\HOTP(); $this->backend ->setDigits($this->config['digits']) ->setDigest($this->config['digest']) ->setIssuer($this->config['issuer']) ->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" console("VERIFY HOTP: no secret set for user $this->username"); return false; } $this->backend->setLabel($this->username)->setSecret($secret)->setCounter($this->get('counter')); $pass = $this->backend->verify($code, $counter, $this->config['window']); // store incremented counter value $this->set('counter', $this->backend->getCounter()); // console('VERIFY HOTP', $this->username, $secret, $counter, $code, $pass); return $pass; } /** * */ public function get_provisioning_uri() { if (!$this->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)->setSecret($this->secret)->setCounter($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 12771afb..8fe9654f 100644 --- a/plugins/kolab_2fa/lib/Kolab2FA/Driver/TOTP.php +++ b/plugins/kolab_2fa/lib/Kolab2FA/Driver/TOTP.php @@ -1,118 +1,114 @@ * * 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\Driver; class TOTP extends Base { public $method = 'totp'; protected $config = array( 'digits' => 6, 'interval' => 30, 'digest' => 'sha1', ); - public $user_settings = array( - 'secret' => array( - 'type' => 'text', - 'private' => true, - 'label' => 'secret', - 'generator' => 'generate_secret', - ), - 'created' => array( - 'type' => 'datetime', - 'editable' => false, - 'hidden' => false, - 'label' => 'created', - 'generator' => 'time', - ), - ); - protected $backend; /** * */ - public function init(array $config) + public function init($config) { parent::init($config); + $this->user_settings += array( + 'secret' => array( + 'type' => 'text', + 'private' => true, + 'label' => 'secret', + 'generator' => 'generate_secret', + ), + ); + // copy config options $this->backend = new \Kolab2FA\OTP\TOTP(); $this->backend ->setDigits($this->config['digits']) ->setInterval($this->config['interval']) ->setDigest($this->config['digest']) ->setIssuer($this->config['issuer']) ->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" console("VERIFY TOTP: no secret set for user $this->username"); return false; } $this->backend->setLabel($this->username)->setSecret($secret); $pass = $this->backend->verify($code); // 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']; } } // console('VERIFY TOTP', $this->username, $secret, $code, $timestamp, $pass); return $pass; } /** * */ public function get_provisioning_uri() { + console('PROV', $this->secret); if (!$this->secret) { // generate new secret and store it $this->set('secret', $this->get('secret', true)); $this->set('created', $this->get('created', true)); + console('PROV2', $this->secret); + $this->commit(); } // TODO: deny call if already active? $this->backend->setLabel($this->username)->setSecret($this->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 15156896..2f227da9 100644 --- a/plugins/kolab_2fa/lib/Kolab2FA/Driver/Yubikey.php +++ b/plugins/kolab_2fa/lib/Kolab2FA/Driver/Yubikey.php @@ -1,135 +1,128 @@ * * 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\Driver; class Yubikey extends Base { public $method = 'yubikey'; protected $config = array( 'clientid' => '42', 'apikey' => 'FOOBAR=', 'hosts' => null, ); - public $user_settings = array( - 'yubikeyid' => array( - 'type' => 'text', - 'editable' => true, - 'label' => 'secret', - ), - 'created' => array( - 'type' => 'datetime', - 'editable' => false, - 'hidden' => false, - 'label' => 'created', - 'generator' => 'time', - ), - ); - protected $backend; /** * */ public function init(array $config) { parent::init($config); + $this->user_settings += array( + 'yubikeyid' => array( + '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']); } } /** * */ 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 } } console('VERIFY TOTP', $this->username, $keyid, $code, $pass); return $pass; } /** * @override */ public function set($key, $value) { 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); } return parent::set($key, $value); } /** * @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 dd11f183..ba9ac8a7 100644 --- a/plugins/kolab_2fa/lib/Kolab2FA/Storage/Base.php +++ b/plugins/kolab_2fa/lib/Kolab2FA/Storage/Base.php @@ -1,90 +1,95 @@ * * 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; abstract class Base { public $username = null; protected $config = array(); /** * */ 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); } /** * Set username to store data for */ public function set_username($username) { $this->username = $username; } + /** + * 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 stoed for the given key */ abstract public function remove($key); } diff --git a/plugins/kolab_2fa/lib/Kolab2FA/Storage/RcubeUser.php b/plugins/kolab_2fa/lib/Kolab2FA/Storage/RcubeUser.php index cdf47393..f0664bb1 100644 --- a/plugins/kolab_2fa/lib/Kolab2FA/Storage/RcubeUser.php +++ b/plugins/kolab_2fa/lib/Kolab2FA/Storage/RcubeUser.php @@ -1,129 +1,183 @@ * * 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( - 'active' => 'kolab_2fa_factors', - ), + '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]) && ($user = $this->get_user($this->username))) { - $prefs = $user->get_prefs(); - $pkey = $this->key2property($key); - $this->cache[$key] = $prefs[$pkey]; + if (!isset($this->cache[$key])) { + $factors = $this->get_factors(); + console('READ', $key, $factors); + $this->cache[$key] = $factors[$key]; } return $this->cache[$key]; } /** * Save data for the given key */ public function write($key, $value) { if ($user = $this->get_user($this->username)) { $this->cache[$key] = $value; - $pkey = $this->key2property($key); - return $user->save_prefs(array($pkey => $value), true); + + $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); } return false; } /** * Remove the data stoed 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']); } 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_props_' . $key; + return 'kolab_2fa_' . $key; } } diff --git a/plugins/kolab_2fa/localization/en_US.inc b/plugins/kolab_2fa/localization/en_US.inc index 81dea1eb..9f0202b9 100644 --- a/plugins/kolab_2fa/localization/en_US.inc +++ b/plugins/kolab_2fa/localization/en_US.inc @@ -1,56 +1,57 @@ FreeOTP and Google Authenticator, but any other TOTP app should also work.

Launch the app on your phone, and add a new entry for this service. When prompted, scan the QR code below to configure your mobile app.'; $labels['qrcodeexplainhotp'] = 'Download an authenticator app on your phone. One app known to work well is Google Authenticator, but any other HOTP app should also work.

Launch the app on your phone, and add a new entry for this service. When prompted, scan the QR code below to configure your mobile app.'; $labels['yubikeyid'] = 'Your YubiKey ID'; $labels['yubikeyidexplainyubikey'] = 'Press your YubiKey once and submit the generated code'; $labels['addfactor'] = 'Add Authentication Factor'; $labels['testfactor'] = 'Test this factor'; $labels['verifycode'] = 'Verify Code'; $labels['verifycodeexplaintotp'] = 'Once you have scanned the QR code, enter the 6-digit verification code generated by the authenticator app.'; $labels['verifycodeexplainhotp'] = $labels['verifycodeexplaintotp']; $labels['loginexpired'] = 'Login request expired! Please try again.'; $labels['authremoveconfirm'] = 'Do you really want to remove this authentication factor from your account?'; $labels['verifycodemissing'] = 'Please enter the verification code from your device'; $labels['factorsavesuccess'] = 'Successfully saved authentication factor'; $labels['factorremovesuccess'] = 'Successfully removed the authentication factor'; $labels['factorsaveerror'] = 'Failed to save authentication factor settings'; $labels['codeverificationpassed'] = 'Code verification for $method passed'; $labels['codeverificationfailed'] = 'Code verification for $method failed'; $labels['highsecurityrequired'] = 'Entering High Security - Verification required'; $labels['highsecuritydialog'] = 'High security mode helps protect your account from security threats, like session theft or someone messing with your stuff while you\'re away. To enter high security mode, confirm your credentials with the additional authentication factor:'; $labels['enterhighsecurity'] = 'Confirm';