From 1b10eb060a22b88d13c45b7fcec6b82ee9f17e33 Mon Sep 17 00:00:00 2001 From: Thomas Bruederli Date: Thu, 4 Jun 2015 15:53:04 +0200 Subject: [PATCH] Initial version of the Kolab 2-Factor-Authentication plugin. This already includes: - Drivers supporting TOTP, HOTP, Yubikey - Storage backend abstraction - Additional authentication step at login - Settings section to provision and remove authentication factors - Sample config with comments Resolves T415 --- plugins/kolab_2fa/composer.json | 28 + plugins/kolab_2fa/config.inc.php.dist | 74 ++ plugins/kolab_2fa/kolab2fa.js | 358 +++++++++ plugins/kolab_2fa/kolab_2fa.php | 713 ++++++++++++++++++ .../kolab_2fa/lib/Kolab2FA/Driver/Base.php | 279 +++++++ .../lib/Kolab2FA/Driver/Exception.php | 8 + .../kolab_2fa/lib/Kolab2FA/Driver/HOTP.php | 131 ++++ .../kolab_2fa/lib/Kolab2FA/Driver/TOTP.php | 122 +++ .../kolab_2fa/lib/Kolab2FA/Driver/Yubikey.php | 140 ++++ plugins/kolab_2fa/lib/Kolab2FA/OTP/HOTP.php | 58 ++ plugins/kolab_2fa/lib/Kolab2FA/OTP/OTP.php | 133 ++++ plugins/kolab_2fa/lib/Kolab2FA/OTP/TOTP.php | 50 ++ .../kolab_2fa/lib/Kolab2FA/Storage/Base.php | 81 ++ .../lib/Kolab2FA/Storage/Exception.php | 8 + .../kolab_2fa/lib/Kolab2FA/Storage/LDAP.php | 203 +++++ .../lib/Kolab2FA/Storage/RcubeUser.php | 103 +++ plugins/kolab_2fa/localization/en_US.inc | 57 ++ plugins/kolab_2fa/skins/larry/kolab2fa.css | 93 +++ .../skins/larry/templates/config.html | 34 + 19 files changed, 2673 insertions(+) create mode 100644 plugins/kolab_2fa/composer.json create mode 100644 plugins/kolab_2fa/config.inc.php.dist create mode 100644 plugins/kolab_2fa/kolab2fa.js create mode 100644 plugins/kolab_2fa/kolab_2fa.php create mode 100644 plugins/kolab_2fa/lib/Kolab2FA/Driver/Base.php create mode 100644 plugins/kolab_2fa/lib/Kolab2FA/Driver/Exception.php create mode 100644 plugins/kolab_2fa/lib/Kolab2FA/Driver/HOTP.php create mode 100644 plugins/kolab_2fa/lib/Kolab2FA/Driver/TOTP.php create mode 100644 plugins/kolab_2fa/lib/Kolab2FA/Driver/Yubikey.php create mode 100644 plugins/kolab_2fa/lib/Kolab2FA/OTP/HOTP.php create mode 100644 plugins/kolab_2fa/lib/Kolab2FA/OTP/OTP.php create mode 100644 plugins/kolab_2fa/lib/Kolab2FA/OTP/TOTP.php create mode 100644 plugins/kolab_2fa/lib/Kolab2FA/Storage/Base.php create mode 100644 plugins/kolab_2fa/lib/Kolab2FA/Storage/Exception.php create mode 100644 plugins/kolab_2fa/lib/Kolab2FA/Storage/LDAP.php create mode 100644 plugins/kolab_2fa/lib/Kolab2FA/Storage/RcubeUser.php create mode 100644 plugins/kolab_2fa/localization/en_US.inc create mode 100644 plugins/kolab_2fa/skins/larry/kolab2fa.css create mode 100644 plugins/kolab_2fa/skins/larry/templates/config.html diff --git a/plugins/kolab_2fa/composer.json b/plugins/kolab_2fa/composer.json new file mode 100644 index 00000000..72223c93 --- /dev/null +++ b/plugins/kolab_2fa/composer.json @@ -0,0 +1,28 @@ +{ + "name": "kolab/kolab_2fa", + "type": "roundcube-plugin", + "description": "Kolab 2-Factor Authentication", + "homepage": "https://git.kolab.org/diffusion/RPK/", + "license": "AGPLv3", + "version": "0.1.0", + "authors": [ + { + "name": "Thomas Bruederli", + "email": "bruederli@kolabsys.com", + "role": "Lead" + } + ], + "repositories": [ + { + "type": "composer", + "url": "http://plugins.roundcube.net" + } + ], + "require": { + "php": ">=5.3.0", + "roundcube/plugin-installer": ">=0.1.3", + "spomky-labs/otphp": "~5.0.0", + "endroid/qrcode": "~1.5.0", + "enygma/yubikey": "~3.2" + } +} diff --git a/plugins/kolab_2fa/config.inc.php.dist b/plugins/kolab_2fa/config.inc.php.dist new file mode 100644 index 00000000..2b02b866 --- /dev/null +++ b/plugins/kolab_2fa/config.inc.php.dist @@ -0,0 +1,74 @@ + + * + * 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 . + */ + +// available methods/providers +$config['kolab_2fa_drivers'] = array('totp','hotp'); + +// backend for storing 2-factor-auth related per-user settings +// available backends are: 'roundcube', 'ldap', 'sql' +$config['kolab_2fa_storage'] = 'roundcube'; + +// additional config options for the above storage backend +// here an example for the LDAP backend: +$config['kolab_2fa_storage_config'] = array( + 'hosts' => array('localhost'), + 'port' => 389, + 'bind_dn' => 'uid=kolab-service,ou=Special Users,dc=example,dc=org', + 'bind_pass' => 'Welcome2KolabSystems', + 'base_dn' => 'ou=People,dc=example,dc=org', + 'filter' => '(&(objectClass=inetOrgPerson)(mail=%fu))', + 'scope' => 'sub', + 'fieldmap' => array( + 'uid' => 'uid', + 'mail' => 'mail', + 'totp' => 'kolabAuthTOTP', + 'yubikey' => 'kolabAuthYubikey', + ), + 'debug' => true, +); + +// force a two-factor authentication for all users +// $config['kolab_2fa_factors'] = array('totp'); + +// timeout for 2nd factor auth submission (in seconds) +$config['kolab_2fa_timeout'] = 60; + +// configuration parameters for TOTP (uncomment to adjust) +$config['kolab_2fa_totp'] = array( + // 'digits' => 6, + // 'interval' => 30, + // 'digest' => 'sha1', + // 'issuer' => 'Roundcube', +); + +// configuration parameters for HOTP (uncomment to adjust) +$config['kolab_2fa_hotp'] = array( + // 'digits' => 6, + // 'window' => 4, + // 'digest' => 'sha1', +); + +// configuration parameters for Yubikey (uncomment to adjust) +$config['kolab_2fa_yubikey'] = array( + 'clientid' => '123456', + 'apikey' => '', + // 'hosts' => array('api.myhost1.com','api2.myhost.com'), +); \ No newline at end of file diff --git a/plugins/kolab_2fa/kolab2fa.js b/plugins/kolab_2fa/kolab2fa.js new file mode 100644 index 00000000..09c3eeb4 --- /dev/null +++ b/plugins/kolab_2fa/kolab2fa.js @@ -0,0 +1,358 @@ +/** + * 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) { + if (props.active) { + var tr = $('').addClass(method).appendTo(table); + $('').addClass('name').text(props.name || method).appendTo(tr); + $('').addClass('created').text(props.created || '??').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++; + } + } + }); + $('#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) { + + }, + 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; + } + render(); + + var lock = rcmail.set_busy(true, 'saving'); + rcmail.http_post('plugin.kolab-2fa-save', { _method: method, _data: 'false' }, lock); + } + + /** + * Submit factor settings form + */ + function save_data(method) { + var lock, 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; + } + + lock = rcmail.set_busy(true, 'saving'); + rcmail.http_post('plugin.kolab-2fa-save', { + _method: method, + _data: JSON.stringify(form_data(form)), + _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) + { + // 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; + } + }); + + // 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, + form = $('#kolab2fa-prop-' + method); + + if (form.length) { + $.each(data, function(field, value) { + form.find('[name="_prop[' + method + '][' + 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 (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'); + + // require auth verification + require_high_security(function() { + if (confirm(rcmail.get_label('authremoveconfirm', 'kolab_2fa'))) { + remove_factor(method); + } + }); + + 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 new file mode 100644 index 00000000..1d6035c8 --- /dev/null +++ b/plugins/kolab_2fa/kolab_2fa.php @@ -0,0 +1,713 @@ + + * + * 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(); + + /** + * 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(); + + 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 = $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()); + } + + // 2. check if this user/system has 2FA enabled + if (count($factors = (array)$rcmail->config->get('kolab_2fa_factors', array())) > 0) { + $args['abort'] = true; + + // 3. flag session as temporary (no further actions allowed) + $_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['hostname'] = $hostname; + $_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 = $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) { + console('VERIFY', $sign, $factors); + + // TODO: check signature + + // try to verify each configured factor + foreach ($factors as $method) { + // 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); + + // 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']; + + // 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'; + + // render input for each configured auth method + foreach ($this->login_factors as $i => $method) { + if ($i > 0) { + $table->add(array('colspan' => 2, 'class' => 'hint'), $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]; + } + + // register library namespace to autoloader + if (!class_exists('\\Kolab3FA\\Driver\\Base', false)) { + $loader = include(INSTALL_PATH . 'vendor/autoload.php'); + $loader->set('Kolab2FA', array($this->home . '/lib')); + } + + $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 = \Kolab2FA\Storage\Base::factory( + $rcmail->config->get('kolab_2fa_storage', 'roundcube'), + $rcmail->config->get('kolab_2fa_storage_config', array()) + ); + + // 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; + } + + /** + * 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(); + $active = (array)$rcmail->config->get('kolab_2fa_factors', 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))); + } + + 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(); + $drivers = (array)$rcmail->config->get('kolab_2fa_drivers', array()); + $factors = (array)$rcmail->config->get('kolab_2fa_factors', array()); + + foreach ($drivers as $j => $method) { + $out .= $this->settings_factor($method, $attrib); + } + + $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), + ); + + if ($props['active'] && ($driver = $me->get_driver($method))) { + $props += $this->format_props($driver->props()); + } + + return $props; + }, $drivers) + )); + + 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'); + $factors = (array)$rcmail->config->get('kolab_2fa_factors', array()); + + if ($driver = $this->get_driver($method)) { + $active = in_array($method, $factors); + $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")) + ); + + } + + $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()) + ) + ); + } + + return $out; + } + + /** + * Render th + */ + public function settings_highsecuritydialog($attrib = array()) + { + $attrib += array('id' => 'kolab2fa-highsecuritydialog'); + + $field_id = 'rcmk2facode'; + $input = new html_inputfield(array('name' => '_code', 'id' => $field_id, 'class' => 'verifycode', 'size' => 20)); + + return html::div($attrib, + html::p('explain', $this->gettext('highsecuritydialog')) . + html::div('propform', html::label($field_id, '$name') . $input->show('')) + ); + } + + /** + * Handler for settings/plugin.kolab-2fa-save requests + */ + public function settings_save() + { + $method = rcube_utils::get_input_value('_method', rcube_utils::INPUT_POST); + $data = @json_decode(rcube_utils::get_input_value('_data', rcube_utils::INPUT_POST), true); + + $rcmail = rcmail::get_instance(); + $active = (array)$rcmail->config->get('kolab_2fa_factors', 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(); + } + 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, + 'success' => false, + 'message' => str_replace('$method', $this->gettext($method), $this->gettext('codeverificationfailed')) + )); + $this->api->output->send(); + } + } + + foreach ($data as $prop => $value) { + if (!$driver->set($prop, $value)) { + $errors++; + } + } + + if (!in_array($method, $active)) { + $driver->set('active', true); + $active[] = $method; + } + } + + // update list of active factors for this user + if (!$errors) { + $success = $rcmail->user->save_prefs(array('kolab_2fa_factors' => $active)); + $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(); + } + */ + + 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); + } + + // 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 new file mode 100644 index 00000000..f9df7921 --- /dev/null +++ b/plugins/kolab_2fa/lib/Kolab2FA/Driver/Base.php @@ -0,0 +1,279 @@ + + * + * 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 $storage; + + protected $config = array(); + protected $props = array(); + protected $user_props = array(); + + protected $allowed_props = array('username'); + + public $user_settings = array(); + + /** + * Static factory method + */ + public static function factory($method, $config) + { + $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); + } + + throw new Exception("Unknown 2FA driver '$method'"); + } + + /** + * 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); + + 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; + } + + /** + * 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)) { + $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_method($this, $setter, $value); + } + else if (in_array($key, $this->allowed_props)) { + $this->props[$key] = $value; + } + + return true; + } + + /** + * Clear data stored for this driver + */ + public function clear() + { + if ($this->storage) { + console('CLEAR prefs'); + $this->storage->remove($this->username . ':' . $this->method); + } + } + + /** + * 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->username . ':' . $this->method); + } + + return $this->user_props[$key]; + } + + /** + * Setter for per-user properties for this method + */ + protected function set_user_prop($key, $value) + { + $success = false; + $this->user_props[$key] = $value; + + if ($this->user_settings[$key] && $this->storage) { + $storage_key = $this->username . ':' . $this->method; + $props = (array)$this->storage->read($storage_key); + $props[$key] = $value; + $success = $this->storage->write($storage_key, $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/Exception.php b/plugins/kolab_2fa/lib/Kolab2FA/Driver/Exception.php new file mode 100644 index 00000000..627cb447 --- /dev/null +++ b/plugins/kolab_2fa/lib/Kolab2FA/Driver/Exception.php @@ -0,0 +1,8 @@ + + * + * 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', + ), + 'active' => array( + 'type' => 'boolean', + 'editable' => false, + 'hidden' => true, + ), + 'counter' => array( + 'type' => 'integer', + 'editable' => false, + 'hidden' => true, + 'generator' => 'random_counter', + ), + ); + + protected $backend; + + /** + * + */ + public function init(array $config) + { + parent::init($config); + + // 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" + 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)); + } + + // 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 new file mode 100644 index 00000000..9ba3e357 --- /dev/null +++ b/plugins/kolab_2fa/lib/Kolab2FA/Driver/TOTP.php @@ -0,0 +1,122 @@ + + * + * 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', + ), + 'active' => array( + 'type' => 'boolean', + 'editable' => false, + 'hidden' => true, + ), + ); + + protected $backend; + + /** + * + */ + public function init(array $config) + { + parent::init($config); + + // 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" + 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() + { + if (!$this->secret) { + // generate new secret and store it + $this->set('secret', $this->get('secret', true)); + $this->set('created', $this->get('created', true)); + } + + // 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 new file mode 100644 index 00000000..72924ebf --- /dev/null +++ b/plugins/kolab_2fa/lib/Kolab2FA/Driver/Yubikey.php @@ -0,0 +1,140 @@ + + * + * 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', + ), + 'active' => array( + 'type' => 'boolean', + 'editable' => false, + 'hidden' => true, + ), + ); + + protected $backend; + + /** + * + */ + public function init(array $config) + { + parent::init($config); + + // 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/OTP/HOTP.php b/plugins/kolab_2fa/lib/Kolab2FA/OTP/HOTP.php new file mode 100644 index 00000000..8b895b0b --- /dev/null +++ b/plugins/kolab_2fa/lib/Kolab2FA/OTP/HOTP.php @@ -0,0 +1,58 @@ + + * + * 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\OTP; + +use OTPHP\HOTP as Base; + +class HOTP extends Base +{ + use OTP; + protected $counter = 0; + + public function setCounter($counter) + { + if (!is_integer($counter) || $counter < 0) { + throw new \Exception('Counter must be at least 0.'); + } + $this->counter = $counter; + + return $this; + } + + public function getCounter() + { + return $this->counter; + } + + public function updateCounter($counter) + { + $this->counter = $counter; + + return $this; + } +} \ No newline at end of file diff --git a/plugins/kolab_2fa/lib/Kolab2FA/OTP/OTP.php b/plugins/kolab_2fa/lib/Kolab2FA/OTP/OTP.php new file mode 100644 index 00000000..aec816cc --- /dev/null +++ b/plugins/kolab_2fa/lib/Kolab2FA/OTP/OTP.php @@ -0,0 +1,133 @@ + + * + * 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\OTP; + +trait OTP +{ + protected $secret = null; + protected $issuer = null; + protected $issuer_included_as_parameter = false; + protected $label = null; + protected $digest = 'sha1'; + protected $digits = 6; + + public function setSecret($secret) + { + $this->secret = $secret; + + return $this; + } + + public function getSecret() + { + return $this->secret; + } + + public function setLabel($label) + { + if ($this->hasSemicolon($label)) { + throw new \Exception('Label must not contain a semi-colon.'); + } + $this->label = $label; + + return $this; + } + + public function getLabel() + { + return $this->label; + } + + public function setIssuer($issuer) + { + if ($this->hasSemicolon($issuer)) { + throw new \Exception('Issuer must not contain a semi-colon.'); + } + $this->issuer = $issuer; + + return $this; + } + + public function getIssuer() + { + return $this->issuer; + } + + public function isIssuerIncludedAsParameter() + { + return $this->issuer_included_as_parameter; + } + + public function setIssuerIncludedAsParameter($issuer_included_as_parameter) + { + $this->issuer_included_as_parameter = $issuer_included_as_parameter; + + return $this; + } + + public function setDigits($digits) + { + if (!is_numeric($digits) || $digits < 1) { + throw new \Exception('Digits must be at least 1.'); + } + $this->digits = $digits; + + return $this; + } + + public function getDigits() + { + return $this->digits; + } + + public function setDigest($digest) + { + if (!in_array($digest, array('md5', 'sha1', 'sha256', 'sha512'))) { + throw new \Exception("'$digest' digest is not supported."); + } + $this->digest = $digest; + + return $this; + } + + public function getDigest() + { + return $this->digest; + } + + private function hasSemicolon($value) + { + $semicolons = array(':', '%3A', '%3a'); + foreach ($semicolons as $semicolon) { + if (false !== strpos($value, $semicolon)) { + return true; + } + } + + return false; + } +} \ No newline at end of file diff --git a/plugins/kolab_2fa/lib/Kolab2FA/OTP/TOTP.php b/plugins/kolab_2fa/lib/Kolab2FA/OTP/TOTP.php new file mode 100644 index 00000000..1e265831 --- /dev/null +++ b/plugins/kolab_2fa/lib/Kolab2FA/OTP/TOTP.php @@ -0,0 +1,50 @@ + + * + * 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\OTP; + +use OTPHP\TOTP as Base; + +class TOTP extends Base +{ + use OTP; + protected $interval = 30; + + public function setInterval($interval) + { + if (!is_integer($interval) || $interval < 1) { + throw new \Exception('Interval must be at least 1.'); + } + $this->interval = $interval; + + return $this; + } + + public function getInterval() + { + return $this->interval; + } +} \ No newline at end of file diff --git a/plugins/kolab_2fa/lib/Kolab2FA/Storage/Base.php b/plugins/kolab_2fa/lib/Kolab2FA/Storage/Base.php new file mode 100644 index 00000000..75497f09 --- /dev/null +++ b/plugins/kolab_2fa/lib/Kolab2FA/Storage/Base.php @@ -0,0 +1,81 @@ + + * + * 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 +{ + 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); + } + + /** + * 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/Exception.php b/plugins/kolab_2fa/lib/Kolab2FA/Storage/Exception.php new file mode 100644 index 00000000..5407acfb --- /dev/null +++ b/plugins/kolab_2fa/lib/Kolab2FA/Storage/Exception.php @@ -0,0 +1,8 @@ + + * + * Copyright (C) 2015, Kolab Systems AG + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +namespace Kolab2FA\Storage; + +use \Net_LDAP3; + +class LDAP extends Base +{ + private $cache = array(); + private $conn; + private $error; + + public function init(array $config) + { + parent::init($config); + + $this->conn = new Net_LDAP3($config); + $this->conn->config_set('log_hook', array($this, 'log')); + + $this->conn->connect(); + + $bind_pass = $this->config['bind_pass']; + $bind_user = $this->config['bind_user']; + $bind_dn = $this->config['bind_dn']; + + $this->ready = $this->conn->bind($bind_dn, $bind_pass); + + if (!$this->ready) { + throw new Exception("LDAP storage not ready: " . $this->error); + } + } + + /** + * Read data for the given key + */ + public function read($key) + { + list($username, $method) = $this->split_key($key); + + if (!$this->config['fieldmap'][$method]) { + $this->cache[$key] = false; + // throw new Exception("No LDAP attribute defined for " . $method); + } + + if (!isset($this->cache[$key]) && ($rec = $this->get_user_record($username))) { + $data = false; + if (!empty($rec[$method])) { + $data = @json_decode($rec[$method], true); + } + $this->cache[$key] = $data; + } + + return $this->cache[$key]; + } + + /** + * Save data for the given key + */ + public function write($key, $value) + { + list($username, $method) = $this->split_key($key); + + if (!$this->config['fieldmap'][$method]) { + // throw new Exception("No LDAP attribute defined for " . $method); + return false; + } +/* + if ($rec = $this->get_user_record($username)) { + $attrib = $this->config['fieldmap'][$method]; + $result = $this->conn->modify_entry($rec['dn], ...); + return !empty($result); + } +*/ + return false; + } + + /** + * Remove the data stoed for the given key + */ + public function remove($key) + { + return $this->write($key, null); + } + + /** + * Helper method to split the storage key into username and auth-method + */ + private function split_key($key) + { + return explode(':', $key, 2); + } + + /** + * Fetches user data from LDAP addressbook + */ + function get_user_record($user) + { + $filter = $this->parse_vars($this->config['filter'], $user); + $base_dn = $this->parse_vars($this->config['base_dn'], $user); + $scope = $this->config['scope'] ?: 'sub'; + + // get record + if ($this->ready && ($result = $this->conn->search($base_dn, $filter, $scope, array_values($this->config['fieldmap'])))) { + if ($result->count() == 1) { + $entries = $result->entries(true); + $dn = key($entries); + $entry = array_pop($entries); + $entry = $this->field_mapping($dn, $entry); + + return $entry; + } + } + + return null; + } + + /** + * Maps LDAP attributes to defined fields + */ + protected function field_mapping($dn, $entry) + { + $entry['dn'] = $dn; + + // fields mapping + foreach ($this->config['fieldmap'] as $field => $attr) { + $attr_lc = strtolower($attr); + if (isset($entry[$attr_lc])) { + $entry[$field] = $entry[$attr_lc]; + } + else if (isset($entry[$attr])) { + $entry[$field] = $entry[$attr]; + } + } + + return $entry; + } + + /** + * Prepares filter query for LDAP search + */ + protected function parse_vars($str, $user) + { + // replace variables in filter + list($u, $d) = explode('@', $user); + + // hierarchal domain string + if (empty($dc)) { + $dc = 'dc=' . strtr($d, array('.' => ',dc=')); + } + + $replaces = array('%dc' => $dc, '%d' => $d, '%fu' => $user, '%u' => $u); + + return strtr($str, $replaces); + } + + /** + * Prints debug/error info to the log + */ + public function log($level, $msg) + { + $msg = implode("\n", $msg); + + switch ($level) { + case LOG_DEBUG: + case LOG_INFO: + case LOG_NOTICE: + if ($this->config['debug'] && class_exists('\\rcube', false)) { + \rcube::write_log('ldap', $msg); + } + break; + + case LOG_EMERGE: + case LOG_ALERT: + case LOG_CRIT: + case LOG_ERR: + case LOG_WARNING: + $this->error = $msg; + // throw new Exception("LDAP storage error: " . $msg); + break; + } + } +} diff --git a/plugins/kolab_2fa/lib/Kolab2FA/Storage/RcubeUser.php b/plugins/kolab_2fa/lib/Kolab2FA/Storage/RcubeUser.php new file mode 100644 index 00000000..865ebba7 --- /dev/null +++ b/plugins/kolab_2fa/lib/Kolab2FA/Storage/RcubeUser.php @@ -0,0 +1,103 @@ + + * + * 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 +{ + private $cache = array(); + + 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']; + } + + /** + * Read data for the given key + */ + public function read($key) + { + list($username, $method) = $this->split_key($key); + + if (!isset($this->cache[$key]) && ($user = $this->get_user($username))) { + $prefs = $user->get_prefs(); + $pkey = 'kolab_2fa_props_' . $method; + $this->cache[$key] = $prefs[$pkey]; + } + + return $this->cache[$key]; + } + + /** + * Save data for the given key + */ + public function write($key, $value) + { + list($username, $method) = $this->split_key($key); + + if ($user = $this->get_user($username)) { + $this->cache[$key] = $value; + $pkey = 'kolab_2fa_props_' . $method; + return $user->save_prefs(array($pkey => $value), true); + } + + return false; + } + + /** + * Remove the data stoed for the given key + */ + public function remove($key) + { + return $this->write($key, null); + } + + /** + * Helper method to split the storage key into username and auth-method + */ + private function split_key($key) + { + return explode(':', $key, 2); + } + + /** + * 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; + } + + return rcube_user::query($username, $this->config['hostname']); + } + +} diff --git a/plugins/kolab_2fa/localization/en_US.inc b/plugins/kolab_2fa/localization/en_US.inc new file mode 100644 index 00000000..6526199e --- /dev/null +++ b/plugins/kolab_2fa/localization/en_US.inc @@ -0,0 +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'; diff --git a/plugins/kolab_2fa/skins/larry/kolab2fa.css b/plugins/kolab_2fa/skins/larry/kolab2fa.css new file mode 100644 index 00000000..96a6de2b --- /dev/null +++ b/plugins/kolab_2fa/skins/larry/kolab2fa.css @@ -0,0 +1,93 @@ + +.propform .formbuttons { + margin-top: 1em; +} + +.propform .explain { + max-width: 40em; + margin-top: 0.4em; + margin-bottom: 0.8em; +} + +.propform .kolab2fa-showqr { + font-weight: bold; +} + +.propform tr.hidden { + display: none; +} + +table.propform thead th { + text-align: left; + padding: 6px 10px; +} + +table.propform td.title { + vertical-align: top; + padding-top: 0.8em; +} + +table.propform td.title > label { + line-height: 1.8em; +} + +.propform img.qrcode { + min-width: 260px; + min-height: 260px; +} + +table.authentication-factors { + width: 99%; + max-width: 60em; + border: 0; +} + +table.authentication-factors td.name { + width: 60%; + font-weight: bold; +} + +table.authentication-factors td.name:before { + content: "\2714"; + display: inline-block; + margin-right: 1em; +/* + width: 1.5em; + line-height: 1.5em; + text-align: center; + color: #c7e3ef; + background: #004458; + border-radius: 50%; +*/ +} + +table.authentication-factors td.created { + width: 12em; + white-space: nowrap; +} + +table.authentication-factors td.actions { + width: 8em; + white-space: nowrap; + text-align: right; +} + +table.authentication-factors td.actions a.button { + padding: 2px 8px; + cursor: default; +} + +.kolab2fa-highsecuritydialog { + display: none; +} + +.ui-dialog-content .propform > label { + display: inline-block; + padding-top: 0.3em; + min-width: 12em; + font-weight: bold; +} + +.ui-dialog-content .propform .verifycode { + width: 20em; +} \ No newline at end of file diff --git a/plugins/kolab_2fa/skins/larry/templates/config.html b/plugins/kolab_2fa/skins/larry/templates/config.html new file mode 100644 index 00000000..c174ec26 --- /dev/null +++ b/plugins/kolab_2fa/skins/larry/templates/config.html @@ -0,0 +1,34 @@ + + + +<roundcube:object name="pagetitle" /> + + + + + + +
+ + + +
+

+
+

+ +

+ +
+
+ +
+ + + + + + + + +