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
This commit is contained in:
Thomas Bruederli 2015-06-04 15:53:04 +02:00
parent 1f9835e918
commit 1b10eb060a
19 changed files with 2673 additions and 0 deletions

View file

@ -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"
}
}

View file

@ -0,0 +1,74 @@
<?php
/**
* Kolab 2-Factor-Authentication plugin configuration
*
* Copyright (C) 2015, Kolab Systems AG <contact@kolabsys.com>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
// 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' => '<your-server-api-key>',
// 'hosts' => array('api.myhost1.com','api2.myhost.com'),
);

View file

@ -0,0 +1,358 @@
/**
* Kolab 2-Factor-Authentication plugin client functions
*
* @author Thomas Bruederli <bruederli@kolabsys.com>
*
* @licstart The following is the entire license notice for the
* JavaScript code in this page.
*
* Copyright (C) 2015, Kolab Systems AG <contact@kolabsys.com>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
* @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 = $('<tr>').addClass(method).appendTo(table);
$('<td>').addClass('name').text(props.name || method).appendTo(tr);
$('<td>').addClass('created').text(props.created || '??').appendTo(tr);
$('<td>').addClass('actions').html('<a class="button delete" rel="'+method+'">' + rcmail.get_label('remove','kolab_2fa') + '</a>').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 <Enter>
$(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 <Enter>
$('.propform input.k2fa-verify').keypress(function(e) {
if (e.which == 13) {
$(this).closest('.propform').find('.button.verify').click();
}
});
// render list initially
render();
});

View file

@ -0,0 +1,713 @@
<?php
/**
* Kolab 2-Factor-Authentication plugin
*
* ...
*
* @author Thomas Bruederli <bruederli@kolabsys.com>
*
* Copyright (C) 2015, Kolab Systems AG <contact@kolabsys.com>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
class kolab_2fa extends rcube_plugin
{
public $task = '(login|settings)';
protected $login_verified = null;
protected $login_factors = 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;
}
}

View file

@ -0,0 +1,279 @@
<?php
/**
* Kolab 2-Factor-Authentication Driver base class
*
* @author Thomas Bruederli <bruederli@kolabsys.com>
*
* Copyright (C) 2015, Kolab Systems AG <contact@kolabsys.com>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
namespace Kolab2FA\Driver;
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]);
}
}

View file

@ -0,0 +1,8 @@
<?php
namespace Kolab2FA\Driver;
class Exception extends \Exception
{
}

View file

@ -0,0 +1,131 @@
<?php
/**
* Kolab 2-Factor-Authentication HOTP driver implementation
*
* @author Thomas Bruederli <bruederli@kolabsys.com>
*
* Copyright (C) 2015, Kolab Systems AG <contact@kolabsys.com>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
namespace Kolab2FA\Driver;
class HOTP extends Base
{
public $method = 'hotp';
protected $config = 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);
}
}

View file

@ -0,0 +1,122 @@
<?php
/**
* Kolab 2-Factor-Authentication TOTP driver implementation
*
* @author Thomas Bruederli <bruederli@kolabsys.com>
*
* Copyright (C) 2015, Kolab Systems AG <contact@kolabsys.com>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
namespace Kolab2FA\Driver;
class TOTP extends Base
{
public $method = 'totp';
protected $config = 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();
}
}

View file

@ -0,0 +1,140 @@
<?php
/**
* Kolab 2-Factor-Authentication Yubikey driver implementation
*
* @author Thomas Bruederli <bruederli@kolabsys.com>
*
* Copyright (C) 2015, Kolab Systems AG <contact@kolabsys.com>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
namespace Kolab2FA\Driver;
class Yubikey extends Base
{
public $method = 'yubikey';
protected $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);
}
}

View file

@ -0,0 +1,58 @@
<?php
/**
* Kolab HOTP implementation based on Spomky-Labs/otphp
*
* This basically follows the exmaple implementation from
* https://github.com/Spomky-Labs/otphp/tree/master/examples
*
* @author Thomas Bruederli <bruederli@kolabsys.com>
*
* Copyright (C) 2015, Kolab Systems AG <contact@kolabsys.com>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
namespace Kolab2FA\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;
}
}

View file

@ -0,0 +1,133 @@
<?php
/**
* Kolab OTP trait based on Spomky-Labs/otphp
*
* This basically follows the exmaple implementation from
* https://github.com/Spomky-Labs/otphp/tree/master/examples
*
* @author Thomas Bruederli <bruederli@kolabsys.com>
*
* Copyright (C) 2015, Kolab Systems AG <contact@kolabsys.com>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
namespace Kolab2FA\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;
}
}

View file

@ -0,0 +1,50 @@
<?php
/**
* Kolab TOTP implementation based on Spomky-Labs/otphp
*
* This basically follows the exmaple implementation from
* https://github.com/Spomky-Labs/otphp/tree/master/examples
*
* @author Thomas Bruederli <bruederli@kolabsys.com>
*
* Copyright (C) 2015, Kolab Systems AG <contact@kolabsys.com>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
namespace Kolab2FA\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;
}
}

View file

@ -0,0 +1,81 @@
<?php
/**
* Abstract storage backend class for the Kolab 2-Factor-Authentication plugin
*
* @author Thomas Bruederli <bruederli@kolabsys.com>
*
* Copyright (C) 2015, Kolab Systems AG <contact@kolabsys.com>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
namespace Kolab2FA\Storage;
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);
}

View file

@ -0,0 +1,8 @@
<?php
namespace Kolab2FA\Storage;
class Exception extends \Exception
{
}

View file

@ -0,0 +1,203 @@
<?php
/**
* Storage backend to store 2-Factor-Authentication settings in LDAP
*
* @author Thomas Bruederli <bruederli@kolabsys.com>
*
* Copyright (C) 2015, Kolab Systems AG <contact@kolabsys.com>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
namespace Kolab2FA\Storage;
use \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;
}
}
}

View file

@ -0,0 +1,103 @@
<?php
/**
* Storage backend to use the Roundcube user prefs to store 2-Factor-Authentication settings
*
* @author Thomas Bruederli <bruederli@kolabsys.com>
*
* Copyright (C) 2015, Kolab Systems AG <contact@kolabsys.com>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
namespace Kolab2FA\Storage;
use \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']);
}
}

View file

@ -0,0 +1,57 @@
<?php
/**
* Localizations for the Kolab 2-Factor-Auth plugin
*
* Copyright (C) 2015, Kolab Systems AG
*
* For translation see https://www.transifex.com/projects/p/kolab/resource/kolab_2fa/
*/
$labels['factor'] = 'Factor';
$labels['secret'] = 'Secret Key';
$labels['created'] = 'Created';
$labels['remove'] = 'Remove';
$labels['continue'] = 'Continue';
$labels['settingslist'] = 'Multi-Factor Auth';
$labels['settingstitle'] = 'Authentication Factors';
$labels['totp'] = 'Mobile App (TOTP)';
$labels['hotp'] = 'Mobile App (HOTP)';
$labels['yubikey'] = 'Yubikey';
$labels['or'] = 'or';
$labels['yes'] = 'yes';
$labels['now'] = 'no';
$labels['qrcode'] = 'QR Code';
$labels['showqrcode'] = 'Show QR Code';
$labels['qrcodeexplaintotp'] = 'Download an authenticator app on your phone. Two apps which work well are <strong>FreeOTP</strong> and <strong>Google Authenticator</strong>, but any other TOTP app should also work.<br/><br/>
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 <strong>Google Authenticator</strong>, but any other HOTP app should also work.<br/><br/>
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';

View file

@ -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;
}

View file

@ -0,0 +1,34 @@
<roundcube:object name="doctype" value="html5" />
<html>
<head>
<title><roundcube:object name="pagetitle" /></title>
<roundcube:include file="/includes/links.html" />
</head>
<body class="noscroll">
<roundcube:include file="/includes/header.html" />
<div id="mainscreen" class="offset">
<roundcube:include file="/includes/settingstabs.html" />
<div id="pluginbody" class="uibox contentbox scroller">
<h1 class="boxtitle"><roundcube:object name="steptitle" /></h1>
<div class="boxcontent propform">
<p class="addfactor">
<roundcube:object name="plugin.factoradder" />
</p>
<roundcube:object name="plugin.settingslist" class="propform authentication-factors" />
</div>
</div>
</div>
<roundcube:object name="plugin.settingsform" class="propform" />
<roundcube:object name="plugin.highsecuritydialog" class="kolab2fa-highsecuritydialog" />
<roundcube:include file="/includes/footer.html" />
</body>
</html>